fix(tradein/scraper-kit): актуализация отпечатка Авито — impersonate, Sec-Fetch-Site, referer, проверка антибот-кук #3038

Merged
lekss361 merged 4 commits from fix/3034-avito-fingerprint into main 2026-08-21 20:45:26 +00:00
Owner

Замер живого браузера 2026-08-21 (владелец прошёл маршрут Яндекс → выдача → карточка → телефон, сеть снималась и сверялась с кодом) разошёлся с тем, что шлёт прогретая curl_cffi-сессия. Четыре связанные правки; у одной широкий blast radius.

Разбор целиком — в волте: research/Avito_Live_Browser_Recon_0821.md. Базовая точка для замера — комментарий в #3034.

Что изменено

1. impersonate сведён к единому источнику. Был захардкожен в 9+ местах; живой Chrome шлёт версию 151, мы заявляли 120 — разрыв в 31 мажорную версию и в TLS-отпечатке, и в client hints. DEFAULT_IMPERSONATE в providers/_base.pychrome146 (максимум, доступный в установленном curl_cffi 0.15.0).

Алиас "chrome" намеренно не используется: он поедет сам при апгрейде библиотеки, и поведение изменится без ревью. Guard-тест грепает всё дерево scraper_kit на литерал chrome120.

Попутно вычищены литералы, которых не было в постановке: cian/valuation.py, yandex/valuation.py, докстринг cian/session.py и один лишний в backend/app/tasks/avito_detail_backfill.py.

2. Sec-Fetch-Site — правка с широким охватом. DOCUMENT_HEADERS ставил "none" на уровне сессии, а Referer добавлялся per-request, и curl_cffi мёржит per-request поверх session-level. У настоящего Chrome живого Referer рядом с none не бывает никогда: none означает «адрес введён руками», и Referer в этом случае отсутствует по определению.

Добавлена referer_headers() с Sec-Fetch-Site: "cross-site" для вызывающих с чужедоменным Referer — это прогрев Авито (ya.ru/yandex.ruavito.ru). Значение "none" в общем словаре осталось дефолтом для запросов без Referer, что корректно.

Внутренний same-origin Referer в fetch_detail (avito-search → avito-detail) не тронут — там cross-site был бы неверен. В коде оставлено обоснование.

3. Referer прогрева yandex.ruya.ru — реальный переход из яндексовой выдачи даёт именно ya.ru.

ysclid сознательно не добавлен: значение выпускает Яндекс, подделка может выйти хуже отсутствия. Отложено осознанно, комментарий в коде.

4. Прогрев не проверял, что антибот-куки пришли. Сессия считалась готовой по HTTP-статусу и отсутствию firewall-маркеров, после чего detail-батч на непрогретой сессии сжигал прокси. Теперь warm_up_session/research_in_session поднимают AvitoWarmupCookiesMissingError — наследник AvitoBlockedError, поэтому существующие except (AvitoBlockedError, AvitoRateLimitedError), ban_kind_of_exception и ротация прокси подхватывают без изменений.

Префиксы ("__zzatw-", "cfidsw-") вынесены в константу с пометкой, что сняты с живого браузера 2026-08-21 и могут протухнуть.

5. Пол curl-cffi поднят 0.7.0 → 0.15.0 (отдельный коммит, найдено при ревью ветки). Оба pyproject продолжали объявлять >=0.7.0, где профиля chrome146 не существует. Текущий прод не пострадал бы — там 0.15.0, — но любая пересборка окружения с резолвом к нижней границе положила бы каждый запрос всех трёх скрапперов на невалидном impersonate, и диагностировалось бы это как «скрапперы легли», а не как несогласованная зависимость.

Проверено

  • uv run pytest -q -rs (backend, полный сьют) — 4672 passed, 31 skipped (все skip — отсутствие локальной Postgres/native deps, к правке не относятся)
  • uv run ruff check . — чисто
  • Новые тесты: guard на литерал chrome120 по всему дереву; 11 тестов на raise/no-raise проверки кук и на проводку Sec-Fetch-Site
  • Расширен test_scraper_kit_providers_base.py

Отдельно к сведению: CI не гоняет ruff по packages/scraper-kit — pre-existing gap, в scope этого PR не входит, проверено вручную теми же правилами.

⚠️ Мержить и замерять поштучно

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

Конфаунд: владелец сменил IP на всех прокси около 12:30 UTC 2026-08-21. Эффект уже наблюдался — ручной прогон avito_city_sweep в 12:53 прошёл done (128 объявлений, 120 новых) после двух подряд IP banned. Поэтому сравнивать надо не с 21-дневным средним, а с окном после смены прокси и до мержа, иначе улучшение от новых IP запишется на счёт кода.

Статистику набирать несколько суток: городские свипы ходят раз в 1-3 дня, full_load — раз в неделю. Один зелёный прогон ничего не доказывает.

SELECT source,
  count(*) AS runs,
  count(*) FILTER (WHERE status='banned') AS banned,
  count(*) FILTER (WHERE status='failed') AS failed,
  count(*) FILTER (WHERE status='done')   AS done,
  round(100.0 * count(*) FILTER (WHERE status IN ('banned','failed'))
        / nullif(count(*),0), 1) AS pct_bad,
  count(*) FILTER (WHERE ban_kind='platform') AS ban_platform,
  count(*) FILTER (WHERE ban_kind='infra')    AS ban_infra
FROM scrape_runs
WHERE (source LIKE 'avito%' OR source LIKE 'cian%' OR source LIKE 'yandex%')
  AND started_at >= :since AND started_at < :until
GROUP BY source ORDER BY runs DESC;

Refs #3034

Замер живого браузера 2026-08-21 (владелец прошёл маршрут Яндекс → выдача → карточка → телефон, сеть снималась и сверялась с кодом) разошёлся с тем, что шлёт прогретая curl_cffi-сессия. Четыре связанные правки; у одной широкий blast radius. Разбор целиком — в волте: `research/Avito_Live_Browser_Recon_0821.md`. Базовая точка для замера — комментарий в #3034. ## Что изменено **1. `impersonate` сведён к единому источнику.** Был захардкожен в 9+ местах; живой Chrome шлёт версию **151**, мы заявляли **120** — разрыв в 31 мажорную версию и в TLS-отпечатке, и в client hints. `DEFAULT_IMPERSONATE` в `providers/_base.py` → **`chrome146`** (максимум, доступный в установленном curl_cffi 0.15.0). Алиас `"chrome"` намеренно **не** используется: он поедет сам при апгрейде библиотеки, и поведение изменится без ревью. Guard-тест грепает всё дерево `scraper_kit` на литерал `chrome120`. Попутно вычищены литералы, которых не было в постановке: `cian/valuation.py`, `yandex/valuation.py`, докстринг `cian/session.py` и один лишний в `backend/app/tasks/avito_detail_backfill.py`. **2. `Sec-Fetch-Site` — правка с широким охватом.** `DOCUMENT_HEADERS` ставил `"none"` на уровне сессии, а Referer добавлялся per-request, и curl_cffi мёржит per-request поверх session-level. У настоящего Chrome живого Referer рядом с `none` не бывает никогда: `none` означает «адрес введён руками», и Referer в этом случае отсутствует по определению. Добавлена `referer_headers()` с `Sec-Fetch-Site: "cross-site"` для вызывающих с чужедоменным Referer — это прогрев Авито (`ya.ru`/`yandex.ru` → `avito.ru`). Значение `"none"` в общем словаре осталось дефолтом для запросов **без** Referer, что корректно. Внутренний same-origin Referer в `fetch_detail` (avito-search → avito-detail) **не тронут** — там `cross-site` был бы неверен. В коде оставлено обоснование. **3. Referer прогрева** `yandex.ru` → `ya.ru` — реальный переход из яндексовой выдачи даёт именно `ya.ru`. `ysclid` сознательно **не** добавлен: значение выпускает Яндекс, подделка может выйти хуже отсутствия. Отложено осознанно, комментарий в коде. **4. Прогрев не проверял, что антибот-куки пришли.** Сессия считалась готовой по HTTP-статусу и отсутствию firewall-маркеров, после чего detail-батч на непрогретой сессии сжигал прокси. Теперь `warm_up_session`/`research_in_session` поднимают `AvitoWarmupCookiesMissingError` — наследник `AvitoBlockedError`, поэтому существующие `except (AvitoBlockedError, AvitoRateLimitedError)`, `ban_kind_of_exception` и ротация прокси подхватывают без изменений. Префиксы `("__zzatw-", "cfidsw-")` вынесены в константу с пометкой, что сняты с живого браузера 2026-08-21 и могут протухнуть. **5. Пол `curl-cffi` поднят 0.7.0 → 0.15.0** (отдельный коммит, найдено при ревью ветки). Оба `pyproject` продолжали объявлять `>=0.7.0`, где профиля `chrome146` не существует. Текущий прод не пострадал бы — там 0.15.0, — но любая пересборка окружения с резолвом к нижней границе положила бы **каждый** запрос всех трёх скрапперов на невалидном impersonate, и диагностировалось бы это как «скрапперы легли», а не как несогласованная зависимость. ## Проверено - `uv run pytest -q -rs` (backend, полный сьют) — **4672 passed, 31 skipped** (все skip — отсутствие локальной Postgres/native deps, к правке не относятся) - `uv run ruff check .` — чисто - Новые тесты: guard на литерал `chrome120` по всему дереву; 11 тестов на raise/no-raise проверки кук и на проводку `Sec-Fetch-Site` - Расширен `test_scraper_kit_providers_base.py` Отдельно к сведению: CI не гоняет ruff по `packages/scraper-kit` — pre-existing gap, в scope этого PR не входит, проверено вручную теми же правилами. ## ⚠️ Мержить и замерять поштучно Правки независимы, и если влить их одним куском, мы не узнаем, что сработало. Есть реальный риск построить дорогую имитацию браузера там, где хватило бы смены версии профиля. **Конфаунд:** владелец сменил IP на всех прокси около 12:30 UTC 2026-08-21. Эффект уже наблюдался — ручной прогон `avito_city_sweep` в 12:53 прошёл `done` (128 объявлений, 120 новых) после двух подряд `IP banned`. Поэтому сравнивать надо **не** с 21-дневным средним, а с окном после смены прокси и до мержа, иначе улучшение от новых IP запишется на счёт кода. Статистику набирать несколько суток: городские свипы ходят раз в 1-3 дня, `full_load` — раз в неделю. Один зелёный прогон ничего не доказывает. ```sql SELECT source, count(*) AS runs, count(*) FILTER (WHERE status='banned') AS banned, count(*) FILTER (WHERE status='failed') AS failed, count(*) FILTER (WHERE status='done') AS done, round(100.0 * count(*) FILTER (WHERE status IN ('banned','failed')) / nullif(count(*),0), 1) AS pct_bad, count(*) FILTER (WHERE ban_kind='platform') AS ban_platform, count(*) FILTER (WHERE ban_kind='infra') AS ban_infra FROM scrape_runs WHERE (source LIKE 'avito%' OR source LIKE 'cian%' OR source LIKE 'yandex%') AND started_at >= :since AND started_at < :until GROUP BY source ORDER BY runs DESC; ``` Refs #3034
lekss361 added 2 commits 2026-08-21 14:07:04 +00:00
Живой замер браузера 2026-08-21 разошёлся с тем, что шлёт прогретая сессия:

- impersonate="chrome120" был захардкожен в 9+ местах (avito/{serp,detail,imv,
  houses}.py, pipeline.py x4, cian/valuation.py, yandex/valuation.py) вместо
  единой DEFAULT_IMPERSONATE (providers/_base.py). Обновление до chrome146
  (макс. доступный профиль curl_cffi 0.15.0; alias "chrome" НЕ используется -
  едет сам при апгрейде библиотеки без ревью) теперь меняется в одном месте.
  Guard-тест backend/tests/test_impersonate_single_source.py грепает всё
  дерево scraper_kit на литерал "chrome120".

- DOCUMENT_HEADERS ставил Sec-Fetch-Site="none" на уровне сессии, а Referer
  добавлялся per-request (curl_cffi мёржит per-request headers поверх
  session-level) - живой Referer с "none" рядом не бывает у настоящего
  Chrome. Добавлен referer_headers() (Sec-Fetch-Site="cross-site") для
  caller'ов с чужедоменным Referer; avito warm-up (yandex/ya.ru -> avito)
  теперь его использует. Внутренний avito-search -> avito-detail Referer
  (fetch_detail, same-origin) НЕ тронут - отдельный явный комментарий почему.

- Referer прогрева заменён с yandex.ru на ya.ru (живой переход из выдачи
  даёт короткий домен). ysclid сознательно не добавлен - значение выпускает
  Яндекс, подделка хуже отсутствия.

- warm_up_session/research_in_session считали прогрев успешным по HTTP-
  статусу и отсутствию firewall-маркеров, не проверяя антибот-cookies
  (__zzatw-*/cfidsw-*, сняты с живого браузера) - detail-батч на такой
  "прогретой" сессии сжигал прокси на обречённых 403. Теперь поднимают
  AvitoWarmupCookiesMissingError (наследник AvitoBlockedError - существующие
  except-блоки/ban_kind/proxy-ротация в pipeline и backfill ловят без
  изменений).

Полный backend pytest suite зелёный (4672 passed), ruff check чист.

Refs #3034
fix(tradein/scraper-kit): поднять пол curl-cffi до 0.15.0 — chrome146 в 0.7.0 не существует
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
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 / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m27s
4ec3b79704
DEFAULT_IMPERSONATE переведён на chrome146, но оба pyproject продолжали
объявлять `curl-cffi>=0.7.0`. В 0.7.0 такого профиля нет: установка по
нижней границе — и curl_cffi роняет КАЖДЫЙ запрос скраппера на невалидном
impersonate, причём одинаково для avito, cian и yandex.

На проде стоит 0.15.0 (проверено в контейнере tradein-scraper: профили
chrome99 … chrome146 плюс алиас chrome), поэтому текущий деплой бы не
пострадал — но любая пересборка окружения с резолвом к нижней границе
воспроизвела бы отказ, и диагностировался бы он как «скрапперы легли»,
а не как несогласованная зависимость.

Refs #3034
Author
Owner

⚠️ Ловушка замера: доля banned сразу после мержа может ВЫРАСТИ — и это не регрессия

Правка №4 (проверка антибот-кук) меняет не поведение сети, а честность отчётности. До неё непрогретая сессия считалась готовой и шла дальше: запросы отбивались по одному, прогон мог доехать до конца и записаться как done с тощим результатом либо упасть позже и с другой причиной. Теперь такая сессия падает сразу и явно — AvitoWarmupCookiesMissingError, классифицируется как ban_kind='platform'.

То есть часть прогонов, которые раньше молча деградировали, теперь честно попадут в banned. Метрика pct_bad может подскочить при том, что реальное состояние улучшилось — мы просто перестали врать себе и экономим прокси вместо того, чтобы жечь их на обречённых запросах.

Поэтому при замере смотреть не только на долю отказов, а на пару:

SELECT date_trunc('day', started_at) AS d,
  count(*) FILTER (WHERE status='done') AS done,
  count(*) FILTER (WHERE status='banned') AS banned,
  count(*) FILTER (WHERE status='failed') AS failed,
  sum(total_seen)  AS seen,
  sum(new_count)   AS new_rows
FROM scrape_runs
WHERE source LIKE 'avito%' AND started_at > now() - interval '14 days'
GROUP BY 1 ORDER BY 1;

Настоящий критерий успеха — seen/new_rows за сутки, а не доля banned. Если собранных объявлений столько же или больше при выросшей доле отказов — правка сработала, просто отказы стали видимыми. Если просело и то и другое — откатывать.

Два независимых конфаунда, оба уже в силе:

  1. Прокси сменили 2026-08-21 ~12:30 UTC — сравнивать только с окном после этого момента.
  2. #3039 (родной фильтр вторички) влит не будет одновременно с этим PR — специально, чтобы эффекты не смешались. Между мержами нужен зазор минимум в несколько суток: городские свипы ходят раз в 1-3 дня, full_load — раз в неделю.

Мержу этот PR первым и оставляю #3039 ждать.

## ⚠️ Ловушка замера: доля `banned` сразу после мержа может ВЫРАСТИ — и это не регрессия Правка №4 (проверка антибот-кук) меняет не поведение сети, а **честность отчётности**. До неё непрогретая сессия считалась готовой и шла дальше: запросы отбивались по одному, прогон мог доехать до конца и записаться как `done` с тощим результатом либо упасть позже и с другой причиной. Теперь такая сессия падает сразу и явно — `AvitoWarmupCookiesMissingError`, классифицируется как `ban_kind='platform'`. То есть часть прогонов, которые раньше молча деградировали, теперь честно попадут в `banned`. **Метрика `pct_bad` может подскочить при том, что реальное состояние улучшилось** — мы просто перестали врать себе и экономим прокси вместо того, чтобы жечь их на обречённых запросах. Поэтому при замере смотреть не только на долю отказов, а на пару: ```sql SELECT date_trunc('day', started_at) AS d, count(*) FILTER (WHERE status='done') AS done, count(*) FILTER (WHERE status='banned') AS banned, count(*) FILTER (WHERE status='failed') AS failed, sum(total_seen) AS seen, sum(new_count) AS new_rows FROM scrape_runs WHERE source LIKE 'avito%' AND started_at > now() - interval '14 days' GROUP BY 1 ORDER BY 1; ``` **Настоящий критерий успеха — `seen`/`new_rows` за сутки, а не доля `banned`.** Если собранных объявлений столько же или больше при выросшей доле отказов — правка сработала, просто отказы стали видимыми. Если просело и то и другое — откатывать. Два независимых конфаунда, оба уже в силе: 1. **Прокси сменили 2026-08-21 ~12:30 UTC** — сравнивать только с окном после этого момента. 2. **#3039 (родной фильтр вторички)** влит не будет одновременно с этим PR — специально, чтобы эффекты не смешались. Между мержами нужен зазор минимум в несколько суток: городские свипы ходят раз в 1-3 дня, `full_load` — раз в неделю. Мержу этот PR первым и оставляю #3039 ждать.
bot-backend added 1 commit 2026-08-21 14:28:09 +00:00
Merge remote-tracking branch 'forgejo/main' into fix/3034-avito-fingerprint
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
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 / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m28s
fc2930fbb6
bot-backend added 1 commit 2026-08-21 20:36:00 +00:00
Merge remote-tracking branch 'forgejo/main' into fix/3034-avito-fingerprint
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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
CI Trade-In / backend-tests (pull_request) Successful in 4m34s
b3a39699d1
lekss361 merged commit ee19254a41 into main 2026-08-21 20:45:26 +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#3038
No description provided.