Есть немало страниц, за которыми стоит следить: страница цен конкурента, статус наличия товара, политика или условия использования, доска объявлений о работе, страница с примечаниями к релизу. Информация публична, но именно изменение, это сигнал, который вам важен, а обновлять вкладку вручную не масштабируется больше чем на пару страниц. Нужен скрипт, который проверяет сам и сообщает только тогда, когда что-то изменилось.

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

Что вы создадите

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

  • Фетчер получает HTML страницы через Crawling API, чтобы блокировки и рендеринг JavaScript обрабатывались автоматически.
  • Экстрактор убирает скрипты, стили, навигацию и подвалы, оставляя читаемый текст тела.
  • Отпечаток, SHA-256 хэш очищенного текста, при котором одно изменённое слово даёт полностью другое значение.
  • Хранилище, JSON-файл, сопоставляющий каждый URL с последним отпечатком, плюс последний текст для диффа.
  • Компаратор загружает предыдущий отпечаток, сравнивает и сообщает «изменилось» или «не изменилось».
  • Планировщик, цикл с интервалом сна или запись в cron, чтобы проверка запускалась самостоятельно.

Почему обычного запроса недостаточно

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

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

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

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

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

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

Python 3.10 или новее. Проверьте версию командой python --version. Код использует синтаксис type-hint str | None, для которого нужна версия 3.10. Если её нет, установите с python.org.

Аккаунт и токен Crawlbase. Зарегистрируйтесь, откройте дашборд и скопируйте токен со страницы документации аккаунта. Бесплатный тариф включает до 20 000 запросов, которых достаточно для тестирования трекера. Обращайтесь с токеном как с паролем и держите его вне системы контроля версий: код ниже считывает его из переменной окружения CRAWLBASE_TOKEN.

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

Создайте виртуальную среду, чтобы зависимости были изолированы, затем установите две сторонние библиотеки. Хэширование, хранение, планирование и диффы используют стандартную библиотеку Python (hashlib, json, time и difflib), поэтому для них ничего дополнительно устанавливать не нужно.

bash
python --version

python -m venv tracker_env
source tracker_env/bin/activate

pip install requests beautifulsoup4

На Windows активируйте среду командой tracker_env\Scripts\activate вместо строки с source. Две зависимости выполняют основную работу: requests отправляет HTTP-запрос к Crawling API, а beautifulsoup4 парсит возвращаемый HTML, чтобы извлечь читаемый текст.

Шаг 1: Загрузите страницу через Crawling API

Начните с подтверждения того, что страница вообще загружается. Функция ниже считывает токен, формирует URL запроса к Crawling API с целевой страницей в URL-кодировке, отправляет запрос и возвращает HTML. Проверка статуса ответа делает сбои явными, а не тихими.

python
import os
from urllib.parse import quote
import requests

CRAWLBASE_API_URL = "https://api.crawlbase.com"

def fetch_page(url: str, token: str | None = None) -> str:
    api_token = token or os.environ.get("CRAWLBASE_TOKEN", "")
    if not api_token:
        raise ValueError("Set CRAWLBASE_TOKEN or pass token=")
    api_url = f"{CRAWLBASE_API_URL}/?token={api_token}&url={quote(url)}"
    response = requests.get(api_url, timeout=30)
    response.raise_for_status()
    return response.text

if __name__ == "__main__":
    html = fetch_page("https://example.com")
    print(html[:300])

Запустите с установленным токеном (export CRAWLBASE_TOKEN="your_token"), и вы должны увидеть первые несколько сотен символов реального HTML страницы. Это единственное подтверждение важно: оно доказывает, что запрос достигает страницы и возвращается с контентом до того, как вы что-то строите поверх. Вызовы timeout=30 и raise_for_status() намеренны, и раздел об обработке ошибок ниже опирается на оба. Если цель рендерит контент с JavaScript, используйте вместо стандартного JavaScript-токен, чтобы страница рендерилась перед возвратом HTML.

Crawlbase Crawling API

Тот первый вызов fetch_page вернул реальный HTML без необходимости управлять хотя бы одним прокси. Crawling API берёт на себя блокировки, дросселирование и CAPTCHA-вызовы, которые топят голый цикл запросов, и ротирует через доверенные IP на стороне сервера, чтобы долгоживущий монитор продолжал получать чистый HTML для отпечатка вместо блокировки. Добавьте JavaScript-токен, и он рендерит клиентские страницы перед их возвратом. Для начала направьте его на публичную страницу в бесплатном тарифе.

Шаг 2: Извлечение и создание отпечатка контента

Сравнение «сырого» HTML ненадёжно, поэтому следующий шаг, свести страницу к читаемому тексту, а затем хэшировать его. Экстрактор загружает HTML в BeautifulSoup, убирает элементы, которые меняются без смысловой нагрузки (script, style, nav, footer), извлекает видимый текст и схлопывает пробелы, чтобы косметические изменения форматирования не регистрировались как изменения.

python
import hashlib
from bs4 import BeautifulSoup

def extract_monitorable_text(html: str) -> str:
    soup = BeautifulSoup(html, "html.parser")
    for tag in soup(["script", "style", "nav", "footer"]):
        tag.decompose()
    text = soup.get_text(separator=" ", strip=True)
    return " ".join(text.split())

def content_fingerprint(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

Отпечаток, это SHA-256 хэш очищенного текста. Хэш, строка фиксированной длины, производная от входных данных, и любое их изменение, даже один символ, даёт полностью другой вывод. Это свойство, именно то, что нужно трекеру: вместо хранения и байтового сравнения целых страниц вы храните одну 64-символьную строку на URL и сравниваете их. Сравнение быстрое, хранение минимальное, а мелкие правки всё равно фиксируются. Совместите это с нашим общим руководством по скрапингу на Python, если хотите расширить экстрактор для отслеживания конкретного региона страницы, а не всего тела.

Шаг 3: Хранение снимков и сравнение

Чтобы обнаружить изменение, инструмент должен помнить последний запуск. Состояние хранится в двух небольших JSON-файлах: snapshots.json сопоставляет каждый URL с последним отпечатком, а snapshots_text.json хранит последний извлечённый текст для показа человекочитаемого диффа при изменении. Функция загрузки возвращает пустой словарь при первом запуске вместо ошибки.

python
import json
from pathlib import Path

def load_json(path: str | Path) -> dict[str, str]:
    p = Path(path)
    if not p.exists():
        return {}
    with open(p, encoding="utf-8") as f:
        return json.load(f)

def save_json(data: dict[str, str], path: str | Path) -> None:
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)

def check_for_change(url: str, current_hash: str, snapshots: dict[str, str]) -> bool:
    previous = snapshots.get(url)
    if previous is None:
        return True
    return previous != current_hash

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

Теперь свяжите части в один запуск. Функция ниже обходит URL, загружает и вычисляет отпечаток для каждого, определяет, изменилось ли, выводит унифицированный дифф по сохранённому тексту при изменении и сохраняет обновлённое состояние в конце, чтобы следующий запуск имел что сравнивать. Дифф использует модуль стандартной библиотеки difflib, без дополнительных зависимостей.

python
import difflib

def run_once(urls: list[str], hash_path="snapshots.json",
             text_path="snapshots_text.json") -> None:
    snapshots = load_json(hash_path)
    snapshot_texts = load_json(text_path)

    for url in urls:
        html = fetch_page(url)
        text = extract_monitorable_text(html)
        if not text:
            print(f"[warn] empty text, skipping {url}")
            continue
        fingerprint = content_fingerprint(text)

        if check_for_change(url, fingerprint, snapshots):
            print(f"[changed] {url}")
            old = snapshot_texts.get(url, "")
            diff = difflib.unified_diff(
                old.split(), text.split(),
                lineterm="", n=0)
            print(" ".join(diff)[:500])
        else:
            print(f"[no change] {url}")

        snapshots[url] = fingerprint
        snapshot_texts[url] = text

    save_json(snapshots, hash_path)
    save_json(snapshot_texts, text_path)

Сохранение и отпечатка, и текста позволяет будущим запускам обнаруживать изменение и объяснять его. Отпечаток отвечает на вопрос «что-то изменилось?», а сохранённый текст позволяет difflib ответить на вопрос «что именно?». Если нужен только ответ «да/нет», можно убрать текстовый файл и оставить только карту отпечатков.

Storage beyond JSON

JSON-файлы идеальны для небольшого числа URL и легко проверяются вручную. Как только вы отслеживаете сотни страниц, замените функции загрузки и сохранения на SQLite через стандартный модуль sqlite3: он обрабатывает параллельные чтения, масштабируется на большие списки URL и хранит всё состояние в одном переносимом файле. Остальная часть скрипта не меняется.

Шаг 4: Запуск трекера по расписанию

Трекер изменений полезен только если запускается самостоятельно. Есть два чистых способа это сделать. Первый встроен в скрипт: опциональный интервальный цикл, перепроверяющий каждый URL каждые N секунд до остановки. Второй, позволить операционной системе запускать один проход по таймеру через cron. Ниже, точка входа CLI с режимами однократного запуска и интервального режима.

python
import argparse
import time

def main() -> None:
    parser = argparse.ArgumentParser(description="Website change tracker")
    parser.add_argument("urls", nargs="+", help="public URLs to monitor")
    parser.add_argument("--interval", type=float, metavar="SECONDS",
        help="re-check every SECONDS (e.g. 3600 for hourly); Ctrl+C to stop")
    args = parser.parse_args()

    while True:
        run_once(args.urls)
        if args.interval is None:
            break
        time.sleep(args.interval)

if __name__ == "__main__":
    main()

Запустите одну проверку или держите цикл с почасовым интервалом:

bash
export CRAWLBASE_TOKEN="your_token"

# one pass, then exit
python tracker.py https://example.com

# check every hour until you stop it
python tracker.py https://example.com --interval 3600

Интервальный цикл, простейший вариант, держащий процесс в одном месте, что удобно при тестировании. Для необслуживаемой production-установки cron обычно лучше: он переживает перезагрузки и не занимает терминал. Запись в crontab, запускающая один проход каждый час и добавляющая вывод в лог, выглядит так:

bash
# run at the top of every hour
0 * * * * cd /path/to/project && \
  CRAWLBASE_TOKEN=your_token \
  ./tracker_env/bin/python tracker.py https://example.com >> tracker.log 2>&1

На Windows аналогом является Планировщик задач, запускающий ту же однопроходную команду по триггеру. В любом случае уберите флаг --interval, когда таймингом управляет cron или Планировщик задач, поскольку планировщик уже обрабатывает повторение.

Как выглядит вывод

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

bash
# first run: no snapshot exists yet
[changed] https://example.com

# later run, content edited
[changed] https://example.com
--- +++ @@ -Old pricing copy +New pricing copy

# later run, nothing moved
[no change] https://example.com

Состояние на диске так же читаемо. snapshots.json, плоская карта URL к отпечатку, и это всё, что нужно для сравнения:

json
{
  "https://example.com": "3e1f9c...a7d2",
  "https://example.com/pricing": "b04c88...11ef"
}

Обработка ошибок и масштабирование

Долгоживущий монитор столкнётся со сбоями, и то, как он их обрабатывает, определяет, продолжит ли работу. Постоянно встречаются три случая. Таймауты: вызов requests.get(timeout=30) генерирует исключение, если API не отвечает вовремя, поэтому оберните загрузку и повторяйте с экспоненциальной задержкой, а не позволяйте одному медленному ответу остановить весь запуск. HTTP-ошибки: raise_for_status() превращает ответы 4xx и 5xx в исключения; логируйте статус и URL, затем пропускайте этот URL и продолжайте с остальными. Пустые извлечения: если extract_monitorable_text возвращает пустую строку, пропускайте сравнение и логируйте предупреждение вместо записи ложного изменения, что уже делает охрана if not text в run_once.

python
def fetch_with_retry(url: str, retries: int = 3,
                     backoff: float = 2.0) -> str:
    for attempt in range(retries):
        try:
            return fetch_page(url)
        except requests.exceptions.RequestException:
            if attempt < retries - 1:
                time.sleep(backoff ** attempt)
            else:
                raise

Масштабирование с одной страницы до многих следует естественно. Отслеживать больше URL, значит просто передать более длинный список в run_once. Чтобы ускорить большие списки, загружайте параллельно через concurrent.futures.ThreadPoolExecutor, так как работа ограничена I/O. Для отслеживания сотен страниц перейдите от JSON к SQLite, как описано выше. А если какие-то целевые страницы рендерят контент на стороне клиента, используйте для загрузки JavaScript-токен, чтобы страница рендерилась до извлечения: наши заметки о скрапинге JavaScript-страниц на Python объясняют, когда это необходимо.

Ответственный мониторинг изменений

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

Оставайтесь на правильной стороне источника. Отслеживайте только публичные страницы, загружаемые любым без аккаунта, и изучайте условия использования сайта и robots.txt перед запуском повторяющегося задания; относитесь к обоим как к границам собираемых данных. Держите объём запросов достаточно низким, чтобы не перегружать сервер, распределяйте проверки по целям вместо того, чтобы постоянно обращаться к одному URL, и снижайте интенсивность при ошибках или вызовах, а не повторяйте с большей настойчивостью. Если сайт предлагает официальный API или ленту изменений, предпочтите их: это путь, предназначенный сайтом для подобного использования, и он обычно более стабилен, чем парсинг HTML.

Итоги

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

  • Отпечатки лучше прямого сравнения. Хэширование очищенного текста через SHA-256 превращает «изменилась ли страница» в быстрое сравнение двух 64-символьных строк вместо целых страниц.
  • Извлекайте перед хэшированием. Удаление скриптов, стилей, навигации и подвалов и схлопывание пробелов не позволяет временным меткам и рекламным блокам вызывать ложные срабатывания.
  • Храните текст, не только хэш. Хранение последнего извлечённого текста рядом с отпечатком позволяет difflib показать точно, что именно изменилось, а не только факт изменения.
  • Блокировки происходят на уровне загрузки. Crawling API обрабатывает рендеринг и ротацию доверенных IP, поэтому долгоживущий монитор продолжает получать чистый HTML вместо блокировки.
  • Планируйте и будьте вежливы. Цикл со сном или запись в cron запускает проверку самостоятельно; соответствуйте интервалу частоте реальных изменений страницы и соблюдайте условия источника и robots.txt.

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

Почему вычислять отпечаток текста, а не сравнивать «сырой» HTML?

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

Работает ли это на JavaScript-насыщенных сайтах?

Да, с одним изменением. Используйте JavaScript-токен с Crawling API вместо стандартного. Это рендерит полную страницу в реальном браузере перед возвратом HTML, поэтому клиентский контент присутствует при извлечении текста BeautifulSoup. Без этого клиентская страница возвращает почти пустую рамку, и ваш отпечаток в итоге отслеживает рамку, а не контент.

Можно ли отслеживать несколько страниц одновременно?

Да. Передайте несколько URL в командной строке, и скрипт обрабатывает их по очереди, храня один отпечаток на URL в файле снимков. Для больших списков загружайте параллельно через concurrent.futures.ThreadPoolExecutor, поскольку работа ограничена I/O, и рассмотрите переход на SQLite, чтобы состояние масштабировалось чисто.

Как получить оповещение при изменении?

Базовый скрипт сообщает об изменениях в stdout, что достаточно, если cron отправляет вам вывод. Для реального оповещения добавьте вызов в том месте, где check_for_change возвращает True: отправьте в вебхук Slack или Discord, вышлите email через транзакционный API или обратитесь к любому HTTP-эндпоинту. Это несколько строк, добавленных в ветку изменения в run_once.

Какой вариант хранения лучше всего подходит для отслеживания сотен URL?

Замените JSON-файлы на SQLite через стандартный модуль sqlite3. Он обрабатывает параллельные чтения, масштабируется на большие списки URL и хранит всё состояние в одном переносимом файле. Меняются только функции загрузки и сохранения; логика загрузки, извлечения, вычисления отпечатка и сравнения остаётся полностью той же.

Как часто должен запускаться трекер?

Соответствуйте интервалу частоте реальных изменений страницы. Быстро меняющиеся новости и дашборды оправдывают каждые 15–60 минут; страницы цен и товаров обычно в порядке через несколько часов; политику и документацию можно проверять ежедневно или еженедельно. Запуск намного чаще, чем меняется страница, только добавляет расходы на запросы и нагрузку на источник без фиксации чего-либо, что иначе было бы пропущено.

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

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

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

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