Написание Веб-пауков (краулеров)

Веб-паук (краулер, от английского crawl — «ползти») — это программа, которая сама ходит по сайту: открывает страницу, находит на ней ссылки, переходит по ним и по дороге собирает нужные данные. Чтобы написать такого паука на Python, хватит трёх вещей: библиотеки requests для скачивания страниц, BeautifulSoup для разбора HTML и очереди адресов с запоминанием уже посещённых. Ниже мы соберём паука шаг за шагом, от десяти строк до программы с ограничением глубины, паузами, robots.txt и сохранением в CSV и JSON.

Все примеры в статье работают на двух учебных сайтах, которые сделаны специально для тренировки: books.toscrape.com (магазин книг с категориями и карточками) и quotes.toscrape.com (цитаты с пагинацией и страницами авторов). Их можно обходить сколько угодно, никто не обидится. Каждый пример я запускал с маленьким лимитом страниц, под кодом — настоящий вывод.

Статья для тех, кто уже знает основы Python и пробовал вытащить данные с одной страницы. Если нет, начните со статьи о веб-скрапинге и парсинге данных, а потом возвращайтесь.

Содержание

Чем паук отличается от обычного парсера

Парсер (скрапер) работает с конкретной страницей. Вы даёте ему адрес, он скачивает HTML и достаёт оттуда цену, заголовок или таблицу. Дальше он никуда не идёт.

Паук добавляет к этому движение. Он сам решает, куда идти дальше: берёт ссылки со страницы, отбирает нужные и ставит их в очередь. Представьте курьера, у которого в руках не один адрес, а стопка: на каждом адресе ему дают ещё пару бумажек с новыми адресами. Его задача — объехать всех, никого не посетить дважды и не уехать в соседний город.

Отсюда три вопроса, на которые отвечает любой паук:

  • Куда идти дальше? Какие ссылки брать, а какие пропускать.
  • Где я уже был? Без этого паук бесконечно ходит по кругу: главная ведёт в каталог, каталог ведёт на главную.
  • Когда остановиться? Лимит страниц, глубина, свой домен.

Плюс вежливость к сайту: пауза между запросами, честный User-Agent и robots.txt. О ней будет отдельный раздел, это не мелочь.

Как устроен алгоритм

Любой паук, от учебного до поискового робота Яндекса, крутится в одном цикле:

  1. Взять адрес из очереди.
  2. Проверить, что мы здесь не были и что сюда можно ходить.
  3. Скачать страницу.
  4. Достать данные, если это страница с данными.
  5. Достать ссылки, отфильтровать, новые положить в конец очереди.
  6. Подождать немного и вернуться к шагу 1.

Цикл заканчивается, когда очередь пуста или сработал лимит.

Две структуры данных держат всю конструкцию. Очередь (в Python — collections.deque) хранит адреса, до которых мы ещё не дошли. В программировании её называют «фронтир» (frontier — граница): это край уже исследованной территории. Множество посещённых (set) помнит, где мы были. Проверка «есть ли адрес в множестве» выполняется мгновенно даже на сотне тысяч адресов, со списком так не выйдет. Подробнее о множествах — в статье про set в Python.

Подготовка

Нужен Python 3.10 или новее (примеры проверены на 3.12) и две библиотеки:

pip install requests beautifulsoup4

Остальное — collections, urllib.parse, urllib.robotparser, csv, json, time, logging — входит в стандартную библиотеку.

Перед тем как писать код, откройте books.toscrape.com в браузере и посмотрите на устройство сайта. Слева — список категорий. В центре — двадцать книг на странице. Внизу — кнопка next. Клик по книге открывает карточку с ценой, наличием и описанием. Это классическая схема интернет-магазина: «каталог → страницы списка → карточки». Наш паук будет ходить именно так.

Привычка, которая экономит часы: прежде чем писать паука, нарисуйте на бумаге, по каким страницам он пойдёт и что заберёт с каждой. Три прямоугольника и стрелки. Код после такого рисунка пишется в разы быстрее, и вы заранее видите, где паук может застрять.

Шаг 1. Собираем все ссылки со страницы

Начнём с самого простого: скачать главную страницу и вытащить из неё все ссылки.

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin

URL = "https://books.toscrape.com/"
HEADERS = {"User-Agent": "MyLearningSpider/0.1 (+https://example.com/contact)"}

response = requests.get(URL, headers=HEADERS, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")

links = set()
for a in soup.find_all("a", href=True):
    links.add(urljoin(URL, a["href"]))

print("Всего уникальных ссылок:", len(links))
for link in sorted(links)[:5]:
    print(link)

Вывод:

Всего уникальных ссылок: 73
https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
https://books.toscrape.com/catalogue/category/books/academic_40/index.html
https://books.toscrape.com/catalogue/category/books/add-a-comment_18/index.html
https://books.toscrape.com/catalogue/category/books/adult-fiction_29/index.html
https://books.toscrape.com/catalogue/category/books/art_25/index.html

Что здесь важно.

urljoin. В HTML ссылки часто относительные: catalogue/category/books/art_25/index.html или даже ../../index.html. Сами по себе они не открываются, их надо «приклеить» к адресу текущей страницы. Функция urljoin(база, ссылка) делает это правильно, включая переходы на уровень вверх через ../. Склеивать адреса строками через + не стоит: на первом же ../ получится мусор.

set вместо списка. На странице одна и та же книга встречается дважды: ссылка на картинке и ссылка на названии. Множество само убирает повторы.

timeout=10. Без тайм-аута (предельного времени ожидания) requests может ждать ответа сервера вечно. Паук, который завис на одной странице, хуже паука, который упал: падение вы хотя бы заметите.

User-Agent. Это строка, которой программа представляется сайту. По умолчанию requests подписывается как python-requests/2.x, и многие сайты таким сразу отказывают. Хорошая практика — назвать своего паука и оставить контакт. И одна грабля, на которую я сам наступил при подготовке статьи: заголовок должен быть только латиницей. Когда я написал в User-Agent «(учебный проект)» по-русски, requests упал с ошибкой UnicodeEncodeError: 'latin-1' codec can't encode characters. HTTP-заголовки передаются в кодировке latin-1, кириллицы там быть не может.

Шаг 2. Чистим и фильтруем ссылки

73 ссылки — это всё подряд: категории, книги, главная, пагинация. Пауку нужно уметь отличать одно от другого. Обычно хватает трёх фильтров:

  • отрезать якорь #...: page.html#reviews и page.html — одна и та же страница;
  • оставить только свой домен, чтобы паук не убежал на соседние сайты;
  • разложить ссылки по типам по шаблону адреса.
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse, urldefrag

START = "https://books.toscrape.com/"
HEADERS = {"User-Agent": "MyLearningSpider/0.1 (+https://example.com/contact)"}


def normalize(base: str, href: str) -> str:
    """Делает ссылку абсолютной и отрезает якорь #..."""
    absolute = urljoin(base, href)
    clean, _fragment = urldefrag(absolute)
    return clean


def is_internal(url: str, domain: str) -> bool:
    return urlparse(url).netloc == domain


html = requests.get(START, headers=HEADERS, timeout=10).text
soup = BeautifulSoup(html, "html.parser")
domain = urlparse(START).netloc

categories = []
books = []
for a in soup.find_all("a", href=True):
    url = normalize(START, a["href"])
    if not is_internal(url, domain):
        continue
    if "/category/books/" in url:
        categories.append(url)
    elif url.endswith("/index.html") and "catalogue/" in url and "/category/" not in url:
        books.append(url)

print("Категорий:", len(set(categories)))
print("Карточек книг:", len(set(books)))
print("Пример категории:", sorted(set(categories))[0])
print("Пример книги:", sorted(set(books))[0])

Вывод:

Категорий: 50
Карточек книг: 20
Пример категории: https://books.toscrape.com/catalogue/category/books/academic_40/index.html
Пример книги: https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html

50 категорий и 20 книг — ровно то, что видно глазами на главной. Совпадение с тем, что вы видите в браузере, — лучшая проверка фильтра.

urlparse(url).netloc возвращает домен вместе с портом: books.toscrape.com. Сравнивая его с доменом стартовой страницы, мы отсекаем внешние ссылки. Учтите: www.site.ru и site.ru для такого сравнения разные домены. На настоящих сайтах это частый источник «паук почему-то ничего не нашёл».

Шаблон адреса. Здесь хватило проверки подстроки "/category/books/". Если правила сложнее (например, «только адреса вида /catalogue/что-то_число/index.html»), берите регулярные выражения:

import re

BOOK_URL = re.compile(r"/catalogue/[a-z0-9-]+_\d+/index\.html$")

print(bool(BOOK_URL.search("https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html")))  # True
print(bool(BOOK_URL.search("https://books.toscrape.com/catalogue/category/books/art_25/index.html")))       # False

Вторая строка даёт False: между /catalogue/ и номером есть ещё слеши (category/books/), а шаблон разрешает там только буквы, цифры и дефисы.

Ещё лучше, чем угадывать по адресам, — брать ссылки из нужного места страницы CSS-селектором. Ссылки на книги на books.toscrape.com лежат внутри article.product_pod h3 a, кнопка «дальше» — в li.next a. Так фильтровать надёжнее: адреса могут поменяться, а вёрстка списка меняется реже. Этим приёмом мы воспользуемся в шаге 4.

Шаг 3. Первый настоящий паук: очередь, посещённые, глубина

Теперь соберём всё в цикл. Паук стартует с главной, ходит только по своему домену, уходит не дальше двух кликов от старта и останавливается после десяти страниц. Между запросами — пауза в секунду. Перед каждым запросом он спрашивает robots.txt, можно ли сюда.

import time
import logging
from collections import deque
from urllib.parse import urljoin, urlparse, urldefrag
from urllib import robotparser

import requests
from bs4 import BeautifulSoup

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

START_URL = "https://books.toscrape.com/"
USER_AGENT = "MyLearningSpider/0.1 (+https://example.com/contact)"
MAX_PAGES = 10      # сколько страниц скачать за запуск
MAX_DEPTH = 2       # на сколько кликов уходить от стартовой
DELAY = 1.0         # пауза между запросами, секунды


def make_robots(start_url: str) -> robotparser.RobotFileParser:
    """Читает robots.txt сайта. Если файла нет, разрешено всё."""
    rp = robotparser.RobotFileParser()
    rp.set_url(urljoin(start_url, "/robots.txt"))
    try:
        rp.read()
    except OSError as error:
        logging.warning("robots.txt не прочитан: %s", error)
    return rp


def fetch(session: requests.Session, url: str) -> str | None:
    """Скачивает страницу. При ошибке пишет в лог и возвращает None."""
    try:
        response = session.get(url, timeout=10)
        response.raise_for_status()
    except requests.RequestException as error:
        logging.warning("Не скачали %s: %s", url, error)
        return None
    return response.text


def extract_links(html: str, base_url: str, domain: str) -> list[str]:
    """Достаёт все ссылки страницы, оставляет только свой домен."""
    soup = BeautifulSoup(html, "html.parser")
    result = []
    for a in soup.find_all("a", href=True):
        url, _ = urldefrag(urljoin(base_url, a["href"]))
        if urlparse(url).netloc == domain:
            result.append(url)
    return result


def crawl(start_url: str) -> list[str]:
    domain = urlparse(start_url).netloc
    robots = make_robots(start_url)
    session = requests.Session()
    session.headers["User-Agent"] = USER_AGENT

    queue = deque([(start_url, 0)])   # (адрес, глубина)
    visited: set[str] = set()

    while queue and len(visited) < MAX_PAGES:
        url, depth = queue.popleft()
        if url in visited:
            continue
        if not robots.can_fetch(USER_AGENT, url):
            logging.info("robots.txt запрещает: %s", url)
            continue

        visited.add(url)
        html = fetch(session, url)
        time.sleep(DELAY)
        if html is None:
            continue
        logging.info("[%d] глубина %d: %s", len(visited), depth, url)

        if depth < MAX_DEPTH:
            for link in extract_links(html, url, domain):
                if link not in visited:
                    queue.append((link, depth + 1))

    return sorted(visited)


if __name__ == "__main__":
    pages = crawl(START_URL)
    print("Обошли страниц:", len(pages))

Вывод (лимит 10 страниц):

INFO [1] глубина 0: https://books.toscrape.com/
INFO [2] глубина 1: https://books.toscrape.com/index.html
INFO [3] глубина 1: https://books.toscrape.com/catalogue/category/books_1/index.html
INFO [4] глубина 1: https://books.toscrape.com/catalogue/category/books/travel_2/index.html
INFO [5] глубина 1: https://books.toscrape.com/catalogue/category/books/mystery_3/index.html
INFO [6] глубина 1: https://books.toscrape.com/catalogue/category/books/historical-fiction_4/index.html
INFO [7] глубина 1: https://books.toscrape.com/catalogue/category/books/sequential-art_5/index.html
INFO [8] глубина 1: https://books.toscrape.com/catalogue/category/books/classics_6/index.html
INFO [9] глубина 1: https://books.toscrape.com/catalogue/category/books/philosophy_7/index.html
INFO [10] глубина 1: https://books.toscrape.com/catalogue/category/books/romance_8/index.html
Обошли страниц: 10

Разберём по частям.

Очередь из пар «адрес, глубина»

В очередь кладём не просто адрес, а кортеж (url, depth). Стартовая страница — глубина 0, ссылки с неё — 1, ссылки со ссылок — 2. Как только глубина достигла MAX_DEPTH, новые ссылки со страницы перестаём собирать. Без этого ограничения паук на большом сайте может уйти в какой-нибудь бесконечный календарь («следующий месяц» → «следующий месяц» → …) и не вернуться.

Почему deque и popleft

popleft() берёт адрес из начала очереди, append() кладёт в конец. Получается обход в ширину: сначала все страницы глубины 1, потом глубины 2. В выводе это хорошо видно, паук прошёл подряд по категориям, ни одной карточки книги. Если брать с конца (pop()), получится обход в глубину: паук ныряет по первой ссылке до упора. Для сбора данных обход в ширину обычно удобнее: самые важные страницы сайта ближе к главной, и при раннем обрыве вы получите именно их.

Почему не обычный список? У списка pop(0) сдвигает все элементы и на длинной очереди тормозит. У deque обе операции быстрые с любого конца. Про списки и их операции есть отдельная статья: списки в Python.

Дубли адресов: index.html

Посмотрите на первые две строки вывода. https://books.toscrape.com/ и https://books.toscrape.com/index.html — это одна и та же главная, а паук скачал её дважды. Для множества это две разные строки. На учебном сайте не страшно, на реальном такие дубли съедают половину лимита: ?utm_source=..., слеш на конце, http и https, заглавные буквы. Лекарство — функция нормализации, которая приводит адрес к одному виду до проверки в visited. Например, отрезать index.html на конце и выкинуть метки из параметров. Это одно из заданий в конце статьи.

robots.txt

robots.txt — текстовый файл в корне сайта, в котором владелец пишет, каким роботам и в какие разделы ходить нельзя. Модуль urllib.robotparser читает его и отвечает на вопрос can_fetch(user_agent, url). У обоих учебных сайтов robots.txt нет (сервер отдаёт 404), а в этом случае по правилам разрешено всё. На реальных сайтах там почти всегда что-то есть: закрытые личные кабинеты, поиск, корзина. Проверять стоит до каждого запроса, это дешево: файл читается один раз.

Многие сайты пишут в robots.txt ещё и Crawl-delay — желаемую паузу между запросами. Её можно прочитать методом robots.crawl_delay(USER_AGENT) и взять большее из двух чисел: своей паузы и запрошенной.

Пауза между запросами

time.sleep(DELAY) — самая короткая строка в программе и самая важная для сайта. Без неё паук отправляет запросы так быстро, как отвечает сервер. Для маленького сайта на дешёвом хостинге это выглядит как атака: страницы начинают тормозить у обычных посетителей. Секунда между запросами — разумный минимум. Если сервер отвечает медленно или начал отдавать ошибки 429 (Too Many Requests, «слишком много запросов») и 503, паузу надо увеличить, а не уменьшить.

Обработка ошибок

Функция fetch ловит requests.RequestException — общего «родителя» всех ошибок requests: обрыв сети, тайм-аут, код ответа 4xx/5xx (его превращает в исключение raise_for_status()). Паук пишет предупреждение в лог и идёт дальше. Одна битая ссылка не должна валить обход тысячи страниц. Но и молча проглатывать ошибки нельзя: в логе потом видно, сколько страниц не скачалось и почему. Подробнее о try/except — в статье об обработке ошибок и исключений.

Вот как разные ошибки выглядят на практике:

import requests

URLS = [
    "https://books.toscrape.com/catalogue/page-2.html",
    "https://books.toscrape.com/catalogue/page-999.html",
    "http://127.0.0.1:9/",
]

session = requests.Session()
session.headers["User-Agent"] = "MyLearningSpider/0.1 (+https://example.com/contact)"

for url in URLS:
    try:
        r = session.get(url, timeout=5)
        r.raise_for_status()
        final = r.url
        hops = len(r.history)
        print(f"OK  {r.status_code} {final} (перенаправлений: {hops})")
    except requests.HTTPError as error:
        print(f"HTTP {error.response.status_code} {url}")
    except requests.RequestException as error:
        print(f"СЕТЬ {url}: {type(error).__name__}")

Вывод:

OK  200 https://books.toscrape.com/catalogue/page-2.html (перенаправлений: 0)
HTTP 404 https://books.toscrape.com/catalogue/page-999.html
СЕТЬ http://127.0.0.1:9/: ConnectionError

HTTPError означает «сервер ответил, но с ошибкой», ConnectionError — «до сервера не достучались». Их полезно различать. 404 повторять бессмысленно, страницы нет. Сетевую ошибку или 503 можно повторить через несколько секунд, один-два раза.

Перенаправления

Сервер может ответить не страницей, а указанием «иди по другому адресу» (коды 301, 302). requests проходит такие перенаправления сам. Итоговый адрес лежит в response.url, а цепочка промежуточных ответов — в response.history. Для паука отсюда следует простое правило: в visited добавлять и исходный адрес, и итоговый. Иначе паук скачает одну страницу под двумя именами. На учебных сайтах перенаправлений нет, поэтому в выводе выше ноль, но поле history стоит проверять всегда.

Бывают и перенаправления на стороне браузера: через JavaScript или тег <meta http-equiv="refresh">. requests их не видит, потому что не выполняет JavaScript. О том, как работать с такими сайтами, — в статье о скрапинге сайтов с JavaScript.

Шаг 4. Паук, который собирает данные: книги с карточками

Обойти сайт — полдела. Нужно ещё что-то унести. Сделаем паука, который заходит в категорию, идёт по всем её страницам через кнопку next, открывает каждую карточку книги и забирает название, цену, наличие и рейтинг. Результат — в CSV и JSON.

Здесь у паука два типа страниц, и это главное отличие от шага 3. Страница списка даёт ссылки на карточки и ссылку на следующую страницу списка. Карточка даёт данные. Для каждого типа — своя функция разбора.

import csv
import json
import time
import logging
from collections import deque
from urllib.parse import urljoin, urldefrag

import requests
from bs4 import BeautifulSoup

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

START_URL = "https://books.toscrape.com/catalogue/category/books/travel_2/index.html"
USER_AGENT = "MyLearningSpider/0.1 (+https://example.com/contact)"
MAX_BOOKS = 5
DELAY = 1.0

RATING = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}


def get_soup(session: requests.Session, url: str) -> BeautifulSoup | None:
    try:
        response = session.get(url, timeout=10)
        response.raise_for_status()
    except requests.RequestException as error:
        logging.warning("Ошибка на %s: %s", url, error)
        return None
    finally:
        time.sleep(DELAY)
    response.encoding = "utf-8"
    return BeautifulSoup(response.text, "html.parser")


def parse_list_page(soup: BeautifulSoup, url: str) -> tuple[list[str], str | None]:
    """Страница списка: ссылки на карточки и ссылка на следующую страницу."""
    cards = [
        urldefrag(urljoin(url, a["href"]))[0]
        for a in soup.select("article.product_pod h3 a")
    ]
    next_link = soup.select_one("li.next a")
    next_url = urljoin(url, next_link["href"]) if next_link else None
    return cards, next_url


def parse_book(soup: BeautifulSoup, url: str) -> dict:
    """Карточка книги: название, цена, наличие, рейтинг."""
    rating_tag = soup.select_one("p.star-rating")
    rating_word = rating_tag["class"][1] if rating_tag else ""
    return {
        "title": soup.select_one("div.product_main h1").get_text(strip=True),
        "price": soup.select_one("p.price_color").get_text(strip=True),
        "in_stock": soup.select_one("p.availability").get_text(strip=True),
        "rating": RATING.get(rating_word, 0),
        "url": url,
    }


def crawl_books(start_url: str) -> list[dict]:
    session = requests.Session()
    session.headers["User-Agent"] = USER_AGENT
    list_queue = deque([start_url])
    seen: set[str] = set()
    books: list[dict] = []

    while list_queue and len(books) < MAX_BOOKS:
        list_url = list_queue.popleft()
        soup = get_soup(session, list_url)
        if soup is None:
            continue
        cards, next_url = parse_list_page(soup, list_url)
        if next_url:
            list_queue.append(next_url)

        for card_url in cards:
            if card_url in seen or len(books) >= MAX_BOOKS:
                continue
            seen.add(card_url)
            card = get_soup(session, card_url)
            if card is None:
                continue
            try:
                book = parse_book(card, card_url)
            except (AttributeError, KeyError, IndexError) as error:
                logging.warning("Не разобрали %s: %s", card_url, error)
                continue
            books.append(book)
            logging.info("%s | %s", book["title"], book["price"])
    return books


def save(books: list[dict]) -> None:
    with open("books.csv", "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=list(books[0].keys()))
        writer.writeheader()
        writer.writerows(books)
    with open("books.json", "w", encoding="utf-8") as f:
        json.dump(books, f, ensure_ascii=False, indent=2)


if __name__ == "__main__":
    result = crawl_books(START_URL)
    if result:
        save(result)
    print("Сохранено книг:", len(result))

Вывод (лимит 5 книг):

INFO It's Only the Himalayas | £45.17
INFO Full Moon over Noah’s Ark: An Odyssey to Mount Ararat and Beyond | £49.43
INFO See America: A Celebration of Our National Parks & Treasured Sites | £48.87
INFO Vagabonding: An Uncommon Guide to the Art of Long-Term World Travel | £36.94
INFO Under the Tuscan Sun | £37.33
Сохранено книг: 5

Первые строки books.csv:

title,price,in_stock,rating,url
It's Only the Himalayas,£45.17,In stock (19 available),2,https://books.toscrape.com/catalogue/its-only-the-himalayas_981/index.html
Full Moon over Noah’s Ark: An Odyssey to Mount Ararat and Beyond,£49.43,In stock (15 available),4,https://books.toscrape.com/catalogue/full-moon-over-noahs-ark-an-odyssey-to-mount-ararat-and-beyond_811/index.html

И начало books.json:

[
  {
    "title": "It's Only the Himalayas",
    "price": "£45.17",
    "in_stock": "In stock (19 available)",
    "rating": 2,
    "url": "https://books.toscrape.com/catalogue/its-only-the-himalayas_981/index.html"
  },

Несколько деталей, которые стоит забрать себе в привычку.

Кодировка ответа. Строка response.encoding = "utf-8" не для красоты. books.toscrape.com не указывает кодировку в заголовке, requests угадывает latin-1, и символ фунта превращается в £. Если в данных появились странные буквы с крышечками, первым делом проверьте кодировку.

Рейтинг из класса. Звёзды на карточке нарисованы картинками, а число спрятано в CSS-классе: <p class="star-rating Two">. BeautifulSoup отдаёт классы списком, второй элемент — слово. Словарь RATING переводит его в число. Такие трюки встречаются постоянно: данные на странице есть, но не там, где их ждёшь. Открывайте инструменты разработчика в браузере (F12) и смотрите исходный HTML.

Ошибки разбора — отдельно от ошибок сети. Если на карточке нет нужного тега, select_one вернёт None, и вызов .get_text() упадёт с AttributeError. Мы ловим это вокруг parse_book и пропускаем одну карточку, а не весь обход. Если таких пропусков много — изменилась вёрстка, пора обновлять селекторы.

utf-8-sig для CSV. Excel на Windows без метки BOM (служебные байты в начале файла) открывает UTF-8 как кракозябры. Вариант utf-8-sig добавляет эту метку, и файл открывается двойным кликом. Для JSON она не нужна, там важен ensure_ascii=False, чтобы кириллица и спецсимволы сохранялись как есть, а не как £.

CSV или JSON? CSV — плоская таблица, удобно открыть в Excel или загрузить в pandas. JSON хранит вложенные данные: список тегов, словарь характеристик. Для данных, которые будут расти, лучше сразу думать о базе данных; SQLite встроен в Python и не требует сервера. Про базы есть вводная статья: базы данных и СУБД.

Шаг 5. Пагинация и вторая очередь: цитаты и авторы

На quotes.toscrape.com по десять цитат на странице, внизу кнопка Next. У каждой цитаты есть ссылка «(about)» на страницу автора. Паук ниже проходит три страницы, забирает текст, автора и теги, а ссылки на авторов копит в множестве на потом.

import json
import time
import logging
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

START_URL = "https://quotes.toscrape.com/"
USER_AGENT = "MyLearningSpider/0.1 (+https://example.com/contact)"
MAX_PAGES = 3
DELAY = 1.0


def crawl_quotes(start_url: str) -> tuple[list[dict], set[str]]:
    session = requests.Session()
    session.headers["User-Agent"] = USER_AGENT
    quotes: list[dict] = []
    author_pages: set[str] = set()
    url: str | None = start_url
    pages = 0

    while url and pages < MAX_PAGES:
        try:
            response = session.get(url, timeout=10)
            response.raise_for_status()
        except requests.RequestException as error:
            logging.warning("Стоп на %s: %s", url, error)
            break
        pages += 1
        soup = BeautifulSoup(response.text, "html.parser")

        for block in soup.select("div.quote"):
            quotes.append({
                "text": block.select_one("span.text").get_text(strip=True),
                "author": block.select_one("small.author").get_text(strip=True),
                "tags": [t.get_text() for t in block.select("a.tag")],
            })
            about = block.select_one("a[href^='/author/']")
            if about:
                author_pages.add(urljoin(url, about["href"]))

        next_link = soup.select_one("li.next a")
        url = urljoin(url, next_link["href"]) if next_link else None
        logging.info("Страница %d готова, цитат всего: %d", pages, len(quotes))
        time.sleep(DELAY)

    return quotes, author_pages


if __name__ == "__main__":
    quotes, authors = crawl_quotes(START_URL)
    with open("quotes.json", "w", encoding="utf-8") as f:
        json.dump(quotes, f, ensure_ascii=False, indent=2)
    print("Цитат:", len(quotes), "| страниц авторов в очереди:", len(authors))
    print(quotes[0]["author"], "-", quotes[0]["tags"])

Вывод (лимит 3 страницы):

INFO Страница 1 готова, цитат всего: 10
INFO Страница 2 готова, цитат всего: 20
INFO Страница 3 готова, цитат всего: 30
Цитат: 30 | страниц авторов в очереди: 20
Albert Einstein - ['change', 'deep-thoughts', 'thinking', 'world']

30 цитат, а уникальных авторов всего 20: Эйнштейн, Роулинг и ещё несколько человек повторяются. Множество снова сработало как фильтр. Если бы мы сразу ходили на страницу автора у каждой цитаты, сделали бы 30 запросов вместо 20.

Обратите внимание на устройство цикла. Здесь нет общей очереди: пагинация линейная, у каждой страницы ровно одна следующая. Паук идёт по цепочке, пока есть кнопка Next или пока не кончился лимит. Это частный случай паука, и для него очередь не нужна. Важно другое: условие остановки. Если кнопки «дальше» нет, переменная url становится None, и цикл заканчивается сам.

Ещё одна деталь: селектор a[href^='/author/'] означает «ссылка, у которой адрес начинается с /author/». Такие селекторы по атрибутам выручают, когда у нужной ссылки нет своего класса.

Внутренние и внешние ссылки

До сих пор паук держался одного домена. Иногда задача обратная: узнать, на какие внешние сайты ссылается ваш сайт. Например, проверить, не ведут ли старые статьи на умершие ресурсы, или собрать список партнёров. Алгоритм тот же, только ссылки делятся на две корзины.

Внутренние ссылки идут в очередь, паук по ним ходит дальше. Внешние складываются в отдельное множество, и по ним паук не ходит, только записывает. Фактически это два цикла, вложенные друг в друга: внешний обходит свой сайт, внутренний на каждой странице раскладывает ссылки. Изменение кода из шага 3 занимает пять строк:

external: set[str] = set()

for a in soup.find_all("a", href=True):
    url, _ = urldefrag(urljoin(base_url, a["href"]))
    if not url.startswith(("http://", "https://")):
        continue                      # mailto:, tel:, javascript:
    if urlparse(url).netloc == domain:
        internal.append(url)          # в очередь
    else:
        external.add(url)             # только запомнить

Фильтр по схеме адреса нужен обязательно. Ссылки mailto:, tel: и javascript:void(0) выглядят как ссылки, но открыть их через requests нельзя.

Пускать паука и по внешним ссылкам, то есть по всему интернету, технически можно: убрать проверку домена. Но так паук за час уходит в непредсказуемые места, на сайты с непонятными правилами, и начинает тратить чужие ресурсы без всякой цели. Если вам действительно нужно обойти много сайтов, составьте список доменов заранее и ходите по каждому отдельно, со своими настройками.

Видимый и невидимый интернет

Паук видит только то, на что есть ссылка. Это ограничение стоит понимать до того, как обещать заказчику «соберём всё с сайта».

  • Страницы без входящих ссылок. Если на страницу никто не ссылается, паук до неё не дойдёт. Частично помогает карта сайта sitemap.xml: многие сайты держат там полный список адресов. Её можно скачать и сразу положить все адреса в очередь.
  • Страницы за формой. Результаты поиска, фильтры, личные кабинеты. Ссылки на них появляются только после того, как человек что-то ввёл или вошёл в аккаунт.
  • Контент, который рисует JavaScript. requests получает HTML до выполнения скриптов. Если в исходнике страницы данных нет, а в браузере они есть — их дорисовал JavaScript. Нужен браузерный инструмент или прямой запрос к API сайта.

Часть интернета, которую не находят поисковики, называют глубоким вебом (deep web). Это не что-то тайное: ваша почта и личный кабинет банка тоже туда входят. Даркнет (dark web) — другое: сайты в отдельных сетях вроде Tor, куда обычный браузер не ходит. Учебному пауку там делать нечего.

Проектирование: паук для нескольких сайтов

Пока сайт один, всё помещается в функции. Когда сайтов становится пять, появляется соблазн скопировать код и поменять селекторы. Через месяц у вас пять почти одинаковых программ, и исправление одной ошибки надо делать пять раз.

Решение — разделить то, что одинаково у всех сайтов, и то, что отличается. Одинаково: скачать страницу, подождать, обработать ошибку, пройти по пагинации, не зайти дважды. Отличается: стартовый адрес, селекторы списка, кнопки «дальше» и полей. Отличия выносим в описание сайта (конфиг), общее — в класс паука.

import time
import logging
from collections import deque
from dataclasses import dataclass
from urllib.parse import urljoin, urldefrag

import requests
from bs4 import BeautifulSoup

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")


@dataclass
class SiteConfig:
    """Всё, чем один сайт отличается от другого."""
    name: str
    start_url: str
    item_links: str          # CSS-селектор ссылок на карточки
    next_page: str           # CSS-селектор ссылки «дальше»
    fields: dict[str, str]   # имя поля -> CSS-селектор на карточке


class Spider:
    def __init__(self, site: SiteConfig, max_items: int = 3, delay: float = 1.0):
        self.site = site
        self.max_items = max_items
        self.delay = delay
        self.session = requests.Session()
        self.session.headers["User-Agent"] = "MyLearningSpider/0.1 (+https://example.com/contact)"
        self.visited: set[str] = set()

    def soup(self, url: str) -> BeautifulSoup | None:
        try:
            response = self.session.get(url, timeout=10)
            response.raise_for_status()
        except requests.RequestException as error:
            logging.warning("%s: %s", url, error)
            return None
        finally:
            time.sleep(self.delay)
        response.encoding = "utf-8"
        return BeautifulSoup(response.text, "html.parser")

    def parse_item(self, page: BeautifulSoup, url: str) -> dict:
        item = {"url": url}
        for field, selector in self.site.fields.items():
            tag = page.select_one(selector)
            item[field] = tag.get_text(strip=True) if tag else None
        return item

    def run(self) -> list[dict]:
        items: list[dict] = []
        pages = deque([self.site.start_url])
        while pages and len(items) < self.max_items:
            page_url = pages.popleft()
            page = self.soup(page_url)
            if page is None:
                continue
            for a in page.select(self.site.item_links):
                url = urldefrag(urljoin(page_url, a["href"]))[0]
                if url in self.visited or len(items) >= self.max_items:
                    continue
                self.visited.add(url)
                card = self.soup(url)
                if card is not None:
                    items.append(self.parse_item(card, url))
            nxt = page.select_one(self.site.next_page)
            if nxt:
                pages.append(urljoin(page_url, nxt["href"]))
        return items


SITES = [
    SiteConfig(
        name="books",
        start_url="https://books.toscrape.com/catalogue/category/books/poetry_23/index.html",
        item_links="article.product_pod h3 a",
        next_page="li.next a",
        fields={"title": "div.product_main h1", "price": "p.price_color"},
    ),
    SiteConfig(
        name="authors",
        start_url="https://quotes.toscrape.com/",
        item_links="div.quote a[href^='/author/']",
        next_page="li.next a",
        fields={"title": "h3.author-title", "born": "span.author-born-date"},
    ),
]

if __name__ == "__main__":
    for site in SITES:
        for item in Spider(site, max_items=2).run():
            print(site.name, "|", item["title"], "|", item.get("price") or item.get("born"))

Вывод (по 2 записи с каждого сайта):

books | A Light in the Attic | £51.77
books | The Black Maria | £52.15
authors | Albert Einstein | March 14, 1879
authors | J.K. Rowling | July 31, 1965

Один класс Spider обошёл два совершенно разных сайта. Для books он прошёл по разделу поэзии и открыл карточки книг. Для quotes — по ленте цитат и открыл страницы авторов. Новый сайт с той же схемой «список → карточки» добавляется одним новым SiteConfig, без единой строки логики.

Плюсы такого подхода:

  • Исправили ошибку или добавили повтор при сбое — это работает для всех сайтов сразу.
  • Конфиги можно хранить не в коде, а в JSON-файле или базе. Добавлять сайты может человек, который не пишет на Python.
  • Легко тестировать: подсунули сохранённый HTML-файл и проверили, что селекторы находят поля.

Минусы тоже есть. Схема хорошо работает, пока сайты похожи. Если у одного сайта данные в таблице, у второго в JSON внутри страницы, а третий требует входа, конфиг обрастает флагами и условиями, и становится труднее, чем отдельный код. Тогда помогает наследование: базовый класс Spider с общей механикой и подклассы, которые переопределяют один-два метода, например parse_item. Ещё один частый шаг — дать каждому типу страниц свой обработчик: «это страница списка — вызвать parse_list», «это карточка — вызвать parse_item». Ровно так устроен Scrapy, о котором ниже.

Из каких частей состоит паук

Если разобрать любого зрелого паука, в нём найдутся одни и те же детали. Полезно знать их названия, чтобы читать чужой код и документацию:

  • Фронтир (очередь адресов) и множество посещённых — решают, куда идти.
  • Загрузчик — скачивает страницы: сессия, заголовки, тайм-ауты, паузы, повторы.
  • Парсеры — по одному на тип страницы; достают данные и новые ссылки.
  • Хранилище — куда пишутся результаты: CSV, JSON, база данных.
  • Журнал (лог) — что скачано, что упало, сколько времени ушло.

Держите эти части отдельно с первого дня. Переход с CSV на базу тогда меняет одну функцию, а не всю программу.

Как продолжить обход после остановки

Паук на тысячу страниц с паузой в секунду работает минут двадцать. За это время может пропасть интернет, уснуть ноутбук или вы сами нажмёте Ctrl+C. Начинать с нуля обидно. Решение простое: время от времени сохранять на диск очередь и множество посещённых, а при старте читать их обратно.

import json
from collections import deque
from pathlib import Path

STATE = Path("spider_state.json")


def save_state(queue, visited) -> None:
    """Сохраняет очередь и посещённые адреса."""
    data = {"queue": list(queue), "visited": sorted(visited)}
    STATE.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")


def load_state(start_url: str):
    """Читает сохранённое состояние или начинает с нуля."""
    if not STATE.exists():
        return deque([(start_url, 0)]), set()
    data = json.loads(STATE.read_text(encoding="utf-8"))
    queue = deque(tuple(item) for item in data["queue"])
    return queue, set(data["visited"])

Вызывайте save_state каждые 50–100 страниц и в блоке finally вокруг главного цикла. Тогда даже при Ctrl+C последнее состояние окажется на диске. Результаты, к слову, тоже лучше дописывать в файл по ходу дела (режим "a" для CSV или по строке JSON на запись), а не держать в списке до конца.

Про скорость. Первое, что ускоряет паука без вреда для сайта, — requests.Session(), который мы уже используем: он держит соединение открытым и не тратит время на новое подключение к каждой странице. Второе — не скачивать лишнее: картинки, PDF и архивы паук для текста трогать не должен, проверяйте расширение адреса до запроса. Параллельные запросы (потоки, asyncio) ускоряют сильнее всего, но и нагрузку на сайт умножают. Для одного чужого сайта лучше обойтись без них.

Частые ошибки начинающих

  • Нет лимита. Паук без MAX_PAGES на реальном сайте работает часами, а вы не знаете, сколько ещё. Ставьте лимит всегда, при отладке — маленький, 5–10 страниц.
  • Адрес добавляют в visited после скачивания и разбора. Если разбор упал, адрес не отмечен, и паук будет пытаться снова и снова. Отмечайте посещение до запроса.
  • Нет нормализации адресов. Слеш на конце, якорь, метки в параметрах — и одна страница превращается в десять.
  • Склейка адресов через +. Используйте urljoin.
  • Паузу ставят только после успешных запросов. Ошибки сервера часто как раз признак перегрузки, после них пауза нужнее всего. Поэтому в примерах выше sleep стоит в finally или сразу после запроса.
  • Всё держится в памяти до конца. Если на 9000-й странице программа упадёт, вы потеряете всё. Пишите результаты по мере сбора или сохраняйте очередь и visited, чтобы продолжить с места остановки.
  • Код из статьи сразу в работу. Учебные примеры показывают идею. В рабочем пауке нужны повторы при сбоях, сохранение прогресса, ротация логов, проверка, что данные действительно появились. Добавляйте это, прежде чем запускать паука на ночь.

Когда своего паука мало: Scrapy

Самописный паук на requests хорош для учёбы и для задач на сотни страниц. Когда страниц десятки тысяч, сайтов много, нужны повторы при сбоях, параллельные запросы и выгрузка в разные форматы, берут Scrapy — фреймворк (готовый каркас программы), в котором всё это уже есть.

Понятия Scrapy ложатся на то, что мы сделали руками:

  • Spider — класс паука, как наш Spider;
  • start_urls — стартовые адреса, начало очереди;
  • parse — метод, который разбирает ответ;
  • yield — отдаёт наружу либо найденные данные, либо новый запрос;
  • response.follow — сам делает urljoin и ставит ссылку в очередь, а повторы отсекает встроенный фильтр;
  • ROBOTSTXT_OBEY и DOWNLOAD_DELAY — robots.txt и пауза, включаются одной настройкой.

Тот же обход цитат с пагинацией на Scrapy:

import scrapy


class QuotesSpider(scrapy.Spider):
    name = "quotes"
    start_urls = ["https://quotes.toscrape.com/"]
    custom_settings = {
        "ROBOTSTXT_OBEY": True,
        "DOWNLOAD_DELAY": 1,
        "USER_AGENT": "MyLearningSpider/0.1",
        "CLOSESPIDER_PAGECOUNT": 3,
    }

    def parse(self, response):
        for quote in response.css("div.quote"):
            yield {
                "text": quote.css("span.text::text").get(),
                "author": quote.css("small.author::text").get(),
                "tags": quote.css("a.tag::text").getall(),
            }
        next_page = response.css("li.next a::attr(href)").get()
        if next_page:
            yield response.follow(next_page, callback=self.parse)

Запуск без создания проекта: scrapy runspider quotes_spider.py -o quotes.json. В моём прогоне (Scrapy 2.19) паук сначала запросил robots.txt (получил 404, значит ограничений нет), затем скачал две страницы и сохранил 20 цитат. Лимит CLOSESPIDER_PAGECOUNT в 3 ответа учёл и сам robots.txt.

Подробно про проекты Scrapy, CrawlSpider, конвейеры обработки (pipelines) и настройки — в отдельной статье «Фреймворк Scrapy». Совет: сначала напишите своего паука, как в этой статье. Тогда Scrapy читается как знакомая схема, а не как магия.

Законно ли это: что важно знать

Скачивать открытые страницы технически просто, но правовые риски у паука есть. Коротко о главном.

  • Правила сайта и robots.txt. В пользовательском соглашении сайта может быть прямо запрещён автоматический сбор. robots.txt сам по себе не закон, но игнорировать его — плохой знак в любом споре.
  • Нагрузка. Паук, из-за которого сайт тормозит или падает, причиняет владельцу ущерб. Паузы и лимиты здесь защищают и вас.
  • Персональные данные. Сбор имён, телефонов, почт и профилей людей может нарушать Федеральный закон № 152-ФЗ «О персональных данных», даже если данные открыто лежат на странице.
  • Базы данных. Если вы копируете существенную часть чужого каталога, есть риск нарушить права изготовителя базы данных (ст. 1334 Гражданского кодекса РФ, часть четвёртая).
  • Обход защиты. Вход в закрытые разделы чужим паролем, обход авторизации или блокировок может подпадать под ст. 272 Уголовного кодекса РФ о неправомерном доступе к компьютерной информации.

Безопасная зона: открытые данные без персональной информации, умеренная скорость, соблюдение robots.txt и правил сайта. Для учёбы — сайты вроде toscrape.com, созданные для этого. Это не юридическая консультация: в спорной ситуации обратитесь к юристу.

Задания для самопроверки

Все задания — на учебных сайтах. У каждого есть результат, который можно проверить глазами в браузере или простым подсчётом.

Задание 1. Нормализация адресов

Напишите функцию normalize(url), которая отрезает якорь, убирает index.html на конце и параметры utm_*. Подключите её к пауку из шага 3 и запустите с MAX_PAGES = 10.

Как проверить: в выводе больше нет пары https://books.toscrape.com/ и https://books.toscrape.com/index.html; normalize("https://books.toscrape.com/index.html#top") возвращает "https://books.toscrape.com/".

Задание 2. Сколько книг в категории

Доработайте паука из шага 4 так, чтобы он проходил всю категорию Travel целиком (без лимита) и считал книги.

Как проверить: на странице категории в браузере написано, сколько в ней книг («results»). Ваше число должно совпасть. Затем запустите для категории Mystery, там уже несколько страниц пагинации.

Задание 3. Все теги цитат

Обойдите все страницы quotes.toscrape.com (кнопка Next до конца) и посчитайте, сколько раз встречается каждый тег. Выведите пять самых частых. Подсказка: collections.Counter.

Как проверить: общее число цитат делится на 10 (по десять на странице), а частоты пяти тегов сравните с блоком Top Ten tags в правой колонке сайта: порядок должен совпасть.

Задание 4. Карточки авторов

Используйте множество author_pages из шага 5: обойдите страницы авторов и сохраните в JSON имя, дату и место рождения.

Как проверить: записей в файле столько же, сколько уникальных авторов вы собрали; ни одна страница автора не скачана дважды (посчитайте запросы в логе).

Задание 5. Карта внешних ссылок

Обойдите quotes.toscrape.com с глубиной 1 и соберите все внешние ссылки (не на свой домен), отбросив mailto: и javascript:.

Как проверить: найденные внешние адреса совпадают с теми, что вы видите в подвале страницы в браузере; ни один адрес quotes.toscrape.com в список не попал.

Задание 6. Вежливость по правилам

Сделайте функцию, которая принимает адрес сайта, читает его robots.txt и возвращает паузу: crawl_delay из файла, если он указан, иначе 1 секунду. Проверьте её на обоих учебных сайтах и на любом крупном сайте, у которого robots.txt есть.

Как проверить: для учебных сайтов функция возвращает 1 (robots.txt там нет); для крупного сайта результат совпадает с тем, что вы видите, открыв его /robots.txt в браузере.

Задание 7. Второй сайт в конфиге

Добавьте в список SITES из раздела про проектирование ещё один конфиг: книги из категории Science. Логику паука не меняйте.

Как проверить: паук вывел названия и цены, которые совпадают с карточками в браузере, а в классе Spider не изменилось ни одной строки.

Задание 8. Тот же паук на Scrapy

Перепишите паука из шага 4 (категория → пагинация → карточки) на Scrapy: в parse ходите по карточкам через response.follow(..., callback=self.parse_book) и по кнопке next обратно в parse.

Как проверить: число книг в итоговом JSON совпадает с результатом задания 2 для той же категории.

Другие статьи о парсинге

  • BeautifulSoup — как паук разбирает каждую скачанную страницу
Понравилась статья? Поделиться с друзьями:
Школа Виктора Комлева
Добавить комментарий

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.