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
254 lines
12 KiB
Python
254 lines
12 KiB
Python
"""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)
|