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
161 lines
5.6 KiB
Python
161 lines
5.6 KiB
Python
"""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)
|