The Jerusalem PostIran’s future will be decided by its people, not separatist factions - opinionESPN DeportesGuardians logra remontada y avanza a Serie de Campeonato contra RaysESPNSources: Eagles to keep retired Johnson on roster amid O-line changesPunchPolice arrest three suspected armed robbers in OyoInquirerCandon mulls calamity state due to El Niño; 5,000 rice farmers at riskUOLJustiça para Franklin, madrinha do DNASky TG24Polonia, riunione di emergenza per possibili attentati a premier e ministro DifesaThe Sydney Morning HeraldPizza ElettricaSoompiWatch: Kim Ji Yeon, Park Seo Ham, And Jang Se Hyuk Impress With Their Team Play On Set Of “Dive Into You”Football ItaliaPicture: Baggio in New York to receive Best Italian Player of All Time awardNOS Tech'Oekraïne valt olieinstallaties Rusland niet aan, als Russen ook stoppen'VilaWebMés Madrid proposa Emilio Delgado com a cap de llista del Front Ampli a Madrid, seguit de Mónica García
The Daily Newsstand · Free, Always
Sunday, October 11, 2026

Мои парсеры не сломались. Они тихо возвращали пустоту

Translate
Пустота — режим отказа по умолчанию для любого извлечения данных. И пустота не падает

Пустота — режим отказа по умолчанию для любого извлечения данных. И пустота не падает

Два парсера курсов валют в моём приложении месяцами возвращали пустоту. Не ошибку. Не падение. Ни одной строчки ни в одном логе. funta.rs и menjacnicegaga.rs в какой-то момент поменяли вёрстку, CSS-селекторы перестали матчиться, функции разбора вернули пустые списки, и экран отрисовал пустой список — совершенно корректная вещь для экрана.

Узнал я об этом, открыв собственное приложение посмотреть курс.

Я уже писал, что скрапер не падает, он возвращает файл уверенной чепухи. Та статья заканчивается признанием: настоящая защита от тихой порчи данных — это измерение, и никакого измерения у меня не было. Эта статья — про то, как я его построил. Она заметно менее красивая, чем первая, и сделала для меня в разы больше.

Фраза, вокруг которой всё держится:

Пустота — режим отказа по умолчанию для любого извлечения данных. И пустота не бросает исключений.

Все остальные классы багов о себе сообщают. Сетевая ошибка — исключение. Битый JSON — исключение. Несовпадение схемы — исключение. А querySelectorAll('.rate-row'), вернувший пустой список, — это не ошибка, это правильный ответ на вопрос о странице, где нет ни одного .rate-row. Твой код спросил, страница честно ответила, и ответом было ничего.

Всё дальше построено на этом одном предложении.

Почему это никто не ловит

Пройдём по слоям и заметим, что каждый ведёт себя корректно, пока данные исчезают.

HTTP-слой получил 200. Сайт жив, отдал страницу, страница в порядке. Сообщать не о чем.

Парсер отработал до конца и не бросил. Поискал строки, не нашёл, вернул []. Ровно то, что делает тотальная функция, когда во входе нет совпадений.

Маппер получил пустой список и выдал пустой список. Корректно.

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

UI получил пустой список и отрисовал пустое состояние. Кто-то это пустое состояние проектировал. Наверное, оно красивое. И оно неотличимо от «эта обменка ещё не выложила сегодняшний курс» — что тоже реально бывает.

Юнит-тесты проходят, и вот это самое важное. Они гоняются по фикстуре — HTML, сохранённому с сайта в день, когда парсер писался. Фикстура по определению есть та версия страницы, под которую парсер делали. Она будет проходить вечно, и будет проходить именно потому, что не способна увидеть то, что изменилось.

Вот ловушка. Тестовый набор проверяет парсер против его же собственных допущений. А истекло — допущение.

Живые тесты в CI, которые вам все запретят

Общепринятый совет однозначен: не ходите из CI в чужие сервисы. Это делает сборки флаки, отказы — невоспроизводимыми, а ваш пайплайн — зависимым от чужого аптайма. Со всем этим я согласен — для обычного случая.

Случай не обычный, и причина стоит аккуратной формулировки:

Юнит-тест утверждает, что мой код корректен. Живой тест парсера утверждает, что моё допущение о чужой странице всё ещё верно. Офлайн проверяется только одно из двух, и это не второе.

Нет такой фикстуры, которая скажет мне, что funta.rs сделал редизайн. Эта информация существует ровно в одном месте. Значит, тест идёт туда, где информация, — но отделяется от сборки, чтобы не делать её флаки:

name: Exchange parser health

# The exchange offices change their markup without warning, and when they do the
# parsers fail silently — that is exactly how funta.rs and menjacnicegaga.rs
# ended up returning nothing for months. This runs the live parser tests daily
# and opens an issue the first time one breaks.

on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:

Джоба по расписанию, а не проверка на PR. Она не может заблокировать мёрж и не может завалить релиз. Её единственная работа — заметить.

Четыре детали в этом воркфлоу, которые не были для меня очевидны, когда я его писал.

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

const { data: existing } = await github.rest.issues.listForRepo({
  owner: context.repo.owner,
  repo: context.repo.repo,
  state: 'open',
  labels: 'parsers',
});
// … if (existing.length > 0) createComment(); else issues.create();

Закрыл issue — взвёл сигнализацию заново. Оставил открытой — ежедневные отказы копятся комментариями в одном треде, и это же готовая доказательная база на тот момент, когда ты наконец сядешь чинить.

set -o pipefail. Вывод тестов прогоняется через tee, чтобы приложить его к summary запуска. Без pipefail код возврата пайплайна — это код tee, а он всегда ноль. Джоба была бы зелёной на каждом отказе. Баг в одно слово, превращающий весь воркфлоу в декорацию, — и его легко выкатить, потому что проявляется он только на пути отказа, то есть на самом непроверяемом пути.

Вывод отказа должен быть в самом уведомлении, а не по ссылке. Последние 4000 символов лога уезжают в тело issue внутрь <details>. Когда уведомление приходит, я прямо из письма вижу, сделала ли одна обменка редизайн или у моего CI просто нет сети, — не открывая ничего. Смысл в том, чтобы уронить стоимость разбора почти до нуля: мониторинг, который разбираешь медленно, — это мониторинг, который ты однажды перестанешь разбирать.

Issue говорит, куда идти чинить. В теле есть фраза о том, что парсеры лежат в lib/service/parser/ и матчат строки по коду валюты, поэтому починка — обычно правка селектора. Я написал это для себя же через полгода в одиннадцать вечера. Ты-будущий — другой человек, и он хуже информирован, чем ты-настоящий; алерт — правильное место, чтобы это компенсировать.

Проблема крупнее: робот коммитит чужой контент в master

Парсеры — мелкий случай. Приложение ещё и возит контент с собой офлайн — путеводитель на 74 статьи, каталог бизнесов, карта некурящих заведений, справочник чатов, — и все четыре набора переscrape-ятся раз в неделю и коммитятся в master ботом, без человека, читающего дифф.

Это либо хорошая идея, либо чудовищная — и зависит целиком от того, что стоит между парсингом и коммитом. А отказ, от которого я защищаюсь, — ровно тот же, что в начале статьи, только в масштабе: парсинг успешно завершается и производит почти пустые статьи, коммит проходит, и приложение уезжает с выпотрошенным путеводителем.

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

// The weekly sync workflow commits scraped third-party content without a human
// looking at it. If srb.guide changes its markup, `tool/sync_guide.dart` can
// still "succeed" while producing near-empty articles — this is the gate that
// stops that from shipping. Exits non-zero with a readable reason on failure.

И проверяет он две совершенно разные по природе вещи — вот это, по-моему, и есть переносимая идея.

Абсолютные полы ловят катастрофу

const int kMinSections     = 6;
const int kMinArticles     = 50;
const int kMinArticleChars = 200;
const int kMinTotalChars   = 500000;

Намеренно сильно ниже реальных чисел — в путеводителе 8 разделов и 74 статьи, так что обычная редактура на сайте-источнике их не задевает никогда. Они ловят полный обвал: селектор не совпал ни с чем, сайт отдал страницу логина, JSON структурно валиден и семантически пуст.

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

Относительные проверки ловят реальный отказ

/// How much smaller than the previous version the guide may get before we
/// treat it as a broken scrape rather than an edit.
const double kMaxShrinkRatio = 0.75;

Валидатор берёт предыдущий закоммиченный бандл как базовую линию и сравнивает:

if (oldChars > 0 && totalChars < oldChars * kMaxShrinkRatio) {
  problems.add('content shrank from $oldChars to $totalChars chars '
      '(more than ${((1 - kMaxShrinkRatio) * 100).round()}%)');
}
if (oldArticles > 0 && articles < oldArticles - 5) {
  problems.add('article count dropped from $oldArticles to $articles');
}
Два шлюза: абсолютные полы ловят катастрофу, сравнение с прошлой неделей ловит деградацию

Два шлюза: абсолютные полы ловят катастрофу, сравнение с прошлой неделей ловит деградацию

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

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

- name: Keep the current datasets for comparison
  run: cp "$GUIDE" /tmp/guide-baseline.json
# … парсинг …
- name: Validate the guide
  run: dart run tool/validate_guide.dart --baseline /tmp/guide-baseline.json

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

Ловушка, которая убила бы всё это: пустой коммит

В бандлах лежит отметка syncedAt. Она меняется на каждом запуске по определению. Значит, файл меняется на каждом запуске, значит git diff непустой на каждом запуске, значит бот коммитит каждый понедельник независимо от того, сдвинулось ли хоть одно слово контента.

Это не косметическая проблема. Это медленно наступающий отказ всей системы. Пятьдесят два коммита в год с текстом «content: sync bundled datasets» и без содержимого приучают тебя — совершенно правильно, — что эти коммиты являются шумом. И на той неделе, когда один из них шумом не является, ты пролистываешь его ровно так, как тебя выдрессировали.

Лечится диффом по полезной нагрузке, а не по файлу:

guide_before=$(jq -S -c '.ru' /tmp/guide-baseline.json | sha256sum | cut -d' ' -f1)
guide_after=$(jq  -S -c '.ru' "$GUIDE"                 | sha256sum | cut -d' ' -f1)
[ "$guide_before" != "$guide_after" ] && changed="$changed $GUIDE"

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

- name: Discard a no-op scrape
  if: steps.diff.outputs.changed != 'true'
  run: git checkout -- "$GUIDE" "$PLACES" "$SMOKING" "$CHATS"

А когда сдвинулось — стейджатся только реально изменившиеся файлы, чтобы набор, у которого весь дифф состоит из собственной отметки времени, в коммит не попал:

git add -- ${{ steps.diff.outputs.files }}

Теперь коммит в этой истории означает, что изменился контент. Это единственное свойство, ради которого историю вообще стоит иметь, и стоит оно примерно пятнадцати строк баша.

Чего это всё равно не ловит, и тут я хочу быть честным

Всё описанное выше детектирует отсутствие. Пусто, усохло, пропало, задублировалось. Это тот режим отказа, которому я проигрывал, и он теперь закрыт.

Ничто из этого не детектирует неверность.

Если menjacnicegaga.rs поменяет десятичный разделитель и курс 117,25 распарсится как 11725, все проверки этой статьи пройдут с отличием. Количество строк верное. Объём контента верный. Ничего не усохло. Данные уверенно и точно неверны — и будут показаны человеку, решающему, где менять деньги.

Чтобы поймать это, нужен другой инструмент: проверки диапазона на самих значениях. Курс EUR/RSD вне 110–125 — это не курс, это ошибка разбора, надевшая число. У парсеров курсов такое частично есть: живые тесты проверяют правдоподобный диапазон, и именно поэтому в парсерах важен матчинг по коду валюты. А у контента путеводителя такого нет практически совсем, потому что у вопроса «верен ли текст этой статьи?» нет дешёвого утверждения.

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

Если забирать одну вещь

Не YAML. Вот это:

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

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

Мне досталось несколько месяцев. Хочется верить, что теперь досталось бы одно утро.

Вот в чём я до сих пор не уверен, и это настоящий вопрос, а не риторический.

kMaxShrinkRatio = 0.75 — число, которое я выдумал. Достаточно свободное, чтобы обычная редактура его не задевала, и достаточно тугое, чтобы поймать пропавший раздел, — но выбрал я его на ощупь. И если сайт законно потеряет четверть контента за неделю, мой пайплайн заблокирует корректный парсинг, а я пойду переопределять собственный шлюз.

Как выбирать такой порог, не угадывая и не дожидаясь года накопленной истории? И удавалось ли кому-нибудь сделать его адаптивным, не превратив в модель, которой самой нужен мониторинг?

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.