From 5be64c66882fa3f85ba36ee865767f8ac18adf0f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 28 Aug 2026 19:02:05 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/scrapers):=20=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=BB=D0=B8=D1=89=D0=B5=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8=20=D0=AF=D0=BD=D0=B4=D0=B5?= =?UTF-8?q?=D0=BA=D1=81.=D0=9D=D0=B5=D0=B4=D0=B2=D0=B8=D0=B6=D0=B8=D0=BC?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=20(#3195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/admin.py | 126 ++++++++ .../backend/app/services/yandex_session.py | 254 +++++++++++++++ .../app/tasks/domclick_detail_backfill.py | 16 +- .../data/sql/274_yandex_session_cookies.sql | 61 ++++ .../tasks/test_domclick_detail_backfill.py | 6 +- .../tests/test_3118_domclick_warm_context.py | 295 ++++++++++++++++++ .../test_admin_yandex_session_endpoints.py | 168 ++++++++++ .../backend/tests/test_yandex_session.py | 215 +++++++++++++ tradein-mvp/browser/server.py | 151 ++++++++- tradein-mvp/browser/test_server.py | 2 + .../browser/test_server_reuse_context.py | 277 ++++++++++++++++ .../src/scraper_kit/browser_fetcher.py | 62 +++- 12 files changed, 1614 insertions(+), 19 deletions(-) create mode 100644 tradein-mvp/backend/app/services/yandex_session.py create mode 100644 tradein-mvp/backend/data/sql/274_yandex_session_cookies.sql create mode 100644 tradein-mvp/backend/tests/test_3118_domclick_warm_context.py create mode 100644 tradein-mvp/backend/tests/test_admin_yandex_session_endpoints.py create mode 100644 tradein-mvp/backend/tests/test_yandex_session.py create mode 100644 tradein-mvp/browser/test_server_reuse_context.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index d4cb58cc..2d742787 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -9,6 +9,7 @@ import asyncio import json import logging import time +from datetime import datetime from typing import Annotated, Any, Literal from urllib.parse import urlparse, urlunparse 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 proxy_rotation as proxy_rotation_svc 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.geocoder import geocode, known_city_hint 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": , "cookieCount": } + """ + 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": , "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) ───────────── diff --git a/tradein-mvp/backend/app/services/yandex_session.py b/tradein-mvp/backend/app/services/yandex_session.py new file mode 100644 index 00000000..b48fa679 --- /dev/null +++ b/tradein-mvp/backend/app/services/yandex_session.py @@ -0,0 +1,254 @@ +"""Yandex.Nedvizhimost session cookie management — load/save/invalidate encrypted cookies. + +Замер на проде (#3192, сайдкар, две карточки, режимы вперемежку, 2026-08-28): +авторизованная сессия Яндекс.Недвижимости отдаёт ключ `mainPhone` и на 11 больше +уникальных телефонов продавца (90→101 / 89→100), чем анонимный запрос той же +карточки — контакты продавца физически недоступны без валидной сессии. + +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) diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index 9e09066f..f4786ffa 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -272,7 +272,17 @@ async def run_domclick_detail_backfill( # module docstring). source="domclick" -- infra identifier, dedicated # residential proxy (scrape_proxies.provider_affinity='domclick', # 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): # Budget guard elapsed = time.monotonic() - start @@ -329,6 +339,10 @@ async def run_domclick_detail_backfill( except DomClickBlockedError as e: consecutive_blocks += 1 counters.blocked += 1 + # #3118: сожжённый переиспользуемый context (протухший qrator_jsid2 + # / стухшая сессия) не должен тянуться дальше по прогону — просим + # ОДИН сброс на следующем fetch(), не на каждый последующий запрос. + bf.request_context_reset() logger.warning( "domclick_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s", run_id, diff --git a/tradein-mvp/backend/data/sql/274_yandex_session_cookies.sql b/tradein-mvp/backend/data/sql/274_yandex_session_cookies.sql new file mode 100644 index 00000000..e3b56240 --- /dev/null +++ b/tradein-mvp/backend/data/sql/274_yandex_session_cookies.sql @@ -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: