Original list (Stage 4 PR) was guessed without real DevTools probe; dropped DMIR_AUTH (main Cian auth token) and several observed cookies. Updated from logged-in cian.ru session dump (2026-05-23). Old entries kept as backward-compat fallback. Adds regression test asserting all real DevTools cookies are present in the filter.
211 lines
7.4 KiB
Python
211 lines
7.4 KiB
Python
"""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
|
||
|
||
import httpx
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import settings
|
||
from app.services.scrapers.cian_state_parser import extract_state
|
||
|
||
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"
|
||
|
||
|
||
async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
||
"""Hit Cian Valuation Calculator with cookies — return parsed state if authenticated.
|
||
|
||
Returns state dict containing user.isAuthenticated + userId, or None if
|
||
cookies are invalid/expired or state extraction fails.
|
||
Никогда не логирует сырые значения cookies.
|
||
"""
|
||
try:
|
||
async with httpx.AsyncClient(
|
||
cookies=cookies,
|
||
timeout=20.0,
|
||
follow_redirects=True,
|
||
) as client:
|
||
resp = await client.get(_VALUATION_TEST_URL)
|
||
resp.raise_for_status()
|
||
state = extract_state(resp.text, mfe=_MFE_VALUATION, key="initialState")
|
||
if state is None:
|
||
logger.warning(
|
||
"Cian cookies verify: state extraction failed (mfe=%s)", _MFE_VALUATION
|
||
)
|
||
return None
|
||
user = state.get("user", {})
|
||
if not user.get("isAuthenticated"):
|
||
logger.warning("Cian cookies verify: user.isAuthenticated=false")
|
||
return None
|
||
logger.info("Cian cookies verified — userId=%s", user.get("userId"))
|
||
return state
|
||
except httpx.HTTPStatusError as exc:
|
||
logger.warning(
|
||
"Cian cookies verify HTTP error: %s %s",
|
||
exc.response.status_code,
|
||
exc.request.url,
|
||
)
|
||
return None
|
||
except Exception as exc:
|
||
logger.warning("Cian cookies verify failed: %s", exc)
|
||
return None
|
||
|
||
|
||
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)
|