gendesign/tradein-mvp/backend/app/services/cian_session.py
bot-backend 0db7a026bd fix(tradein/scrapers): silent-failure guards for MERA scraper audit findings
Общая тема: скрапер тихо возвращал пустоту вместо сигнала об ошибке, продукт
считал оценку по остаткам данных, не зная что источник отвалился.

1. avito serp.py (medium): citywide/byrooms/exhaustive sweep больше не путает
   DOM-drift с "объявлений нет". _is_unexpected_empty_page() перепроверяет
   независимый сигнал (_extract_total_count — счётчик page-title/count либо
   no-results маркер): page=1 c 0 карточками и БЕЗ no-results маркера →
   AvitoContentBlockedError (переиспользует существующий механизм #754/#779).
   Exhaustive-бакеты (_paginate_leaf_bucket) получают expected_total из probe —
   total>0 но 0 карточек после пагинации всех страниц тоже поднимает ошибку.
   page>1 c 0 карточками остаётся graceful end-of-pagination (не regressed).

2. cian serp.py (low): _parse_serp_html сравнивает totalOffers vs
   results.offers из ОДНОГО state-блоба. totalOffers>0 при пустом offers —
   report как schema regression (elevated до ERROR + Glitchtip через новый
   _report_schema_regression(), общий с существующей offer-level охраной).
   Порог 0-vs->0 выбран т.к. это единственный позиционно-независимый сигнал:
   функция не знает номер страницы, дробный порог ложно сработал бы на
   легитимной последней частичной странице exhaustive-пагинации.

3. avito detail.py (low): publish_date больше не завышает свежесть лота на
   границе года. Если "текущий год + месяц/день" даёт дату в будущем
   относительно момента парсинга (декабрьское объявление читается в январе) —
   откатываем на год назад.

4. cian_session.py (low): verify_session больше не конфлирует expired/
   source-down/markup-changed. Новые VERIFY_SOURCE_UNAVAILABLE_SENTINEL (5xx,
   network/timeout exception) и VERIFY_MARKUP_CHANGED_SENTINEL (HTTP 200 но
   header-frontend auth-state не распарсился) отделены от None (единственный
   сигнал реального логаута: 401 или isAuthenticated=false). Callers
   (product_handlers.py::_cian_pre_claim, admin.py) НЕ обновлены — вне
   разрешённого scope этого PR (только cian_session.py + scraper-kit),
   отдельный follow-up.

Тесты: 19 новых (8 avito sweep drift, 6 cian totalOffers mismatch, 5 date
rollover) + обновлены 2 существующих (cian_session markup-changed вместо
None) под новый, более точный контракт. Полный backend suite:
2649 passed, 8 skipped, 1 deselected (без регрессий).
2026-07-26 23:52:39 +03:00

307 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Cian session cookie management — load/save/verify encrypted cookies.
Used by cian_valuation.py (Stage 7) to authenticate Valuation Calculator API.
Cookies stored encrypted (pgp_sym_encrypt) in cian_session_cookies table.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from curl_cffi.requests import AsyncSession
# #2306: extract_state migrated to scraper_kit (byte-identical to legacy
# cian_state_parser module, deleted #2397 финальный шаг E — golden-parity против
# него уже недоступна, kit — единственный живой путь).
from scraper_kit.cian_state_parser import extract_state
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
logger = logging.getLogger(__name__)
# Cookies критичные для Cian auth — фильтр перед сохранением.
# Список обновлён по реальному DevTools-дампу из logged-in сессии cian.ru (2026-05-23).
# Старые записи оставлены как fallback (backward compat).
CIAN_REQUIRED_COOKIES: set[str] = {
# --- Cian auth & session (critical) ---
"DMIR_AUTH", # Cian auth token (httpOnly) — основной auth-сигнал
"_CIAN_GK", # Cian session GUID
# --- Yandex Metrika (обычно присутствуют, не critical) ---
"_ym_uid",
"_ym_d",
"_ym_isad",
"_ym_visorc",
"_yasc", # Yandex anti-spam
# --- Cian UX state ---
"uxfb_card_satisfaction",
# --- Cian session IDs (fallback / legacy deployments) ---
"_cian_visitor_session_id",
"_cian_app_session_id",
"_cian_uid",
# --- Misc (legacy / optional) ---
"_ga", # Google Analytics
"tlsr_id", # legacy fingerprint
"cf_clearance", # Cloudflare bot check
"session-id", # generic session cookie
"anti_bot",
"csrftoken",
}
_VALUATION_TEST_URL = (
"https://www.cian.ru/kalkulator-nedvizhimosti/"
"?address=%D0%A1%D0%B2%D0%B5%D1%80%D0%B4%D0%BB%D0%BE%D0%B2%D1%81%D0%BA%D0%B0%D1%8F"
"%20%D0%BE%D0%B1%D0%BB%2C%20%D0%95%D0%BA%D0%B0%D1%82%D0%B5%D1%80%D0%B8%D0%BD%D0%B1%D1%83%D1%80%D0%B3"
"&totalArea=40&roomsCount=1&valuationType=sale"
"&floor%5B0%5D=floorOther&repairType%5B0%5D=repairTypeCosmetic"
)
_MFE_VALUATION = "valuation-for-agent-frontend"
# #639 (2026-05): auth-сигнал (user.isAuthenticated/userId) уехал из valuation-MFE
# (там user теперь всегда anonymous) в site-header MFE `header-frontend`, который
# присутствует на любой cian-странице. Verify читает auth именно оттуда.
_MFE_AUTH = "header-frontend"
# Returned by verify_session when a TLS/bot-fingerprint ban (HTTP 403) is detected.
# Callers that check `if state is None` will NOT treat this as "expired cookies" —
# the sentinel is truthy (non-None), so cookie-refresh alerts are suppressed.
# Callers that need to distinguish ban from valid auth should check state.get("_ban").
VERIFY_BAN_SENTINEL: dict[str, Any] = {"_ban": True}
# audit-scrapers finding 4: verify_session раньше сводило 5xx / сетевой сбой /
# смену вёрстки к тому же None, что и реальный логаут (401 / isAuthenticated=false) —
# вызывающие (_cian_pre_claim, admin upload/auto-login) реагировали "куки протухли,
# перезалей" там, где куки ни при чём (Cian недоступен ИЛИ scraper_kit.cian_state_parser
# больше не находит header-frontend initialState). Два отдельных сигнала ниже НЕ
# триггерят "cookies expired" алерт у вызывающих.
# Cian источник недоступен прямо сейчас (5xx-ответ ИЛИ сетевой/транспортный сбой —
# timeout, DNS, connection reset). Cookies могут быть абсолютно валидны — просто
# нечем было их проверить. Retry позже, БЕЗ пометки session invalid.
VERIFY_SOURCE_UNAVAILABLE_SENTINEL: dict[str, Any] = {"_source_unavailable": True}
# HTTP 200 получен, но ожидаемый auth-state (header-frontend/initialState с
# user.isAuthenticated) не найден/не распарсился — Cian изменил вёрстку/MFE-схему.
# Это engineering-проблема (extract_state/_MFE_AUTH нужно обновить), НЕ протухшие
# cookies — переставлять куки здесь бесполезно.
VERIFY_MARKUP_CHANGED_SENTINEL: dict[str, Any] = {"_markup_changed": True}
def _classify_verify_response(
status_code: int,
html: str | None,
) -> dict[str, Any] | None:
"""Pure classifier — maps (status_code, html) to verify_session outcome.
Returns:
VERIFY_BAN_SENTINEL — 403/TLS ban (cookies могут быть в порядке,
блокирует сервер)
VERIFY_SOURCE_UNAVAILABLE_SENTINEL — 5xx/иной non-200 без содержимого —
источник недоступен, НЕ cookies
VERIFY_MARKUP_CHANGED_SENTINEL — HTTP 200, но auth-state не найден/не
распарсился — вёрстка/схема изменилась
None — 401 ИЛИ isAuthenticated=false — cookies
ДЕЙСТВИТЕЛЬНО протухли/разлогинены
state dict — authenticated successfully
"""
if status_code == 403:
return VERIFY_BAN_SENTINEL
if status_code == 401:
return None
if status_code != 200 or html is None:
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
state = extract_state(html, mfe=_MFE_AUTH, key="initialState")
if state is None:
return VERIFY_MARKUP_CHANGED_SENTINEL
user = state.get("user", {}) or {}
if not user.get("isAuthenticated"):
return None
return state
async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
"""Hit Cian Valuation Calculator with cookies — return parsed state if authenticated.
Uses curl_cffi with impersonate='chrome120' (same as prod scrapers) to avoid
TLS-fingerprint bans that httpx would trigger.
Returns (проверяй через `is`, НЕ `==` — это sentinel-объекты):
state dict — authenticated (user.isAuthenticated + userId)
VERIFY_BAN_SENTINEL — HTTP 403 TLS/bot ban; cookies могут быть
валидны — НЕ триггерить cookie-refresh alert
VERIFY_SOURCE_UNAVAILABLE_SENTINEL — 5xx/network/timeout; источник недоступен,
НЕ триггерить cookie-refresh alert, retry позже
VERIFY_MARKUP_CHANGED_SENTINEL — HTTP 200 но auth-state не распарсился;
Cian изменил вёрстку — НЕ cookie-проблема,
нужен engineering-фикс extract_state/_MFE_AUTH
None — HTTP 401 или isAuthenticated=false; cookies
ДЕЙСТВИТЕЛЬНО протухли — здесь и только здесь
имеет смысл просить re-upload
Никогда не логирует сырые значения cookies.
"""
try:
# proxies: mobile-proxy egress (#806) — Cian блокирует datacenter-IP даже
# при валидных DMIR_AUTH cookies. Без прокси verify всегда вернёт 403.
# Пусто (env не задан) → прямое подключение (dev/no-op).
_proxy_url = settings.cian_proxy_url
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
async with AsyncSession(
impersonate="chrome120",
cookies=cookies,
timeout=20.0,
proxies=_proxies,
) as session:
resp = await session.get(_VALUATION_TEST_URL, allow_redirects=True)
status = resp.status_code
html: str | None = resp.text if status == 200 else None
result = _classify_verify_response(status, html)
if result is VERIFY_BAN_SENTINEL:
logger.warning(
"Cian cookies verify: HTTP 403 TLS/bot ban — cookies NOT marked expired"
)
elif result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
logger.warning(
"Cian cookies verify: source unavailable (status=%d) — "
"cookies NOT marked expired, retry later",
status,
)
elif result is VERIFY_MARKUP_CHANGED_SENTINEL:
logger.error(
"Cian cookies verify: HTTP 200 but auth-state not found/parseable "
"(mfe=%s) — markup/schema changed, cookies NOT marked expired",
_MFE_AUTH,
)
elif result is None:
logger.warning("Cian cookies verify: expired/unauthenticated (status=%d)", status)
else:
user = result.get("user", {}) or {}
logger.info("Cian cookies verified — userId=%s", user.get("userId"))
return result
except Exception as exc:
# Сетевой/транспортный сбой (timeout, DNS, connection reset и т.п.) — источник
# недоступен, НЕ признак протухших cookies (finding 4). Раньше здесь везде
# возвращался None, конфлируя с реальным логаутом.
logger.warning("Cian cookies verify: transport/network error — %s", exc)
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
def save_session(
db: Session,
account_user_id: int,
cookies: dict[str, str],
ttl_days: int = 30,
) -> None:
"""Encrypt cookies via pgp_sym_encrypt and UPSERT into cian_session_cookies.
Uses settings.cookie_encryption_key as the encryption secret.
Никогда не логирует сырые значения cookies.
"""
cookies_json = json.dumps(cookies)
db.execute(
text("""
INSERT INTO cian_session_cookies (
account_user_id,
cookies_encrypted,
expires_at_estimate,
uploaded_at
) VALUES (
CAST(:uid AS bigint),
pgp_sym_encrypt(:cookies_json, :key),
NOW() + (CAST(:ttl_days AS int) || ' days')::interval,
NOW()
)
ON CONFLICT (account_user_id) DO UPDATE SET
cookies_encrypted = EXCLUDED.cookies_encrypted,
expires_at_estimate = EXCLUDED.expires_at_estimate,
uploaded_at = NOW(),
last_invalid_at = NULL
"""),
{
"uid": account_user_id,
"cookies_json": cookies_json,
"key": settings.cookie_encryption_key,
"ttl_days": ttl_days,
},
)
db.commit()
logger.info(
"Cian cookies saved for userId=%s (count=%d, ttl=%d days)",
account_user_id,
len(cookies),
ttl_days,
)
def load_session(db: Session) -> dict[str, str] | None:
"""Load most-recently-uploaded valid Cian cookies (decrypt).
Returns dict[cookie_name, cookie_value] или None если нет валидной session.
Выбирает только записи где expires_at_estimate > NOW() и сессия не
была инвалидирована после последнего upload'а.
"""
row = (
db.execute(
text("""
SELECT
account_user_id,
pgp_sym_decrypt(cookies_encrypted, :key)::text AS cookies_json,
expires_at_estimate
FROM cian_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 Cian session cookies in DB")
return None
cookies: dict[str, str] = json.loads(row["cookies_json"])
# Обновляем last_used_at — не критично, игнорируем ошибки.
try:
db.execute(
text(
"UPDATE cian_session_cookies SET last_used_at = NOW()"
" WHERE account_user_id = CAST(:uid AS bigint)"
),
{"uid": row["account_user_id"]},
)
db.commit()
except Exception as exc:
logger.warning(
"Failed to update last_used_at for userId=%s: %s", row["account_user_id"], exc
)
logger.info(
"Cian cookies loaded for userId=%s (count=%d)",
row["account_user_id"],
len(cookies),
)
return cookies
def mark_session_invalid(db: Session, account_user_id: int) -> None:
"""Flag session как expired/invalid (например после 401 во время scrape)."""
db.execute(
text(
"UPDATE cian_session_cookies SET last_invalid_at = NOW()"
" WHERE account_user_id = CAST(:uid AS bigint)"
),
{"uid": account_user_id},
)
db.commit()
logger.warning("Cian session marked invalid for userId=%s", account_user_id)