gendesign/tradein-mvp/backend/app/services/domclick_session.py
lekss361 ced616d150
All checks were successful
Deploy Trade-In / test (push) Successful in 49s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m13s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 7m16s
feat(tradein/domclick): cookie-injection MVP для обхода QRATOR-блока (#2000) (#2433)
2026-07-04 17:49:40 +00:00

161 lines
5.6 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.

"""DomClick session cookie management — load/save/invalidate encrypted cookies.
Empirically verified live (2026-07-04): DomClick's QRATOR anti-bot reputation-block
(даже на прокси-IP, уже помеченном как suspicious) полностью обходится инъекцией
cookies валидной аутентифицированной test-аккаунт сессии (Sber ID login) перед
навигацией на карточку. Cookies хранятся зашифрованно (pgp_sym_encrypt) в
domclick_session_cookies (зеркалит cian_session_cookies, но account_cas_id вместо
account_user_id).
MVP: без verify_session (в отличие от app.services.cian_session). DomClick-
верификация требует реального browser-фетча (SSR-стейт страницы), а не простого
curl_cffi-запроса как у Cian Valuation Calculator — вне рамок этой итерации,
future enhancement.
"""
from __future__ import annotations
import json
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
logger = logging.getLogger(__name__)
# Cookies критичные для DomClick auth (Sber ID) — фильтр перед сохранением.
# Список составлен по реальному DevTools/Cookie-Editor дампу авторизованной
# test-аккаунт сессии (Sber ID login), 2026-07-04.
DOMCLICK_REQUIRED_COOKIES: set[str] = {
"qrator_jsid2",
"CAS_ID",
"CAS_ID_SIGNED",
"sessionId",
"UNIQ_SESSION_ID",
"_visitId",
"ns_session",
"RETENTION_COOKIES_NAME",
"regionName",
"region",
"currentRegionGuid",
"currentLocalityGuid",
"adtech_uid",
"ftgl_cookie_id",
"_ym_uid",
"_ym_d",
}
def save_session(
db: Session,
account_cas_id: int,
cookies: dict[str, str],
ttl_days: int = 30,
) -> None:
"""Encrypt cookies via pgp_sym_encrypt and UPSERT into domclick_session_cookies.
Uses settings.cookie_encryption_key as the encryption secret.
Никогда не логирует сырые значения cookies.
"""
cookies_json = json.dumps(cookies)
db.execute(
text("""
INSERT INTO domclick_session_cookies (
account_cas_id,
cookies_encrypted,
expires_at_estimate,
uploaded_at
) VALUES (
CAST(:cas_id AS bigint),
pgp_sym_encrypt(:cookies_json, :key),
NOW() + (CAST(:ttl_days AS int) || ' days')::interval,
NOW()
)
ON CONFLICT (account_cas_id) DO UPDATE SET
cookies_encrypted = EXCLUDED.cookies_encrypted,
expires_at_estimate = EXCLUDED.expires_at_estimate,
uploaded_at = NOW(),
last_invalid_at = NULL
"""),
{
"cas_id": account_cas_id,
"cookies_json": cookies_json,
"key": settings.cookie_encryption_key,
"ttl_days": ttl_days,
},
)
db.commit()
logger.info(
"DomClick cookies saved for casId=%s (count=%d, ttl=%d days)",
account_cas_id,
len(cookies),
ttl_days,
)
def load_session(db: Session) -> dict[str, str] | None:
"""Load most-recently-uploaded valid DomClick cookies (decrypt).
Returns dict[cookie_name, cookie_value] или None если нет валидной session.
Выбирает только записи где expires_at_estimate > NOW() и сессия не
была инвалидирована после последнего upload'а.
"""
row = (
db.execute(
text("""
SELECT
account_cas_id,
pgp_sym_decrypt(cookies_encrypted, :key)::text AS cookies_json,
expires_at_estimate
FROM domclick_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 DomClick session cookies in DB")
return None
cookies: dict[str, str] = json.loads(row["cookies_json"])
# Обновляем last_used_at — не критично, игнорируем ошибки.
try:
db.execute(
text(
"UPDATE domclick_session_cookies SET last_used_at = NOW()"
" WHERE account_cas_id = CAST(:cas_id AS bigint)"
),
{"cas_id": row["account_cas_id"]},
)
db.commit()
except Exception as exc:
logger.warning("Failed to update last_used_at for casId=%s: %s", row["account_cas_id"], exc)
logger.info(
"DomClick cookies loaded for casId=%s (count=%d)",
row["account_cas_id"],
len(cookies),
)
return cookies
def mark_session_invalid(db: Session, account_cas_id: int) -> None:
"""Flag session как expired/invalid (например после блока во время scrape)."""
db.execute(
text(
"UPDATE domclick_session_cookies SET last_invalid_at = NOW()"
" WHERE account_cas_id = CAST(:cas_id AS bigint)"
),
{"cas_id": account_cas_id},
)
db.commit()
logger.warning("DomClick session marked invalid for casId=%s", account_cas_id)