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
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:
parent
49b70a67f3
commit
5be64c6688
12 changed files with 1614 additions and 19 deletions
|
|
@ -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": <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) ─────────────
|
||||
|
||||
|
||||
|
|
|
|||
254
tradein-mvp/backend/app/services/yandex_session.py
Normal file
254
tradein-mvp/backend/app/services/yandex_session.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
61
tradein-mvp/backend/data/sql/274_yandex_session_cookies.sql
Normal file
61
tradein-mvp/backend/data/sql/274_yandex_session_cookies.sql
Normal 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;
|
||||
|
|
@ -81,6 +81,10 @@ def _mock_browser_fetcher_cls() -> MagicMock:
|
|||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -159,7 +163,7 @@ async def test_backfill_processes_snapshot_with_cookies_threaded() -> None:
|
|||
assert result.failed == 0
|
||||
assert mock_fetch.call_count == 3
|
||||
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:
|
||||
_, kwargs = call
|
||||
|
|
|
|||
295
tradein-mvp/backend/tests/test_3118_domclick_warm_context.py
Normal file
295
tradein-mvp/backend/tests/test_3118_domclick_warm_context.py
Normal 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
|
||||
168
tradein-mvp/backend/tests/test_admin_yandex_session_endpoints.py
Normal file
168
tradein-mvp/backend/tests/test_admin_yandex_session_endpoints.py
Normal 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
|
||||
215
tradein-mvp/backend/tests/test_yandex_session.py
Normal file
215
tradein-mvp/backend/tests/test_yandex_session.py
Normal 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
|
||||
|
|
@ -366,6 +366,13 @@ def _parse_proxy(proxy_url: str | None) -> dict[str, str] | None:
|
|||
# одновременно. Разные поставщики держат РАЗНЫЕ локи → работают параллельно.
|
||||
_browsers: dict[str, object] = {} # provider → Browser
|
||||
_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'а
|
||||
_locks: dict[str, asyncio.Lock] = {} # provider → Lock (весь доступ к инстансу)
|
||||
_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:
|
||||
"""Закрывает per-provider браузер и чистит состояние для поставщика."""
|
||||
"""Закрывает per-provider браузер + переиспользуемый context (если есть, #3118)."""
|
||||
await _close_reusable_context(provider)
|
||||
cm = _browser_cms.get(provider)
|
||||
if cm is not None:
|
||||
try:
|
||||
|
|
@ -764,6 +792,18 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
|||
# yandex его никогда не передают).
|
||||
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)
|
||||
proxy_override = _resolve_proxy_override(body, provider)
|
||||
|
||||
|
|
@ -796,7 +836,14 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
|||
)
|
||||
|
||||
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:
|
||||
logger.error(
|
||||
"tradein-browser[%s]: fetch error url=%r: %s: %s",
|
||||
|
|
@ -952,17 +999,30 @@ async def _do_fetch(
|
|||
*,
|
||||
origin: str | None = None,
|
||||
cookies: dict | None = None,
|
||||
reuse_context: bool = False,
|
||||
reset_context: bool = False,
|
||||
) -> str:
|
||||
"""Одна попытка навигации; при краше браузера — relaunch и один retry.
|
||||
|
||||
Caller держит _locks[provider] (нет параллельных страниц на этом инстансе),
|
||||
поэтому relaunch безопасен. Crash-relaunch сохраняет текущий прокси инстанса
|
||||
(_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:
|
||||
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:
|
||||
if _is_browser_crash(exc):
|
||||
logger.warning(
|
||||
|
|
@ -974,7 +1034,14 @@ async def _do_fetch(
|
|||
await _relaunch_browser(provider)
|
||||
if _browsers.get(provider) is None:
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -1110,14 +1177,46 @@ async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
|
|||
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(
|
||||
provider: str,
|
||||
url: str,
|
||||
*,
|
||||
origin: str | None = None,
|
||||
cookies: dict | None = None,
|
||||
reuse_context: bool = False,
|
||||
reset_context: bool = False,
|
||||
) -> str:
|
||||
"""Открывает СОБСТВЕННУЮ страницу, переходит по URL, ждёт JS, возвращает HTML.
|
||||
"""Открывает страницу, переходит по URL, ждёт JS, возвращает HTML.
|
||||
|
||||
Caller держит _locks[provider], поэтому страницы на этом инстансе не
|
||||
параллелятся — recycle через _relaunch_browser безопасен прямо здесь.
|
||||
|
|
@ -1128,21 +1227,42 @@ async def _fetch_once(
|
|||
card-fetch, эмпирически подтверждено вживую 2026-07-04). None (дефолт) → поведение
|
||||
не меняется, ровно один goto(url) как раньше (avito/cian/yandex не передают origin).
|
||||
|
||||
cookies (опционально) — dict cookie_name→value для инъекции в контекст страницы
|
||||
ДО любой навигации (обход QRATOR-блока DomClick при валидной test-аккаунт сессии,
|
||||
эмпирически подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ
|
||||
захардкожен, а выводится из hostname целевого url (с ведущей точкой — покрывает
|
||||
поддомены, зеркалит реальную DomClick cookie-scope ".domclick.ru" для
|
||||
cookies (опционально) — dict cookie_name→value для инъекции ДО любой навигации
|
||||
(обход QRATOR-блока DomClick при валидной test-аккаунт сессии, эмпирически
|
||||
подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ захардкожен, а
|
||||
выводится из hostname целевого url (с ведущей точкой — покрывает поддомены,
|
||||
зеркалит реальную DomClick cookie-scope ".domclick.ru" для
|
||||
ekaterinburg.domclick.ru/spb.domclick.ru/etc, но работает для любого хоста, не
|
||||
только DomClick) — механизм пригоден для будущих caller'ов (Cian/Avito/Yandex).
|
||||
None (дефолт) → без инъекции, поведение не меняется (avito/cian/yandex сейчас
|
||||
cookies не передают).
|
||||
При reuse_context=False (дефолт) вливаются в КАЖДУЮ новую страницу, как раньше.
|
||||
При 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)
|
||||
assert browser is not None, "browser not launched"
|
||||
|
||||
page = await browser.new_page() # type: ignore[attr-defined]
|
||||
try:
|
||||
if reset_context:
|
||||
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:
|
||||
cookie_domain = f".{urlparse(url).hostname or ''}"
|
||||
await page.context.add_cookies( # type: ignore[attr-defined]
|
||||
|
|
@ -1151,6 +1271,7 @@ async def _fetch_once(
|
|||
for name, value in cookies.items()
|
||||
]
|
||||
)
|
||||
try:
|
||||
await _apply_resource_block(page)
|
||||
await _pace_provider(provider)
|
||||
if origin:
|
||||
|
|
|
|||
|
|
@ -174,6 +174,8 @@ class _OverlapProbe:
|
|||
*,
|
||||
origin: str | None = None,
|
||||
cookies: dict | None = None,
|
||||
reuse_context: bool = False,
|
||||
reset_context: bool = False,
|
||||
) -> str:
|
||||
self.active[provider] = self.active.get(provider, 0) + 1
|
||||
self.peak[provider] = max(self.peak.get(provider, 0), self.active[provider])
|
||||
|
|
|
|||
277
tradein-mvp/browser/test_server_reuse_context.py
Normal file
277
tradein-mvp/browser/test_server_reuse_context.py
Normal 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
|
||||
|
|
@ -272,6 +272,7 @@ class BrowserFetcher:
|
|||
proxy_provider: ProxyProvider | None = None,
|
||||
use_pool: bool = False,
|
||||
environment: str = "dev",
|
||||
reuse_context: bool = False,
|
||||
) -> None:
|
||||
# source — логический источник ("avito"/"cian"/"yandex"/"domclick"). Сервер
|
||||
# роутит /fetch по нему на отдельный браузер+прокси, когда включён
|
||||
|
|
@ -306,6 +307,22 @@ class BrowserFetcher:
|
|||
# падаем на env-прокси (все мертвы, #2613) — NoProxyAvailableError вместо тела
|
||||
# без "proxy" (см. _acquire_lease). Дефолт "dev" — легитимный fallback на env
|
||||
# для 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._fetch_timeout_s = fetch_timeout_s
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
|
@ -354,6 +371,7 @@ class BrowserFetcher:
|
|||
*,
|
||||
origin: str | None = None,
|
||||
cookies: dict[str, str] | None = None,
|
||||
reset_context: bool = False,
|
||||
) -> str:
|
||||
"""Запрашивает HTML страницы через tradein-browser HTTP-сервис.
|
||||
|
||||
|
|
@ -368,6 +386,13 @@ class BrowserFetcher:
|
|||
domclick_session). None (дефолт) → поведение не меняется, инъекции нет —
|
||||
таков путь всех 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 делает одну повторную попытку после
|
||||
короткой паузы. Остальные исключения всплывают к вызывающему коду.
|
||||
|
||||
|
|
@ -377,8 +402,18 @@ class BrowserFetcher:
|
|||
assert self._client is not None, "BrowserFetcher: используй как async context manager"
|
||||
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:
|
||||
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:
|
||||
logger.warning(
|
||||
"BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s",
|
||||
|
|
@ -387,7 +422,7 @@ class BrowserFetcher:
|
|||
url,
|
||||
)
|
||||
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(
|
||||
self,
|
||||
|
|
@ -530,6 +565,20 @@ class BrowserFetcher:
|
|||
"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:
|
||||
"""Пометить ТЕКУЩИЙ lease забаненным площадкой (#2600 п.1, п.2).
|
||||
|
||||
|
|
@ -641,6 +690,7 @@ class BrowserFetcher:
|
|||
url: str,
|
||||
origin: str | None = None,
|
||||
cookies: dict[str, str] | None = None,
|
||||
reset_context: bool = False,
|
||||
) -> str:
|
||||
"""Один HTTP POST к /fetch эндпоинту сервиса.
|
||||
|
||||
|
|
@ -648,6 +698,11 @@ class BrowserFetcher:
|
|||
_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 на каждый вызов
|
||||
(#2164 P4 sticky-session fix, живая регрессия 2026-08). Исход репортится в lease
|
||||
через _report_fetch_result (touch-heartbeat + mark_health + осознанная ротация
|
||||
|
|
@ -663,6 +718,9 @@ class BrowserFetcher:
|
|||
"origin": origin,
|
||||
"cookies": cookies,
|
||||
}
|
||||
if self._reuse_context:
|
||||
payload["reuse_context"] = True
|
||||
payload["reset_context"] = reset_context
|
||||
if proxy_url:
|
||||
payload["proxy"] = proxy_url
|
||||
if proxy_kind:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue