gendesign/tradein-mvp/backend/app/services/domclick_session.py
bot-backend 46bbb79881
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
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) Successful in 2m58s
fix(tradein): сигналы о сбоях наконец становятся событиями, а протухание кук предупреждает заранее (#2674)
В скрапер-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR),
поэтому любой сигнал уровня WARNING событием не становится — сколько бы раз он
ни срабатывал. Прод это подтвердил: монитор устаревания СберИндекса отработал
24 раза, 9 из них со staleness-вердиктом, событий ноль; куки Домклика протухли
2026-08-03 и об этом никто не узнал.

Разбирали не «поменять warning на error», а по каждому сигналу: сбой, из-за
которого данные перестают обновляться — событие; рутина и ожидаемые состояния —
лог. Плюс предупреждение ЗАРАНЕЕ там, где чинить нужно руками (куки Домклика —
по образцу #2658 для Циана, переиспользован тот же подход session_expires_at +
COOKIE_EXPIRY_WARN_DAYS).

У поллера Росреестра выход нового квартала оставлен уровнем info, но получил
явный capture_message(level="info"): новость хорошая, но требует ручного импорта
оператором, а INFO-строка живёт только до ближайшего редеплоя. logger.error для
неё был бы враньём в error-rate и стрик-алертах.

Оговорка: у GlitchTip-проекта сейчас нет ни правил, ни получателей (#2673) —
события станут видны в интерфейсе, но никому не отправятся.

Refs #2674
2026-08-06 02:29:11 +05:00

200 lines
8 KiB
Python
Raw Permalink 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 datetime import datetime
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
logger = logging.getLogger(__name__)
# За сколько дней до протухания кук предупреждать (#2674, по образцу #2658 для Циана).
# Обновление кук — РУЧНАЯ операция (залить дамп через админку), человеку нужен запас:
# сигнал по факту протухания приходит, когда обогащение уже встало. Прод 2026-08-03:
# куки протухли, единственным следом был WARNING в docker-логе, который к тому же
# теряется при редеплое. save_session ставит ttl 30 дней, так что окно широкое.
COOKIE_EXPIRY_WARN_DAYS = 5
# 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 session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
"""Когда протухают самые свежезагруженные куки (#2674, зеркалит cian_session #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 domclick_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_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)