gendesign/tradein-mvp/backend/app/services/domclick_session.py
bot-backend 0b9dfcc691
All checks were successful
CI / changes (pull_request) Successful in 7s
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 Trade-In / changes (pull_request) Successful in 7s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 52s
feat(tradein/domclick): cookie-injection MVP для обхода QRATOR-блока (#2000)
Эмпирически подтверждено вживую 2026-07-04: инъекция cookies валидной
аутентифицированной test-аккаунт сессии (Sber ID) полностью обходит QRATOR
reputation-блок DomClick, даже на уже подозрительном прокси-IP (проверено:
проход через camoufox context.addCookies() → 200 + полный __SSR_STATE__ на
IP, который секундами ранее отдавал anti-bot страницу без cookies).

- domclick_session_cookies (миграция 174) + app/services/domclick_session.py —
  зеркалит существующий cian_session.py (pgp_sym_encrypt, account_cas_id вместо
  account_user_id), без verify_session (DomClick-верификация требует реального
  browser-фетча, не curl_cffi — future enhancement).
- POST /scrape/domclick/upload-cookies — ручная загрузка (зеркалит upload_cian_cookies).
- POST /scrape/domclick/debug/detail-fetch — единственный сейчас живой путь
  прогнать связку session→cookies→fetch_detail (боевой backfill-оркестратор
  для DomClick ещё не смёржен). source="domclick" — выделенный резидентный
  прокси-пул (id=1, provider_affinity='domclick'), построенный и verified
  в этой же сессии, а не устаревший source="cian" из старой рекомендации.
- _assert_domclick_host — SSRF-guard для *.domclick.ru поддоменов (реальные
  карточки живут на региональных поддоменах, не на голом domclick.ru).
- server.py/browser_fetcher.py: cookies-параметр расширяет origin-механизм
  (#2430) той же схемой — keyword-only, default None → byte-identical для
  avito/cian/yandex. Домен cookie выводится из hostname целевого URL
  (провайдер-агностично), не захардкожен под domclick.
2026-07-04 20:46:20 +03: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)