Авито detail: авторизованные сессии — хранение кук, липкость к прокси, прогрев в браузерном пути #3177

Open
opened 2026-08-28 16:52:10 +00:00 by bot-backend · 4 comments
Collaborator

Заведено по итогам замера 28.08 (подробности и числа — в комментарии к #3045).

Установлено измерением

Авторизованная сессия открывает detail-страницы Авито там, где анонимная получает 439/403 — пять карточек подряд, все 200, при паузе 1,5 с, тогда как прод в тот же день даёт пять блоков подряд при бОльших паузах. Дело не в темпе и не в адресе.

Что нужно построить

  1. Хранилище сессий. Куки целиком, включая HttpOnly (cookie авторизации именно такая) — снимать на уровне CDP Network.getAllCookies, не через document.cookie. Сейчас хранения кук между прогонами нет вовсе: каждый прогон стартует с чистого браузера.

  2. Липкость сессия ↔ узел пула. Менять exit-IP под одной учётной записью — быстрый путь к бану аккаунта. Аренда прокси уже привязана к run_id; нужна привязка на уровне аккаунта, переживающая прогоны.

  3. Прогрев в браузерном пути. Цепочка «органический referer → поиск Авито → антибот-куки» существует только для curl-сессии (warm_up_session(session: AsyncSession), providers/avito/detail.py:345). Прод с 22.08 (b61123d7) работает браузером и этой цепочки не имеет.

  4. Сохранять ?context=. Карточки в замере открывались с токеном из выдачи. Пересекается с #3035, где бэкфилл его отрезает.

  5. Учётные данные — только в вольте (meta/00_credentials.md), уже записаны владельцем 28.08. В репозиторий не попадают, сабагентам не передаются.

Что решить до реализации

  • Потолок одной сессии — сколько карточек она держит. Замер дал 5/5, ceiling не найден. От этого числа зависит, сколько аккаунтов нужно и с каким темпом их пускать.
  • Поведение при протухании сессии и при 2FA/SMS — ре-логин руками или полуавтоматически.
  • Различать бан аккаунта и бан адреса. Сейчас ban_kind во всех прогонах unknown, а в алерт пишется захардкоженная строка «IP rate-limited», которую код не проверял (avito_detail_backfill.py:604; у Домклика то же в domclick_detail_backfill.py:343). Без этого мы не увидим, что именно сгорело.

Риски

Бан аккаунта необратим дороже бана адреса — восстановление через телефон. Авторизованный сбор персонифицирован, в отличие от анонимного чтения публичной выдачи; юридическая рамка меняется, решение владельца.

Приёмка

  • Сессия переживает рестарт контейнера и переиспользуется следующим прогоном
  • Один аккаунт всегда ходит через один и тот же exit-IP
  • Измерен потолок карточек на сессию, темп выставлен с запасом
  • ban_kind различает «аккаунт», «адрес», «инфраструктура»; текст алерта берётся из измеренного признака
  • Обогащение Авито вернулось к порядку 150 карточек в сутки

Refs #3045, #2827, #3035, #2674

Заведено по итогам замера 28.08 (подробности и числа — в комментарии к #3045). ## Установлено измерением Авторизованная сессия открывает detail-страницы Авито там, где анонимная получает 439/403 — **пять карточек подряд, все 200, при паузе 1,5 с**, тогда как прод в тот же день даёт пять блоков подряд при бОльших паузах. Дело не в темпе и не в адресе. ## Что нужно построить 1. **Хранилище сессий.** Куки целиком, включая `HttpOnly` (cookie авторизации именно такая) — снимать на уровне CDP `Network.getAllCookies`, не через `document.cookie`. Сейчас хранения кук между прогонами нет вовсе: каждый прогон стартует с чистого браузера. 2. **Липкость сессия ↔ узел пула.** Менять exit-IP под одной учётной записью — быстрый путь к бану аккаунта. Аренда прокси уже привязана к `run_id`; нужна привязка на уровне аккаунта, переживающая прогоны. 3. **Прогрев в браузерном пути.** Цепочка «органический referer → поиск Авито → антибот-куки» существует только для curl-сессии (`warm_up_session(session: AsyncSession)`, `providers/avito/detail.py:345`). Прод с 22.08 (`b61123d7`) работает браузером и этой цепочки не имеет. 4. **Сохранять `?context=`.** Карточки в замере открывались с токеном из выдачи. Пересекается с #3035, где бэкфилл его отрезает. 5. **Учётные данные — только в вольте** (`meta/00_credentials.md`), уже записаны владельцем 28.08. В репозиторий не попадают, сабагентам не передаются. ## Что решить до реализации - **Потолок одной сессии** — сколько карточек она держит. Замер дал 5/5, ceiling не найден. От этого числа зависит, сколько аккаунтов нужно и с каким темпом их пускать. - **Поведение при протухании сессии и при 2FA/SMS** — ре-логин руками или полуавтоматически. - **Различать бан аккаунта и бан адреса.** Сейчас `ban_kind` во всех прогонах `unknown`, а в алерт пишется захардкоженная строка «IP rate-limited», которую код не проверял (`avito_detail_backfill.py:604`; у Домклика то же в `domclick_detail_backfill.py:343`). Без этого мы не увидим, что именно сгорело. ## Риски Бан аккаунта необратим дороже бана адреса — восстановление через телефон. Авторизованный сбор персонифицирован, в отличие от анонимного чтения публичной выдачи; юридическая рамка меняется, решение владельца. ## Приёмка - [ ] Сессия переживает рестарт контейнера и переиспользуется следующим прогоном - [ ] Один аккаунт всегда ходит через один и тот же exit-IP - [ ] Измерен потолок карточек на сессию, темп выставлен с запасом - [ ] `ban_kind` различает «аккаунт», «адрес», «инфраструктура»; текст алерта берётся из измеренного признака - [ ] Обогащение Авито вернулось к порядку 150 карточек в сутки Refs #3045, #2827, #3035, #2674
bot-backend added the
enhancement
priority/p1
scope/backend
scrapers
tradein
labels 2026-08-28 16:52:10 +00:00
Author
Collaborator

Уточнение к моему же выводу: «дело не в адресе» — сказано слишком сильно.

Прогон 5200, стартовал 16:47 уже на обновлённых прокси, через 8 минут:

attempted=25  enriched=16  blocked=9   status=running

Против семи предыдущих прогонов, все banned, enriched 0-2:

прогон время статус enriched/attempted
5200 28.08 16:47 running 16/25
5190 28.08 13:47 banned 0/5
5182 28.08 10:47 banned 0/5
5169 28.08 07:46 banned 2/11
5152 28.08 04:46 banned 0/5
5140 28.08 01:46 banned 0/5
5129 27.08 22:46 banned 0/5

Свежий адрес возвращает анонимный сбор — 64% успеха вместо нуля. Значит адрес влияет, и моя формулировка это смазала.

Что остаётся верным без изменений: в один и тот же момент, на выгоревшем адресе, авторизованная сессия дала 5/5, анонимная — 0/5. И 36% блоков в 5200 — это всё ещё блоки на свежем адресе, а история показывает, как эта доля растёт (161 → 161 → 116 → 23 → 1 → 2 по дням).

Аккуратная формулировка: адрес задаёт стартовый запас, сессия — насколько быстро он тратится. Обновление IP — расходник (владелец меняет их вручную), сессия — то, что переживает прогоны. Обе части нужны; строить надо ту, что не требует ручного вмешательства раз в несколько дней.

**Уточнение к моему же выводу: «дело не в адресе» — сказано слишком сильно.** Прогон **5200**, стартовал 16:47 уже на обновлённых прокси, через 8 минут: ``` attempted=25 enriched=16 blocked=9 status=running ``` Против семи предыдущих прогонов, все `banned`, `enriched` 0-2: | прогон | время | статус | enriched/attempted | |---|---|---|---| | **5200** | 28.08 16:47 | **running** | **16/25** | | 5190 | 28.08 13:47 | banned | 0/5 | | 5182 | 28.08 10:47 | banned | 0/5 | | 5169 | 28.08 07:46 | banned | 2/11 | | 5152 | 28.08 04:46 | banned | 0/5 | | 5140 | 28.08 01:46 | banned | 0/5 | | 5129 | 27.08 22:46 | banned | 0/5 | Свежий адрес **возвращает анонимный сбор** — 64% успеха вместо нуля. Значит адрес влияет, и моя формулировка это смазала. Что остаётся верным без изменений: в один и тот же момент, на выгоревшем адресе, авторизованная сессия дала 5/5, анонимная — 0/5. И 36% блоков в 5200 — это всё ещё блоки на свежем адресе, а история показывает, как эта доля растёт (161 → 161 → 116 → 23 → 1 → 2 по дням). **Аккуратная формулировка:** адрес задаёт стартовый запас, сессия — насколько быстро он тратится. Обновление IP — расходник (владелец меняет их вручную), сессия — то, что переживает прогоны. Обе части нужны; строить надо ту, что не требует ручного вмешательства раз в несколько дней.
Author
Collaborator

Разбивка

Порядок: честный ban_kind -> хранилище сессий -> воркеры -> (параллельно) измерение потолка.
Измерение стоит запустить рано: если прогретых кук достаточно без авторизации, второй пункт сильно упрощается и риск бана аккаунта снимается.

## Разбивка - [ ] #3178 - [ ] #3179 - [ ] #3180 - [ ] #3181 Порядок: честный `ban_kind` -> хранилище сессий -> воркеры -> (параллельно) измерение потолка. Измерение стоит запустить рано: если прогретых кук достаточно без авторизации, второй пункт сильно упрощается и риск бана аккаунта снимается.
Author
Collaborator

Итог разбора: пять параллельных разведчиков по подсистемам, затем проект, затем три скептика с разными линзами (существование сущностей в коде / осуществимость в сайдкаре / риск и обратимость), затем сведение с учётом выживших возражений.

Главное, что изменилось против моих исходных постановок: инъекция кук в сайдкар уже работает сквознякомbrowser_fetcher.fetch(cookies)POST /fetchpage.context.add_cookies до навигации. И хранилище сессий изобретено дважды, причём providers/cian/session.py читает его прямо из kit без импорта app.* — то есть образец есть и на стороне kit.

Скептики нашли два звена, которые проект молча пропускал, и одно моё утверждение поправили: недостающих частей не три, а пять.


#3179 / #3180 — авторизованная браузерная сессия Avito: план после разбора возражений

Краткая суть

Инъекция кук в сайдкар сквозная (browser_fetcher.fetch(cookies)POST /fetchadd_cookies до навигации), хранилище сессий уже изобретено дважды (cian_session_cookies, domclick_session_cookies), причём providers/cian/session.py читает его прямо из kit без импорта app.*.

Разбор тремя линзами показал, что недостающих звеньев не три, а пять, и два из них план молча пропускал:

Что оказалось Следствие для плана
Проводной формат кук — плоский dict[str,str], домен фабрикует сервер из URL (server.py:1087-1094) Схема jsonb с httpOnly/secure/expires не доезжает; .avito.ru превращается в .www.avito.ru. Нужен отдельный шаг на контракт /fetch
Лиза прокси ротируется в середине прогона (browser_fetcher.py:130,613-624,636-637) после N подряд провалов Пин «на входе в async with» рвётся ровно тогда, когда начинаются блоки — то есть в единственном сценарии, ради которого он заводится
Три места конструируют BrowserFetcher(source="avito") (pipeline.py:718,1248,2057), уникальный индекс на pinned_proxy_id держит один «pin miss» станет штатным путём, а не исключением. Нужна лиза на самой сессии
Прогрев производит анонимные __zzatw-/cfidsw- куки, auth там нет account_label NOT NULL делает шаг 6 несовместимым с шагом 1. Дефолт обратный: анонимная сессия, аккаунт — опциональная надстройка
Куки шага 6 при вызове /fetch на yandex.ru уедут Яндексу под доменом .yandex.ru Куки шлём только в avito-хоп, Яндекс — как origin=/предыдущий хоп без кук

Битые адреса в исходном плане исправлены: enrich_consecutive_blocksorchestration/pipeline.py:698-847 (не 920-960); proxy_pool.mark_banned:728 (не 900-980); образец cian upload-cookies — admin.py:427 (domclick :604 верен).

DDL

-- backend/data/sql/274_avito_browser_sessions.sql  (после 273, номер свободен)
BEGIN;
CREATE TABLE IF NOT EXISTS avito_browser_sessions (
  id bigserial PRIMARY KEY,
  label text NOT NULL UNIQUE,            -- 'anon-1', 'acct-a'; идентификатор сессии, не аккаунта
  account_label text,                    -- NULL = анонимная прогретая сессия (дефолтный режим)
  cookies jsonb NOT NULL,                -- [{name,value,domain,path,httpOnly,secure,expires}]
  pinned_proxy_id bigint REFERENCES scrape_proxies(id) ON DELETE RESTRICT,
  pinned_exit_ip text,
  leased_by text,                        -- single-flight владельца сессии
  leased_at timestamptz,
  warmed_at timestamptz,
  expires_at_estimate timestamptz NOT NULL,
  last_used_at timestamptz,
  last_ok_at timestamptz,
  last_invalid_at timestamptz,
  consecutive_blocks smallint NOT NULL DEFAULT 0,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  notes text,
  CHECK (jsonb_typeof(cookies) = 'array')
);
CREATE UNIQUE INDEX IF NOT EXISTS avito_sess_pin_uniq
  ON avito_browser_sessions (pinned_proxy_id) WHERE pinned_proxy_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS avito_sess_free_idx
  ON avito_browser_sessions (expires_at_estimate DESC)
  WHERE last_invalid_at IS NULL AND leased_by IS NULL;
COMMIT;

Изменения против исходного DDL: account_label стал nullable (возражение об анонимном прогреве), добавлены label, leased_by/leased_at (возражение об отсутствии владельца), ON DELETE SET NULLRESTRICT (снятие пина не должно быть молчаливым). Шифрование pgp_sym_encrypt, как у cian/domclick, — вопрос владельцу (ниже).

Шаги #3179 (инфраструктура)

1. Миграция 274 + kit-модуль сессий.
backend/data/sql/274_avito_browser_sessions.sql (new); packages/scraper-kit/src/scraper_kit/providers/avito/session.py (new, образец providers/cian/session.py); contracts.py:121-146, SessionFactorycontracts.py:259.
API: acquire_session/release_session (лиза FOR UPDATE SKIP LOCKED), save_cookies/touch_used/mark_invalid/pin_proxy. Raw SQL через SessionFactory, без импорта app.* — инвариант kit. Возвращает playwright-список + pinned_proxy_id + pinned_exit_ip.

2. Двусторонний контракт кук в сайдкаре (новый шаг; в исходном плане его не было).
browser/server.py:706,740,895-920,1055-1130; packages/scraper-kit/src/scraper_kit/browser_fetcher.py:351-357,639-680,220-248.

  • Вход: cookies принимает и dict[str,str] (обратная совместимость с probe_proxy_via_browser), и список словарей; для списка domain/path/httpOnly/secure/expires берутся из самой куки, а не из urlparse(url).hostname — иначе .avito.ru мутирует в .www.avito.ru.
  • Выход: return_cookies=True{html, cookies: page.context.cookies()} до page.close() (:1118). Расширение строго аддитивное: _do_fetch и /fetch сегодня возвращают str, их парсит проба прокси — старая форма ответа остаётся дефолтом.

3. Пин прокси, живущий весь прогон.
backend/app/services/proxy_pool.py:259-430; contracts.py:194-215 (ProxyProvider.acquire — сейчас без run_id); backend/app/services/scraper_adapters.py:218-278; browser_fetcher.py:472-520 и :613-637.
acquire(db, provider, *, run_id=None, prefer_proxy_id=None). Ключевое отличие от исходного плана: prefer_proxy_id запоминается на объекте BrowserFetcher и передаётся и в _acquire_lease при ре-акquire после N провалов — иначе пин рвётся ровно в момент блокировок. Pin miss при живой сессии — не WARNING с чужим узлом, а AvitoPinUnavailableError: detail-фаза либо отказывает, либо деградирует до безкукового прохода. Выбор — за владельцем.

4. Лиза сессии (single-flight).
providers/avito/session.py; orchestration/pipeline.py:718,1248,2057.
Три конструкции BrowserFetcher(source="avito") берут одну лизу сессии на прогон и передают её вниз; второй параллельный прогон получает «нет свободной сессии» и идёт без кук, а не гоняет те же куки со второго exit-IP через relaunch инстанса (server.py:471-508 под локом).

5. Админ-путь.
backend/app/api/v1/admin.py:427 (cian), :604 (domclick) — образцы.
POST /scrape/avito/upload-cookies (дамп CDP Network.getAllCookies; HttpOnly обязателен — в document.cookie auth-куки нет), GET /scrape/avito/session-status. Валидация: обязательна антибот-группа (sx, __zzatw-avito, f, cfidsw-avito); auth-куки — опционально, их отсутствие переводит запись в анонимный режим, а не отклоняет.

Шаги #3180 (прикладное)

6. fetch_detail(cookies=...) и проброс из pipeline.
providers/avito/detail.py:471-536 (сейчас параметра нет, :506fetch(full_url) без origin); orchestration/pipeline.py:910-913,1554-1560; прецедент domclick/detail.py:504,534.
Pipeline один раз за прогон берёт сессию (шаг 4) и тредит в detail- и house-фазы.

7. Браузерный прогрев warm_up_browser_session.
providers/avito/detail.py:310-430 (сегодня curl-only warm_up_session); serp.py:370-373 (_FIREWALL_MARKERS).
Хоп 1: yandex.ru/search без кук. Пауза 2.0-3.5 с — на стороне kit, между двумя вызовами /fetch (внутрисерверный FETCH_JSON_SETTLE_MS тут ни при чём). Хоп 2: avito.ru/... с origin='https://ya.ru/' и куками. Критерий: 200 + нет firewall-маркеров + __zzatw-/cfidsw- присутствуют в возвращённых куках (шаг 2 — предусловие, иначе критерий ложно краснеет: _has_antibot_cookies читает session.cookies.keys(), где HttpOnly не видны). Провал → AvitoWarmupCookiesMissingError.

8. Учёт блоков, инвалидация, ритм.
pipeline.py:698-847 (enrich_consecutive_blocks); providers/avito/session.py; proxy_pool.py:728 (mark_banned).
403/firewall → инкремент; порог 3 → last_invalid_at, снятие пина, mark_banned, стоп detail-фазы. Возврат mark_banned разбирается: "protected"/"missing"/"deferred" означают, что узел остался в выдаче — тогда сессия помечается invalid, а вердикт «мы защитились» не выносится. Расхождение pinned_exit_ip с текущим (ASocks крутит exit-IP того же узла, proxy_rotation.py) → принудительный re-warm. Пауза ~1.5 с с джиттером; куки перезаписываются каждые N карточек.

9. Тесты и запись в vault.
backend/tests/scrapers/test_avito_detail_warmup_antibot_cookies.py:56-115 — образец контракта; новые тесты: проброс cookies в fetch; домен куки не переписывается на .www.*; на yandex-хоп куки не уходят; порядок 2 запросов + Referer ya.ru; 4 ветки исходов; round-trip jsonb; pin сохраняется при ре-acquire; разбор кодов mark_banned. Vault: причина деградации 22.08 (браузерный режим без прогрева, 161→1) + решение.

Отклонённые возражения

Возражение Почему отклонено
«Пауза 2.0-3.5 с между хопами невыразима, т.к. server.py:1099-1101 жёстко ставит FETCH_JSON_SETTLE_MS» Смешаны два таймера. FETCH_JSON_SETTLE_MS — внутризапросный settle одной навигации. Пауза между хопами живёт в kit между двумя вызовами /fetch и никак сервером не ограничена
«Схема jsonb с атрибутами бессмысленна — надо хранить плоский dict» Из двух предложенных ветвей выбрана первая: чиним контракт /fetch (шаг 2). Плоский dict консервирует дефект домена и делает round-trip лоссовым навсегда
«Дубли кук одного имени — аккаунтный сигнал аномалии» Механика скоупа (.www.avito.ru.avito.ru, два значения в одном запросе) подтверждена и чинится шагом 2. А вот что именно Авито считает сигналом аномалии — ничем не проверено, это догадка; в обоснование шага она не идёт
«Пересоздание фингерпринта (server.py:370-401, релонч по BROWSER_RECYCLE_PAGES на :1128-1134) обесценивает пин» Факт верен, вывод — нет. Это управляемая константа, а не свойство архитектуры. Переведено в риск + подшаг: на время detail-фазы прогретого прогона рециклинг не выполнять. Блокером плана не является
«ProxyProvider.acquire нельзя расширить — у него нет даже run_id» (как аргумент против шага 3) Отсутствие параметра — это цена шага, а не препятствие: Protocol и RealProxyProvider правятся в том же PR, вызовов немного

Риски

  • Замер 5/5 сделан in-page fetch() внутри живого Chrome — верхняя граница. Прод ходит сайдкаром с новым new_page на каждый запрос; план обязан переживать деградацию.
  • Переносятся только куки, не storage_state. localStorage/IndexedDB/фингерпринт не сохраняются. Если часть сессии живёт вне cookie-jar — прогрев даст 200, а карточки 403 (ложно-зелёный вердикт).
  • Фингерпринт нестабилен (нет user_data_dir/seed, geoip=True, релонч по счётчику страниц и на рестарт контейнера) — «закреплённая» сессия периодически видится Авито как новое устройство.
  • jsonb в открытом виде расходится с прецедентом cian/domclick (pgp_sym_encrypt в bytea, ключ в .env.runtime).
  • Один camoufox-инстанс на провайдера, смена proxy = close+relaunch под локом: несколько аккаунтов с разными IP параллельно невыразимы в принципе, лиза сессии лишь делает это явным отказом.
  • pinned_proxy_id ≠ закреплённый IP: ASocks меняет exit-IP того же узла до 3 раз в сутки.

Что решает владелец

Вопрос Варианты
Поведение при pin miss / истёкшей сессии (а) отказ detail-фазы; (б) деградация до безкукового прохода с пометкой в run-логе
Шифровать ли cookies (а) plaintext jsonb, доступ только через admin-token; (б) pgp_sym_encrypt как у cian/domclick — дороже в kit, но единообразно
Режим по умолчанию (а) анонимная прогретая сессия (рекомендуется: цена восстановления — минуты); (б) аккаунт (цена — восстановление по телефону)
Рециклинг браузера в detail-фазе отключать на время прогона с пином или оставить как есть
Разбить ли #3179 5 шагов — это M/L; шаги 1-2 и 3-5 разделяются на два PR без взаимной блокировки

Чего мы НЕ знаем

  • Живёт ли сессия Авито полностью в cookie-jar. Ни один шаг плана этого не проверяет; узнаем только по факту — прогрев зелёный, карточки 403.
  • Реальный TTL сессии. expires_at_estimate — оценка, откуда берётся значение, не определено ни планом, ни возражениями.
  • Пересекаются ли два прогона avito в проде. Три конструкции BrowserFetcher найдены в коде, но одновременность не измерена — лиза сессии заводится как страховка, а не по замеру.
  • Переживает ли сессия смену фингерпринта. Не проверялось; если нет — пин прокси даёт немного, и вся ставка переезжает на persistent-контекст (в план не входит).
  • Что именно триггерит блокировку — IP, фингерпринт, ритм или их комбинация. Порог consecutive_blocks = 3 взят по аналогии с proxy_pool, эмпирики по Авито нет.
  • Даёт ли аккаунтная сессия выигрыш над анонимной прогретой. Замер 5/5 не разделял эти режимы.
  • Поведение mark_banned при узком пуле известно по коду ("protected"), но насколько часто пул сжимается до защиты последнего узла — не измерено.
Итог разбора: пять параллельных разведчиков по подсистемам, затем проект, затем три скептика с разными линзами (существование сущностей в коде / осуществимость в сайдкаре / риск и обратимость), затем сведение с учётом выживших возражений. Главное, что изменилось против моих исходных постановок: **инъекция кук в сайдкар уже работает сквозняком** — `browser_fetcher.fetch(cookies)` → `POST /fetch` → `page.context.add_cookies` до навигации. И хранилище сессий изобретено дважды, причём `providers/cian/session.py` читает его прямо из kit без импорта `app.*` — то есть образец есть и на стороне kit. Скептики нашли два звена, которые проект молча пропускал, и одно моё утверждение поправили: недостающих частей не три, а пять. --- # #3179 / #3180 — авторизованная браузерная сессия Avito: план после разбора возражений ## Краткая суть Инъекция кук в сайдкар сквозная (`browser_fetcher.fetch(cookies)` → `POST /fetch` → `add_cookies` до навигации), хранилище сессий уже изобретено дважды (`cian_session_cookies`, `domclick_session_cookies`), причём `providers/cian/session.py` читает его прямо из kit без импорта `app.*`. Разбор тремя линзами показал, что недостающих звеньев не три, а **пять**, и два из них план молча пропускал: | Что оказалось | Следствие для плана | |---|---| | Проводной формат кук — плоский `dict[str,str]`, домен фабрикует сервер из URL (`server.py:1087-1094`) | Схема `jsonb` с `httpOnly/secure/expires` не доезжает; `.avito.ru` превращается в `.www.avito.ru`. Нужен отдельный шаг на контракт `/fetch` | | Лиза прокси ротируется в середине прогона (`browser_fetcher.py:130,613-624,636-637`) после N подряд провалов | Пин «на входе в `async with`» рвётся ровно тогда, когда начинаются блоки — то есть в единственном сценарии, ради которого он заводится | | Три места конструируют `BrowserFetcher(source="avito")` (`pipeline.py:718,1248,2057`), уникальный индекс на `pinned_proxy_id` держит один | «pin miss» станет штатным путём, а не исключением. Нужна лиза на самой сессии | | Прогрев производит **анонимные** `__zzatw-/cfidsw-` куки, auth там нет | `account_label NOT NULL` делает шаг 6 несовместимым с шагом 1. Дефолт обратный: анонимная сессия, аккаунт — опциональная надстройка | | Куки шага 6 при вызове `/fetch` на `yandex.ru` уедут Яндексу под доменом `.yandex.ru` | Куки шлём только в avito-хоп, Яндекс — как `origin=`/предыдущий хоп без кук | Битые адреса в исходном плане исправлены: `enrich_consecutive_blocks` — `orchestration/pipeline.py:698-847` (не 920-960); `proxy_pool.mark_banned` — `:728` (не 900-980); образец cian upload-cookies — `admin.py:427` (domclick `:604` верен). ## DDL ```sql -- backend/data/sql/274_avito_browser_sessions.sql (после 273, номер свободен) BEGIN; CREATE TABLE IF NOT EXISTS avito_browser_sessions ( id bigserial PRIMARY KEY, label text NOT NULL UNIQUE, -- 'anon-1', 'acct-a'; идентификатор сессии, не аккаунта account_label text, -- NULL = анонимная прогретая сессия (дефолтный режим) cookies jsonb NOT NULL, -- [{name,value,domain,path,httpOnly,secure,expires}] pinned_proxy_id bigint REFERENCES scrape_proxies(id) ON DELETE RESTRICT, pinned_exit_ip text, leased_by text, -- single-flight владельца сессии leased_at timestamptz, warmed_at timestamptz, expires_at_estimate timestamptz NOT NULL, last_used_at timestamptz, last_ok_at timestamptz, last_invalid_at timestamptz, consecutive_blocks smallint NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), notes text, CHECK (jsonb_typeof(cookies) = 'array') ); CREATE UNIQUE INDEX IF NOT EXISTS avito_sess_pin_uniq ON avito_browser_sessions (pinned_proxy_id) WHERE pinned_proxy_id IS NOT NULL; CREATE INDEX IF NOT EXISTS avito_sess_free_idx ON avito_browser_sessions (expires_at_estimate DESC) WHERE last_invalid_at IS NULL AND leased_by IS NULL; COMMIT; ``` Изменения против исходного DDL: `account_label` стал nullable (возражение об анонимном прогреве), добавлены `label`, `leased_by/leased_at` (возражение об отсутствии владельца), `ON DELETE SET NULL` → `RESTRICT` (снятие пина не должно быть молчаливым). Шифрование `pgp_sym_encrypt`, как у cian/domclick, — вопрос владельцу (ниже). ## Шаги #3179 (инфраструктура) **1. Миграция 274 + kit-модуль сессий.** `backend/data/sql/274_avito_browser_sessions.sql` (new); `packages/scraper-kit/src/scraper_kit/providers/avito/session.py` (new, образец `providers/cian/session.py`); `contracts.py:121-146`, `SessionFactory` — `contracts.py:259`. API: `acquire_session/release_session` (лиза `FOR UPDATE SKIP LOCKED`), `save_cookies/touch_used/mark_invalid/pin_proxy`. Raw SQL через `SessionFactory`, без импорта `app.*` — инвариант kit. Возвращает playwright-список + `pinned_proxy_id` + `pinned_exit_ip`. **2. Двусторонний контракт кук в сайдкаре (новый шаг; в исходном плане его не было).** `browser/server.py:706,740,895-920,1055-1130`; `packages/scraper-kit/src/scraper_kit/browser_fetcher.py:351-357,639-680,220-248`. - Вход: `cookies` принимает **и** `dict[str,str]` (обратная совместимость с `probe_proxy_via_browser`), **и** список словарей; для списка `domain/path/httpOnly/secure/expires` берутся из самой куки, а не из `urlparse(url).hostname` — иначе `.avito.ru` мутирует в `.www.avito.ru`. - Выход: `return_cookies=True` → `{html, cookies: page.context.cookies()}` **до** `page.close()` (`:1118`). Расширение строго аддитивное: `_do_fetch` и `/fetch` сегодня возвращают `str`, их парсит проба прокси — старая форма ответа остаётся дефолтом. **3. Пин прокси, живущий весь прогон.** `backend/app/services/proxy_pool.py:259-430`; `contracts.py:194-215` (`ProxyProvider.acquire` — сейчас без `run_id`); `backend/app/services/scraper_adapters.py:218-278`; `browser_fetcher.py:472-520` **и `:613-637`**. `acquire(db, provider, *, run_id=None, prefer_proxy_id=None)`. Ключевое отличие от исходного плана: `prefer_proxy_id` запоминается на объекте `BrowserFetcher` и передаётся **и в `_acquire_lease` при ре-акquire** после N провалов — иначе пин рвётся ровно в момент блокировок. Pin miss при живой сессии — не WARNING с чужим узлом, а `AvitoPinUnavailableError`: detail-фаза либо отказывает, либо деградирует до безкукового прохода. Выбор — за владельцем. **4. Лиза сессии (single-flight).** `providers/avito/session.py`; `orchestration/pipeline.py:718,1248,2057`. Три конструкции `BrowserFetcher(source="avito")` берут одну лизу сессии на прогон и передают её вниз; второй параллельный прогон получает «нет свободной сессии» и идёт без кук, а не гоняет те же куки со второго exit-IP через relaunch инстанса (`server.py:471-508` под локом). **5. Админ-путь.** `backend/app/api/v1/admin.py:427` (cian), `:604` (domclick) — образцы. `POST /scrape/avito/upload-cookies` (дамп CDP `Network.getAllCookies`; HttpOnly обязателен — в `document.cookie` auth-куки нет), `GET /scrape/avito/session-status`. Валидация: обязательна антибот-группа (`sx`, `__zzatw-avito`, `f`, `cfidsw-avito`); auth-куки — **опционально**, их отсутствие переводит запись в анонимный режим, а не отклоняет. ## Шаги #3180 (прикладное) **6. `fetch_detail(cookies=...)` и проброс из pipeline.** `providers/avito/detail.py:471-536` (сейчас параметра нет, `:506` — `fetch(full_url)` без origin); `orchestration/pipeline.py:910-913,1554-1560`; прецедент `domclick/detail.py:504,534`. Pipeline один раз за прогон берёт сессию (шаг 4) и тредит в detail- и house-фазы. **7. Браузерный прогрев `warm_up_browser_session`.** `providers/avito/detail.py:310-430` (сегодня curl-only `warm_up_session`); `serp.py:370-373` (`_FIREWALL_MARKERS`). Хоп 1: `yandex.ru/search` **без кук**. Пауза 2.0-3.5 с — **на стороне kit, между двумя вызовами `/fetch`** (внутрисерверный `FETCH_JSON_SETTLE_MS` тут ни при чём). Хоп 2: `avito.ru/...` с `origin='https://ya.ru/'` и куками. Критерий: 200 + нет firewall-маркеров + `__zzatw-/cfidsw-` присутствуют в **возвращённых** куках (шаг 2 — предусловие, иначе критерий ложно краснеет: `_has_antibot_cookies` читает `session.cookies.keys()`, где HttpOnly не видны). Провал → `AvitoWarmupCookiesMissingError`. **8. Учёт блоков, инвалидация, ритм.** `pipeline.py:698-847` (`enrich_consecutive_blocks`); `providers/avito/session.py`; `proxy_pool.py:728` (`mark_banned`). 403/firewall → инкремент; порог 3 → `last_invalid_at`, снятие пина, `mark_banned`, стоп detail-фазы. **Возврат `mark_banned` разбирается**: `"protected"`/`"missing"`/`"deferred"` означают, что узел остался в выдаче — тогда сессия помечается invalid, а вердикт «мы защитились» не выносится. Расхождение `pinned_exit_ip` с текущим (ASocks крутит exit-IP того же узла, `proxy_rotation.py`) → принудительный re-warm. Пауза ~1.5 с с джиттером; куки перезаписываются каждые N карточек. **9. Тесты и запись в vault.** `backend/tests/scrapers/test_avito_detail_warmup_antibot_cookies.py:56-115` — образец контракта; новые тесты: проброс cookies в `fetch`; **домен куки не переписывается на `.www.*`**; на yandex-хоп куки не уходят; порядок 2 запросов + Referer `ya.ru`; 4 ветки исходов; round-trip jsonb; pin сохраняется при ре-acquire; разбор кодов `mark_banned`. Vault: причина деградации 22.08 (браузерный режим без прогрева, 161→1) + решение. ## Отклонённые возражения | Возражение | Почему отклонено | |---|---| | «Пауза 2.0-3.5 с между хопами невыразима, т.к. `server.py:1099-1101` жёстко ставит `FETCH_JSON_SETTLE_MS`» | Смешаны два таймера. `FETCH_JSON_SETTLE_MS` — внутризапросный settle одной навигации. Пауза между хопами живёт в kit между двумя вызовами `/fetch` и никак сервером не ограничена | | «Схема jsonb с атрибутами бессмысленна — надо хранить плоский dict» | Из двух предложенных ветвей выбрана первая: чиним контракт `/fetch` (шаг 2). Плоский dict консервирует дефект домена и делает round-trip лоссовым навсегда | | «Дубли кук одного имени — аккаунтный сигнал аномалии» | Механика скоупа (`.www.avito.ru` ≠ `.avito.ru`, два значения в одном запросе) подтверждена и чинится шагом 2. А вот что именно Авито считает сигналом аномалии — ничем не проверено, это догадка; в обоснование шага она не идёт | | «Пересоздание фингерпринта (`server.py:370-401`, релонч по `BROWSER_RECYCLE_PAGES` на `:1128-1134`) обесценивает пин» | Факт верен, вывод — нет. Это управляемая константа, а не свойство архитектуры. Переведено в риск + подшаг: на время detail-фазы прогретого прогона рециклинг не выполнять. Блокером плана не является | | «`ProxyProvider.acquire` нельзя расширить — у него нет даже `run_id`» (как аргумент против шага 3) | Отсутствие параметра — это цена шага, а не препятствие: Protocol и `RealProxyProvider` правятся в том же PR, вызовов немного | ## Риски - **Замер 5/5 сделан in-page `fetch()` внутри живого Chrome** — верхняя граница. Прод ходит сайдкаром с новым `new_page` на каждый запрос; план обязан переживать деградацию. - **Переносятся только куки, не `storage_state`.** localStorage/IndexedDB/фингерпринт не сохраняются. Если часть сессии живёт вне cookie-jar — прогрев даст 200, а карточки 403 (ложно-зелёный вердикт). - **Фингерпринт нестабилен** (нет `user_data_dir`/seed, `geoip=True`, релонч по счётчику страниц и на рестарт контейнера) — «закреплённая» сессия периодически видится Авито как новое устройство. - **`jsonb` в открытом виде** расходится с прецедентом cian/domclick (`pgp_sym_encrypt` в bytea, ключ в `.env.runtime`). - **Один camoufox-инстанс на провайдера**, смена proxy = close+relaunch под локом: несколько аккаунтов с разными IP параллельно невыразимы в принципе, лиза сессии лишь делает это явным отказом. - **`pinned_proxy_id` ≠ закреплённый IP**: ASocks меняет exit-IP того же узла до 3 раз в сутки. ## Что решает владелец | Вопрос | Варианты | |---|---| | Поведение при pin miss / истёкшей сессии | (а) отказ detail-фазы; (б) деградация до безкукового прохода с пометкой в run-логе | | Шифровать ли `cookies` | (а) plaintext jsonb, доступ только через admin-token; (б) `pgp_sym_encrypt` как у cian/domclick — дороже в kit, но единообразно | | Режим по умолчанию | (а) анонимная прогретая сессия (рекомендуется: цена восстановления — минуты); (б) аккаунт (цена — восстановление по телефону) | | Рециклинг браузера в detail-фазе | отключать на время прогона с пином или оставить как есть | | Разбить ли #3179 | 5 шагов — это M/L; шаги 1-2 и 3-5 разделяются на два PR без взаимной блокировки | ## Чего мы НЕ знаем - **Живёт ли сессия Авито полностью в cookie-jar.** Ни один шаг плана этого не проверяет; узнаем только по факту — прогрев зелёный, карточки 403. - **Реальный TTL сессии.** `expires_at_estimate` — оценка, откуда берётся значение, не определено ни планом, ни возражениями. - **Пересекаются ли два прогона avito в проде.** Три конструкции `BrowserFetcher` найдены в коде, но одновременность не измерена — лиза сессии заводится как страховка, а не по замеру. - **Переживает ли сессия смену фингерпринта.** Не проверялось; если нет — пин прокси даёт немного, и вся ставка переезжает на persistent-контекст (в план не входит). - **Что именно триггерит блокировку** — IP, фингерпринт, ритм или их комбинация. Порог `consecutive_blocks = 3` взят по аналогии с `proxy_pool`, эмпирики по Авито нет. - **Даёт ли аккаунтная сессия выигрыш над анонимной прогретой.** Замер 5/5 не разделял эти режимы. - **Поведение `mark_banned` при узком пуле** известно по коду (`"protected"`), но насколько часто пул сжимается до защиты последнего узла — не измерено.
Author
Collaborator

Замер проведён прямо в сайдкаре прода (28.08, ~17:10 UTC). Результат не тот, которого я ждал, и часть моих предыдущих выводов он ослабляет.

16 карточек, ожидающих обогащения, через POST /fetch живого tradein-browser. Чередование режимов, чтобы дрейф состояния пула бил по обеим группам одинаково. Дискриминатор — размер: заглушка ~6 КБ против живой карточки 550-900 КБ.

холодный goto (как ходит прод)   7/8 прошло
origin = SERP Авито              8/8 прошло

Разницы между режимами на этой выборке нет. Единственный провал в холодной группе — HTTP 500 самого сайдкара, а не блок площадки.

Что замер всё-таки установил твёрдо

Аккаунт для этих карточек сейчас не нужен. 15 из 16 анонимных запросов, без единой куки и без авторизации, вернули полные карточки. При нынешнем состоянии пула авторизация ничего не решает.

Что он опроверг

Первый заход (n=1) выглядел решающе: карточка 8270330752 холодным заходом дала 5 918 байт — заглушку, — а с origin те же 712 239 байт живой карточки. Я успел назвать это решающим.

Через несколько минут та же карточка тем же холодным режимом отдалась целиком (663 886 байт).

Отсюда главное следствие: блок стохастичен, а не детерминирован по карточке. Сравнения на малых выборках — включая мои собственные 5/5 из #3177 — доказывают меньше, чем кажется. Пять успехов подряд при вероятности успеха ~0.7 случаются в каждом шестом заходе.

Новая находка: порог обрыва принимает случайную серию за бан

Прогон 5200 завершился так:

enriched=43  attempted=63  blocked=20  status=banned

68% успеха — и всё равно banned, потому что где-то сошлись 5 блоков подряд.

При доле блоков 0.32 пять подряд в одной позиции — вероятность 0.0034, но позиций в прогоне около шестидесяти. Итого примерно каждый пятый совершенно здоровый прогон обрывается случайной серией и рапортует «IP rate-limited».

Для выгоревшего пула правило верное: прогоны 5140-5190 давали 5 блоков из 5 попыток, там серия — не случайность. Для здорового — ложная тревога, которая к тому же выбрасывает остаток очереди.

Правило должно смотреть на долю блоков в окне, а не на длину серии. Завожу отдельным тикетом.

Следствие для плана #3177

Различающий эксперимент «аккаунт против прогретых кук» сейчас поставить нельзя: пул здоров, анонимный холодный заход и так даёт 94%, и все режимы неразличимы. Мерить надо на выгоревшем пуле — то есть либо ждать деградации, либо воспроизводить её намеренно.

Практический вывод: не строить машинерию аккаунтов на нынешних данных. Сначала честный порог обрыва (он даёт выигрыш немедленно и без риска), потом замер под нагрузкой, и только потом решение про сессии.

**Замер проведён прямо в сайдкаре прода (28.08, ~17:10 UTC). Результат не тот, которого я ждал, и часть моих предыдущих выводов он ослабляет.** 16 карточек, ожидающих обогащения, через `POST /fetch` живого `tradein-browser`. Чередование режимов, чтобы дрейф состояния пула бил по обеим группам одинаково. Дискриминатор — размер: заглушка ~6 КБ против живой карточки 550-900 КБ. ``` холодный goto (как ходит прод) 7/8 прошло origin = SERP Авито 8/8 прошло ``` **Разницы между режимами на этой выборке нет.** Единственный провал в холодной группе — HTTP 500 самого сайдкара, а не блок площадки. ## Что замер всё-таки установил твёрдо **Аккаунт для этих карточек сейчас не нужен.** 15 из 16 анонимных запросов, без единой куки и без авторизации, вернули полные карточки. При нынешнем состоянии пула авторизация ничего не решает. ## Что он опроверг Первый заход (n=1) выглядел решающе: карточка `8270330752` холодным заходом дала 5 918 байт — заглушку, — а с `origin` те же 712 239 байт живой карточки. Я успел назвать это решающим. Через несколько минут **та же карточка тем же холодным режимом отдалась целиком** (663 886 байт). Отсюда главное следствие: **блок стохастичен, а не детерминирован по карточке**. Сравнения на малых выборках — включая мои собственные 5/5 из #3177 — доказывают меньше, чем кажется. Пять успехов подряд при вероятности успеха ~0.7 случаются в каждом шестом заходе. ## Новая находка: порог обрыва принимает случайную серию за бан Прогон 5200 завершился так: ``` enriched=43 attempted=63 blocked=20 status=banned ``` 68% успеха — и всё равно `banned`, потому что где-то сошлись 5 блоков подряд. При доле блоков 0.32 пять подряд в одной позиции — вероятность 0.0034, но позиций в прогоне около шестидесяти. Итого **примерно каждый пятый совершенно здоровый прогон обрывается случайной серией** и рапортует «IP rate-limited». Для выгоревшего пула правило верное: прогоны 5140-5190 давали 5 блоков из 5 попыток, там серия — не случайность. Для здорового — ложная тревога, которая к тому же выбрасывает остаток очереди. Правило должно смотреть на **долю блоков в окне**, а не на длину серии. Завожу отдельным тикетом. ## Следствие для плана #3177 Различающий эксперимент «аккаунт против прогретых кук» **сейчас поставить нельзя**: пул здоров, анонимный холодный заход и так даёт 94%, и все режимы неразличимы. Мерить надо на выгоревшем пуле — то есть либо ждать деградации, либо воспроизводить её намеренно. Практический вывод: **не строить машинерию аккаунтов на нынешних данных**. Сначала честный порог обрыва (он даёт выигрыш немедленно и без риска), потом замер под нагрузкой, и только потом решение про сессии.
Sign in to join this conversation.
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#3177
No description provided.