За полтора месяца я собрал браузером около двухсот карточек товаров, счётчики просмотров с одиннадцати площадок и содержимое пары десятков объявлений. Данные нужны были не для продажи, а для собственной аналитики: я веду небольшой сайт и считаю по нему цифры.
Все шесть случаев ниже объединяет одно свойство. Код не упал. Он отработал, записал результат в базу, и всё выглядело правильным ровно до того момента, пока я не полез проверять руками.
421 от сервера, который в браузере открывается
Первое, на что я потратил месяц.
Каталог, из которого я собирал карточки, на обычный запрос из скрипта отвечал 421 Misdirected Request. Не 403, не 429, а именно 421 - код, который обычно означает, что запрос ушёл не на тот сервер.
Я решил, что нас забанили по адресу, и месяц собирал данные из веб-архива. Это отдельный вид мучения: архив режет частоту и после двух десятков страниц начинает отдавать отказы, а числа в нём августовские.
Потом от отчаяния я открыл тот же адрес в headless Chromium через Playwright. Ответ 200, страница целиком, за полторы секунды.
Заголовки я подделывал и раньше - User-Agent, Accept-Language, Referer. Не помогало. Дело оказалось не в них: сервер смотрит на то, как устроено само TLS-соединение и HTTP/2-рукопожатие. Порядок расширений, набор шифров, окна и приоритеты потоков у консольного клиента складываются в отпечаток, который не совпадает ни с одним живым браузером. Подделать это заголовками нельзя, проще взять настоящий браузер.
Вывод для себя записала короткий: прежде чем считать сайт закрытым, открой его headless-браузером. Одна проверка - и месяц работы по протухшим данным не случился бы.

Значение, которое пришло из бокового меню
Дальше было хуже, потому что эта ошибка тихая.
Мне нужно было снять со страницы товара величину, подписанную словом «Роялти». Первый вариант кода был такой: разбираем текст страницы построчно, находим строку, равную метке, берём следующую.
МЕТКА = re.compile(r"^роялти\s*:?\s*$", re.I) for н, строка in enumerate(строки): if МЕТКА.match(строка.strip()): return строки[н + 1]
На ста страницах отработало нормально. Потом в собранных данных мне попалась карточка, где ежемесячный платёж составлял 62 рубля.
Шестьдесят два рубля. Я пошёл смотреть страницу глазами.
В боковом меню каталога есть фильтр, и один из его пунктов называется «Без роялти». На части страниц этот блок оказывается в разметке раньше карточки товара, и мой цикл честно находил первое совпадение - в меню.
Лечится сужением области. Я перестал искать по всему тексту и начал брать самый маленький элемент, внутри которого одновременно встречаются все нужные поля:
блок = страница.evaluate("""() => { const годится = [...document.querySelectorAll('div,section,ul,table')] .filter(э => { const т = э.innerText || ''; return /Роялти/i.test(т) && /инвестиц/i.test(т) && т.length < 600; }) .map(э => ({э, n: э.innerText.length})) .sort((a, b) => a.n - b.n); return годится.length ? годится[0].э.innerText : null; }""")
Ограничение по длине тут не для красоты: без него в выборку попадает body, в котором тоже есть оба слова.
Сбор пришлось повторить с нуля. Доверия к 107 уже снятым значениям не осталось - я не мог сказать, какие из них из карточки, а какие из фильтра.
Пробел, который склеил время со счётчиком
Это классика, и я на неё наступил трижды за один день.
Числа на русскоязычных сайтах пишут с разделителем тысяч, часто неразрывным пробелом. Поэтому в регулярке появляется что-то вроде ([\d\s]+). А потом попадается страница, где рядом стоит время публикации:
сегодня в 16:32 116 просмотров
Из этой строки жадное выражение достаёт 32115. Формально всё верно: цифры и пробелы подряд, как просили.
В тот день у меня так склеились время с числом просмотров, счётчик с рейтингом (227 | ★1 превратились в 2221) и просмотры с номером страницы. Три разных парсера, одна и та же причина.
Рабочая версия выглядит так:
# пробел допустим только как разделитель тысяч, то есть если после него # ровно три цифры; слева не должно стоять ни цифры, ни двоеточия ПРОСМОТРЫ = re.compile(r"(?<![\d:])(\d{1,3}(?:[ ]\d{3})*)\s*просмотр")
Обратите внимание на символы внутри класса: там обычный пробел и неразрывный . Если оставить только обычный, половина сайтов перестанет разбираться, и это тоже выяснится не сразу.

Страница отдаёт 404 и показывает чужие цифры
Эту ловушку я знаю не по своему опыту, и рассказываю именно поэтому: она дороже всех остальных вместе взятых.
Когда пост на крупной площадке снимают, страница отвечает кодом 404. Но тело у неё не пустое: сайт рисует ленту рекомендаций, и в разметке оказываются чужие посты со своими счётчиками.
Парсер, который ищет «первый попавшийся элемент с классом счётчика», находит его у чужого материала. Знакомый рассказывал, как в их проекте после блокировки аккаунта пять статей трое суток показывали одинаковые 1,4 миллиона просмотров, растущие каждый час. Сумма в отчёте выросла с семидесяти тысяч до семи миллионов, и никто не усомнился, пока не открыл страницу руками.
С тех пор у меня два обязательных условия в каждом сборщике. Первое: проверять код ответа и при 400 и выше выходить сразу, ничего не разбирая. Второе: искать счётчик не «где-нибудь», а внутри своего объекта по идентификатору:
м = re.search(r"_(\d+)/?$", адрес.rstrip("/")) область = f'.story[data-story-id="{м.group(1)}"]' if м else ".story"
Число, которого ещё нет в узле
Счётчики почти везде рисует клиентский скрипт. Узел в разметке уже есть, а внутри пусто: значение приезжает отдельным запросом через неопределённое время.
Наивный код ставит паузу на полторы секунды. У меня примерно в половине случаев этого хватало, а в половине возвращалась пустая строка, которая дальше превращалась в ноль.
Ждать нужно не время, а состояние:
await страница.wait_for_function("""(scope) => { const root = document.querySelector(scope) || document; const v = root.querySelector('.story__views-count'); return !v || v.textContent.trim().length > 0; }""", arg=область, timeout=8000)
Сюда же относится ленивая загрузка картинок. Если снимать адреса изображений сразу после domcontentloaded, вместо ссылок получите прозрачные заглушки в один пиксель, закодированные прямо в атрибуте. Помогает прокрутка до конца страницы перед сбором - у меня это цикл из восьми колёсиков с паузой в 350 миллисекунд.
Ноль вместо пустого значения
Последнее правило самое скучное и самое дорогое.
Когда замер не удался, у кода есть соблазн вернуть ноль. Он того же типа, что и нормальный результат, с ним ничего не надо делать дальше, и функция получается красивой.
Но ноль в базе читается как «никто не смотрел». Если вы потом строите динамику, один неудачный замер навсегда останется провалом на графике, которого не было. И восстановить это нельзя: данные за вчера уже не собрать.
Поэтому у меня неудача возвращает None и не пишет строку вовсе. Прежнее значение остаётся жить, а в отчёт попадает причина:
ПРОПУСК Spark Сравнил цены входа challenge DDoS-Guard, замер пропущен ПРОПУСК Pikabu Проверил 13 компаний код 404, пост снят или недоступен
Читать второй список неприятно, зато в данных нет вранья.

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

