gendesign/tradein-mvp/backend/app/services/cian_session.py
bot-backend ac5b044f7e
Some checks failed
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 18s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 6m17s
fix(scrapers): ожидаемые исходы сбора (бан, пустой пул, капча) больше не error
Скрапер один давал 3006 error-строк в сутки из ~3700 по всему Trade-In —
ленту перестали читать, и настоящая поломка терялась в ней.

Принцип: ожидаемый исход сбора (площадка забанила, пул прокси пуст,
капча/недогруз, серия блоков перевалила порог circuit breaker) — это
состояние работы против недружелюбного источника, а не инцидент. В error
остаётся только неожиданное: изменившаяся вёрстка/схема (Cian markup
change), просроченный токен ротации прокси (ASocks 401), неразобранное
исключение.

Переведено error -> warning в 9 файлах, 12 мест: "СТОП — пул прокси пуст"
(avito/domclick/cian_history/yandex_newbuilding_sweep x2), "пул прокси
исчерпан" (cian_session, cian_price_history, yandex_address_backfill),
FAIL-CLOSED без здорового узла для source (proxy_egress), ABORT по счётчику
подтверждённых блоков площадки (avito, domclick, yandex_detail_backfill).

Оставлено error намеренно: cookie-алерты Cian/DomClick (#2658, #2674) —
они рассчитаны именно на LoggingIntegration(event_level=ERROR) в
scheduler_main.py и без него молчат по 37 дней; ABORT по смешанным/soft
причинам без единого подтверждённого блока площадки (#3272, #2674/#3196) —
это может быть наш баг, а не бан, сигнал сознательно не приглушали.

GlitchTip: сентри-интеграция скрапера уже настроена как
LoggingIntegration(level=INFO, event_level=ERROR) в scheduler_main.py —
отдельной правки sentry_scrub.py не требуется, понижение уровня logger
само убирает эти записи из GlitchTip.

Итоговая FINISHED-строка со счётчиками (attempted/enriched/blocked/failed)
уже существует в каждом detail_backfill — новую не добавлял.

Refs #3471
2026-09-12 14:15:59 +03:00

361 lines
17 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 datetime import datetime
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 scraper_kit.providers._base import DEFAULT_IMPERSONATE
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url_sync
logger = logging.getLogger(__name__)
# За сколько дней до протухания кук предупреждать (#2658). Обновление кук — РУЧНАЯ
# операция (залить дамп через админку), человеку нужен запас: алерт по факту протухания
# приходит, когда сбор уже встал. save_session ставит ttl 30 дней, так что окно широкое.
COOKIE_EXPIRY_WARN_DAYS = 5
# 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 the kit-wide TLS profile (`DEFAULT_IMPERSONATE`, тот же,
что у прод-скраперов) 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.
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
# scrape_proxy_source_bans, fallback на settings.cian_proxy_url только если
# пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/
# нездоровы для cian -- ProxyPoolExhaustedError (fail-closed, #2616), см. except
# ниже.
_proxy_url = resolve_proxy_url_sync("cian")
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
async with AsyncSession(
impersonate=DEFAULT_IMPERSONATE,
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 ProxyPoolExhaustedError as exc:
# Fail-closed (#2616, #2825): пул scrape_proxies не пуст, но все узлы забанены
# ИМЕННО для cian/нездоровы — НЕ уходим на settings.cian_proxy_url (тот самый
# статичный узел мог быть источником бана, см. proxy_egress module docstring).
# Явный отказ вместо слепого прохода через заведомо подозрительный egress.
logger.warning(
"Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, "
"cookies НЕ помечены протухшими, retry на следующем такте",
exc,
)
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
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 session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
"""Когда протухают самые свежезагруженные куки (#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 cian_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_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)