fix(tradein/cian): 403 снимает узел с выдачи, а не гаснет в return None (#2700) #2821

Merged
bot-backend merged 2 commits from fix/2700-cian-detail-403 into main 2026-08-10 11:29:19 +00:00
Collaborator

Что установлено пробой, а не рассуждением

Различающая проба на проде 2026-08-10 11:0x UTC: один и тот же detail-URL
(ekb.cian.ru/sale/flat/332775238/), один и тот же код (build_curl_cffi_session +
те же заголовки, что в боевом providers/cian/detail.py), менялся только
прокси-узел. 4 запроса, пауза 4 с.

узел метка exit-IP статус размер маркеры defaultState
1 asocks-residential-1 46.8.110.92 403 21 564 cian_waf_block
9 asocks-mobile-1 5.227.16.0 200 617 352 нет ok
10 asocks-mobile-2 95.104.183.29 200 617 355 нет ok
11 asocks-mobile-3 95.55.49.98 200 617 407 нет ok

Отбита пара «узел × Циан», а не площадка. И не наш запрос: 200 пришли без
единой куки
— detail-страница Циана авторизации не требует, к протухшим 30.06
кукам эти 403 отношения не имели.

Почему это держалось 15 суток

403 умирал внутри провайдера в return None. Наружу не выходило ничего:

  1. curl_proxy_url видел чистый выход блока → mark_health(ok=True) на отбитый узел
    → пул продолжал выдавать его Циану;
  2. вызывающий в pipeline считал detail_failed, но не errors_count (тот растёт
    только на исключении) → detail=0/50 и errors=0 в одной строке лога;
  3. mark_done писал done.

Правки

  • providers/cian/detail.py: HTTP 403 → CianBlockedError (новый
    cian_exceptions.py, наследник ProxyBanError — тот же приём, что у
    AvitoBlockedError/DomClickBlockedError). Generic curl-слой сам делает
    mark_banned на пару «узел × cian», оба вызывающих уже считают исключение в
    errors_count, ротация/abort фазы работают как у соседей. 404 баном не
    считается
    — снятое объявление не должно снимать с выдачи здоровый узел.
    Маркеры страницы намеренно НЕ проверяются: список маркеров объясняет уже
    случившийся отказ и молчит про неизвестный (урок #2767 от 09.08).
  • scrape_runs.mark_done (обе копии, app + kit): прогон, у которого отказала
    каждая попытка целой фазы (порог 3 попытки), больше не done. Пары
    <phase>_attempted/<phase>_failed ищутся в самих counters, а не по зашитому
    списку фаз — список это ровно то место, куда забывают дописать новую фазу.
    Статус failed без диагноза (кто виноват — не установлено), как в #2625/#2764.

Замер влияния (прод, 90 суток, до мержа)

Правило переводит в failed 52 прогона из 3 292 done (1.6%): 33
cian_city_sweep* (detail 26 + houses 15, часть попадает по обеим фазам) и 11
avito_city_sweep* — про последние никто не знал. Порог в 3 попытки отсекает 2
прогона с единственной попыткой. Остальные 3 240 остаются done.

Ожидаемо и намеренно: пока фаза houses у cian-развёрток отказывает 10 из 10
(это #2767, сегодняшний прогон 3597 — тоже 10/10), ежедневный cian_city_sweep
будет failed. Это и есть предмет задачи: направление работы мертво, а суммарный
сбор 1 651 лота его прятал.

Test plan

  • tests/test_2700_cian_detail_403_node.pyкрасный на старом коде в трёх
    утверждениях: узел не банился (mark_banned не звался, fetch_detail вернул
    None) и оба финализатора писали done прод-прогону 3258 (detail 50/50 отказ).
    Прогон на origin/main (с одним лишь добавленным cian_exceptions.py, чтобы
    краснело поведение, а не отсутствие имени): 3 failed, 8 passed. На ветке —
    11 passed.
  • Полный набор backend: 4150 passed, 18 skipped.
  • ruff (конфиг проекта) + pre-commit на изменённых файлах — чисто.

Критерий приёмки, записанный ДО факта

A. Честность статуса — ближайший cian_city_sweep, 2026-08-11 ~03:00-05:30 UTC.

SELECT id, status, left(error, 80), counters
FROM scrape_runs
WHERE source LIKE 'cian_city_sweep%' AND started_at > '2026-08-11';

Принято, если прогон, у которого houses_failed = houses_attempted >= 3 (или то же
по detail), имеет status='failed' и error начинается с phase-honest-status:.
Опровергнуто, если такой прогон снова done — значит правка не доехала до образа
либо проводка оборвана.

B. Снятие отбитого узла — не раньше 2026-08-12 08:14 UTC (сейчас узел 1 и так
вне выдачи Циану: проба #2803 забанила пару до этого времени).

SELECT proxy_id, source, reason, banned_at, ban_count
FROM scrape_proxy_source_bans WHERE source = 'cian';

Принято, если после первого же 403 боевого сбора появляется строка с
reason='banned:cian' (не probe:browser), а в counters того прогона
detail_failed ≤ 3 при detail_attempted в разы больше. Опровергнуто, если снова
detail_failed = detail_attempted без строки бана.

Что этой правкой НЕ чинится (уходит владельцу)

Седьмой источник эстиматора (cian_valuation) и cian_history_backfill стоят на
куках, и это не прокси: та же проба со стороны здорового узла 10 отдала
HTTP 200 без маркеров и isAuthenticated=false — сессия мертва на стороне
Циана, а не по нашему TTL. Требуется вход владельца; цена простоя — 320 оценок,
выданных с 29.06 без седьмого источника, и 42 суток без cian_history_backfill.

Refs #2700

## Что установлено пробой, а не рассуждением Различающая проба на проде **2026-08-10 11:0x UTC**: один и тот же detail-URL (`ekb.cian.ru/sale/flat/332775238/`), один и тот же код (`build_curl_cffi_session` + те же заголовки, что в боевом `providers/cian/detail.py`), менялся **только** прокси-узел. 4 запроса, пауза 4 с. | узел | метка | exit-IP | статус | размер | маркеры | `defaultState` | |---|---|---|---:|---:|---|---| | 1 | asocks-residential-1 | 46.8.110.92 | **403** | 21 564 | `cian_waf_block` | — | | 9 | asocks-mobile-1 | 5.227.16.0 | 200 | 617 352 | нет | ok | | 10 | asocks-mobile-2 | 95.104.183.29 | 200 | 617 355 | нет | ok | | 11 | asocks-mobile-3 | 95.55.49.98 | 200 | 617 407 | нет | ok | Отбита **пара «узел × Циан»**, а не площадка. И не наш запрос: 200 пришли **без единой куки** — detail-страница Циана авторизации не требует, к протухшим 30.06 кукам эти 403 отношения не имели. ## Почему это держалось 15 суток 403 умирал внутри провайдера в `return None`. Наружу не выходило ничего: 1. `curl_proxy_url` видел чистый выход блока → `mark_health(ok=True)` на отбитый узел → пул продолжал выдавать его Циану; 2. вызывающий в `pipeline` считал `detail_failed`, но не `errors_count` (тот растёт только на исключении) → `detail=0/50` и `errors=0` в одной строке лога; 3. `mark_done` писал `done`. ## Правки - **`providers/cian/detail.py`**: HTTP 403 → `CianBlockedError` (новый `cian_exceptions.py`, наследник `ProxyBanError` — тот же приём, что у `AvitoBlockedError`/`DomClickBlockedError`). Generic curl-слой сам делает `mark_banned` на пару «узел × cian», оба вызывающих уже считают исключение в `errors_count`, ротация/abort фазы работают как у соседей. **404 баном не считается** — снятое объявление не должно снимать с выдачи здоровый узел. Маркеры страницы намеренно НЕ проверяются: список маркеров объясняет уже случившийся отказ и молчит про неизвестный (урок #2767 от 09.08). - **`scrape_runs.mark_done`** (обе копии, app + kit): прогон, у которого отказала **каждая** попытка целой фазы (порог 3 попытки), больше не `done`. Пары `<phase>_attempted`/`<phase>_failed` ищутся в самих counters, а не по зашитому списку фаз — список это ровно то место, куда забывают дописать новую фазу. Статус `failed` **без диагноза** (кто виноват — не установлено), как в #2625/#2764. ## Замер влияния (прод, 90 суток, до мержа) Правило переводит в `failed` **52 прогона из 3 292 `done` (1.6%)**: 33 `cian_city_sweep*` (detail 26 + houses 15, часть попадает по обеим фазам) и 11 `avito_city_sweep*` — про последние никто не знал. Порог в 3 попытки отсекает 2 прогона с единственной попыткой. Остальные 3 240 остаются `done`. **Ожидаемо и намеренно:** пока фаза `houses` у cian-развёрток отказывает 10 из 10 (это #2767, сегодняшний прогон 3597 — тоже 10/10), ежедневный `cian_city_sweep` будет `failed`. Это и есть предмет задачи: направление работы мертво, а суммарный сбор 1 651 лота его прятал. ## Test plan - [x] `tests/test_2700_cian_detail_403_node.py` — **красный на старом коде** в трёх утверждениях: узел не банился (`mark_banned` не звался, `fetch_detail` вернул None) и оба финализатора писали `done` прод-прогону 3258 (detail 50/50 отказ). Прогон на origin/main (с одним лишь добавленным `cian_exceptions.py`, чтобы краснело поведение, а не отсутствие имени): `3 failed, 8 passed`. На ветке — `11 passed`. - [x] Полный набор backend: `4150 passed, 18 skipped`. - [x] ruff (конфиг проекта) + pre-commit на изменённых файлах — чисто. ## Критерий приёмки, записанный ДО факта **A. Честность статуса — ближайший `cian_city_sweep`, 2026-08-11 ~03:00-05:30 UTC.** ```sql SELECT id, status, left(error, 80), counters FROM scrape_runs WHERE source LIKE 'cian_city_sweep%' AND started_at > '2026-08-11'; ``` Принято, если прогон, у которого `houses_failed = houses_attempted >= 3` (или то же по detail), имеет `status='failed'` и `error` начинается с `phase-honest-status:`. Опровергнуто, если такой прогон снова `done` — значит правка не доехала до образа либо проводка оборвана. **B. Снятие отбитого узла — не раньше 2026-08-12 08:14 UTC** (сейчас узел 1 и так вне выдачи Циану: проба #2803 забанила пару до этого времени). ```sql SELECT proxy_id, source, reason, banned_at, ban_count FROM scrape_proxy_source_bans WHERE source = 'cian'; ``` Принято, если после первого же 403 боевого сбора появляется строка с `reason='banned:cian'` (не `probe:browser`), а в counters того прогона `detail_failed` ≤ 3 при `detail_attempted` в разы больше. Опровергнуто, если снова `detail_failed = detail_attempted` без строки бана. ## Что этой правкой НЕ чинится (уходит владельцу) Седьмой источник эстиматора (`cian_valuation`) и `cian_history_backfill` стоят на куках, и это **не** прокси: та же проба со стороны здорового узла 10 отдала **HTTP 200 без маркеров и `isAuthenticated=false`** — сессия мертва на стороне Циана, а не по нашему TTL. Требуется вход владельца; цена простоя — 320 оценок, выданных с 29.06 без седьмого источника, и 42 суток без `cian_history_backfill`. Refs #2700
bot-backend added 1 commit 2026-08-10 11:14:24 +00:00
fix(tradein/cian): 403 снимает узел с выдачи, а не гаснет в return None (#2700)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 / changes (pull_request) Successful in 10s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m57s
51a53cd173
Различающая проба на проде 10.08: один и тот же detail-URL, один и тот же код,
менялся ТОЛЬКО прокси-узел. Узел 1 — HTTP 403, 21 564 байта, маркер
`cian_waf_block`; узлы 9/10/11 — HTTP 200, ~617 КБ, состояние разбирается. Отбита
пара «узел × Циан», а не площадка: те же 200 пришли без единой куки — detail-
страница авторизации не требует, и к протухшим 30.06 кукам эти 403 отношения не
имели.

Держалось это пятнадцать суток потому, что 403 умирал внутри провайдера в
`return None`: наружу не выходило ничего, пул получал mark_health(ok=True) на
отбитый узел и продолжал выдавать его Циану, а прогон писал detail_failed=50 при
errors_count=0 и статусе done.

- providers/cian/detail.py: 403 → CianBlockedError (наследник ProxyBanError, как
  AvitoBlockedError/DomClickBlockedError). Generic curl-слой сам делает
  mark_banned на пару «узел × cian», оба вызывающих в pipeline уже считают
  исключение в errors_count. 404 (снятое объявление) баном не считается.
- scrape_runs.mark_done (обе копии): прогон, у которого отказала КАЖДАЯ попытка
  целой фазы (>=3 попыток), больше не done. Пары <phase>_attempted/<phase>_failed
  ищутся в самих counters, а не по зашитому списку фаз. Замер на проде за 90
  суток: 52 прогона из 3292 done (1.6%) — 33 cian_city_sweep*, 11 avito_city_sweep*.

Тест красный на старом коде в трёх утверждениях: узел не банился, и оба
финализатора писали 'done' прогону 3258 (detail 50/50 отказ).

Refs #2700
Light1YT added 1 commit 2026-08-10 11:20:04 +00:00
fix(tradein/cian): ad-hoc ручка detail отвечает 502 на блок узла, а не 500 (#2700)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m1s
7acefc0c9d
Побочный эффект предыдущего коммита: 403 стал исключением, и POST /scrape/cian-detail
уронил бы его наружу необработанным. «Страницу не разобрали» (404) и «нас не пустили
с этого узла» (502) — разные новости для того, кто дёргает ручку руками.

Refs #2700
bot-backend merged commit 20ec6a5d33 into main 2026-08-10 11:29:19 +00:00
bot-backend deleted branch fix/2700-cian-detail-403 2026-08-10 11:29:20 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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#2821
No description provided.