fix(tradein/scrapers): хранилище авторизованной сессии Яндекс.Недвижимости (#3195)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m18s
Deploy Trade-In / test (push) Successful in 4m4s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 2m0s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s

This commit is contained in:
bot-backend 2026-08-28 19:02:05 +00:00
parent 49b70a67f3
commit 5be64c6688
12 changed files with 1614 additions and 19 deletions

View file

@ -9,6 +9,7 @@ import asyncio
import json import json
import logging import logging
import time import time
from datetime import datetime
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from uuid import uuid4 from uuid import uuid4
@ -72,6 +73,7 @@ from app.services import cian_session as cian_session_svc
from app.services import domclick_session as domclick_session_svc from app.services import domclick_session as domclick_session_svc
from app.services import proxy_rotation as proxy_rotation_svc from app.services import proxy_rotation as proxy_rotation_svc
from app.services import scrape_runs as runs_mod from app.services import scrape_runs as runs_mod
from app.services import yandex_session as yandex_session_svc
from app.services.estimator import LISTINGS_FRESH_DAYS from app.services.estimator import LISTINGS_FRESH_DAYS
from app.services.geocoder import geocode, known_city_hint from app.services.geocoder import geocode, known_city_hint
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
@ -722,6 +724,130 @@ async def debug_domclick_detail_fetch(
) )
# ── Yandex session cookie management (mainPhone/contact reveal, #3192) ───────
#
# Эмпирически подтверждено на проде (сайдкар, две карточки, режимы вперемежку,
# 2026-08-28): авторизованная сессия Яндекс.Недвижимости отдаёт ключ mainPhone
# и на 11 больше уникальных телефонов продавца (90→101 / 89→100), чем анонимный
# запрос той же карточки. Cookies хранятся зашифрованно (pgp_sym_encrypt) в
# yandex_session_cookies.
#
# В отличие от Cian/DomClick — payload это ПОЛНЫЙ CDP-дамп (Network.getAllCookies:
# список объектов name/value/domain/path/httpOnly/secure/expires), не плоский
# dict[name, value], и НЕТ allowlist-фильтра на сохранение (см.
# app.services.yandex_session.filter_analytics_cookies docstring) — сохраняем
# весь дамп, кроме заведомой аналитики.
#
# MVP: без verify_session (зеркалит domclick_session) — инъекция кук в browser
# context и реальный fetch-verify — следующая часть #3192 (providers/yandex/**,
# browser/server.py), не эта.
class YandexCookieItem(BaseModel):
"""Один cookie-объект из CDP Network.getAllCookies (или эквивалентного дампа)."""
name: str
value: str
domain: str | None = None
path: str | None = None
# CDP отдаёт camelCase "httpOnly" — alias, чтобы не нарушать N815 (ruff)
# snake_case-конвенцию полей.
http_only: bool | None = Field(default=None, alias="httpOnly")
secure: bool | None = None
expires: float | None = None
model_config = {"extra": "allow", "populate_by_name": True}
class YandexUploadCookiesRequest(BaseModel):
account_label: str
cookies: list[YandexCookieItem]
@router.post("/scrape/yandex/upload-cookies", status_code=200)
async def upload_yandex_cookies(
body: YandexUploadCookiesRequest,
db: Annotated[Session, Depends(get_db)],
) -> dict:
"""Upload Yandex.Nedvizhimost session cookies (авторизованный contact-reveal, #3192).
Body: {"account_label": "<метка аккаунта>", "cookies": [{"name": ..., "value": ...,
"domain": ..., "path": ..., "httpOnly": ..., "secure": ..., "expires": ...}, ...]}
прямой дамп CDP Network.getAllCookies (DevTools Protocol) или его JSON-эквивалент.
account_label произвольная текстовая метка аккаунта (у Яндекса нет числового
id, доступного из cookie-дампа, в отличие от Cian userId / DomClick CAS_ID).
Шаги:
1. Отбрасывает заведомую аналитику (_ym_*, yabs-*, _yasc*)
yandex_session_svc.filter_analytics_cookies. Остальное сохраняется КАК
ПРИШЛО здесь нет allowlist-фильтра, как у Cian/DomClick, см. модульный
docstring app.services.yandex_session.
2. Проверяет наличие ключевой auth-cookie (Session_id) её отсутствие значит
дамп анонимный, сохранять его бессмысленно.
3. Сохраняет зашифрованно (pgp_sym_encrypt) в yandex_session_cookies.
Returns: {"ok": true, "accountLabel": <str>, "cookieCount": <int>}
"""
if not settings.cookie_encryption_key:
raise HTTPException(status_code=503, detail="COOKIE_ENCRYPTION_KEY not configured")
account_label = body.account_label.strip()
if not account_label:
raise HTTPException(status_code=400, detail="account_label must not be empty")
# by_alias=True — сохраняем оригинальные CDP-ключи (httpOnly, не http_only):
# будущая browser-инъекция (следующая часть #3192) ожидает формат
# Network.getAllCookies как есть.
raw_cookies = [c.model_dump(exclude_none=True, by_alias=True) for c in body.cookies]
cleaned = yandex_session_svc.filter_analytics_cookies(raw_cookies)
if not cleaned:
raise HTTPException(status_code=400, detail="No cookies left after analytics filter")
if not yandex_session_svc.has_auth_marker(cleaned):
raise HTTPException(
status_code=400,
detail=(
f"В дампе нет ключевой auth-cookie ({yandex_session_svc.AUTH_MARKER_COOKIE!r})"
" — похоже на анонимную сессию, залейте дамп из авторизованного окна."
),
)
yandex_session_svc.save_session(db, account_label=account_label, cookies=cleaned)
return {"ok": True, "accountLabel": account_label, "cookieCount": len(cleaned)}
@router.get("/scrape/yandex/session-status", status_code=200)
async def get_yandex_session_status(
db: Annotated[Session, Depends(get_db)],
) -> dict:
"""Статус сохранённой Yandex-сессии — есть ли валидные куки и когда протухают.
Без verify_session (MVP, см. модульный docstring yandex_session) статус
основан только на expires_at_estimate/last_invalid_at из БД, не на живом
fetch к Яндексу.
Returns: {"hasSession": bool, "expiresAt": <iso str|null>, "expiresSoon": bool}
"""
if not settings.cookie_encryption_key:
return {"hasSession": False, "expiresAt": None, "expiresSoon": False}
cookies = yandex_session_svc.load_session(db)
has_session = cookies is not None
expires_at = yandex_session_svc.session_expires_at(db, valid_only=has_session)
expires_soon = False
if expires_at is not None:
days_left = (expires_at - datetime.now(expires_at.tzinfo)).days
expires_soon = days_left <= yandex_session_svc.COOKIE_EXPIRY_WARN_DAYS
return {
"hasSession": has_session,
"expiresAt": expires_at.isoformat() if expires_at else None,
"expiresSoon": expires_soon,
}
# ── Geocode backfill: batch address-dedup geocoder (all sources) ───────────── # ── Geocode backfill: batch address-dedup geocoder (all sources) ─────────────

View file

@ -0,0 +1,254 @@
"""Yandex.Nedvizhimost session cookie management — load/save/invalidate encrypted cookies.
Замер на проде (#3192, сайдкар, две карточки, режимы вперемежку, 2026-08-28):
авторизованная сессия Яндекс.Недвижимости отдаёт ключ `mainPhone` и на 11 больше
уникальных телефонов продавца (90101 / 89100), чем анонимный запрос той же
карточки контакты продавца физически недоступны без валидной сессии.
Cookies хранятся зашифрованно (pgp_sym_encrypt) в yandex_session_cookies
зеркалит app.services.cian_session / app.services.domclick_session, но:
- ключ записи account_label (text), НЕ числовой id: у Яндекса нет внутреннего
числового id аккаунта, доступного из cookie-дампа;
- хранится ПОЛНЫЙ CDP cookie-объект (name/value/domain/path/httpOnly/secure/
expires), а не плоский dict[name, value] как у Cian/DomClick инъекция кук в
browser context (следующая часть #3192) требует domain/path, не только value;
- НЕТ allowlist-фильтра на сохранение (см. filter_analytics_cookies docstring)
Cian/DomClick фильтруют payload по списку известных cookie-имён перед
сохранением, здесь так делать нельзя: замер выше сделан на ПОЛНОМ наборе из
44 cookies, и какая из них существенна для mainPhone-раскрытия неизвестно.
MVP: без verify_session (аналогично domclick_session) верификация Yandex-сессии
требует реального browser-фетча, не простого curl_cffi-запроса; вне рамок этой
итерации.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
logger = logging.getLogger(__name__)
# За сколько дней до протухания кук предупреждать (зеркалит #2658/#2674). Обновление
# кук — РУЧНАЯ операция (залить дамп через админку), человеку нужен запас: сигнал по
# факту протухания приходит, когда сбор уже встал. save_session ставит ttl 30 дней.
COOKIE_EXPIRY_WARN_DAYS = 5
# Cookies, реально наблюдаемые в авторизованной Yandex-сессии (замер #3192,
# 2026-08-28), все HttpOnly кроме отмеченных.
#
# ВАЖНО: это НЕ allowlist для фильтрации того, что сохранять (в отличие от
# CIAN_REQUIRED_COOKIES / DOMCLICK_REQUIRED_COOKIES) — используется ТОЛЬКО в
# has_auth_marker() для проверки "дамп вообще похож на авторизованную сессию".
# Замер #3192 сделан на ПОЛНОМ наборе из 44 cookies, какая именно cookie
# существенна для mainPhone-раскрытия — неизвестно, поэтому save_session хранит
# весь дамп целиком (за вычетом аналитики), а не только эти имена. НЕ сужай
# набор, который реально сохраняется, до этого списка.
YANDEX_CRITICAL_COOKIES: set[str] = {
"Session_id",
"sessionid2",
"sessar",
"i",
"L",
"sessguard",
"yandexuid", # не HttpOnly
"yandex_login", # не HttpOnly
}
# Ключевая auth-cookie, которую ставит Яндекс.Паспорт при логине — её отсутствие
# в дампе означает "это анонимная сессия", независимо от того, сколько прочих
# cookies присутствует.
AUTH_MARKER_COOKIE = "Session_id"
# Префиксы имён cookies, которые ВСЕГДА аналитика (Яндекс.Метрика / РСЯ /
# анти-спам виджет), а не auth-состояние — единственное, что выкидываем из
# дампа перед сохранением. Всё остальное сохраняется как пришло.
ANALYTICS_COOKIE_PREFIXES: tuple[str, ...] = ("_ym_", "yabs-", "_yasc")
def filter_analytics_cookies(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Drop только заведомую аналитику (_ym_*, yabs-*, _yasc*) — остальное как есть.
В отличие от Cian/DomClick здесь нет allowlist на сохранение: замер #3192
сделан на ПОЛНОМ наборе из 44 cookies, значимость конкретной cookie для
mainPhone-раскрытия неизвестна. Сужать этот фильтр до allowlist известных
"критичных" имён значит потерять часть замера у следующего инженера,
который решит его "оптимизировать". YANDEX_CRITICAL_COOKIES используется
только в has_auth_marker(), не здесь.
"""
return [c for c in cookies if not str(c.get("name", "")).startswith(ANALYTICS_COOKIE_PREFIXES)]
def has_auth_marker(cookies: list[dict[str, Any]]) -> bool:
"""True если в дампе присутствует ключевая auth-cookie (Session_id).
Используется как дешёвая пре-проверка "это вообще похоже на авторизованный
дамп", НЕ полноценная верификация (см. модульный docstring — verify_session
для Yandex вне рамок этой итерации).
"""
names = {c.get("name") for c in cookies}
return AUTH_MARKER_COOKIE in names
def save_session(
db: Session,
account_label: str,
cookies: list[dict[str, Any]],
ttl_days: int = 30,
) -> None:
"""Encrypt cookies via pgp_sym_encrypt and UPSERT into yandex_session_cookies.
cookies список полных CDP cookie-объектов (name/value/domain/path/httpOnly/
secure/expires), НЕ плоский dict[name, value] как у Cian/DomClick инъекция
кук в browser context требует domain/path.
Uses settings.cookie_encryption_key as the encryption secret.
Никогда не логирует сырые значения cookies.
"""
cookies_json = json.dumps(cookies)
db.execute(
text("""
INSERT INTO yandex_session_cookies (
account_label,
cookies_encrypted,
expires_at_estimate,
uploaded_at
) VALUES (
:label,
pgp_sym_encrypt(:cookies_json, :key),
NOW() + (CAST(:ttl_days AS int) || ' days')::interval,
NOW()
)
ON CONFLICT (account_label) DO UPDATE SET
cookies_encrypted = EXCLUDED.cookies_encrypted,
expires_at_estimate = EXCLUDED.expires_at_estimate,
uploaded_at = NOW(),
last_invalid_at = NULL
"""),
{
"label": account_label,
"cookies_json": cookies_json,
"key": settings.cookie_encryption_key,
"ttl_days": ttl_days,
},
)
db.commit()
logger.info(
"Yandex cookies saved for label=%s (count=%d, ttl=%d days)",
account_label,
len(cookies),
ttl_days,
)
def load_session(db: Session) -> list[dict[str, Any]] | None:
"""Load most-recently-uploaded valid Yandex cookies (decrypt).
Returns список CDP cookie-объектов или None если нет валидной session.
Выбирает только записи где expires_at_estimate > NOW() и сессия не была
инвалидирована после последнего upload'а.
"""
row = (
db.execute(
text("""
SELECT
account_label,
pgp_sym_decrypt(cookies_encrypted, :key)::text AS cookies_json,
expires_at_estimate
FROM yandex_session_cookies
WHERE expires_at_estimate > NOW()
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at)
ORDER BY uploaded_at DESC
LIMIT 1
"""),
{"key": settings.cookie_encryption_key},
)
.mappings()
.first()
)
if row is None:
logger.warning("No valid Yandex session cookies in DB")
return None
cookies: list[dict[str, Any]] = json.loads(row["cookies_json"])
# Обновляем last_used_at — не критично, игнорируем ошибки.
try:
db.execute(
text(
"UPDATE yandex_session_cookies SET last_used_at = NOW()"
" WHERE account_label = :label"
),
{"label": row["account_label"]},
)
db.commit()
except Exception as exc:
# Ошибка на execute() оставляет сессию в aborted-транзакции (psycopg/PG:
# "current transaction is aborted") — если db переживёт этот вызов (caller
# держит ту же сессию дальше, напр. admin.py session-status делает
# session_expires_at(db, ...) сразу следом), последующие запросы иначе все
# падали бы с той же ошибкой (зеркалит app.services.proxy_egress паттерн).
try:
db.rollback()
except Exception:
logger.warning("Failed to rollback after last_used_at update failure", exc_info=True)
logger.warning("Failed to update last_used_at for label=%s: %s", row["account_label"], exc)
logger.info(
"Yandex cookies loaded for label=%s (count=%d)",
row["account_label"],
len(cookies),
)
return cookies
def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
"""Когда протухают самые свежезагруженные куки (зеркалит cian_session #2658).
`load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на
протухших отдаёт None вызывающий не мог отличить «кук никогда не загружали» от
«протухли позавчера» и не мог предупредить ЗАРАНЕЕ.
valid_only=False (диагностика после None от load_session) свежайшая запись любая:
валидных по определению нет, нужен именно срок протухшей. valid_only=True та же
запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок
ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке.
"""
row = db.execute(
text(
"""
SELECT expires_at_estimate FROM yandex_session_cookies
WHERE NOT CAST(:valid_only AS boolean)
OR (expires_at_estimate > NOW()
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at))
ORDER BY uploaded_at DESC
LIMIT 1
"""
),
{"valid_only": valid_only},
).first()
if row is None:
return None
expires_at: datetime | None = row[0]
return expires_at
def mark_session_invalid(db: Session, account_label: str) -> None:
"""Flag session как expired/invalid (например после логаута во время scrape)."""
db.execute(
text(
"UPDATE yandex_session_cookies SET last_invalid_at = NOW() WHERE account_label = :label"
),
{"label": account_label},
)
db.commit()
logger.warning("Yandex session marked invalid for label=%s", account_label)

View file

@ -272,7 +272,17 @@ async def run_domclick_detail_backfill(
# module docstring). source="domclick" -- infra identifier, dedicated # module docstring). source="domclick" -- infra identifier, dedicated
# residential proxy (scrape_proxies.provider_affinity='domclick', # residential proxy (scrape_proxies.provider_affinity='domclick',
# 173_scrape_proxies_add_domclick_affinity.sql). # 173_scrape_proxies_add_domclick_affinity.sql).
async with BrowserFetcher(source="domclick", endpoint=settings.browser_http_endpoint) as bf: # reuse_context=True (#3118): sidecar's browser.new_page() creates a fresh
# isolated context on EVERY /fetch, so the once-per-run cookie injection above
# never sees the live qrator_jsid2 the site rotates via Set-Cookie (~2.5h TTL)
# -- confirmed live: 26 sequential sidecar fetches = 100% blocked, same cards
# in a warm browser context = 5/5 in ~2s each. reuse_context keeps ONE sidecar
# context alive for the whole run instead, so the cookie jar evolves on its
# own. Reset happens once per detected block (see reset_context_next below),
# not on every subsequent fetch.
async with BrowserFetcher(
source="domclick", endpoint=settings.browser_http_endpoint, reuse_context=True
) as bf:
for idx, row in enumerate(snapshot): for idx, row in enumerate(snapshot):
# Budget guard # Budget guard
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
@ -329,6 +339,10 @@ async def run_domclick_detail_backfill(
except DomClickBlockedError as e: except DomClickBlockedError as e:
consecutive_blocks += 1 consecutive_blocks += 1
counters.blocked += 1 counters.blocked += 1
# #3118: сожжённый переиспользуемый context (протухший qrator_jsid2
# / стухшая сессия) не должен тянуться дальше по прогону — просим
# ОДИН сброс на следующем fetch(), не на каждый последующий запрос.
bf.request_context_reset()
logger.warning( logger.warning(
"domclick_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s", "domclick_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s",
run_id, run_id,

View file

@ -0,0 +1,61 @@
-- 274_yandex_session_cookies.sql
-- Purpose: Encrypted storage for Yandex.Nedvizhimost browser session cookies.
-- Замер на проде (сайдкар, две карточки, вперемежку с/без кук, 2026-08-28):
-- author.phoneNumbers присутствует ТОЛЬКО в авторизованном режиме
-- (0 без кук → 2 с куками, одинаково на обеих карточках), плюс три
-- номера в author.phones сверху. Это контакт продавца с тегом канала
-- и redirectId.
-- NB: mainPhone НЕ собирать — он лежит в паспортном блоке рядом с
-- login/passportHost/passportPhones, то есть это номер НАШЕЙ учётки,
-- а не продавца. Первая версия этого замера приняла его за данные
-- объявления; счётчики по конкретным полям это опровергли.
-- encryptedPhones одинаков в обоих режимах (54) — зашифрованные токены
-- отдаются всегда, различие не в них.
-- Uses pgcrypto pgp_sym_encrypt for AES encryption at rest (зеркалит
-- 027_cian_session_cookies.sql / 174_domclick_session_cookies.sql).
-- Dependencies:
-- - pgcrypto extension (installed here via CREATE EXTENSION IF NOT EXISTS)
-- Deploy order: Apply after 273.
--
-- Security notes:
-- - cookies_encrypted stores AES-encrypted JSON blob via pgp_sym_encrypt.
-- - Encryption key lives in .env.runtime (COOKIE_ENCRYPTION_KEY), never in DB.
-- - Access restricted to admin-token-gated API endpoint only.
--
-- Schema note: ключ записи — account_label (text), НЕ числовой id как у Cian
-- (account_user_id) / DomClick (account_cas_id) — у Яндекса нет внутреннего
-- числового id аккаунта, доступного из cookie-дампа; используем произвольную
-- текстовую метку аккаунта, которую задаёт оператор при заливке дампа.
--
-- Sources: issue #3192 (Yandex session cookies — author.phoneNumbers reveal)
BEGIN;
-- #2752: без lock_timeout CREATE INDEX встанет в очередь за чужой долгой
-- сессией и уведёт за собой запросы приложения — таблица новая и пустая, но
-- очередь блокировок этого не знает. Гейт check-migration-lock-timeout.py
-- держит правило на каждом PR.
SET LOCAL lock_timeout = '5s';
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS yandex_session_cookies (
account_label text PRIMARY KEY, -- произвольная метка аккаунта (нет числового id)
cookies_encrypted bytea NOT NULL, -- pgp_sym_encrypt(json_cookies, key)
expires_at_estimate timestamptz NOT NULL, -- estimated expiry (~30 days from upload)
uploaded_at timestamptz NOT NULL DEFAULT NOW(),
last_used_at timestamptz, -- set on each successful authenticated fetch
last_invalid_at timestamptz, -- set when session no longer authenticated
notes text -- e.g. 'Account: <label / email>'
);
-- Most recent upload first (admin dashboard ordering)
CREATE INDEX IF NOT EXISTS yandex_cookies_uploaded_idx
ON yandex_session_cookies (uploaded_at DESC);
-- Active session lookup (non-expired and not invalidated)
CREATE INDEX IF NOT EXISTS yandex_cookies_active_idx
ON yandex_session_cookies (expires_at_estimate)
WHERE last_invalid_at IS NULL;
COMMIT;

View file

@ -81,6 +81,10 @@ def _mock_browser_fetcher_cls() -> MagicMock:
instance = AsyncMock() instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance) instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False) instance.__aexit__ = AsyncMock(return_value=False)
# request_context_reset (#3118) — синхронный метод, не awaited caller'ом; явный
# MagicMock вместо auto-AsyncMock-атрибута, чтобы вызов не оставлял неawaited
# coroutine (RuntimeWarning в тестах с блоками).
instance.request_context_reset = MagicMock()
return MagicMock(return_value=instance) return MagicMock(return_value=instance)
@ -159,7 +163,7 @@ async def test_backfill_processes_snapshot_with_cookies_threaded() -> None:
assert result.failed == 0 assert result.failed == 0
assert mock_fetch.call_count == 3 assert mock_fetch.call_count == 3
mock_bf_cls.assert_called_once_with( mock_bf_cls.assert_called_once_with(
source="domclick", endpoint=fake_settings.browser_http_endpoint source="domclick", endpoint=fake_settings.browser_http_endpoint, reuse_context=True
) )
for call in mock_fetch.call_args_list: for call in mock_fetch.call_args_list:
_, kwargs = call _, kwargs = call

View file

@ -0,0 +1,295 @@
"""Tests для тёплого переиспользуемого browser-context в DomClick detail-бэкфилле (#3118).
Проблема: sidecar's browser.new_page() создавал НОВЫЙ изолированный context на КАЖДЫЙ
/fetch, поэтому кук-инъекция (см. domclick_session), сделанная ОДИН РАЗ в начале
прогона, не видела Set-Cookie от площадки живой qrator_jsid2 (~2.5ч TTL) протухал
уже после первого запроса. Живой замер: 26 подряд sidecar-фетчей = 100% блоков, те же
карточки в тёплом browser-контексте = 5/5 успешно.
Фикс: BrowserFetcher(reuse_context=True) держит per-provider context живым между
вызовами; куки вливаются один раз при его создании. При обнаруженном блоке следующий
fetch должен сбросить сожжённый context так как fetch_detail() (scraper_kit,
намеренно не в scope этого фикса) не прокидывает reset_context, оркестратор сигналит
через bf.request_context_reset() (BrowserFetcher: отложенный флаг, потребляется РОВНО
одним следующим fetch()).
Зеркалит конвенции tests/tasks/test_domclick_detail_backfill.py (module-level
patch-target строки, _mock_db/_mock_session_svc helpers).
"""
from __future__ import annotations
import os
import sys
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402
from app.tasks.domclick_detail_backfill import run_domclick_detail_backfill # noqa: E402
_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail"
_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment"
_RUNS = "app.tasks.domclick_detail_backfill.runs_mod"
_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep"
_SETTINGS = "app.tasks.domclick_detail_backfill.settings"
_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher"
_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc"
def _make_snapshot(n: int) -> list[dict]:
return [
{
"id": i + 1,
"source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}",
}
for i in range(n)
]
def _mock_db(snapshot: list[dict]) -> MagicMock:
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
db.execute.return_value = sel
return db
def _mock_session_svc(cookies: dict[str, str] | None) -> MagicMock:
svc = MagicMock()
svc.load_session.return_value = cookies
svc.COOKIE_EXPIRY_WARN_DAYS = 5
svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30)
return svc
def _mock_browser_fetcher_cls() -> tuple[MagicMock, AsyncMock]:
"""Возвращает (класс-мок, instance-мок) — instance с реальным request_context_reset."""
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
# request_context_reset — синхронный метод (не awaited caller'ом), явный MagicMock
# вместо auto-AsyncMock-атрибута, чтобы вызов не оставлял неawaited coroutine.
instance.request_context_reset = MagicMock()
return MagicMock(return_value=instance), instance
@pytest.mark.asyncio
async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None:
"""BrowserFetcher поднимается с reuse_context=True (#3118) -- тёплый context sidecar'а."""
snapshot = _make_snapshot(1)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(return_value=MagicMock())
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
mock_svc = _mock_session_svc({"CAS_ID": "123", "qrator_jsid2": "live"})
mock_bf_cls, _instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION_SVC, mock_svc),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_domclick_detail_backfill(
db, run_id=100, params={"batch_size": 10, "budget_sec": 3600}
)
mock_bf_cls.assert_called_once_with(
source="domclick", endpoint=fake_settings.browser_http_endpoint, reuse_context=True
)
@pytest.mark.asyncio
async def test_block_triggers_exactly_one_context_reset_request() -> None:
"""DomClickBlockedError -> bf.request_context_reset() вызывается РОВНО один раз на блок.
3 подряд блока (max_consecutive_blocks=3) -> сброс запрашивается 3 раза (по разу
НА КАЖДЫЙ обнаруженный блок), а не один раз на весь прогон и не на каждый успешный
fetch между блоками.
"""
snapshot = _make_snapshot(10)
db = _mock_db(snapshot)
runs = MagicMock()
blocked_exc = DomClickBlockedError("browser fetch failed")
mock_fetch = AsyncMock(side_effect=blocked_exc)
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
mock_svc = _mock_session_svc({"CAS_ID": "123"})
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION_SVC, mock_svc),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_domclick_detail_backfill(
db,
run_id=101,
params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3},
)
assert result.blocked == 3
assert instance.request_context_reset.call_count == 3, (
"ожидался ровно один запрос сброса НА КАЖДЫЙ обнаруженный блок"
)
@pytest.mark.asyncio
async def test_successful_fetch_does_not_request_context_reset() -> None:
"""Успешный прогон без блоков -> request_context_reset() ни разу не вызывается."""
snapshot = _make_snapshot(3)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(return_value=MagicMock())
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
mock_svc = _mock_session_svc({"CAS_ID": "123"})
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION_SVC, mock_svc),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_domclick_detail_backfill(
db, run_id=102, params={"batch_size": 10, "budget_sec": 3600}
)
assert result.blocked == 0
instance.request_context_reset.assert_not_called()
@pytest.mark.asyncio
async def test_block_then_recovery_requests_reset_only_on_the_block() -> None:
"""Блок -> успех -> успех: сброс запрошен РОВНО один раз (на самом блоке).
Один сброс на блок, не на каждый последующий запрос (см. docstring задачи) --
после восстановления (успешный fetch) счётчик подряд-блоков сбрасывается и
request_context_reset() больше не должен вызываться.
"""
snapshot = _make_snapshot(3)
db = _mock_db(snapshot)
runs = MagicMock()
blocked_exc = DomClickBlockedError("browser fetch failed")
mock_fetch = AsyncMock(side_effect=[blocked_exc, MagicMock(), MagicMock()])
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
mock_svc = _mock_session_svc({"CAS_ID": "123"})
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION_SVC, mock_svc),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_domclick_detail_backfill(
db,
run_id=103,
params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3},
)
assert result.blocked == 1
assert result.enriched == 2
instance.request_context_reset.assert_called_once()
# ── BrowserFetcher: сама логика отложенного флага (#3118) ──────────────────────
# Тесты выше мокают BrowserFetcher целиком и проверяют ОРКЕСТРАТОР («на блок зовём
# request_context_reset ровно один раз»). Ниже — реальный BrowserFetcher с моком
# _post_fetch: проверяем потребление флага, ради которого метод и заведён.
def _fetcher(*, reuse_context: bool):
from scraper_kit.browser_fetcher import BrowserFetcher
bf = BrowserFetcher(
source="domclick", endpoint="http://browser:3000", reuse_context=reuse_context
)
bf._client = MagicMock() # обходим assert «используй как async context manager»
return bf
@pytest.mark.asyncio
async def test_3118_pending_reset_consumed_exactly_once():
"""request_context_reset() влияет РОВНО на один следующий fetch, дальше — обычные."""
bf = _fetcher(reuse_context=True)
seen: list[bool] = []
async def _fake_post(url, origin=None, cookies=None, reset_context=False):
seen.append(reset_context)
return "<html></html>"
with patch.object(bf, "_post_fetch", side_effect=_fake_post):
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__1")
bf.request_context_reset()
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__2")
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__3")
assert seen == [False, True, False], seen
assert bf._context_reset_pending is False
@pytest.mark.asyncio
async def test_3118_pending_reset_survives_transport_retry():
"""Transport-ошибка → retry идёт с тем же reset (сброс на сайдкаре идемпотентен)."""
import httpx
bf = _fetcher(reuse_context=True)
seen: list[bool] = []
async def _fake_post(url, origin=None, cookies=None, reset_context=False):
seen.append(reset_context)
if len(seen) == 1:
raise httpx.ConnectError("boom")
return "<html></html>"
with (
patch.object(bf, "_post_fetch", side_effect=_fake_post),
patch("scraper_kit.browser_fetcher.asyncio.sleep", new=AsyncMock()),
):
bf.request_context_reset()
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__4")
assert seen == [True, True], seen
assert bf._context_reset_pending is False
@pytest.mark.asyncio
async def test_3118_payload_unchanged_when_reuse_context_off():
"""reuse_context=False (дефолт) → в payload /fetch новых ключей нет вовсе."""
bf = _fetcher(reuse_context=False)
captured: dict = {}
class _Resp:
@staticmethod
def json():
return {"html": "<html></html>"}
async def _fake_client_post(url, json=None):
captured.update(json or {})
return _Resp()
bf._client.post = _fake_client_post
with (
patch("scraper_kit.browser_fetcher._raise_for_sidecar_status", new=MagicMock()),
patch.object(bf, "_report_fetch_result", new=MagicMock()),
):
bf.request_context_reset() # no-op при выключенном флаге
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__5")
assert "reuse_context" not in captured
assert "reset_context" not in captured

View file

@ -0,0 +1,168 @@
"""Offline tests для Yandex session-cookie admin-эндпоинтов (#3192 MVP).
Покрытие 2 эндпоинтов (db мокается, NO live network/DB), зеркалит паттерн
test_domclick_admin_apis.py / test_admin_cian_session_endpoints.py
(dependency_overrides[get_db] + TestClient):
- POST /api/v1/admin/scrape/yandex/upload-cookies
- GET /api/v1/admin/scrape/yandex/session-status
Про app-уровневый 403/401 для не-админа: намеренно НЕ тестируется как и у
Cian/DomClick-соседей, admin.py не гейтит auth сам (см. модульный docstring
"Auth: Caddy basic_auth гейтит /trade-in/api/v1/admin/* — application layer
открыт"), TestClient здесь Caddy обходит по конструкции. Это не регрессия
именно этого эндпоинта, а свойство всего роутера.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
@pytest.fixture
def client() -> TestClient:
from app.api.v1 import admin as admin_module
from app.core.db import get_db
app = FastAPI()
app.include_router(admin_module.router, prefix="/api/v1/admin")
def fake_db():
yield MagicMock()
app.dependency_overrides[get_db] = fake_db
return TestClient(app)
_UPLOAD_URL = "/api/v1/admin/scrape/yandex/upload-cookies"
_STATUS_URL = "/api/v1/admin/scrape/yandex/session-status"
_SESSION_ID_COOKIE = {"name": "Session_id", "value": "abc", "domain": ".yandex.ru", "path": "/"}
_YANDEXUID_COOKIE = {"name": "yandexuid", "value": "def", "domain": ".yandex.ru", "path": "/"}
_YM_COOKIE = {"name": "_ym_uid", "value": "ghi", "domain": ".yandex.ru", "path": "/"}
# ── POST /scrape/yandex/upload-cookies ────────────────────────────────────────
def test_upload_cookies_503_when_no_encryption_key(client: TestClient) -> None:
with patch("app.api.v1.admin.settings.cookie_encryption_key", ""):
resp = client.post(
_UPLOAD_URL,
json={"account_label": "acc", "cookies": [_SESSION_ID_COOKIE]},
)
assert resp.status_code == 503
def test_upload_cookies_400_when_no_session_id_marker(client: TestClient) -> None:
"""Дамп без Session_id — похоже на анонимную сессию, отказ, ничего не сохраняем."""
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch("app.api.v1.admin.yandex_session_svc.save_session") as mock_save,
):
resp = client.post(
_UPLOAD_URL,
json={"account_label": "acc", "cookies": [_YANDEXUID_COOKIE, _YM_COOKIE]},
)
assert resp.status_code == 400
assert "Session_id" in resp.json()["detail"]
mock_save.assert_not_called()
def test_upload_cookies_400_when_account_label_empty(client: TestClient) -> None:
with patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"):
resp = client.post(
_UPLOAD_URL,
json={"account_label": " ", "cookies": [_SESSION_ID_COOKIE]},
)
assert resp.status_code == 400
assert "account_label" in resp.json()["detail"]
def test_upload_cookies_success_filters_analytics_and_saves(client: TestClient) -> None:
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch("app.api.v1.admin.yandex_session_svc.save_session") as mock_save,
):
resp = client.post(
_UPLOAD_URL,
json={
"account_label": "my-account",
"cookies": [_SESSION_ID_COOKIE, _YANDEXUID_COOKIE, _YM_COOKIE],
},
)
assert resp.status_code == 200
body = resp.json()
assert body == {"ok": True, "accountLabel": "my-account", "cookieCount": 2}
mock_save.assert_called_once()
_, kwargs = mock_save.call_args
assert kwargs["account_label"] == "my-account"
saved_names = {c["name"] for c in kwargs["cookies"]}
assert saved_names == {"Session_id", "yandexuid"} # _ym_uid отфильтрован
# ── GET /scrape/yandex/session-status ─────────────────────────────────────────
def test_session_status_no_key_returns_no_session(client: TestClient) -> None:
with patch("app.api.v1.admin.settings.cookie_encryption_key", ""):
resp = client.get(_STATUS_URL)
assert resp.status_code == 200
assert resp.json() == {"hasSession": False, "expiresAt": None, "expiresSoon": False}
def test_session_status_no_session_in_db(client: TestClient) -> None:
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch("app.api.v1.admin.yandex_session_svc.load_session", return_value=None),
patch("app.api.v1.admin.yandex_session_svc.session_expires_at", return_value=None),
):
resp = client.get(_STATUS_URL)
assert resp.status_code == 200
assert resp.json() == {"hasSession": False, "expiresAt": None, "expiresSoon": False}
def test_session_status_valid_session_far_from_expiry(client: TestClient) -> None:
far_future = datetime.now(UTC) + timedelta(days=20)
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch(
"app.api.v1.admin.yandex_session_svc.load_session",
return_value=[_SESSION_ID_COOKIE],
),
patch(
"app.api.v1.admin.yandex_session_svc.session_expires_at",
return_value=far_future,
),
):
resp = client.get(_STATUS_URL)
assert resp.status_code == 200
body = resp.json()
assert body["hasSession"] is True
assert body["expiresSoon"] is False
def test_session_status_expiring_soon(client: TestClient) -> None:
soon = datetime.now(UTC) + timedelta(days=2)
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch(
"app.api.v1.admin.yandex_session_svc.load_session",
return_value=[_SESSION_ID_COOKIE],
),
patch("app.api.v1.admin.yandex_session_svc.session_expires_at", return_value=soon),
):
resp = client.get(_STATUS_URL)
assert resp.status_code == 200
body = resp.json()
assert body["hasSession"] is True
assert body["expiresSoon"] is True

View file

@ -0,0 +1,215 @@
"""Tests for yandex_session — cookie management service (#3192).
Зеркалит test_domclick_session.py (MagicMock db, никакого live DB) без
verify_session, т.к. yandex_session MVP её не реализует (см. модуль docstring).
"""
from __future__ import annotations
import json
import re
from unittest.mock import MagicMock
import pytest
from app.services.yandex_session import (
ANALYTICS_COOKIE_PREFIXES,
AUTH_MARKER_COOKIE,
YANDEX_CRITICAL_COOKIES,
filter_analytics_cookies,
has_auth_marker,
load_session,
mark_session_invalid,
save_session,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def mock_db() -> MagicMock:
db = MagicMock()
# Default: no row found (load_session → None)
db.execute.return_value.mappings.return_value.first.return_value = None
return db
def _cookie(name: str, value: str = "x") -> dict[str, str]:
return {"name": name, "value": value, "domain": ".yandex.ru", "path": "/"}
# ---------------------------------------------------------------------------
# YANDEX_CRITICAL_COOKIES / AUTH_MARKER_COOKIE
# ---------------------------------------------------------------------------
def test_critical_cookies_set_not_empty() -> None:
assert isinstance(YANDEX_CRITICAL_COOKIES, set)
assert len(YANDEX_CRITICAL_COOKIES) >= 5
def test_critical_cookies_contains_key_names() -> None:
assert "Session_id" in YANDEX_CRITICAL_COOKIES
assert "yandexuid" in YANDEX_CRITICAL_COOKIES
assert "sessguard" in YANDEX_CRITICAL_COOKIES
def test_auth_marker_is_session_id() -> None:
assert AUTH_MARKER_COOKIE == "Session_id"
# ---------------------------------------------------------------------------
# filter_analytics_cookies — NOT an allowlist, only drops analytics prefixes
# ---------------------------------------------------------------------------
def test_filter_analytics_drops_only_analytics_prefixes() -> None:
cookies = [
_cookie("Session_id"),
_cookie("_ym_uid"),
_cookie("_ym_d"),
_cookie("yabs-sid"),
_cookie("_yasc"),
_cookie("some_unknown_cookie_not_in_any_allowlist"),
]
result = filter_analytics_cookies(cookies)
names = {c["name"] for c in result}
assert names == {"Session_id", "some_unknown_cookie_not_in_any_allowlist"}
def test_filter_analytics_keeps_full_dump_minus_analytics() -> None:
"""Замер #3192 сделан на 44 cookies — фильтр НЕ должен сужать до allowlist."""
cookies = [_cookie(f"unknown_cookie_{i}") for i in range(40)] + [
_cookie("Session_id"),
_cookie("_ym_isad"),
_cookie("_yasc"),
_cookie("yabs-sid"),
]
result = filter_analytics_cookies(cookies)
assert len(result) == 41 # 40 unknown + Session_id, 3 analytics dropped
def test_analytics_prefixes_tuple() -> None:
assert ANALYTICS_COOKIE_PREFIXES == ("_ym_", "yabs-", "_yasc")
# ---------------------------------------------------------------------------
# has_auth_marker
# ---------------------------------------------------------------------------
def test_has_auth_marker_true_when_session_id_present() -> None:
assert has_auth_marker([_cookie("Session_id"), _cookie("yandexuid")]) is True
def test_has_auth_marker_false_when_session_id_absent() -> None:
assert has_auth_marker([_cookie("yandexuid"), _cookie("_ym_uid")]) is False
def test_has_auth_marker_false_on_empty_list() -> None:
assert has_auth_marker([]) is False
# ---------------------------------------------------------------------------
# save_session
# ---------------------------------------------------------------------------
def test_save_session_calls_pgp_sym_encrypt(mock_db: MagicMock) -> None:
save_session(mock_db, account_label="test-account", cookies=[_cookie("Session_id")])
args, _ = mock_db.execute.call_args
sql_text = str(args[0])
assert "pgp_sym_encrypt" in sql_text
assert "yandex_session_cookies" in sql_text
def test_save_session_uses_cast_not_colon_colon(mock_db: MagicMock) -> None:
"""Verify psycopg v3 compatibility — no bind-param immediately followed by ::type.
`)::interval` is legit (`::` glued to a closing paren, not to a bind-name see
`.claude/rules/backend.md` CAST(:x AS type) exception), so we assert against the
actual forbidden pattern (`:name::type`) rather than a blanket "::" absence.
"""
save_session(mock_db, account_label="acc", cookies=[_cookie("Session_id")])
args, _ = mock_db.execute.call_args
sql_text = str(args[0])
assert "CAST(" in sql_text
assert re.search(r":[a-z_]+::[a-z]", sql_text) is None
def test_save_session_commits(mock_db: MagicMock) -> None:
save_session(mock_db, account_label="acc", cookies=[_cookie("Session_id")])
assert mock_db.commit.called
def test_save_session_on_conflict_do_update(mock_db: MagicMock) -> None:
save_session(mock_db, account_label="acc", cookies=[_cookie("Session_id")])
args, _ = mock_db.execute.call_args
sql_text = str(args[0])
assert "ON CONFLICT" in sql_text
assert "DO UPDATE" in sql_text
def test_save_session_passes_correct_params(mock_db: MagicMock) -> None:
cookies = [_cookie("Session_id", "abc123")]
save_session(mock_db, account_label="my-account", cookies=cookies, ttl_days=14)
call_args = mock_db.execute.call_args
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("params", {})
assert params["label"] == "my-account"
assert params["ttl_days"] == 14
cookies_payload = json.loads(params["cookies_json"])
assert cookies_payload == cookies
# ---------------------------------------------------------------------------
# load_session
# ---------------------------------------------------------------------------
def test_load_session_returns_none_when_empty(mock_db: MagicMock) -> None:
result = load_session(mock_db)
assert result is None
def test_load_session_decodes_json(mock_db: MagicMock) -> None:
stored_cookies = [_cookie("Session_id", "abc"), _cookie("yandexuid", "def")]
mock_row = {
"account_label": "acc",
"cookies_json": json.dumps(stored_cookies),
"expires_at_estimate": None,
}
mock_db.execute.return_value.mappings.return_value.first.return_value = mock_row
result = load_session(mock_db)
assert result == stored_cookies
def test_load_session_updates_last_used_at(mock_db: MagicMock) -> None:
mock_row = {
"account_label": "acc",
"cookies_json": json.dumps([_cookie("Session_id")]),
"expires_at_estimate": None,
}
mock_db.execute.return_value.mappings.return_value.first.return_value = mock_row
load_session(mock_db)
# Должно быть минимум 2 вызова execute: SELECT + UPDATE last_used_at
assert mock_db.execute.call_count >= 2
# ---------------------------------------------------------------------------
# mark_session_invalid
# ---------------------------------------------------------------------------
def test_mark_session_invalid_updates_table(mock_db: MagicMock) -> None:
mark_session_invalid(mock_db, account_label="acc")
args, _ = mock_db.execute.call_args
sql = str(args[0])
assert "last_invalid_at" in sql
assert "yandex_session_cookies" in sql
def test_mark_session_invalid_commits(mock_db: MagicMock) -> None:
mark_session_invalid(mock_db, account_label="acc")
assert mock_db.commit.called

View file

@ -366,6 +366,13 @@ def _parse_proxy(proxy_url: str | None) -> dict[str, str] | None:
# одновременно. Разные поставщики держат РАЗНЫЕ локи → работают параллельно. # одновременно. Разные поставщики держат РАЗНЫЕ локи → работают параллельно.
_browsers: dict[str, object] = {} # provider → Browser _browsers: dict[str, object] = {} # provider → Browser
_browser_cms: dict[str, object] = {} # provider → AsyncCamoufox CM (для __aexit__) _browser_cms: dict[str, object] = {} # provider → AsyncCamoufox CM (для __aexit__)
# provider → переиспользуемый BrowserContext (#3118, opt-in reuse_context). Создаётся
# ЛЕНИВО в _get_or_create_context на первом /fetch с reuse_context=True; куки вливаются
# ТОЛЬКО в момент создания — дальше cookie-jar эволюционирует сам через Set-Cookie
# площадки (browser.new_page() без reuse_context создаёт НОВЫЙ изолированный context на
# каждый вызов и убивает живой qrator_jsid2 DomClick, см. #3118). При выключенном флаге
# (дефолт) словарь остаётся пустым — поведение прочих провайдеров не меняется.
_contexts: dict[str, object] = {}
_page_counters: dict[str, int] = {} # provider → страниц с момента launch'а _page_counters: dict[str, int] = {} # provider → страниц с момента launch'а
_locks: dict[str, asyncio.Lock] = {} # provider → Lock (весь доступ к инстансу) _locks: dict[str, asyncio.Lock] = {} # provider → Lock (весь доступ к инстансу)
_retry_tasks: dict[str, asyncio.Task[None]] = {} # provider → фоновая retry-задача _retry_tasks: dict[str, asyncio.Task[None]] = {} # provider → фоновая retry-задача
@ -464,8 +471,29 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N
) )
async def _close_reusable_context(provider: str) -> None:
"""Закрывает и выбрасывает сохранённый переиспользуемый context провайдера (#3118).
No-op если контекста нет (флаг reuse_context никогда не включался или уже сброшен).
Best-effort ошибка закрытия не должна ронять caller'а (reset_context/relaunch/
shutdown идут дальше вне зависимости от исхода close()).
"""
ctx = _contexts.pop(provider, None)
if ctx is None:
return
try:
await ctx.close() # type: ignore[attr-defined]
except Exception as exc:
logger.warning(
"tradein-browser[%s]: ошибка при закрытии переиспользуемого context: %s",
provider,
type(exc).__name__,
)
async def _close_browser(provider: str) -> None: async def _close_browser(provider: str) -> None:
"""Закрывает per-provider браузер и чистит состояние для поставщика.""" """Закрывает per-provider браузер + переиспользуемый context (если есть, #3118)."""
await _close_reusable_context(provider)
cm = _browser_cms.get(provider) cm = _browser_cms.get(provider)
if cm is not None: if cm is not None:
try: try:
@ -764,6 +792,18 @@ async def fetch_handler(request: web.Request) -> web.Response:
# yandex его никогда не передают). # yandex его никогда не передают).
cookies: dict | None = body.get("cookies") cookies: dict | None = body.get("cookies")
# reuse_context/reset_context (#3118) — тёплый переиспользуемый browser-context для
# DomClick detail-бэкфилла: browser.new_page() каждый раз создаёт НОВЫЙ изолированный
# context, и куки (см. cookies выше), залитые ОДИН РАЗ, не видят Set-Cookie от
# площадки — живой qrator_jsid2 (~2.5ч TTL) протухает уже после первого запроса.
# reuse_context=True держит per-provider context живым между вызовами (куки вливаются
# ТОЛЬКО при его создании, дальше jar эволюционирует сам); reset_context=True
# выбрасывает сожжённый context ДО работы (следующее создание будет чистым). Оба
# дефолт False — поведение всех прочих provider'ов (avito/cian/yandex/generic) не
# меняется ни на байт.
reuse_context: bool = bool(body.get("reuse_context", False))
reset_context: bool = bool(body.get("reset_context", False))
provider = _resolve_provider(body, url) provider = _resolve_provider(body, url)
proxy_override = _resolve_proxy_override(body, provider) proxy_override = _resolve_proxy_override(body, provider)
@ -796,7 +836,14 @@ async def fetch_handler(request: web.Request) -> web.Response:
) )
try: try:
html = await _do_fetch(provider, url, origin=origin, cookies=cookies) html = await _do_fetch(
provider,
url,
origin=origin,
cookies=cookies,
reuse_context=reuse_context,
reset_context=reset_context,
)
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"tradein-browser[%s]: fetch error url=%r: %s: %s", "tradein-browser[%s]: fetch error url=%r: %s: %s",
@ -952,17 +999,30 @@ async def _do_fetch(
*, *,
origin: str | None = None, origin: str | None = None,
cookies: dict | None = None, cookies: dict | None = None,
reuse_context: bool = False,
reset_context: bool = False,
) -> str: ) -> str:
"""Одна попытка навигации; при краше браузера — relaunch и один retry. """Одна попытка навигации; при краше браузера — relaunch и один retry.
Caller держит _locks[provider] (нет параллельных страниц на этом инстансе), Caller держит _locks[provider] (нет параллельных страниц на этом инстансе),
поэтому relaunch безопасен. Crash-relaunch сохраняет текущий прокси инстанса поэтому relaunch безопасен. Crash-relaunch сохраняет текущий прокси инстанса
(_relaunch_browser reuse _launched_proxy) динамический прокси пула не теряется. (_relaunch_browser reuse _launched_proxy) динамический прокси пула не теряется.
Crash-relaunch (через _close_browser) заодно выбрасывает переиспользуемый context
провайдера (#3118) — на retry reuse_context=True создаст его заново с чистого листа,
а не обратится к context'у уже мёртвого браузера.
origin/cookies см. _fetch_once. None (дефолт) не меняет поведение. origin/cookies/reuse_context/reset_context см. _fetch_once. Дефолты не меняют
поведение.
""" """
try: try:
return await _fetch_once(provider, url, origin=origin, cookies=cookies) return await _fetch_once(
provider,
url,
origin=origin,
cookies=cookies,
reuse_context=reuse_context,
reset_context=reset_context,
)
except Exception as exc: except Exception as exc:
if _is_browser_crash(exc): if _is_browser_crash(exc):
logger.warning( logger.warning(
@ -974,7 +1034,14 @@ async def _do_fetch(
await _relaunch_browser(provider) await _relaunch_browser(provider)
if _browsers.get(provider) is None: if _browsers.get(provider) is None:
raise raise
return await _fetch_once(provider, url, origin=origin, cookies=cookies) return await _fetch_once(
provider,
url,
origin=origin,
cookies=cookies,
reuse_context=reuse_context,
reset_context=reset_context,
)
raise raise
@ -1110,14 +1177,46 @@ async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
return settled return settled
async def _get_or_create_context(
provider: str,
browser: object,
cookies: dict | None,
url: str,
) -> object:
"""Возвращает сохранённый переиспользуемый context провайдера либо создаёт новый (#3118).
Куки вливаются ТОЛЬКО в момент создания в уже существующий (сохранённый) context
add_cookies больше НИКОГДА не вызывается: повторная заливка замороженного снимка
из БД убивала бы живой qrator_jsid2, который площадка успела выдать через
Set-Cookie, ровно это и было причиной бана DomClick detail-бэкфилла.
"""
existing = _contexts.get(provider)
if existing is not None:
return existing
context = await browser.new_context() # type: ignore[attr-defined]
if cookies:
cookie_domain = f".{urlparse(url).hostname or ''}"
await context.add_cookies( # type: ignore[attr-defined]
[
{"name": name, "value": value, "domain": cookie_domain, "path": "/"}
for name, value in cookies.items()
]
)
_contexts[provider] = context
logger.info("tradein-browser[%s]: создан переиспользуемый browser context (#3118)", provider)
return context
async def _fetch_once( async def _fetch_once(
provider: str, provider: str,
url: str, url: str,
*, *,
origin: str | None = None, origin: str | None = None,
cookies: dict | None = None, cookies: dict | None = None,
reuse_context: bool = False,
reset_context: bool = False,
) -> str: ) -> str:
"""Открывает СОБСТВЕННУЮ страницу, переходит по URL, ждёт JS, возвращает HTML. """Открывает страницу, переходит по URL, ждёт JS, возвращает HTML.
Caller держит _locks[provider], поэтому страницы на этом инстансе не Caller держит _locks[provider], поэтому страницы на этом инстансе не
параллелятся recycle через _relaunch_browser безопасен прямо здесь. параллелятся recycle через _relaunch_browser безопасен прямо здесь.
@ -1128,21 +1227,42 @@ async def _fetch_once(
card-fetch, эмпирически подтверждено вживую 2026-07-04). None (дефолт) поведение card-fetch, эмпирически подтверждено вживую 2026-07-04). None (дефолт) поведение
не меняется, ровно один goto(url) как раньше (avito/cian/yandex не передают origin). не меняется, ровно один goto(url) как раньше (avito/cian/yandex не передают origin).
cookies (опционально) dict cookie_namevalue для инъекции в контекст страницы cookies (опционально) dict cookie_namevalue для инъекции ДО любой навигации
ДО любой навигации (обход QRATOR-блока DomClick при валидной test-аккаунт сессии, (обход QRATOR-блока DomClick при валидной test-аккаунт сессии, эмпирически
эмпирически подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ захардкожен, а
захардкожен, а выводится из hostname целевого url (с ведущей точкой покрывает выводится из hostname целевого url (с ведущей точкой покрывает поддомены,
поддомены, зеркалит реальную DomClick cookie-scope ".domclick.ru" для зеркалит реальную DomClick cookie-scope ".domclick.ru" для
ekaterinburg.domclick.ru/spb.domclick.ru/etc, но работает для любого хоста, не ekaterinburg.domclick.ru/spb.domclick.ru/etc, но работает для любого хоста, не
только DomClick) механизм пригоден для будущих caller'ов (Cian/Avito/Yandex). только DomClick) механизм пригоден для будущих caller'ов (Cian/Avito/Yandex).
None (дефолт) без инъекции, поведение не меняется (avito/cian/yandex сейчас При reuse_context=False (дефолт) вливаются в КАЖДУЮ новую страницу, как раньше.
cookies не передают). При reuse_context=True ТОЛЬКО в момент создания переиспользуемого context'а
(см. _get_or_create_context); в уже существующий context повторно не вливаются
никогда. None (дефолт) без инъекции, поведение не меняется.
reuse_context (#3118, опционально, дефолт False) — держит per-provider
BrowserContext живым между вызовами вместо browser.new_page() (который создаёт
НОВЫЙ изолированный context на КАЖДЫЙ запрос и убивает cookie-jar в т.ч. живой
qrator_jsid2 DomClick, ~2.5ч TTL, ротируемый площадкой через Set-Cookie). Страница
создаётся ИЗ этого context'а и закрывается после отдачи HTML, сам context остаётся
жить. False (дефолт) browser.new_page() как раньше avito/cian/yandex/generic
его никогда не включают, поведение не меняется ни на байт.
reset_context (#3118, опционально, дефолт False) — закрывает и выбрасывает
сохранённый context провайдера ДО работы (следующий reuse_context=True создаст его
заново, с чистым cookie-jar). Вызывающая сторона использует его ровно один раз на
обнаруженный блок, не на каждый последующий запрос.
""" """
browser = _browsers.get(provider) browser = _browsers.get(provider)
assert browser is not None, "browser not launched" assert browser is not None, "browser not launched"
page = await browser.new_page() # type: ignore[attr-defined] if reset_context:
try: await _close_reusable_context(provider)
if reuse_context:
context = await _get_or_create_context(provider, browser, cookies, url)
page = await context.new_page() # type: ignore[attr-defined]
else:
page = await browser.new_page() # type: ignore[attr-defined]
if cookies: if cookies:
cookie_domain = f".{urlparse(url).hostname or ''}" cookie_domain = f".{urlparse(url).hostname or ''}"
await page.context.add_cookies( # type: ignore[attr-defined] await page.context.add_cookies( # type: ignore[attr-defined]
@ -1151,6 +1271,7 @@ async def _fetch_once(
for name, value in cookies.items() for name, value in cookies.items()
] ]
) )
try:
await _apply_resource_block(page) await _apply_resource_block(page)
await _pace_provider(provider) await _pace_provider(provider)
if origin: if origin:

View file

@ -174,6 +174,8 @@ class _OverlapProbe:
*, *,
origin: str | None = None, origin: str | None = None,
cookies: dict | None = None, cookies: dict | None = None,
reuse_context: bool = False,
reset_context: bool = False,
) -> str: ) -> str:
self.active[provider] = self.active.get(provider, 0) + 1 self.active[provider] = self.active.get(provider, 0) + 1
self.peak[provider] = max(self.peak.get(provider, 0), self.active[provider]) self.peak[provider] = max(self.peak.get(provider, 0), self.active[provider])

View file

@ -0,0 +1,277 @@
"""test_server_reuse_context.py — тёплый переиспользуемый browser context (#3118).
Проблема: browser.new_page() создаёт НОВЫЙ изолированный context на КАЖДЫЙ /fetch,
поэтому залитый ОДИН РАЗ снимок cookies (см. cookies в /fetch) не видит Set-Cookie
от площадки живой qrator_jsid2 DomClick (~2.5ч TTL) протухает после первого же
запроса. reuse_context=True держит per-provider context живым между вызовами; куки
вливаются ТОЛЬКО в момент его создания. reset_context=True выбрасывает сожжённый
context ДО работы.
Проверяет:
1. reuse_context=True context создаётся ОДИН раз на несколько _fetch_once,
куки вливаются только при создании (не повторно);
2. reset_context=True старый context закрывается и выбрасывается, следующий
_fetch_once создаёт новый (с новой инъекцией кук);
3. reuse_context=False (дефолт) поведение НЕ меняется: browser.new_page()
на каждый вызов, context вообще не создаётся/не трогается;
4. _close_browser закрывает и выбрасывает сохранённый context провайдера.
camoufox НЕ запускается browser/context/page подделаны.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_reuse_context.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
from pathlib import Path
from typing import Any
import pytest
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(server, "_browsers", {})
monkeypatch.setattr(server, "_browser_cms", {})
monkeypatch.setattr(server, "_contexts", {})
monkeypatch.setattr(server, "_page_counters", {})
monkeypatch.setattr(server, "_locks", {})
monkeypatch.setattr(server, "_last_goto_at", {})
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
class _FakePage:
"""Поддельная page: фиксирует goto/close, минимальный контракт _fetch_once."""
def __init__(self) -> None:
self.goto_urls: list[str] = []
self.closed = 0
async def route(self, pattern: str, handler: Any) -> None:
return None
async def goto(self, url: str, **kwargs: Any) -> None:
self.goto_urls.append(url)
async def wait_for_timeout(self, ms: int) -> None:
return None
async def content(self) -> str:
return "<html>ok</html>"
async def close(self) -> None:
self.closed += 1
class _FakeContext:
"""Поддельный BrowserContext: фиксирует add_cookies/new_page/close."""
def __init__(self) -> None:
self.add_cookies_calls: list[list[dict]] = []
self.pages: list[_FakePage] = []
self.closed = 0
async def add_cookies(self, cookies: list[dict]) -> None:
self.add_cookies_calls.append(cookies)
async def new_page(self) -> _FakePage:
page = _FakePage()
self.pages.append(page)
return page
async def close(self) -> None:
self.closed += 1
class _FakeReuseBrowser:
"""Поддельный Browser: считает new_context()/new_page() вызовы, копит контексты."""
def __init__(self) -> None:
self.contexts_created: list[_FakeContext] = []
self.new_page_calls = 0
async def new_context(self) -> _FakeContext:
ctx = _FakeContext()
self.contexts_created.append(ctx)
return ctx
async def new_page(self) -> _FakePage:
self.new_page_calls += 1
return _FakePage()
# ── reuse_context=True: один context на несколько fetch ───────────────────────────
def test_reuse_context_creates_context_once_across_fetches() -> None:
browser = _FakeReuseBrowser()
server._browsers["avito"] = browser
async def _run() -> None:
await server._fetch_once("avito", "https://www.avito.ru/1", reuse_context=True)
await server._fetch_once("avito", "https://www.avito.ru/2", reuse_context=True)
await server._fetch_once("avito", "https://www.avito.ru/3", reuse_context=True)
asyncio.run(_run())
assert len(browser.contexts_created) == 1, "context должен создаваться РОВНО один раз"
ctx = browser.contexts_created[0]
assert len(ctx.pages) == 3, "каждый fetch — своя страница ИЗ переиспользуемого context"
assert browser.new_page_calls == 0, "browser.new_page() не должен зваться при reuse_context"
# Все страницы закрыты, context — нет (остаётся жить).
assert all(p.closed == 1 for p in ctx.pages)
assert ctx.closed == 0
assert server._contexts["avito"] is ctx
def test_reuse_context_injects_cookies_only_on_creation() -> None:
browser = _FakeReuseBrowser()
server._browsers["domclick"] = browser
cookies = {"qrator_jsid2": "live-token"}
async def _run() -> None:
await server._fetch_once(
"domclick", "https://domclick.ru/card/1", cookies=cookies, reuse_context=True
)
await server._fetch_once(
"domclick", "https://domclick.ru/card/2", cookies=cookies, reuse_context=True
)
asyncio.run(_run())
ctx = browser.contexts_created[0]
assert len(ctx.add_cookies_calls) == 1, "повторный fetch НЕ должен вливать куки заново"
injected = ctx.add_cookies_calls[0]
assert {
"name": "qrator_jsid2",
"value": "live-token",
"domain": ".domclick.ru",
"path": "/",
} in injected
# ── reset_context=True: старый context выбрасывается, следующий — заново ──────────
def test_reset_context_discards_old_context_and_creates_new() -> None:
browser = _FakeReuseBrowser()
server._browsers["domclick"] = browser
async def _run() -> None:
await server._fetch_once(
"domclick", "https://domclick.ru/1", cookies={"a": "1"}, reuse_context=True
)
await server._fetch_once(
"domclick",
"https://domclick.ru/2",
cookies={"a": "2"},
reuse_context=True,
reset_context=True,
)
asyncio.run(_run())
assert len(browser.contexts_created) == 2, "reset_context должен вызвать пересоздание"
old_ctx, new_ctx = browser.contexts_created
assert old_ctx.closed == 1, "старый context должен быть закрыт"
assert new_ctx.add_cookies_calls == [
[{"name": "a", "value": "2", "domain": ".domclick.ru", "path": "/"}]
]
assert server._contexts["domclick"] is new_ctx
def test_reset_context_without_reuse_is_noop_for_new_page_path() -> None:
"""reset_context=True без reuse_context (не должно ломать текущий new_page-путь)."""
browser = _FakeReuseBrowser()
server._browsers["avito"] = browser
html = asyncio.run(
server._fetch_once("avito", "https://www.avito.ru/1", reset_context=True)
)
assert html == "<html>ok</html>"
assert browser.new_page_calls == 1
assert browser.contexts_created == []
# ── reuse_context=False (дефолт): golden-parity, поведение не меняется ────────────
def test_reuse_context_false_uses_new_page_every_time() -> None:
browser = _FakeReuseBrowser()
server._browsers["cian"] = browser
async def _run() -> None:
await server._fetch_once("cian", "https://www.cian.ru/1")
await server._fetch_once("cian", "https://www.cian.ru/2")
asyncio.run(_run())
assert browser.new_page_calls == 2
assert browser.contexts_created == [], "reuse_context=False не должен создавать context"
assert "cian" not in server._contexts
def test_reuse_context_false_still_injects_cookies_into_page_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Golden-parity: cookies без reuse_context вливаются в page.context, как раньше."""
class _FakePageContext:
def __init__(self) -> None:
self.add_cookies_calls: list[list[dict]] = []
async def add_cookies(self, cookies: list[dict]) -> None:
self.add_cookies_calls.append(cookies)
class _PageWithContext(_FakePage):
def __init__(self) -> None:
super().__init__()
self.context = _FakePageContext()
class _Browser:
def __init__(self) -> None:
self.page = _PageWithContext()
async def new_page(self) -> _PageWithContext:
return self.page
browser = _Browser()
server._browsers["yandex"] = browser
asyncio.run(
server._fetch_once("yandex", "https://realty.yandex.ru/1", cookies={"sid": "x"})
)
assert browser.page.context.add_cookies_calls == [
[{"name": "sid", "value": "x", "domain": ".realty.yandex.ru", "path": "/"}]
]
# ── _close_browser закрывает переиспользуемый context (#3118) ─────────────────────
def test_close_browser_closes_and_pops_reusable_context() -> None:
ctx = _FakeContext()
server._contexts["avito"] = ctx
asyncio.run(server._close_browser("avito"))
assert ctx.closed == 1
assert "avito" not in server._contexts
def test_close_browser_noop_when_no_context() -> None:
"""Нет сохранённого context — _close_browser не падает (no-op ветка)."""
asyncio.run(server._close_browser("generic"))
assert "generic" not in server._contexts

View file

@ -272,6 +272,7 @@ class BrowserFetcher:
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
use_pool: bool = False, use_pool: bool = False,
environment: str = "dev", environment: str = "dev",
reuse_context: bool = False,
) -> None: ) -> None:
# source — логический источник ("avito"/"cian"/"yandex"/"domclick"). Сервер # source — логический источник ("avito"/"cian"/"yandex"/"domclick"). Сервер
# роутит /fetch по нему на отдельный браузер+прокси, когда включён # роутит /fetch по нему на отдельный браузер+прокси, когда включён
@ -306,6 +307,22 @@ class BrowserFetcher:
# падаем на env-прокси (все мертвы, #2613) — NoProxyAvailableError вместо тела # падаем на env-прокси (все мертвы, #2613) — NoProxyAvailableError вместо тела
# без "proxy" (см. _acquire_lease). Дефолт "dev" — легитимный fallback на env # без "proxy" (см. _acquire_lease). Дефолт "dev" — легитимный fallback на env
# для dev/test, поведение не меняется. # для dev/test, поведение не меняется.
# reuse_context (#3118) — сессионный флаг: держать тёплый переиспользуемый
# browser-context сайдкара (tradein-browser server.py::_get_or_create_context)
# вместо browser.new_page() на каждый /fetch. Нужен, потому что new_page()
# создаёт НОВЫЙ изолированный context на каждый вызов и убивает cookie-jar — в
# т.ч. живой qrator_jsid2 DomClick, который куки-инъекция (см. fetch()) не
# успевает подхватить повторно. False (дефолт) → payload /fetch остаётся БЕЗ
# ключа reuse_context (см. _post_fetch) — сайдкар старой версии, не знающий
# этого поля, ничего не заметит; поведение всех прочих providers не меняется.
self._reuse_context = reuse_context
# Отложенный запрос сброса context'а сайдкара (#3118) — выставляется
# request_context_reset() (caller не имеет прямого доступа к call-сайту
# fetch(), напр. domclick_detail_backfill вызывает его через fetch_detail(),
# который reset_context НЕ прокидывает) и потребляется РОВНО одним следующим
# fetch() (см. fetch()), затем сбрасывается — один флаг на один блок, не на
# каждый последующий запрос.
self._context_reset_pending: bool = False
self._source = source self._source = source
self._fetch_timeout_s = fetch_timeout_s self._fetch_timeout_s = fetch_timeout_s
self._client: httpx.AsyncClient | None = None self._client: httpx.AsyncClient | None = None
@ -354,6 +371,7 @@ class BrowserFetcher:
*, *,
origin: str | None = None, origin: str | None = None,
cookies: dict[str, str] | None = None, cookies: dict[str, str] | None = None,
reset_context: bool = False,
) -> str: ) -> str:
"""Запрашивает HTML страницы через tradein-browser HTTP-сервис. """Запрашивает HTML страницы через tradein-browser HTTP-сервис.
@ -368,6 +386,13 @@ class BrowserFetcher:
domclick_session). None (дефолт) поведение не меняется, инъекции нет domclick_session). None (дефолт) поведение не меняется, инъекции нет
таков путь всех providers кроме domclick detail-debug. таков путь всех providers кроме domclick detail-debug.
reset_context (#3118) — просит сайдкар выбросить сохранённый переиспользуемый
context ЭТОГО источника ДО фетча (см. reuse_context в __init__). Имеет смысл
только вместе с reuse_context=True; при reuse_context=False payload остаётся
неизменным (см. _post_fetch) параметр молча игнорируется сайдкаром.
Фактически применяемое значение ``reset_context OR`` отложенный флаг из
request_context_reset() (потребляется здесь ровно один раз).
При HTTPError или ConnectError делает одну повторную попытку после При HTTPError или ConnectError делает одну повторную попытку после
короткой паузы. Остальные исключения всплывают к вызывающему коду. короткой паузы. Остальные исключения всплывают к вызывающему коду.
@ -377,8 +402,18 @@ class BrowserFetcher:
assert self._client is not None, "BrowserFetcher: используй как async context manager" assert self._client is not None, "BrowserFetcher: используй как async context manager"
assert self._endpoint is not None, "BrowserFetcher: endpoint не задан" assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
# Отложенный флаг потребляется РОВНО здесь и сразу гасится — следующий fetch()
# уже не сбрасывает контекст (один сброс на один блок). На retry ниже тот же
# effective_reset уходит вторым POST'ом ОСОЗНАННО: сброс на стороне сайдкара
# идемпотентен (pop с дефолтом), а исход первого POST'а неизвестен — он мог
# упасть транспортом как до, так и после пересоздания контекста. Цена лишнего
# срабатывания — один new_context()+add_cookies(), цена пропущенного — прогон
# доживает на сожжённом контексте, поэтому выбран заведомо безопасный вариант.
effective_reset = reset_context or self._context_reset_pending
self._context_reset_pending = False
try: try:
return await self._post_fetch(url, origin, cookies) return await self._post_fetch(url, origin, cookies, effective_reset)
except (httpx.HTTPError, httpx.TransportError) as exc: except (httpx.HTTPError, httpx.TransportError) as exc:
logger.warning( logger.warning(
"BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s", "BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s",
@ -387,7 +422,7 @@ class BrowserFetcher:
url, url,
) )
await asyncio.sleep(_RETRY_SLEEP_S) await asyncio.sleep(_RETRY_SLEEP_S)
return await self._post_fetch(url, origin, cookies) return await self._post_fetch(url, origin, cookies, effective_reset)
async def fetch_json( async def fetch_json(
self, self,
@ -530,6 +565,20 @@ class BrowserFetcher:
"BrowserFetcher: proxy_pool release failed for %s", self._source, exc_info=True "BrowserFetcher: proxy_pool release failed for %s", self._source, exc_info=True
) )
def request_context_reset(self) -> None:
"""Просит сбросить переиспользуемый sidecar-context на СЛЕДУЮЩЕМ fetch() (#3118).
Нужен вызывающему коду, который не дёргает fetch() напрямую (например,
domclick_detail_backfill идёт через scraper_kit.providers.domclick.detail.
fetch_detail(), который reset_context не прокидывает) сюда можно
сигнализировать «контекст сожжён блоком» без изменения того промежуточного
слоя. Потребляется ровно один раз: следующий fetch() применит сброс и
обнулит флаг сам, дальнейшие запросы идут как обычно (никакого «сброс на
каждый последующий»). No-op, если reuse_context=False тогда fetch() всё
равно не кладёт reuse_context/reset_context в payload.
"""
self._context_reset_pending = True
def report_ban(self, reason: str) -> None: def report_ban(self, reason: str) -> None:
"""Пометить ТЕКУЩИЙ lease забаненным площадкой (#2600 п.1, п.2). """Пометить ТЕКУЩИЙ lease забаненным площадкой (#2600 п.1, п.2).
@ -641,6 +690,7 @@ class BrowserFetcher:
url: str, url: str,
origin: str | None = None, origin: str | None = None,
cookies: dict[str, str] | None = None, cookies: dict[str, str] | None = None,
reset_context: bool = False,
) -> str: ) -> str:
"""Один HTTP POST к /fetch эндпоинту сервиса. """Один HTTP POST к /fetch эндпоинту сервиса.
@ -648,6 +698,11 @@ class BrowserFetcher:
_post_fetch_json, сервер (body.get("origin")/body.get("cookies")) _post_fetch_json, сервер (body.get("origin")/body.get("cookies"))
корректно обрабатывает оба случая. корректно обрабатывает оба случая.
reuse_context/reset_context (#3118) кладутся в payload ТОЛЬКО когда сессионный
self._reuse_context включён при выключенном флаге (дефолт) payload остаётся
БАЙТ-В-БАЙТ таким же, как раньше: сайдкар старой версии (без этих полей в
схеме) не должен получать неожиданные ключи.
proxy из ТЕКУЩЕГО session-lease (_current_proxy), НЕ acquire на каждый вызов proxy из ТЕКУЩЕГО session-lease (_current_proxy), НЕ acquire на каждый вызов
(#2164 P4 sticky-session fix, живая регрессия 2026-08). Исход репортится в lease (#2164 P4 sticky-session fix, живая регрессия 2026-08). Исход репортится в lease
через _report_fetch_result (touch-heartbeat + mark_health + осознанная ротация через _report_fetch_result (touch-heartbeat + mark_health + осознанная ротация
@ -663,6 +718,9 @@ class BrowserFetcher:
"origin": origin, "origin": origin,
"cookies": cookies, "cookies": cookies,
} }
if self._reuse_context:
payload["reuse_context"] = True
payload["reset_context"] = reset_context
if proxy_url: if proxy_url:
payload["proxy"] = proxy_url payload["proxy"] = proxy_url
if proxy_kind: if proxy_kind: