{
 "№": 88,
 "заголовок": "Парсер, который не падает, а врёт: шесть случаев из моей практики",
 "площадка": "Habr",
 "статус": "Опубликовано",
 "тип": "разбор",
 "контур": "спутник · разбор",
 "рубрика": "Франшизы",
 "кластер": "",
 "волна": "П1",
 "приоритет": "P1",
 "создана": "2026-09-21",
 "выход": null,
 "просмотры": 6496,
 "ключи": [],
 "ключи_нота": "материал писался под жанр площадки, а не под поисковый запрос: кластера у него нет",
 "ключи_кластер": null,
 "ключи_замер": null,
 "описание": null,
 "ключи_из_текста": [],
 "знаков": 8658,
 "слов": 1263,
 "файл": "завод/публикации/habr-02-parser-vret.md",
 "статус_текста": "ОПУБЛИКОВАНО на Habr 21.09.2026 руководителем, статья 1084552.",
 "ссылки": {
  "опубликована": "https://habr.com/ru/articles/1084552/",
  "витрина": [],
  "документ": null,
  "документ_локальный": true,
  "страница": true
 },
 "картинки": [
  {
   "файл": "habr-02-chislo.jpg",
   "alt": "Схема: строка «сегодня в 16:32 116 просмотров» превращается выражением «цифры и пробелы подряд» в число 32115, склеенное из времени и счётчика",
   "подпись": null,
   "адрес": "данные/картинки/habr-02-chislo.jpg",
   "блок": 34,
   "обложка": false,
   "ключи": []
  },
  {
   "файл": "habr-02-curl.jpg",
   "alt": "Схема: один и тот же адрес каталога отвечает кодом 421 Misdirected Request консольному клиенту и кодом 200 настоящему браузеру Chromium",
   "подпись": null,
   "адрес": "данные/картинки/habr-02-curl.jpg",
   "блок": 12,
   "обложка": false,
   "ключи": []
  },
  {
   "файл": "habr-02-kdpv.jpg",
   "alt": "Шесть граблей при сборе данных с чужих сайтов - числа, которые парсер отдавал как настоящие, 421 вместо 200, 62 рубля вместо 12 000, 32115 вместо 116",
   "подпись": null,
   "адрес": "данные/картинки/habr-02-kdpv.jpg",
   "блок": null,
   "обложка": false,
   "ключи": []
  },
  {
   "файл": "habr-02-none.jpg",
   "alt": "Схема: два ряда столбиков за неделю. В первом на месте неудачного замера стоит ноль, и в истории появляется провал, которого не было. Во втором замера просто нет, а прежнее значение остаётся",
   "подпись": null,
   "адрес": "данные/картинки/habr-02-none.jpg",
   "блок": 54,
   "обложка": false,
   "ключи": []
  }
 ],
 "картинки_нота": null,
 "иллюстрации": {
  "слов": 1208,
  "норма": 12,
  "в_тексте": 3,
  "ключи": [],
  "повторы": []
 },
 "площадка_правила": {
  "имя": "Habr",
  "адрес": "https://habr.com/ru/",
  "как_публикуем": "руками",
  "ссылки_вес": null,
  "ссылки_правило": null,
  "картинки": null,
  "объём": null,
  "регистрация": [
   "почта",
   "никнейм",
   "пароль дважды",
   "капча",
   "согласие с соглашением и политикой"
  ],
  "телефон": "нет",
  "аккаунт": "заведён",
  "статус_площадки": "РАБОТАЕТ, ЧЕТЫРЕ МАТЕРИАЛА. 16.09.2026 первый (регулярки, 8500 просмотров - лучший результат среди всех наших площадок), 21.09.2026 второй («Парсер, который не падает, а врёт», 6200), 30.09.2026 третий - «Источник данных, который закрыт и для меня, и для веб-архива»: https://habr.com/ru/articles/1088562/ , 13.10.2026 четвёртый - «Пять тысяч строк одного списка: собираю франчайзинговую сеть 1С по открытым данным и сверяю с ЕГРЮЛ»: https://habr.com/ru/articles/1093722/ . Отказов не было ни разу, второй, третий и четвёртый материалы вышли сразу в ленту, без песочницы. Ссылку на портал здесь не ставим вовсе - не из-за прогрева, а по правилам площадки: самопиар разрешён только в корпоративном блоге",
  "ограничения": [
   "для публикации нужна карма или инвайт - новый аккаунт пишет в песочницу, оттуда выпускают модераторы",
   "аудитория враждебна к маркетинговым текстам, наказывает кармой",
   "тематика франшиз ей чужая: заходить только с разборами с цифрами и без продающего тона",
   "есть платный корпоративный блог - отдельная статья расходов, решение руководителя",
   "проверено чужим опытом: у PR-завода Adonis четыре статьи подряд не прошли модерацию, площадку припарковали как неподходящую по теме (созвон 27.08.2026, материалы/разбор-видео-27.08.md)",
   "ПРОВЕРЕНО 09.09.2026, страница о Песочнице: полноправный аккаунт даётся через Песочницу, первая статья проходит модерацию и ждёт приглашения. Точно не пройдут: пресс-релизы, рекламные материалы, СГЕНЕРИРОВАННЫЕ материалы, тексты из списков более чем наполовину и статьи, слабо относящиеся к тематике сайта. Уникальность ниже 75% - почти отказ",
   "ПРОВЕРЕНО 09.09.2026: форма на account.habr.com - почта, никнейм, пароль дважды, капча и два чекбокса. Телефон не спрашивают",
   "ЧАСТОТА ПУБЛИКАЦИЙ, из справки Хабра 17.09.2026 (раздел «Публикации»). Формальный лимит мягкий: «если пользователь опубликовал три материала с интервалами менее 24 часов, то следующий он сможет опубликовать не ранее чем через 24 часа с момента последнего». То есть до трёх статей в сутки, дальше пауза сутки. Отдельно: в хаб «Я пиарюсь» нельзя две и более публикации подряд - но мы туда и не ходим. РЕАЛЬНОЕ ОГРАНИЧЕНИЕ НЕ В ЛИМИТЕ, А В КАРМЕ. Публиковать без модерации может только полноправный аккаунт с положительной кармой, а карму ставят читатели. Аудитория наказывает кармой за маркетинговый тон, и при уходе в минус публиковать станет нельзя. Поэтому темп держим по качеству темы, а не по лимиту: одна сильная техническая статья в неделю-полторы"
  ]
 },
 "чеклист": [
  {
   "что": "Ссылка на портал в тексте",
   "готово": false
  },
  {
   "что": "Заголовок совпадает с тем, что в плане",
   "готово": true
  },
  {
   "что": "Текст написан и лежит в заводе",
   "готово": true
  },
  {
   "что": "Документ для редакции собран",
   "готово": true
  },
  {
   "что": "alt у каждой картинки",
   "готово": true
  },
  {
   "что": "Дисклеймер про доход и окупаемость, если в тексте есть такие цифры",
   "готово": true
  }
 ],
 "письмо": null,
 "размещение": [
  {
   "поле": "Хабы",
   "значение": "Python, Data Mining, Программирование, Открытые данные. ПРОВЕРЕНО 21.09.2026: живые слаги - /hubs/python/ (93 статьи на странице), /hubs/data_mining/ (96), /hubs/programming/ (98). Слаги web_scraping, parsing, open_data и testing отдают 404, то есть отдельного хаба про парсинг у площадки нет. «Открытые данные» берём тем же, что стоял у первой статьи"
  }
 ],
 "анонс": null,
 "текст_html": "<blockquote>    </blockquote>\n<h2>Парсер, который не падает, а врёт: шесть случаев из моей практики</h2>\n<p>За полтора месяца я собрал браузером около двухсот карточек товаров, счётчики просмотров с одиннадцати площадок и содержимое пары десятков объявлений. Данные нужны были не для продажи, а для собственной аналитики: я веду небольшой сайт и считаю по нему цифры.</p>\n<p>Все шесть случаев ниже объединяет одно свойство. Код не упал. Он отработал, записал результат в базу, и всё выглядело правильным ровно до того момента, пока я не полез проверять руками.</p>\n<h3>421 от сервера, который в браузере открывается</h3>\n<p>Первое, на что я потратил месяц.</p>\n<p>Каталог, из которого я собирал карточки, на обычный запрос из скрипта отвечал <code>421 Misdirected Request</code>. Не 403, не 429, а именно 421 - код, который обычно означает, что запрос ушёл не на тот сервер.</p>\n<p>Я решил, что нас забанили по адресу, и месяц собирал данные из веб-архива. Это отдельный вид мучения: архив режет частоту и после двух десятков страниц начинает отдавать отказы, а числа в нём августовские.</p>\n<p>Потом от отчаяния я открыл тот же адрес в headless Chromium через Playwright. Ответ 200, страница целиком, за полторы секунды.</p>\n<p>Заголовки я подделывал и раньше - User-Agent, Accept-Language, Referer. Не помогало. Дело оказалось не в них: сервер смотрит на то, как устроено само TLS-соединение и HTTP/2-рукопожатие. Порядок расширений, набор шифров, окна и приоритеты потоков у консольного клиента складываются в отпечаток, который не совпадает ни с одним живым браузером. Подделать это заголовками нельзя, проще взять настоящий браузер.</p>\n<p>Вывод для себя записала короткий: прежде чем считать сайт закрытым, открой его headless-браузером. Одна проверка - и месяц работы по протухшим данным не случился бы.</p>\n<figure class=\"статья-кадр\" data-файл=\"habr-02-curl.jpg\"><img src=\"данные/картинки/habr-02-curl.jpg\" alt=\"Схема: один и тот же адрес каталога отвечает кодом 421 Misdirected Request консольному клиенту и кодом 200 настоящему браузеру Chromium\" loading=\"lazy\"><figcaption>Схема: один и тот же адрес каталога отвечает кодом 421 Misdirected Request консольному клиенту и кодом 200 настоящему браузеру Chromium</figcaption></figure>\n<h3>Значение, которое пришло из бокового меню</h3>\n<p>Дальше было хуже, потому что эта ошибка тихая.</p>\n<p>Мне нужно было снять со страницы товара величину, подписанную словом «Роялти». Первый вариант кода был такой: разбираем текст страницы построчно, находим строку, равную метке, берём следующую.</p>\n<p>```python МЕТКА = re.compile(r\"^роялти\\s*:?\\s*$\", re.I)</p>\n<p>for н, строка in enumerate(строки): if МЕТКА.match(строка.strip()): return строки[н + 1] ```</p>\n<p>На ста страницах отработало нормально. Потом в собранных данных мне попалась карточка, где ежемесячный платёж составлял 62 рубля.</p>\n<p>Шестьдесят два рубля. Я пошёл смотреть страницу глазами.</p>\n<p>В боковом меню каталога есть фильтр, и один из его пунктов называется «Без роялти». На части страниц этот блок оказывается в разметке раньше карточки товара, и мой цикл честно находил первое совпадение - в меню.</p>\n<p>Лечится сужением области. Я перестал искать по всему тексту и начал брать самый маленький элемент, внутри которого одновременно встречаются все нужные поля:</p>\n<p>``<code>python блок = страница.evaluate(\"\"\"() =&gt; { const годится = [...document.querySelectorAll('div,section,ul,table')] .filter(э =&gt; { const т = э.innerText || ''; return /Роялти/i.test(т) &amp;&amp; /инвестиц/i.test(т) &amp;&amp; т.length &lt; 600; }) .map(э =&gt; ({э, n: э.innerText.length})) .sort((a, b) =&gt; a.n - b.n); return годится.length ? годится[0].э.innerText : null; }\"\"\") </code>``</p>\n<p>Ограничение по длине тут не для красоты: без него в выборку попадает <code>body</code>, в котором тоже есть оба слова.</p>\n<p>Сбор пришлось повторить с нуля. Доверия к 107 уже снятым значениям не осталось - я не мог сказать, какие из них из карточки, а какие из фильтра.</p>\n<h3>Пробел, который склеил время со счётчиком</h3>\n<p>Это классика, и я на неё наступил трижды за один день.</p>\n<p>Числа на русскоязычных сайтах пишут с разделителем тысяч, часто неразрывным пробелом. Поэтому в регулярке появляется что-то вроде <code>([\\d\\s]+)</code>. А потом попадается страница, где рядом стоит время публикации:</p>\n<p>``<code> сегодня в 16:32  116 просмотров </code>``</p>\n<p>Из этой строки жадное выражение достаёт <code>32115</code>. Формально всё верно: цифры и пробелы подряд, как просили.</p>\n<p>В тот день у меня так склеились время с числом просмотров, счётчик с рейтингом (<code>227 | ★1</code> превратились в <code>2221</code>) и просмотры с номером страницы. Три разных парсера, одна и та же причина.</p>\n<p>Рабочая версия выглядит так:</p>\n<p>```python</p>\n<h2>пробел допустим только как разделитель тысяч, то есть если после него</h2>\n<h2>ровно три цифры; слева не должно стоять ни цифры, ни двоеточия</h2>\n<p>ПРОСМОТРЫ = re.compile(r\"(?&lt;![\\d:])(\\d{1,3}(?:[  ]\\d{3})*)\\s*просмотр\") ```</p>\n<p>Обратите внимание на символы внутри класса: там обычный пробел и неразрывный <code> </code>. Если оставить только обычный, половина сайтов перестанет разбираться, и это тоже выяснится не сразу.</p>\n<figure class=\"статья-кадр\" data-файл=\"habr-02-chislo.jpg\"><img src=\"данные/картинки/habr-02-chislo.jpg\" alt=\"Схема: строка «сегодня в 16:32 116 просмотров» превращается выражением «цифры и пробелы подряд» в число 32115, склеенное из времени и счётчика\" loading=\"lazy\"><figcaption>Схема: строка «сегодня в 16:32 116 просмотров» превращается выражением «цифры и пробелы подряд» в число 32115, склеенное из времени и счётчика</figcaption></figure>\n<h3>Страница отдаёт 404 и показывает чужие цифры</h3>\n<p>Эту ловушку я знаю не по своему опыту, и рассказываю именно поэтому: она дороже всех остальных вместе взятых.</p>\n<p>Когда пост на крупной площадке снимают, страница отвечает кодом 404. Но тело у неё не пустое: сайт рисует ленту рекомендаций, и в разметке оказываются чужие посты со своими счётчиками.</p>\n<p>Парсер, который ищет «первый попавшийся элемент с классом счётчика», находит его у чужого материала. Знакомый рассказывал, как в их проекте после блокировки аккаунта пять статей трое суток показывали одинаковые 1,4 миллиона просмотров, растущие каждый час. Сумма в отчёте выросла с семидесяти тысяч до семи миллионов, и никто не усомнился, пока не открыл страницу руками.</p>\n<p>С тех пор у меня два обязательных условия в каждом сборщике. Первое: проверять код ответа и при 400 и выше выходить сразу, ничего не разбирая. Второе: искать счётчик не «где-нибудь», а внутри своего объекта по идентификатору:</p>\n<p>``<code>python м = re.search(r\"_(\\d+)/?$\", адрес.rstrip(\"/\")) область = f'.story[data-story-id=\"{м.group(1)}\"]' if м else \".story\" </code>``</p>\n<h3>Число, которого ещё нет в узле</h3>\n<p>Счётчики почти везде рисует клиентский скрипт. Узел в разметке уже есть, а внутри пусто: значение приезжает отдельным запросом через неопределённое время.</p>\n<p>Наивный код ставит паузу на полторы секунды. У меня примерно в половине случаев этого хватало, а в половине возвращалась пустая строка, которая дальше превращалась в ноль.</p>\n<p>Ждать нужно не время, а состояние:</p>\n<p>``<code>python await страница.wait_for_function(\"\"\"(scope) =&gt; { const root = document.querySelector(scope) || document; const v = root.querySelector('.story__views-count'); return !v || v.textContent.trim().length &gt; 0; }\"\"\", arg=область, timeout=8000) </code>``</p>\n<p>Сюда же относится ленивая загрузка картинок. Если снимать адреса изображений сразу после <code>domcontentloaded</code>, вместо ссылок получите прозрачные заглушки в один пиксель, закодированные прямо в атрибуте. Помогает прокрутка до конца страницы перед сбором - у меня это цикл из восьми колёсиков с паузой в 350 миллисекунд.</p>\n<h3>Ноль вместо пустого значения</h3>\n<p>Последнее правило самое скучное и самое дорогое.</p>\n<p>Когда замер не удался, у кода есть соблазн вернуть ноль. Он того же типа, что и нормальный результат, с ним ничего не надо делать дальше, и функция получается красивой.</p>\n<p>Но ноль в базе читается как «никто не смотрел». Если вы потом строите динамику, один неудачный замер навсегда останется провалом на графике, которого не было. И восстановить это нельзя: данные за вчера уже не собрать.</p>\n<p>Поэтому у меня неудача возвращает <code>None</code> и не пишет строку вовсе. Прежнее значение остаётся жить, а в отчёт попадает причина:</p>\n<p>``<code> ПРОПУСК  Spark   Сравнил цены входа   challenge DDoS-Guard, замер пропущен ПРОПУСК  Pikabu  Проверил 13 компаний  код 404, пост снят или недоступен </code>``</p>\n<p>Читать второй список неприятно, зато в данных нет вранья.</p>\n<figure class=\"статья-кадр\" data-файл=\"habr-02-none.jpg\"><img src=\"данные/картинки/habr-02-none.jpg\" alt=\"Схема: два ряда столбиков за неделю. В первом на месте неудачного замера стоит ноль, и в истории появляется провал, которого не было. Во втором замера просто нет, а прежнее значение остаётся\" loading=\"lazy\"><figcaption>Схема: два ряда столбиков за неделю. В первом на месте неудачного замера стоит ноль, и в истории появляется провал, которого не было. Во втором замера просто нет, а прежнее значение остаётся</figcaption></figure>\n<h3>Что из этого стоит забрать</h3>\n<p>Если коротко, то вот список, который я теперь держу в голове перед каждым новым сборщиком.</p>\n<p>Проверить сайт настоящим браузером, прежде чем решать, что он закрыт.</p>\n<p>Искать значение внутри блока, где лежат все нужные поля, а не по всей странице: меню и фильтры воруют совпадения.</p>\n<p>Разрешать пробел в числе только перед тремя цифрами и не забывать про неразрывный.</p>\n<p>Смотреть код ответа и привязываться к идентификатору своего объекта.</p>\n<p>Ждать появления данных, а не фиксированное время.</p>\n<p>И никогда не подменять неудачу нулём.</p>\n<p>Первые пять пунктов стоили мне по дню каждый, второй - ещё и полного пересбора. Шестой не стоил ничего, потому что я вовремя прочитал чужую историю про полтора миллиона просмотров у снятой статьи.</p>",
 "есть_текст": true
}
