fix(tradein/scrapers): детекция дрейфа разметки вместо тихой пустоты #2535

Merged
lekss361 merged 2 commits from fix/tradein-audit-scrapers into main 2026-07-26 22:33:55 +00:00
Owner

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

1. Обход города Авито обнулялся без тревоги

avito/serp.py — при смене DOM-маркера карточки обход возвращал ноль лотов, и это было неотличимо от «объявлений действительно нет».

Добавлена перепроверка по независимому сигналу (_extract_total_count — счётчик на странице либо маркер «ничего не найдено»). Применено в трёх местах: обход города, обход по комнатности и обход с делением по цене. Если первая страница дала ноль карточек, а маркера «ничего не найдено» нет — это ошибка парсера, поднимается существующий AvitoContentBlockedError (механизм уже был, но только в одном из путей).

Порог намеренно строгий, ноль против больше нуля: страницы после первой с нулём карточек — это нормальный конец пагинации, ломать его нельзя.

2. Расхождение счётчика и выдачи ЦИАН

cian/serp.pytotalOffers и results.offers берутся из одного блоба состояния. Если счётчик больше нуля, а список пуст — это внутреннее противоречие ответа, то есть регрессия схемы. Поднято до ERROR с отправкой в трекер.

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

3. Дата публикации на границе года

avito/detail.py — объявлению от декабря, прочитанному в январе, проставлялся текущий год без отката. Свежесть лота завышалась. Теперь если дата получилась в будущем относительно момента разбора — год откатывается назад.

4. Проверка сессии ЦИАН смешивала три причины

cian_session.py — смена вёрстки, ошибка сервера и сетевой сбой одинаково трактовались как «куки протухли», из-за чего оператору уходил ложный сигнал перезалить куки. Разведено на три состояния: источник недоступен (5xx или сетевой сбой), разметка изменилась (200, но структура не найдена) и собственно разлогин. None теперь означает только реальный разлогин.

⚠️ Незакрытый хвост, о котором надо знать

Задача ограничивала правки пакетом скраперов и cian_session.py. Реальные вызывающие verify_session()product_handlers.py::_cian_pre_claim (главный сценарий из аудита: именно он шлёт «cookies expired, перезалейте») и три места в admin.py — оказались вне разрешённых границ, и агент их намеренно откатил.

Следствие: контракт исправлен и покрыт тестами, но вызывающие пока проверяют только if state is None. Новые сигналы truthy, поэтому они проваливаются в ветку «аутентифицировано» — это лучше прежнего поведения (ложный алерт про куки больше не уходит), но точной диагностики на стороне вызывающего пока нет. Нужен отдельный follow-up PR, чтобы _cian_pre_claim реально различал причины.

Test plan

  • 19 новых тестов + 2 обновлённых: детекция дрейфа во всех трёх путях обхода, расхождение счётчиков, откат года через границу декабрь-январь плюс три контрольных случая, три состояния сессии
  • uv run pytest -q — 2649 passed, 8 skipped
  • uv run ruff check с проектным конфигом — чисто
  • Живые источники не дёргались, только синтетические фикстуры. Если Авито или ЦИАН уже сменили вёрстку после последнего фикса, сами регулярки могут требовать обновления — но теперь это будет видно, а не проглатываться молча
Правки по аудиту. Общая тема: **скрапер возвращает пустоту вместо сигнала об ошибке**, а продукт продолжает считать оценку по остаткам данных, не зная, что источник отвалился. ## 1. Обход города Авито обнулялся без тревоги `avito/serp.py` — при смене DOM-маркера карточки обход возвращал ноль лотов, и это было неотличимо от «объявлений действительно нет». Добавлена перепроверка по независимому сигналу (`_extract_total_count` — счётчик на странице либо маркер «ничего не найдено»). Применено в трёх местах: обход города, обход по комнатности и обход с делением по цене. Если первая страница дала ноль карточек, а маркера «ничего не найдено» нет — это ошибка парсера, поднимается существующий `AvitoContentBlockedError` (механизм уже был, но только в одном из путей). Порог намеренно строгий, ноль против больше нуля: страницы после первой с нулём карточек — это нормальный конец пагинации, ломать его нельзя. ## 2. Расхождение счётчика и выдачи ЦИАН `cian/serp.py` — `totalOffers` и `results.offers` берутся из **одного** блоба состояния. Если счётчик больше нуля, а список пуст — это внутреннее противоречие ответа, то есть регрессия схемы. Поднято до ERROR с отправкой в трекер. Порог тоже строгий: функция разбора не знает номер страницы, поэтому дробный порог ложно срабатывал бы на легитимной последней неполной странице. ## 3. Дата публикации на границе года `avito/detail.py` — объявлению от декабря, прочитанному в январе, проставлялся текущий год без отката. Свежесть лота завышалась. Теперь если дата получилась в будущем относительно момента разбора — год откатывается назад. ## 4. Проверка сессии ЦИАН смешивала три причины `cian_session.py` — смена вёрстки, ошибка сервера и сетевой сбой одинаково трактовались как «куки протухли», из-за чего оператору уходил ложный сигнал перезалить куки. Разведено на три состояния: источник недоступен (5xx или сетевой сбой), разметка изменилась (200, но структура не найдена) и собственно разлогин. `None` теперь означает **только** реальный разлогин. ## ⚠️ Незакрытый хвост, о котором надо знать Задача ограничивала правки пакетом скраперов и `cian_session.py`. Реальные вызывающие `verify_session()` — `product_handlers.py::_cian_pre_claim` (главный сценарий из аудита: именно он шлёт «cookies expired, перезалейте») и три места в `admin.py` — оказались вне разрешённых границ, и агент их намеренно откатил. Следствие: контракт исправлен и покрыт тестами, но вызывающие пока проверяют только `if state is None`. Новые сигналы truthy, поэтому они проваливаются в ветку «аутентифицировано» — это **лучше** прежнего поведения (ложный алерт про куки больше не уходит), но точной диагностики на стороне вызывающего пока нет. Нужен отдельный follow-up PR, чтобы `_cian_pre_claim` реально различал причины. ## Test plan - [x] 19 новых тестов + 2 обновлённых: детекция дрейфа во всех трёх путях обхода, расхождение счётчиков, откат года через границу декабрь-январь плюс три контрольных случая, три состояния сессии - [x] `uv run pytest -q` — 2649 passed, 8 skipped - [x] `uv run ruff check` с проектным конфигом — чисто - [ ] Живые источники не дёргались, только синтетические фикстуры. Если Авито или ЦИАН уже сменили вёрстку после последнего фикса, сами регулярки могут требовать обновления — но теперь это будет видно, а не проглатываться молча
lekss361 added 2 commits 2026-07-26 20:58:40 +00:00
Общая тема: скрапер тихо возвращал пустоту вместо сигнала об ошибке, продукт
считал оценку по остаткам данных, не зная что источник отвалился.

1. avito serp.py (medium): citywide/byrooms/exhaustive sweep больше не путает
   DOM-drift с "объявлений нет". _is_unexpected_empty_page() перепроверяет
   независимый сигнал (_extract_total_count — счётчик page-title/count либо
   no-results маркер): page=1 c 0 карточками и БЕЗ no-results маркера →
   AvitoContentBlockedError (переиспользует существующий механизм #754/#779).
   Exhaustive-бакеты (_paginate_leaf_bucket) получают expected_total из probe —
   total>0 но 0 карточек после пагинации всех страниц тоже поднимает ошибку.
   page>1 c 0 карточками остаётся graceful end-of-pagination (не regressed).

2. cian serp.py (low): _parse_serp_html сравнивает totalOffers vs
   results.offers из ОДНОГО state-блоба. totalOffers>0 при пустом offers —
   report как schema regression (elevated до ERROR + Glitchtip через новый
   _report_schema_regression(), общий с существующей offer-level охраной).
   Порог 0-vs->0 выбран т.к. это единственный позиционно-независимый сигнал:
   функция не знает номер страницы, дробный порог ложно сработал бы на
   легитимной последней частичной странице exhaustive-пагинации.

3. avito detail.py (low): publish_date больше не завышает свежесть лота на
   границе года. Если "текущий год + месяц/день" даёт дату в будущем
   относительно момента парсинга (декабрьское объявление читается в январе) —
   откатываем на год назад.

4. cian_session.py (low): verify_session больше не конфлирует expired/
   source-down/markup-changed. Новые VERIFY_SOURCE_UNAVAILABLE_SENTINEL (5xx,
   network/timeout exception) и VERIFY_MARKUP_CHANGED_SENTINEL (HTTP 200 но
   header-frontend auth-state не распарсился) отделены от None (единственный
   сигнал реального логаута: 401 или isAuthenticated=false). Callers
   (product_handlers.py::_cian_pre_claim, admin.py) НЕ обновлены — вне
   разрешённого scope этого PR (только cian_session.py + scraper-kit),
   отдельный follow-up.

Тесты: 19 новых (8 avito sweep drift, 6 cian totalOffers mismatch, 5 date
rollover) + обновлены 2 существующих (cian_session markup-changed вместо
None) под новый, более точный контракт. Полный backend suite:
2649 passed, 8 skipped, 1 deselected (без регрессий).
Merge remote-tracking branch 'forgejo/main' into fix/tradein-audit-scrapers
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 5m8s
CI Trade-In / changes (pull_request) Successful in 16s
CI / changes (pull_request) Successful in 15s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
77dafed63e
lekss361 merged commit 35f5c3426b into main 2026-07-26 22:33:55 +00:00
lekss361 deleted branch fix/tradein-audit-scrapers 2026-07-26 22:33:55 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2535
No description provided.