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
В скрапер-контейнере 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
200 lines
8 KiB
Python
200 lines
8 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 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)
|