gendesign/tradein-mvp/backend/app/services/yandex_session.py
bot-backend 5be64c6688
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m18s
Deploy Trade-In / test (push) Successful in 4m4s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 2m0s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
fix(tradein/scrapers): хранилище авторизованной сессии Яндекс.Недвижимости (#3195)
2026-08-28 19:02:05 +00:00

254 lines
12 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.

"""Yandex.Nedvizhimost session cookie management — load/save/invalidate encrypted cookies.
Замер на проде (#3192, сайдкар, две карточки, режимы вперемежку, 2026-08-28):
авторизованная сессия Яндекс.Недвижимости отдаёт ключ `mainPhone` и на 11 больше
уникальных телефонов продавца (90→101 / 89→100), чем анонимный запрос той же
карточки — контакты продавца физически недоступны без валидной сессии.
Cookies хранятся зашифрованно (pgp_sym_encrypt) в yandex_session_cookies —
зеркалит app.services.cian_session / app.services.domclick_session, но:
- ключ записи — account_label (text), НЕ числовой id: у Яндекса нет внутреннего
числового id аккаунта, доступного из cookie-дампа;
- хранится ПОЛНЫЙ CDP cookie-объект (name/value/domain/path/httpOnly/secure/
expires), а не плоский dict[name, value] как у Cian/DomClick — инъекция кук в
browser context (следующая часть #3192) требует domain/path, не только value;
- НЕТ allowlist-фильтра на сохранение (см. filter_analytics_cookies docstring) —
Cian/DomClick фильтруют payload по списку известных cookie-имён перед
сохранением, здесь так делать нельзя: замер выше сделан на ПОЛНОМ наборе из
44 cookies, и какая из них существенна для mainPhone-раскрытия — неизвестно.
MVP: без verify_session (аналогично domclick_session) — верификация Yandex-сессии
требует реального browser-фетча, не простого curl_cffi-запроса; вне рамок этой
итерации.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
logger = logging.getLogger(__name__)
# За сколько дней до протухания кук предупреждать (зеркалит #2658/#2674). Обновление
# кук — РУЧНАЯ операция (залить дамп через админку), человеку нужен запас: сигнал по
# факту протухания приходит, когда сбор уже встал. save_session ставит ttl 30 дней.
COOKIE_EXPIRY_WARN_DAYS = 5
# Cookies, реально наблюдаемые в авторизованной Yandex-сессии (замер #3192,
# 2026-08-28), все HttpOnly кроме отмеченных.
#
# ВАЖНО: это НЕ allowlist для фильтрации того, что сохранять (в отличие от
# CIAN_REQUIRED_COOKIES / DOMCLICK_REQUIRED_COOKIES) — используется ТОЛЬКО в
# has_auth_marker() для проверки "дамп вообще похож на авторизованную сессию".
# Замер #3192 сделан на ПОЛНОМ наборе из 44 cookies, какая именно cookie
# существенна для mainPhone-раскрытия — неизвестно, поэтому save_session хранит
# весь дамп целиком (за вычетом аналитики), а не только эти имена. НЕ сужай
# набор, который реально сохраняется, до этого списка.
YANDEX_CRITICAL_COOKIES: set[str] = {
"Session_id",
"sessionid2",
"sessar",
"i",
"L",
"sessguard",
"yandexuid", # не HttpOnly
"yandex_login", # не HttpOnly
}
# Ключевая auth-cookie, которую ставит Яндекс.Паспорт при логине — её отсутствие
# в дампе означает "это анонимная сессия", независимо от того, сколько прочих
# cookies присутствует.
AUTH_MARKER_COOKIE = "Session_id"
# Префиксы имён cookies, которые ВСЕГДА аналитика (Яндекс.Метрика / РСЯ /
# анти-спам виджет), а не auth-состояние — единственное, что выкидываем из
# дампа перед сохранением. Всё остальное сохраняется как пришло.
ANALYTICS_COOKIE_PREFIXES: tuple[str, ...] = ("_ym_", "yabs-", "_yasc")
def filter_analytics_cookies(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Drop только заведомую аналитику (_ym_*, yabs-*, _yasc*) — остальное как есть.
В отличие от Cian/DomClick здесь нет allowlist на сохранение: замер #3192
сделан на ПОЛНОМ наборе из 44 cookies, значимость конкретной cookie для
mainPhone-раскрытия неизвестна. Сужать этот фильтр до allowlist известных
"критичных" имён — значит потерять часть замера у следующего инженера,
который решит его "оптимизировать". YANDEX_CRITICAL_COOKIES используется
только в has_auth_marker(), не здесь.
"""
return [c for c in cookies if not str(c.get("name", "")).startswith(ANALYTICS_COOKIE_PREFIXES)]
def has_auth_marker(cookies: list[dict[str, Any]]) -> bool:
"""True если в дампе присутствует ключевая auth-cookie (Session_id).
Используется как дешёвая пре-проверка "это вообще похоже на авторизованный
дамп", НЕ полноценная верификация (см. модульный docstring — verify_session
для Yandex вне рамок этой итерации).
"""
names = {c.get("name") for c in cookies}
return AUTH_MARKER_COOKIE in names
def save_session(
db: Session,
account_label: str,
cookies: list[dict[str, Any]],
ttl_days: int = 30,
) -> None:
"""Encrypt cookies via pgp_sym_encrypt and UPSERT into yandex_session_cookies.
cookies — список полных CDP cookie-объектов (name/value/domain/path/httpOnly/
secure/expires), НЕ плоский dict[name, value] как у Cian/DomClick — инъекция
кук в browser context требует domain/path.
Uses settings.cookie_encryption_key as the encryption secret.
Никогда не логирует сырые значения cookies.
"""
cookies_json = json.dumps(cookies)
db.execute(
text("""
INSERT INTO yandex_session_cookies (
account_label,
cookies_encrypted,
expires_at_estimate,
uploaded_at
) VALUES (
:label,
pgp_sym_encrypt(:cookies_json, :key),
NOW() + (CAST(:ttl_days AS int) || ' days')::interval,
NOW()
)
ON CONFLICT (account_label) DO UPDATE SET
cookies_encrypted = EXCLUDED.cookies_encrypted,
expires_at_estimate = EXCLUDED.expires_at_estimate,
uploaded_at = NOW(),
last_invalid_at = NULL
"""),
{
"label": account_label,
"cookies_json": cookies_json,
"key": settings.cookie_encryption_key,
"ttl_days": ttl_days,
},
)
db.commit()
logger.info(
"Yandex cookies saved for label=%s (count=%d, ttl=%d days)",
account_label,
len(cookies),
ttl_days,
)
def load_session(db: Session) -> list[dict[str, Any]] | None:
"""Load most-recently-uploaded valid Yandex cookies (decrypt).
Returns список CDP cookie-объектов или None если нет валидной session.
Выбирает только записи где expires_at_estimate > NOW() и сессия не была
инвалидирована после последнего upload'а.
"""
row = (
db.execute(
text("""
SELECT
account_label,
pgp_sym_decrypt(cookies_encrypted, :key)::text AS cookies_json,
expires_at_estimate
FROM yandex_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 Yandex session cookies in DB")
return None
cookies: list[dict[str, Any]] = json.loads(row["cookies_json"])
# Обновляем last_used_at — не критично, игнорируем ошибки.
try:
db.execute(
text(
"UPDATE yandex_session_cookies SET last_used_at = NOW()"
" WHERE account_label = :label"
),
{"label": row["account_label"]},
)
db.commit()
except Exception as exc:
# Ошибка на execute() оставляет сессию в aborted-транзакции (psycopg/PG:
# "current transaction is aborted") — если db переживёт этот вызов (caller
# держит ту же сессию дальше, напр. admin.py session-status делает
# session_expires_at(db, ...) сразу следом), последующие запросы иначе все
# падали бы с той же ошибкой (зеркалит app.services.proxy_egress паттерн).
try:
db.rollback()
except Exception:
logger.warning("Failed to rollback after last_used_at update failure", exc_info=True)
logger.warning("Failed to update last_used_at for label=%s: %s", row["account_label"], exc)
logger.info(
"Yandex cookies loaded for label=%s (count=%d)",
row["account_label"],
len(cookies),
)
return cookies
def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
"""Когда протухают самые свежезагруженные куки (зеркалит 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 yandex_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_label: str) -> None:
"""Flag session как expired/invalid (например после логаута во время scrape)."""
db.execute(
text(
"UPDATE yandex_session_cookies SET last_invalid_at = NOW() WHERE account_label = :label"
),
{"label": account_label},
)
db.commit()
logger.warning("Yandex session marked invalid for label=%s", account_label)