Сайт может измениться так, что посетитель ничего не заметит. Обновлённый счётчик комментариев или атрибут трекинга меняют хеш HTML, а новое изображение в главном блоке может почти не затронуть разметку. Ни в одном из этих случаев система мониторинга не узнаёт, изменился ли на самом деле важный элемент лендинга.
Чтобы измерять то, что видят посетители, мы написали монитор на Python: он снимает отрендеренные страницы через Crawlbase, сравнивает скриншоты по индексу структурного сходства (SSIM) и просит визуальную модель описать разницу, только когда превышен порог, рассчитанный по изображению. Монитор сохраняет HTML из того же обхода для перепроверки, пишет локальные оповещения и ведёт эталон для каждой пары URL и устройства.
Код лежит в репозитории ScraperHub/visual-change-detection-with-screenshots-and-vision-models: по одному промежуточному варианту на каждый этап в steps/ и полный монитор в final/, а также тесты, конфигурация и примеры запуска по расписанию. Приведённые ниже цифры получены в небольшом живом прогоне на Hacker News и Wikipedia, а не в синтетическом бенчмарке.
- Снимите JPEG всей страницы через Crawling API и сразу скачайте его вместе с HTML из того же ответа: вместе со скриншотом HTML не сохраняется.
- Приведите оба скриншота к общей ширине, затем оцените их по SSIM и доле изменённой площади. Если любая из оценок превышает свой порог, фильтр пропускает один вызов визуальной модели.
- Считайте абзац модели лишь возможным объяснением. Доказательства здесь: скриншоты, тепловая карта и сохранённый HTML.
Почему хеши HTML не показывают, что изменилось визуально
На двух десктопных обходах Hacker News объём HTML изменился с 34 626 до 34 508 байт и префиксы SHA-256 тоже различались: 45b9d53f52b3 и efc0c606f53b. Статья о Python в Wikipedia тоже изменилась на уровне разметки: с 1 309 531 до 1 310 770 байт, с разными хешами. При этом извлечённый из Wikipedia h1 не изменился: Python (programming language).
Хеш отвечает лишь на вопрос, совпадают ли две последовательности байтов. Он не отличит сменившийся nonce от изменившегося предложения и не измеряет ни макет, ни пиксели. И наоборот, изображение может измениться без явных структурных изменений в HTML. Для мониторинга лендингов мало знать ответ на вопрос изменился ли документ? Полезнее спросить: что изменилось на отрендеренной странице и стоит ли в этом разбираться?
Тестовыми целями были главная страница Hacker News на десктопе и мобильном устройстве, а также статья Wikipedia о Python на десктопе. Для каждой пары URL и устройства ведётся свой эталонный скриншот.
Захват скриншотов с рендерингом JavaScript через Crawlbase
Монитор использует Crawling API с JavaScript token. Каждый запрос на захват передаёт screenshot=true, store=true и format=json; прокрутка и ожидание страницы позволяют отложенно загружаемому контенту появиться до того, как будет сделан скриншот. Шаг захвата формирует параметры как строки, поэтому при URL-кодировании уходит true вместо питоновского True:
# steps/02-capture/capture.py def build_options( device: str, page_wait: int, scroll_interval: int, css_click_selector: str = "", ) -> dict[str, str]: """Query params for one rendered JPEG. Strings, so urlencode sends true rather than True.""" options = { "screenshot": "true", "store": "true", "format": "json", "device": device, "scroll": "true", "scroll_interval": str(scroll_interval), "page_wait": str(page_wait), } selector = with_body_fallback(css_click_selector) if selector: options["css_click_selector"] = selector return options
В этом запуске page_wait=2000 добавлял две секунды ожидания после загрузки. scroll_interval=10 задаёт минимальное значение, которое принимает API, и это не ограничение по времени: API превращает его максимум в три прохода прокрутки до конца страницы и на каждом проходе ждёт до 40 секунд, пока страница не вырастет. Страница, которая никогда не растёт, как главная Hacker News, всё равно проводит на этапе прокрутки около 43 секунд. Необязательный параметр country не передавался, поэтому маршрутизация была автоматической.
css_click_selector позволяет закрыть баннер перед захватом. Для Wikipedia селектор .mw-cookiebanner-dismiss отправляется как .mw-cookiebanner-dismiss,body, потому что без прокрутки ненайденный селектор приводит к ошибке запроса с cb_status 595, а запасной вариант ,body совпадает всегда. При scroll=true, который этот монитор передаёт всегда, API выполняет клики внутри цикла прокрутки и просто пропускает отсутствующий элемент. Этот путь принимает один список селекторов, поэтому вместо цепочки кликов через | при прокрутке используйте один селектор.
Параметр Crawling API screenshot=true возвращает JPEG всей страницы по умолчанию. При mode=viewport, width и height масштабируют и обрезают этот снимок всей страницы точно до заданных размеров, по умолчанию от правого верхнего угла; без mode=viewport они игнорируются. Отдельный Screenshots API закрыт для новых регистраций, поэтому эта реализация остаётся на Crawling API. У Crawlbase Web MCP Server тоже есть инструмент crawl_screenshot для интерактивных снимков, но монитору, работающему по расписанию, нужен собственный воспроизводимый цикл захвата.
Первая попытка с Hacker News упёрлась в 120-секундный клиентский тайм-аут, а дополнительная попытка с прокруткой вернула HTTP 500. В успешных запусках использовалось 240 секунд: это клиентская настройка с запасом, а не требование рендерить каждую страницу по четыре минуты. Прокрутка влияет и на стоимость: запрос с прокруткой тарифицирует первые 8 секунд как один JavaScript-запрос, а каждые следующие 5 секунд как ещё один (подробности в разделе параметры рендеринга документации). При прокрутке около 43 секунд снимок страницы, которая не растёт, тарифицируется примерно как восемь JavaScript-запросов, поэтому включайте прокрутку, только если сравниваемый контент подгружается ниже первого экрана.
Шесть успешных захватов вернули cb_status 200, но их размеры сильно различались:
| Захват | Размер скриншота | Байт JPEG |
|---|---|---|
| Hacker News, десктоп, эталон | 3840×2144 | 215 392 |
| Hacker News, десктоп, сравнение | 1939×1249 | 199 382 |
| Hacker News, мобильный, эталон | 375×1777 | 175 304 |
| Hacker News, мобильный, сравнение | 1080×2324 | 159 983 |
| Wikipedia, десктоп, эталон | 1680×1050 | 198 864 |
| Wikipedia, десктоп, сравнение | 2560×1080 | 215 711 |
Запросы с рендерингом не используют окно браузера фиксированного размера, поэтому ширина снимка всей страницы может меняться от обхода к обходу, даже для одного и того же устройства. Именно этот разброс становится главным ограничением, когда ниже мы разбираем различия изображений.
Скачайте JPEG, пока его URL не истёк
Если задан format=json, ответ содержит screenshot_url в теле JSON (при формате ответа HTML он приходит в заголовке); подробности в разделе документация по ответам (там описаны оба варианта). Crawlbase указывает, что ссылка временная, и в ней содержится токен вашего аккаунта, поэтому скачивайте JPEG сразу и никогда не записывайте URL в логи и не передавайте его.
Монитор сразу скачивает JPEG в shots/{url_hash}/{device}/{timestamp}.jpg, проверяет JPEG-сигнатуру FF D8, а затем записывает его через временный файл .part и только после этого заменяет целевой файл. Кроме того, он сохраняет HTML из того же ответа в файл .html рядом с изображением, так что доказательства остаются на диске. В этих ответах ID запроса был доступен в параметре запроса rid в URL скриншота:
# steps/02-capture/capture.py def rid_from_screenshot_url(url: str) -> str: if not url: return "" values = parse_qs(urlparse(url).query).get("rid") or [] return values[0] if values else ""
Первый успешный десктопный скриншот Hacker News получил rid 907337a6a19e932cabde4165, а его URL /storage вернул JPEG. Так и задумано: для запроса скриншота Cloud Storage хранит изображение под идентификатором запроса rid и не сохраняет HTML вне зависимости от store=true. Параметр store=true сохраняет JPEG как сохранённую страницу и добавляет плату за хранение; монитор от него не зависит, потому что читает локально сохранённый HTML.
Нормализуйте скриншоты, затем вычислите SSIM
SSIM сравнивает соответствующие области изображений, поэтому у обоих входов должны быть одинаковые размеры. Шаг нормализации приводит каждый скриншот к ширине 1280 пикселей с ресэмплингом Lanczos, а затем обрезает оба до меньшей из двух высот, сохраняя верхнюю часть:
# steps/03-normalize/normalize.py shared = min(fitted_before.height, fitted_after.height) if shared < 1: raise ValueError("normalized height is empty") box = (0, 0, width, shared)
Так массивы становятся сопоставимыми, но это не делает макеты эквивалентными. Мобильные снимки Hacker News, например, масштабируются примерно до 1280×6065 и 1280×2754. SSIM измеряет только общую верхнюю часть, а разница высот в 3 311 пикселя записывается отдельно. Если между снимками сменилась контрольная точка адаптивной вёрстки, оценка смешивает изменения макета с изменениями контента.
Шаг сравнения переводит оба изображения в оттенки серого и вычисляет structural_similarity с data_range=255 и окном в семь пикселей. OpenCV вычисляет абсолютную разницу в оттенках серого, и пиксели с разницей более 24 уровней яркости учитываются в доле изменённой площади. Контуры превращаются в ограничивающие прямоугольники (двенадцать самых крупных, без прямоугольников меньше 64 квадратных пикселей), а тепловая карта подкрашивает изменённые пиксели красным и обводит прямоугольники. Проход сравнения показал:
| Цель | SSIM | Изменённая площадь | Разница нормализованной высоты |
|---|---|---|---|
| Hacker News, десктоп | 0.7934 | 8.27% | 110 px |
| Hacker News, мобильный | 0.5468 | 24.50% | 3 311 px |
| Wikipedia, десктоп | 0.5694 | 30.99% | 260 px |
Идентичная тестовая фикстура 80×80 получила SSIM не ниже 0.99 при нулевой изменённой площади. Все три живые пары разошлись гораздо сильнее, но по одним числам нельзя отличить изменённый главный блок от изменённого блока цен. Скриншоты были кадрированы неодинаково, из-за чего разброс условий захвата становится искажающим фактором.
Фильтр перед визуальной моделью на дешёвых оценках изображений
Визуальной модели не нужно разбирать каждый снимок. Сначала монитор решает, достаточно ли велики разница SSIM или доля изменённой площади, чтобы разбираться дальше, и сам фильтр намеренно компактен:
# steps/05-gate/gate.py def crosses(ssim: float, changed_area: float, ssim_delta: float, area_delta: float) -> bool: return (1.0 - ssim) >= ssim_delta or changed_area >= area_delta
Пример конфигурации, final/targets.example.toml, задаёт ssim_delta=0.04 и area_delta=0.03. Любое из двух условий открывает фильтр: структурная разница не менее 0.04 или изменённые пиксели, покрывающие не менее 3% сравниваемой площади. Это стартовые пороги из реализации и её тестов, а не пороги, проверенные на множестве спокойных продакшен-запусков.
Каждая живая пара превысила оба порога, но это не значит, что каждая страница изменилась существенно. Карусель, ротация рекламы, часы или скачок ширины снимка тоже могут открыть фильтр. В продакшене калибруйте пороги на повторных снимках, сделанных в стабильных условиях, и по возможности маскируйте заведомо динамические области.
Отрендерите страницу в настоящем браузере и получите скриншот всей страницы вместе с HTML из того же обхода, готовые к сравнению. Неудачные запросы не оплачиваются. Начните бесплатно: до 5 000 запросов, без банковской карты.
Спросите визуальную модель, что изменилось, и проверьте её ответ
Когда фильтр срабатывает, шаг объяснения отправляет более ранний и более поздний скриншоты в gpt-4o. Каждое изображение уменьшается до 1280 пикселей по длинной стороне, перекодируется в JPEG с качеством 70 и отправляется с detail="low". Промпт просит один фактический абзац только о видимых различиях и одну метку: pricing, hero, banner или other.
В проверочном прогоне использовался флаг --max-vision-calls 1, поэтому описание получило только десктопное сравнение Hacker News; мобильная пара и пара Wikipedia всё равно дали оценки и оповещения. Модель вернула other, но в её абзаце упоминались заголовки публикаций и год набора YC, которых не было ни на одном из скриншотов. Такой сбой полезно увидеть пораньше: правдоподобное объяснение не обязательно точно описывает пиксели.
Парсеру также пришлось обрабатывать случай, когда метка LABEL: other дописана в конец предложения, а не вынесена на отдельную строку. После разбора оповещение сохраняет абзац модели в исходном виде, без метки. Главными доказательствами остаются SSIM, тепловая карта и исходные скриншоты; абзац остаётся интерпретацией для проверки, а не подтверждённым журналом изменений.
Для изменений с меткой pricing, hero или banner, монитор может извлечь подтверждающий текст из HTML, сохранённого при том же захвате:
# steps/08-monitor/monitor.py if vision and needs_html(label): extracted = storage_fn( token, shot.rid, target.extract_selector, label, timeout=settings.timeout, html_path=shot.html_path, )
Несмотря на storage_fn в названии, реализация по умолчанию _extract_saved читает html_path локально. Настроенный селектор (тег, #id или .class) может извлечь элемент, например неизменившийся в Wikipedia h1; без селектора извлечение цен ищет денежную сумму, а если не находит, берёт заголовок. Это добавляет контекст, но простое извлечение не доказывает, что модель правильно определила изменение. В этом запуске единственной меткой была other, поэтому ветка с HTML не выполнялась.
Храните оповещения, обновляйте эталоны и запускайте монитор по расписанию
Каждое превышение порога создаёт JSON-оповещение с URL, устройством, SSIM, долей изменённой площади, rid, путём к тепловой карте, меткой и сгенерированным объяснением, если оно есть. Тепловая карта в PNG остаётся на диске. Если SLACK_WEBHOOK_URL задан, уведомитель публикует текстовую сводку; сам PNG он не загружает.
Тест создал три JSON-оповещения, по одному на сравнение, при незаданном вебхуке Slack. На проход по эталонам ушло 173 секунды (на проход сравнения 391 секунда). В SQLite записано шесть запусков: три начальных эталона и три сравнения. Метка и абзац от модели были только у десктопного сравнения.
Менеджер эталонов на SQLite, final/baselines.py, хранит по одному эталону на каждую пару URL и устройства в таблице baselines и ведёт историю запусков и оповещений в таблицах runs и alerts соответственно. Если задано rotate_after_n=5, последний скриншот становится эталоном после пяти сравнений, включая спокойные. Подтверждение оповещения командой --ack <id> сразу переносит эталон на скриншот этого оповещения, так что проверенное изменение становится новой точкой отсчёта.
Чтобы попробовать, клонируйте репозиторий, установите зависимости Python, скопируйте final/.env.example в final/.env и задайте CRAWLBASE_JS_TOKEN. Задайте OPENAI_API_KEY для описаний изменений от модели, а SLACK_WEBHOOK_URL оставьте пустым, если нужны только локальные оповещения. Из корня репозитория:
python -m pip install -r requirements.txt python -m pytest final python final/monitor.py --once
Запускайте монитор по расписанию через cron с флагом --once вместо постоянно работающего процесса. Пути в targets.example.toml разрешаются относительно этого файла.
Чему учит живой прогон
Конвейер сделал шесть скриншотов, выполнил три сравнения с тепловыми картами, пропустил через фильтр один запрос к визуальной модели и записал три локальных оповещения. Заодно он выявил две проблемы, которые нужно решить, прежде чем применять тот же процесс для мониторинга конкурентов или проверки визуальных регрессий.
Во-первых, зафиксируйте прямоугольник захвата до настройки порогов. Одни лишь метки устройств не дают стабильной ширины скриншотов, потому что окно рендеринга меняется. mode=viewport с фиксированными width и height даёт фиксированные размеры результата, но страница по-прежнему верстается по ширине окна рендеринга, поэтому проверяйте макет на повторных снимках, а при мониторинге всей страницы сравнивайте также ширину, масштаб и высоту контента. Держите одинаковыми время ожидания, локаль и поведение баннеров и подумайте о сравнении отдельных областей, если на всей странице есть посторонний динамический контент.
Во-вторых, считайте описание от ИИ лишь возможным объяснением. Единственный вызов визуальной модели описал детали, которые нельзя было подтвердить по скриншотам. Если оповещение важно, изучите пару «до» и «после» и тепловую карту, а затем сверьте изменения текста или цен с сохранённым HTML. Не переносите сгенерированный абзац напрямую в журнал аудита, на который будут опираться как на источник истины.
С такими мерами контроля этот процесс подходит для мониторинга лендингов конкурентов, проверки визуальных регрессий после развёртывания и сравнения одного и того же лендинга в разных странах с помощью необязательного параметра Crawlbase country (маршрутизация по стране). Для каждого сценария нужен свой стабильный эталон и пороги, откалиброванные под конкретную страницу.
Заключение
Полезный визуальный монитор разделяет захват, измерение и интерпретацию. Crawlbase даёт отрендеренные скриншоты без собственного парка браузеров; оценки SSIM и изменённой площади решают, какие снимки заслуживают внимания; визуальная модель добавляет понятное объяснение, а сохранённый HTML, тепловые карты и ID запросов позволяют проверить каждое оповещение.
Эксперимент показывает, что конвейер работает, но не доказывает, что его первые пороги готовы к продакшену. Именно стабильная геометрия захвата и объяснения, подкреплённые доказательствами, превращают различия скриншотов в практичное обнаружение изменений. Полная реализация на Python готова к адаптации: создайте аккаунт Crawlbase и запустите этап скриншотов на собственных публичных страницах, а на странице тарифов Crawlbase смотрите цены на захват и рендеринг.
Часто задаваемые вопросы (FAQ)
Чем визуальное обнаружение изменений отличается от сравнения хешей HTML?
Хеш HTML фиксирует любое различие разметки на уровне байтов, включая невидимые. Визуальное обнаружение сравнивает отрендеренные скриншоты, поэтому выявляет изменения макета, изображений и оформления. Сочетание обоих подходов связывает видимое различие с контентом, который за ним стоит.
Может ли Crawlbase снимать лендинги, активно использующие JavaScript?
Да. Crawling API рендерит страницы в браузере, если вы используете JavaScript token (или Normal token с javascript=true), а его page_wait, прокрутка и клики подготавливают динамический контент до того, как screenshot=true сделает снимок страницы.
Всегда ли более низкий SSIM говорит о важном изменении страницы?
Нет. SSIM измеряет структурное сходство между выровненными массивами изображений. Разная ширина области просмотра, адаптивная вёрстка, ротация контента и другие различия в условиях захвата могут снизить оценку, даже если предложение или сообщение не изменились.
Как сократить число ложных срабатываний при мониторинге скриншотов?
Держите стабильными устройство и размеры захвата, стандартизируйте ожидание рендеринга, закрывайте временные оверлеи каждый раз одинаково и по возможности исключайте динамические области. Задавайте пороги по нескольким спокойным снимкам каждой цели, а не полагайтесь на универсальную оценку.
Зачем ставить порог SSIM перед визуальной моделью?
Сравнение изображений дешёвое и детерминированное по сравнению с вызовом модели. Фильтр пропускает рутинные снимки, не превышающие пороги, сокращая использование модели и при этом позволяя ей объяснять потенциально значимые изменения. Превышение порога не гарантирует, что изменение существенно.
Как автоматизировать оповещения и обновлять эталон?
Запускайте python final/monitor.py --once из cron или другого планировщика. Монитор хранит локальные JSON-оповещения, при необходимости публикует сводки в Slack и держит эталон в SQLite. Эталон обновляется после заданного числа сравнений или сразу после подтверждения оповещения.
Обходите любой сайт в масштабе, без борьбы с инфраструктурой.
Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда занималась конвейерами данных, а не поддержкой инфраструктуры обхода. До 5 000 запросов бесплатно, карта не нужна.
