Большинство команд, которые следят за рынком, строят поисковый индекс или наполняют датасет, начинают одинаково: обходят данные с набора публичных веб-страниц и превращают их в чистые записи. Сложность редко в одной странице. Она в том, чтобы делать это на сотнях страниц без того, чтобы ваши запросы получали троттлинг, блокировки или тихо возвращали наполовину пустой HTML.

Это руководство показывает, как построить небольшой, запускаемый веб-краулер на Python. Он получает стартовую страницу через Crawling API, извлекает на ней ссылки, следует по тем, что остаются в рамках вашей целевой области, парсит нужные поля на каждой странице, удаляет дубликаты и экспортирует чистый JSON и CSV. Разбор остаётся на нейтральном примере сайта, чтобы вы могли запустить его как есть, а затем направить на собственный публичный источник.

Краулинг против скрапинга в одном абзаце

Эти два слова используют как взаимозаменяемые, но они называют разные задачи. Краулинг это обнаружение: начиная с одного или нескольких URL, следование по ссылкам и движение наружу, чтобы найти страницы, которые стоит посетить. Скрапинг это извлечение: взятие HTML одной страницы и вытягивание из него конкретных полей, которые вам важны, таких как заголовок, цена или дата. Реальный конвейер делает и то и другое. Краулер решает, какие страницы посещать, а скрапер решает, что оставить с каждой из них. Скрипт в этом руководстве это краулер со скрапером, прикрученным к каждой посещаемой им странице.

Что вы построите

Единый скрипт на Python, который принимает стартовый URL, обнаруживает ссылки на статьи, следуя по ссылкам в области, получает каждую страницу через Crawling API и извлекает структурированную запись на страницу. В рабочем примере используется https://example.com как заместитель публичного списка или индекса блога. Каждая запись несёт такие поля:

  • Заголовок главный заголовок страницы.
  • URL канонический адрес, с которого была собрана запись.
  • Сводка вводный абзац или мета-описание.
  • Дата дата публикации или обновления, когда страница её показывает.
  • Ссылки количество ссылок в области, обнаруженных на странице.

Почему простой запрос часто терпит неудачу

Наивная версия этого это цикл вокруг голого HTTP-клиента: получить URL, разобрать его, поставить ссылки в очередь, повторить. Это работает на игрушечном сайте и разваливается на реальном по двум причинам.

Во-первых, отрисовка. Многие современные страницы отгружают тонкую HTML-оболочку и загружают свой настоящий контент в браузере через JavaScript и Ajax. Запросите эту оболочку обычным клиентом, и нужных вам ссылок и полей ещё нет в теле, поэтому ваш краулер ничего не обнаруживает и ничего не парсит. Во-вторых, блокировки. Сайты следят за автоматическим трафиком: диапазоны IP дата-центров, отсутствующие заголовки браузера и шаблоны запросов, срабатывающие быстрее любого человека, получают лимит частоты, блокировку по IP или CAPTCHA ещё до того, как дотянутся до контента.

Поэтому краулеру, который держится в масштабе, в каждом запросе нужны две вещи: браузер, который отрисовывает страницу, и IP, который сайт читает как настоящего посетителя. Вы можете собрать это сами из headless-браузера плюс пула вращающихся резидентных прокси, но поддержание этого стека в рабочем состоянии и есть большая часть работы. Crawling API складывает оба в один вызов: вы отправляете ему URL, он отрисовывает страницу за доверенным IP и возвращает готовый HTML, который вам остаётся разобрать.

Предварительные требования

Сначала нужно подготовить несколько вещей. Ни одна не займёт много времени.

Базовый Python. Вам стоит уметь писать и запускать скрипт и устанавливать пакеты через pip. Если сторона парсинга для вас нова, руководство по BeautifulSoup хорошо сочетается с этим туториалом.

Python 3.8 или новее. Подтвердите командой python --version. Если его нет, установите с python.org или через дистрибутив вроде Anaconda и убедитесь, что Python находится в вашем PATH.

Аккаунт Crawlbase и токен. Зарегистрируйтесь, откройте панель управления и скопируйте свой токен со страницы аккаунта. Crawlbase включает до 20 000 бесплатных запросов для старта, чего с лихвой хватит, чтобы проработать это руководство. Есть два типа токенов: обычный токен получает статический HTML, а JavaScript-токен сначала отрисовывает страницу в настоящем браузере. Используйте обычный токен для статических страниц и JavaScript-токен, когда контент загружается на стороне клиента. Относитесь к токену как к паролю и держите его вне системы контроля версий.

Настройка проекта

Создайте виртуальное окружение, чтобы зависимости оставались изолированными, затем установите две библиотеки, которые нужны краулеру.

bash
python --version

python -m venv crawler_env
source crawler_env/bin/activate

pip install crawlbase beautifulsoup4

В Windows активируйте окружение командой crawler_env\Scripts\activate вместо строки с source. Работу делают две зависимости: crawlbase это официальный клиент для Crawling API, а beautifulsoup4 разбирает возвращённый HTML, чтобы вы могли вытаскивать поля и ссылки по CSS-селектору. И json, и csv поставляются со стандартной библиотекой, поэтому для шага экспорта больше ничего не нужно.

Шаг 1: Получите страницу через Crawlbase

Начните с надёжного получения одной страницы. Импортируйте класс CrawlingAPI, инициализируйте его своим токеном и запросите стартовый URL. Проверка cb_status (legacy pc_status) от Crawlbase перед разбором делает сбои громкими, а не тихими, и даёт вам чистое место для повторной попытки.

python
import time
from crawlbase import CrawlingAPI

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})

def fetch_html(page_url, max_retries=2):
    for attempt in range(max_retries + 1):
        response = api.get(page_url)
        if response["headers"]["cb_status"] == "200":
            return response["body"].decode("utf-8")
        if attempt < max_retries:
            print(f"Retrying ({attempt + 1}/{max_retries})...")
            time.sleep(1)
    print(f"Failed: {page_url} ({response['headers']['cb_status']})")
    return None

if __name__ == "__main__":
    html = fetch_html("https://example.com")
    print(html[:500] if html else "No HTML returned")

Помощник fetch_html это основа всего краулера. Он отправляет URL через Crawlbase, повторяет попытку до двух раз с короткой паузой, когда получение терпит неудачу, и возвращает декодированный HTML при успехе или None, когда сдаётся. Запустите его командой python crawler.py, и вы должны увидеть, как печатается настоящая разметка, что подтверждает работу пути запроса до того, как вы напишете хоть один селектор. Если ваша цель загружает контент на стороне клиента, инициализируйте с JavaScript-токеном и передайте {"ajax_wait": "true", "page_wait": 5000} вторым аргументом в api.get, чтобы API дождался динамического контента перед захватом страницы.

Crawlbase Crawling API

Помощник fetch_html выше опирается на одно: каждый запрос возвращается отрисованным и с IP, которому сайт доверяет. Crawling API делает именно это. Он прогоняет страницу в настоящем браузере, когда вам это нужно, ротирует резидентные IP на стороне сервера и отдаёт вам готовый HTML, так что вы избегаете развёртывания собственного флота headless-браузеров и пула прокси. Для начала направьте его на публичную страницу на бесплатном тарифе.

Шаг 2: Извлеките ссылки на странице

Обнаружение это просто извлечение ссылок, выполняемое в цикле. Загрузите HTML в BeautifulSoup, вытяните href каждого якоря и разрешите относительные пути относительно страницы, на которой они были найдены, чтобы вы всегда работали с абсолютными URL.

python
from urllib.parse import urljoin, urldefrag
from bs4 import BeautifulSoup

def extract_links(html, base_url):
    soup = BeautifulSoup(html, "html.parser")
    links = set()
    for a in soup.select("a[href]"):
        href = a["href"].strip()
        if not href or href.startswith(("mailto:", "tel:", "javascript:")):
            continue
        absolute = urljoin(base_url, href)
        absolute, _ = urldefrag(absolute)
        links.add(absolute)
    return links

Три небольших решения делают это устойчивым. Функция пропускает якоря mailto:, tel: и javascript:, которые не являются настоящими страницами. Она использует urljoin, чтобы относительный href вроде /articles/web-data становился полным URL относительно страницы, с которой он пришёл. И она вызывает urldefrag, чтобы отбросить фрагмент #section, потому что /page и /page#top это один и тот же документ, и вы не хотите посещать оба. Возврат set дедуплицирует ссылки, найденные на этой одной странице, ещё до того, как они попадут в очередь.

Шаг 3: Держите обход в области

Не ограниченный, краулер следует по ссылкам прочь от вашего целевого сайта и никогда не останавливается. Решение это правило области: следовать только по ссылкам, которые разделяют хост стартового URL и, опционально, лежат под префиксом пути, который вам важен. Это краулерный эквивалент того, чтобы оставаться в разделе товаров, а не забредать в справочный центр.

python
from urllib.parse import urlparse

def in_scope(url, root):
    root_parts = urlparse(root)
    url_parts = urlparse(url)
    if url_parts.scheme not in ("http", "https"):
        return False
    if url_parts.netloc != root_parts.netloc:
        return False
    return url_parts.path.startswith(root_parts.path)

in_scope сравнивает каждый кандидатский URL с корнем, с которого вы начали. Он отвергает всё, что не является HTTP или HTTPS, всё на другом хосте (netloc) и всё, чей путь не начинается с корневого пути. Установите корень в https://example.com/, чтобы обойти весь хост, или в https://example.com/blog/, чтобы оставаться внутри одного раздела. Сужение области здесь это самый большой рычаг того, сколько вы получаете.

Шаг 4: Распарсите поля на каждой странице

Обнаружение говорит вам, какие страницы посещать; парсинг решает, что оставить. Вытягивайте небольшую, чётко определённую запись с каждой страницы и защищайте каждый поиск, чтобы отсутствующее поле возвращало None, а не обрушивало прогон.

python
def text_of(soup, selector):
    el = soup.select_one(selector)
    return el.get_text(strip=True) if el else None

def attr_of(soup, selector, attr):
    el = soup.select_one(selector)
    return el.get(attr) if el else None

def parse_page(html, url):
    soup = BeautifulSoup(html, "html.parser")
    summary = (
        attr_of(soup, 'meta[name="description"]', "content")
        or text_of(soup, "article p")
    )
    return {
        "url": url,
        "title": text_of(soup, "h1") or text_of(soup, "title"),
        "summary": summary,
        "date": attr_of(soup, "time[datetime]", "datetime"),
    }

Два помощника, text_of и attr_of, запрашивают один элемент и возвращают его текст или один атрибут, откатываясь к None, когда элемент отсутствует. parse_page использует цепочку запасных вариантов: для сводки он предпочитает тег meta[name="description"] и опускается к первому абзацу article, если его нет, а для заголовка берёт h1, но использует тег <title>, когда h1 не существует. Эти селекторы намеренно обобщённые, чтобы скрипт работал на примере сайта. Для реальной цели откройте страницу в инструментах разработчика браузера и замените их селекторами, которые соответствуют её фактической разметке.

Шаг 5: Соберите цикл обхода

Теперь свяжите части в один краулер в ширину. Очередь хранит URL для посещения, множество visited предотвращает получение одной и той же страницы дважды, а потолок max_pages останавливает прогон от того, чтобы идти вечно. Для каждой получаемой страницы краулер парсит запись, считает ссылки в области и ставит новые в очередь.

python
import csv
import json
import time
from collections import deque
from urllib.parse import urljoin, urldefrag, urlparse
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})

def fetch_html(page_url, max_retries=2):
    for attempt in range(max_retries + 1):
        response = api.get(page_url)
        if response["headers"]["cb_status"] == "200":
            return response["body"].decode("utf-8")
        if attempt < max_retries:
            time.sleep(1)
    return None

def extract_links(html, base_url):
    soup = BeautifulSoup(html, "html.parser")
    links = set()
    for a in soup.select("a[href]"):
        href = a["href"].strip()
        if not href or href.startswith(("mailto:", "tel:", "javascript:")):
            continue
        absolute, _ = urldefrag(urljoin(base_url, href))
        links.add(absolute)
    return links

def in_scope(url, root):
    r, u = urlparse(root), urlparse(url)
    return (
        u.scheme in ("http", "https")
        and u.netloc == r.netloc
        and u.path.startswith(r.path)
    )

def text_of(soup, selector):
    el = soup.select_one(selector)
    return el.get_text(strip=True) if el else None

def attr_of(soup, selector, attr):
    el = soup.select_one(selector)
    return el.get(attr) if el else None

def parse_page(html, url, link_count):
    soup = BeautifulSoup(html, "html.parser")
    summary = (
        attr_of(soup, 'meta[name="description"]', "content")
        or text_of(soup, "article p")
    )
    return {
        "url": url,
        "title": text_of(soup, "h1") or text_of(soup, "title"),
        "summary": summary,
        "date": attr_of(soup, "time[datetime]", "datetime"),
        "links": link_count,
    }

def crawl(start_url, max_pages=25):
    queue = deque([start_url])
    visited = set()
    records = []
    while queue and len(visited) < max_pages:
        url = queue.popleft()
        if url in visited:
            continue
        visited.add(url)
        html = fetch_html(url)
        if not html:
            continue
        found = {l for l in extract_links(html, url) if in_scope(l, start_url)}
        records.append(parse_page(html, url, len(found)))
        for link in found:
            if link not in visited:
                queue.append(link)
        print(f"[{len(visited)}/{max_pages}] {url}")
        time.sleep(2)
    return records

Это хрестоматийный обход в ширину. Множество visited это страж дедупликации на уровне обхода: URL добавляется до того, как он получен, поэтому даже если три страницы все ссылаются на одну и ту же статью, она запрашивается ровно один раз. max_pages ограничивает общую работу, фильтр области не даёт очереди заполниться внешними ссылками, а двухсекундная пауза размеряет прогон, чтобы вы не долбили сервер. Строка print даёт вам живой след прогресса, пока он работает.

Шаг 6: Дедуплицируйте и экспортируйте в JSON и CSV

Множество visited уже предотвращает получение URL дважды, но редиректы и варианты с косой чертой в конце всё же могут породить две записи, описывающие одну и ту же страницу. Финальный проход по ключу URL схлопывает их перед экспортом.

python
def dedupe(records):
    seen = {}
    for record in records:
        seen[record["url"].rstrip("/")] = record
    return list(seen.values())

def save_outputs(records):
    with open("crawl_results.json", "w") as f:
        json.dump(records, f, indent=2)
    if not records:
        return
    with open("crawl_results.csv", "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=records[0].keys())
        writer.writeheader()
        writer.writerows(records)

def main():
    records = crawl("https://example.com", max_pages=25)
    records = dedupe(records)
    save_outputs(records)
    print(f"Saved {len(records)} pages")

if __name__ == "__main__":
    main()

dedupe ключует каждую запись по её URL с убранной косой чертой в конце, поэтому /article и /article/ разрешаются в одну запись, и побеждает более поздняя запись. save_outputs записывает файл JSON и CSV, используя ключи первой записи как заголовок, давая вам данные в той форме, которую хочет ваш следующий инструмент. Добавьте эти две функции ниже цикла обхода из шага 5, и скрипт работает от начала до конца.

Как выглядит результат

Запустите полный скрипт командой python crawler.py, и вы получите одну структурированную запись на страницу, готовую для анализа, базы данных или таблицы.

json
[
  {
    "url": "https://example.com/articles/web-data",
    "title": "A Practical Guide to Web Data",
    "summary": "How teams turn public pages into clean, structured records.",
    "date": "2024-09-18",
    "links": 12
  },
  {
    "url": "https://example.com/articles/crawling-basics",
    "title": "Crawling Basics",
    "summary": "Discovery, scope, and dedupe explained from first principles.",
    "date": "2024-08-02",
    "links": 9
  }
]

Соответствующий CSV несёт те же столбцы, одну строку на страницу, что прямо ложится в pandas или любую таблицу для сортировки, фильтрации или соединения с другим датасетом. Если вы хотите продвинуть шаг хранения дальше, хранение собранных данных в облаке и загрузка их в SQL это естественные следующие шаги.

Масштабирование обхода

Скрипт выше намеренно однопоточный, чтобы его было легко читать и легко держать вежливым. Несколько изменений превращают его из демонстрации в задачу, которую можно оставить работать.

  • Поднимайте потолок осторожно. max_pages это ваш предохранительный клапан. Увеличивайте его шагами и следите, сколько ссылок в области обнаруживает обход, прежде чем решиться на большой прогон.
  • Сохраняйте фронтир. Для долгих обходов записывайте очередь и множество visited на диск, чтобы прерванный прогон возобновлялся, а не начинался заново и не перекачивал всё.
  • Переходите на асинхронность ради объёма. Когда вам нужны тысячи страниц, асинхронный Crawler ставит запросы в очередь и проталкивает результаты в вебхук, поэтому вы не держите открытыми соединения, пока страницы отрисовываются.

Для целей с обилием JavaScript, где сами ссылки загружаются на стороне клиента, тот же цикл работает, как только вы переключитесь на JavaScript-токен и опции ожидания; подробности смотрите в материале обход JavaScript-сайтов.

Как оставаться незаблокированным

Даже когда отрисовка и доверенные IP решены, несколько привычек поддерживают более длинный обход здоровым.

  • Размеряйте свои запросы. Двухсекундная пауза в цикле это пол, а не потолок. Расширяйте её для более крупных задач и избегайте обхода одного пути так быстро, как сервер будет отвечать.
  • Опирайтесь на ротацию. Пул резидентных IP распределяет запросы по множеству адресов реальных пользователей, поэтому ни один из них не срабатывает на лимите частоты. Crawling API делает это за вас; если вы строите собственный стек, это та часть, которую нужно сделать правильно.
  • Читайте коды состояния. Прогон, который начинает возвращать значения cb_status, отличные от 200, говорит вам, что текущей частоты или уровня IP уже недостаточно. Воспринимайте это как сигнал отступить, а не как шум, который можно игнорировать.

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

Ответственный скрапинг

Обходите только публичные данные и уважайте правила сайтов, которые посещаете. Прочитайте условия обслуживания каждой цели и её robots.txt, прежде чем начать, держите частоту запросов разумной, чтобы не нагружать чьи-либо серверы, и держитесь в стороне от всего, что за логином или платным доступом. Когда собираемые вами страницы содержат персональные данные, законы о приватности, такие как GDPR и CCPA, применяются к тому, как вы их храните и используете, поэтому ограничьте свои поля тем, что вам действительно нужно, и избегайте сбора деталей, привязанных к идентифицируемым лицам. Код в этом руководстве заставляет работать техническую часть; удержание проекта на правильной стороне этих линий лежит на вас.

Итоги

Ключевые выводы

  • Краулинг и скрапинг это две задачи. Краулер обнаруживает, какие страницы посещать, следуя по ссылкам; скрапер извлекает поля, которые вы оставляете с каждой из них.
  • Отрисовывайте и маршрутизируйте через доверенный IP. Обычный клиент пропускает отрисованный на клиенте контент и получает блокировку; Crawling API возвращает готовый HTML с доверенного IP в одном вызове.
  • Область и дедупликация держат обход в здравом уме. Проверка in_scope не даёт прогону забредать за пределы сайта, а множество visited плюс проход по ключу URL убирают дублирующуюся работу и дублирующиеся записи.
  • Парсите оборонительно. Защищайте каждый селектор, чтобы отсутствующее поле возвращало None и одна странная страница не завершала прогон.
  • Экспортируйте один раз, используйте где угодно. Запись и JSON, и CSV позволяет одному и тому же датасету течь в pandas, базу данных или таблицу без переделки.

Часто задаваемые вопросы

В чём разница между веб-краулингом и веб-скрапингом?

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

Почему мой краулер возвращает пустой или частичный HTML?

Обычно потому, что страница отрисовывает свой контент в браузере с помощью JavaScript, поэтому исходный HTML это тонкая оболочка, и ваших ссылок и полей в нём ещё нет. Получите страницу через Crawling API с JavaScript-токеном и опциями ajax_wait и page_wait, которые сначала отрисовывают страницу и возвращают готовую разметку, которую вам остаётся разобрать.

Как остановить краулер от ухода с сайта, на который я нацеливаюсь?

Используйте правило области. Функция in_scope сравнивает каждую кандидатскую ссылку с хостом и путём вашего стартового URL и отвергает всё, что не совпадает. Установите корневой путь узко, например https://example.com/blog/, чтобы держать обход внутри одного раздела, а не всего домена.

Как краулер избегает посещения одной и той же страницы дважды?

Два слоя. Множество visited записывает каждый URL до того, как он получен, поэтому страница, на которую ссылаются из многих мест, всё равно запрашивается только один раз. После обхода проход дедупликации по ключу URL (с нормализованной косой чертой в конце) схлопывает любые записи, которые всё ещё описывают одну и ту же страницу, до того, как они достигнут JSON и CSV.

Экспортировать в JSON или CSV?

И то и другое, и пусть решает инструмент дальше по цепочке. JSON сохраняет вложенную, типизированную форму, которую предпочитают код и API, тогда как CSV прямо ложится в таблицы и pandas. Функция save_outputs записывает оба из одних и тех же записей, поэтому вы не заперты в одном формате. Подробнее о компромиссах смотрите в материале о разнице между JSON и CSV.

Сколько страниц можно обойти на бесплатном тарифе?

Crawlbase включает до 20 000 бесплатных запросов для старта, и вы платите только за успешные запросы. Каждая страница, которую получает краулер, это один запрос, поэтому потолок max_pages в скрипте напрямую отображается на ваше использование. Для более крупных или повторяющихся задач асинхронный Crawler масштабирует тот же подход без удержания открытых соединений.

Начать создавать

Обходите любой сайт в масштабе, без борьбы с инфраструктурой.

Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда выпускала конвейеры данных вместо поддержки обвязки краулинга. 1 000 запросов бесплатно, без карты.

Самообслуживание · Звонок отдела продаж не требуется · Доступны корпоративные объёмы краулинга