Merge remote-tracking branch 'forgejo/main' into feat/3471-tg-topic-check-and-group-rate-limit
Some checks failed
CI / changes (pull_request) Successful in 13s
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 2m27s

This commit is contained in:
bot-backend 2026-09-12 15:34:28 +03:00
commit acbfdf0a18
8 changed files with 795 additions and 9 deletions

View file

@ -62,6 +62,7 @@ import hashlib
import logging import logging
import re import re
import secrets import secrets
import time
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated, Literal from typing import Annotated, Literal
@ -170,6 +171,14 @@ _SEND_UNAVAILABLE_DETAIL = "Telegram сейчас недоступен. Попр
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`. # треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
_LIST_MESSAGES_LIMIT = 200 _LIST_MESSAGES_LIMIT = 200
# Идемпотентность отправки (#3471 retry-storm) — см. `_resolve_idempotency_key`.
_IDEMPOTENCY_HEADER = "idempotency-key"
# Форма клиентского ключа — как у anon-токена (`_ANON_TOKEN_RE`): произвольная
# opaque-строка клиента, без пробелов/спецсимволов, которые попали бы в SQL-параметр
# как есть. Не матчится — считаем заголовок отсутствующим и уходим на fallback,
# а не пытаемся его "починить" (тот же принцип, что у `_read_anon_token`).
_CLIENT_IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]{8,128}\Z")
def _require_username(request: Request) -> str: def _require_username(request: Request) -> str:
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его """Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
@ -345,8 +354,51 @@ def _rollback_quietly(db: Session) -> None:
logger.warning("web support: rollback после сбоя БД тоже не удался", exc_info=True) logger.warning("web support: rollback после сбоя БД тоже не удался", exc_info=True)
def _resolve_idempotency_key(request: Request, *, identity_key: str, text: str) -> str:
"""Ключ идемпотентности inbound-отправки (#3471, миграция 301).
Почему заголовок + fallback, а не что-то одно. Явный `Idempotency-Key`
ЕДИНСТВЕННЫЙ надёжный путь: клиент генерирует ключ ОДИН раз на "намерение
отправить" и переиспользует его на любом ретрае (fetch retry / переотправка
после таймаута) независимо от того, что именно менялось в UI между
попытками (см. `useSendSupportMessage` во фронтенде реальный трафик
обязан идти этим путём). Fallback без заголовка детерминированный
отпечаток sha256(identity, текст, минутное окно) существует ТОЛЬКО для
клиентов, которые заголовок не прислали (п.5 требования старое поведение
не должно сломаться), и у него два честных изъяна, оба снимаются самим
фактом использования заголовка:
1. два РАЗНЫХ по смыслу сообщения с одинаковым текстом от одного и того
же человека в течение одной минуты ("да" и ещё раз "да", "+", "ок")
СХЛОПНУТСЯ в одно второе будет молча проглочено, клиент получит id
первого, оператор не увидит второе сообщение вообще;
2. окно не скользящие 60 секунд, а округление `time.time() // 60` вниз:
фактический срок дедупликации случаен от 0 до 60с в зависимости от
момента внутри минуты, и часть настоящих повторов (ретрай ровно на
границе окна) эту защиту не получит.
Оба пункта перестают быть важны, когда клиент реально шлёт заголовок
(см. `useSendSupportMessage`).
Хранит и потенциально логирует (см. вызовы в этом файле) только САМ ключ
он либо непрозрачный клиентский токен, либо хэш. Текст сообщения сюда
попадает ТОЛЬКО как вход в sha256, в открытом виде не сохраняется и не
возвращается жёсткое правило проекта "текст не в логах" (ПДн) при этом
не нарушается, даже если бы этот ключ где-то залогировали.
Префиксы `client:`/`auto:` разводят два namespace'а ключей (защита от
случайного совпадения клиентского токена с fallback-хэшем) дёшево и не
требует отдельной колонки.
"""
header = (request.headers.get(_IDEMPOTENCY_HEADER) or "").strip()
if header and _CLIENT_IDEMPOTENCY_KEY_RE.match(header):
return f"client:{header}"
window = int(time.time() // 60)
fingerprint = f"{identity_key}|{text}|{window}"
return "auto:" + hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()
@router.post("/support/messages", response_model=SupportMessageOut) @router.post("/support/messages", response_model=SupportMessageOut)
async def send_support_message( async def send_support_message(
request: Request,
payload: SupportMessageInput, payload: SupportMessageInput,
username: Annotated[str, Depends(_require_username)], username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
@ -380,6 +432,37 @@ async def send_support_message(
if cooldown is not None: if cooldown is not None:
raise _too_many_failures_error(cooldown) raise _too_many_failures_error(cooldown)
# Идемпотентность (#3471). Что именно закрывает этот pre-check — важно не
# переоценить: строка в БД (и её idempotency_key) появляется ТОЛЬКО ПОСЛЕ
# успешной доставки в Telegram (H1 ниже), поэтому потерю самого запроса на
# плече Selectel -> api.telegram.org этот механизм НЕ дедуплицирует — если
# `send_message` не удался, ключ нигде не записан, и повтор клиента после
# неудачи это законная первая попытка. Реально закрывается другой, тоже
# частый случай: доставка УЖЕ состоялась (Telegram принял, строка
# закоммичена), но ответ до клиента не дошёл (обрыв на обратном пути,
# клиентский таймаут) или пользователь кликнул "отправить" второй раз по
# той же ещё не отрисовавшейся отправке — тогда pre-check находит уже
# записанное сообщение по ключу и отдаёт его же, не уходя в Telegram снова.
# Тред может ещё не существовать (это первая попытка этого ключа) — тогда
# сравнивать не с чем, идём в Telegram как обычно; сам факт "нет треда"
# здесь безопасен, т.к. тред создаётся ТОЛЬКО в этой же ручке (см. H1) —
# если бы сообщение под этим ключом уже было записано, тред уже был бы.
idempotency_key = _resolve_idempotency_key(request, identity_key=username, text=payload.text)
existing_thread_id = storage.find_thread_id(db, username)
if existing_thread_id is not None:
existing = storage.find_inbound_by_idempotency_key(
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
)
if existing is not None:
logger.info(
"web support: repeat send (idempotency key already recorded) "
"username=%s thread_id=%d message_id=%s",
username,
existing_thread_id,
existing["id"],
)
return SupportMessageOut(**existing)
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос # Общий клиент приложения (#tg-connection-resilience): на каждый запрос
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк # свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
# до api.telegram.org перед каждой отправкой. Живёт в lifespan. # до api.telegram.org перед каждой отправкой. Живёт в lifespan.
@ -438,6 +521,7 @@ async def send_support_message(
text_body=payload.text, text_body=payload.text,
topic_message_id=topic_message_id, topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id, support_chat_id=settings.telegram_support_chat_id,
idempotency_key=idempotency_key,
) )
db.commit() db.commit()
except SQLAlchemyError: except SQLAlchemyError:
@ -608,6 +692,31 @@ async def send_anon_support_message(
raise _too_many_failures_error(cooldown) raise _too_many_failures_error(cooldown)
display_id = _anon_display_id(token) display_id = _anon_display_id(token)
# Идемпотентность (#3471) — см. развёрнутый комментарий в авторизованной
# ветке. Ограничение специфично для анонимной ветки: если предыдущая
# попытка сама провалилась ДО выдачи куки (`is_new_token=True` тогда и
# сейчас), identity_key каждый раз новый (случайный токен) и pre-check
# структурно не может найти прошлую попытку — это тот же класс проблемы,
# что и потеря куки в сети, вне scope этой правки.
idempotency_key = _resolve_idempotency_key(request, identity_key=thread_key, text=payload.text)
existing_thread_id = storage.find_thread_id(db, thread_key)
if existing_thread_id is not None:
existing = storage.find_inbound_by_idempotency_key(
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
)
if existing is not None:
logger.info(
"web support (anon): repeat send (idempotency key already recorded) "
"%s thread_id=%d message_id=%s",
display_id,
existing_thread_id,
existing["id"],
)
if is_new_token:
_set_anon_cookie(response, token)
return SupportMessageOut(**existing)
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос # Общий клиент приложения (#tg-connection-resilience): на каждый запрос
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк # свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
# до api.telegram.org перед каждой отправкой. Живёт в lifespan. # до api.telegram.org перед каждой отправкой. Живёт в lifespan.
@ -657,6 +766,7 @@ async def send_anon_support_message(
text_body=payload.text, text_body=payload.text,
topic_message_id=topic_message_id, topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id, support_chat_id=settings.telegram_support_chat_id,
idempotency_key=idempotency_key,
) )
db.commit() db.commit()
except SQLAlchemyError: except SQLAlchemyError:

View file

@ -61,6 +61,36 @@ def get_or_create_thread(db: Session, username: str) -> int:
return int(row[0]) return int(row[0])
def find_inbound_by_idempotency_key(
db: Session, *, thread_id: int, idempotency_key: str
) -> dict[str, Any] | None:
"""Уже записанное inbound-сообщение с этим ключом идемпотентности в треде,
если есть (#3471). Вызывается ИЗ `app.api.v1.support` ДО похода в Telegram
(`send_support_message` / `send_anon_support_message`) повтор с тем же
ключом не должен создавать второе зеркало в топике, а не только вторую
строку в БД. `thread_id`, а не username/anon-token: таблица не хранит
identity напрямую, а тред уже гарантированно существует к моменту, когда
этот ключ мог быть записан (тред создаётся ДО `record_inbound`, см. H1 в
докстринге `app.api.v1.support`)."""
row = (
db.execute(
text(
"""
SELECT id, direction, text_body, operator_tg_id, created_at
FROM web_support_messages
WHERE thread_id = CAST(:thread_id AS bigint)
AND direction = 'in'
AND idempotency_key = :idempotency_key
"""
),
{"thread_id": thread_id, "idempotency_key": idempotency_key},
)
.mappings()
.one_or_none()
)
return dict(row) if row is not None else None
def record_inbound( def record_inbound(
db: Session, db: Session,
*, *,
@ -68,6 +98,7 @@ def record_inbound(
text_body: str, text_body: str,
topic_message_id: int | None, topic_message_id: int | None,
support_chat_id: int | None, support_chat_id: int | None,
idempotency_key: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` — """Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` —
id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора. id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора.
@ -75,18 +106,103 @@ def record_inbound(
review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ
support-группе если группу когда-нибудь сменят/пересоздадут, Telegram support-группе если группу когда-нибудь сменят/пересоздадут, Telegram
message_id стартует заново с 1 в новом чате и может совпасть с числом из message_id стартует заново с 1 в новом чате и может совпасть с числом из
старого без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188).""" старого без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188).
`idempotency_key` (#3471, миграция 301) — вызывающая сторона (`app.api.v1.support`)
делает SELECT-затем-действие pre-check ДО Telegram-похода (см.
`find_inbound_by_idempotency_key`), но этот pre-check САМ ПО СЕБЕ гонку не
закрывает (TOCTOU): два запроса с одним ключом могут пройти его одновременно
и оба уйти в Telegram. Последняя линия защиты здесь: `INSERT ... ON
CONFLICT (thread_id, idempotency_key) DO NOTHING` на partial unique индексе
(мигр. 301, тот же predicate). Если конфликт всё же случился, проигравший
НЕ создаёт вторую строку читает уже вставленную и возвращает её, так что
оба запроса-конкурента получают ОДИН и тот же id. `idempotency_key=None`
(дефолт) ведёт себя как раньше: NULL никогда не конфликтует сам с собой в
partial-индексе (WHERE idempotency_key IS NOT NULL), INSERT всегда проходит."""
row = ( row = (
db.execute( db.execute(
text( text(
""" """
INSERT INTO web_support_messages INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id, (thread_id, direction, text_body, topic_message_id,
support_chat_id, operator_tg_id, created_at) support_chat_id, operator_tg_id, idempotency_key, created_at)
VALUES VALUES
(CAST(:thread_id AS bigint), 'in', :text_body, (CAST(:thread_id AS bigint), 'in', :text_body,
CAST(:topic_message_id AS bigint), CAST(:topic_message_id AS bigint),
CAST(:support_chat_id AS bigint), NULL, NOW()) CAST(:support_chat_id AS bigint), NULL, :idempotency_key, NOW())
ON CONFLICT (thread_id, idempotency_key)
WHERE idempotency_key IS NOT NULL AND direction = 'in'
DO NOTHING
RETURNING id, direction, text_body, operator_tg_id, created_at
"""
),
{
"thread_id": thread_id,
"text_body": text_body,
"topic_message_id": topic_message_id,
"support_chat_id": support_chat_id,
"idempotency_key": idempotency_key,
},
)
.mappings()
.one_or_none()
)
if row is not None:
return dict(row)
# Конфликт пойман индексом — idempotency_key почти наверняка не NULL здесь
# (при NULL partial-индекс в конфликт не участвует), но НЕ `assert`: это
# прод-путь ПОСЛЕ уже доставленного в Telegram сообщения (см. H1 в
# app.api.v1.support), а `except SQLAlchemyError` в вызывающей стороне
# `AssertionError` не ловит — под `python -O` assert вдобавок исчезает
# молча и `existing` осталось бы `None`, что развалилось бы чуть ниже при
# сборке ответа. Вместо падения — явная ветка с логом и WORKING откатом.
existing = (
find_inbound_by_idempotency_key(db, thread_id=thread_id, idempotency_key=idempotency_key)
if idempotency_key is not None
else None
)
if existing is not None:
# Ожидаемый случай гонки (#3471): проигравший запрос уже отправил своё
# СОБСТВЕННОЕ зеркало в Telegram (свой topic_message_id) до того, как
# обнаружил конфликт здесь — эта копия зеркала теперь осиротела
# (реплай оператора на неё никуда не смаршрутизируется, т.к. строки
# для неё в БД нет). Осознанно не чиним это в этом PR (см. коммент
# выше по коду), но фиксируем в логе, а не молчим — ровно то же самое,
# для чего уже есть `_warn_operator_message_not_recorded` в другом месте.
logger.warning(
"web support: гонка по idempotency_key — зеркало topic_message_id=%s "
"(thread_id=%d) отправлено, но НЕ записано, победила строка id=%d",
topic_message_id,
thread_id,
existing["id"],
)
return existing
# Крайний случай (в норме недостижим при READ COMMITTED, которую использует
# этот сервис): индекс сообщил о конфликте, но повторное чтение строку не
# нашло. Логируем и пишем БЕЗ идемпотентности — NULL-ключ никогда не
# конфликтует сам с собой (partial-индекс его не видит), INSERT гарантированно
# пройдёт; для этого одного сообщения дедупликация отключается, но клиент
# получает корректный ответ вместо 500 после уже состоявшейся доставки.
logger.warning(
"web support: ON CONFLICT сообщил о конфликте (thread_id=%d, "
"idempotency_key=%s), но повторное чтение строки её не нашло — "
"записываем без идемпотентности",
thread_id,
"<set>" if idempotency_key is not None else None,
)
row = (
db.execute(
text(
"""
INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id,
support_chat_id, operator_tg_id, idempotency_key, created_at)
VALUES
(CAST(:thread_id AS bigint), 'in', :text_body,
CAST(:topic_message_id AS bigint),
CAST(:support_chat_id AS bigint), NULL, NULL, NOW())
RETURNING id, direction, text_body, operator_tg_id, created_at RETURNING id, direction, text_body, operator_tg_id, created_at
""" """
), ),

View file

@ -77,10 +77,21 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны. заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны.
Отбор Москвы (source=domclick) тоже стоит ноль вызовов, но по другой причине:
сырьё собрано запросом с GUID-ом Москвы и отфильтровано по bbox ещё на стороне
сборщика, поэтому областных карточек в нём нет по построению. Регион читается
ПЕРВЫМ компонентом адреса («Москва, улица Нижние Мнёвники, 16 к2»), и фильтр
оставлен второй линией на случай, если в ту же таблицу когда-нибудь приедет
второй город. Замер по 5 024 карточкам первого прохода: единственное значение
первого компонента «Москва». Новая Москва приходит как «Москва, пос. Птичное,
», то есть посёлок стоит вторым компонентом и регион 77 не ломает. Координаты
заполнены у 100% карточек.
Запуск: Запуск:
python -m app.tasks.msk_raw_import --dry-run python -m app.tasks.msk_raw_import --dry-run
python -m app.tasks.msk_raw_import --limit 500 python -m app.tasks.msk_raw_import --limit 500
python -m app.tasks.msk_raw_import --source yandex python -m app.tasks.msk_raw_import --source yandex
python -m app.tasks.msk_raw_import --source domclick
python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000 python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000
python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
""" """
@ -160,6 +171,7 @@ SOURCE_VIEWS = {
"cian": "msk_raw.cian_latest", "cian": "msk_raw.cian_latest",
"avito": "msk_raw.avito_latest", "avito": "msk_raw.avito_latest",
"yandex": "msk_raw.yandex_latest", "yandex": "msk_raw.yandex_latest",
"domclick": "msk_raw.domclick_latest",
} }
_PAGE_SQL = """ _PAGE_SQL = """
@ -485,11 +497,30 @@ def is_moscow_yandex_address(address: str | None) -> bool:
return len(parts) > 1 and parts[1] == "Москва" return len(parts) > 1 and parts[1] == "Москва"
def is_moscow_domclick_address(address: str | None) -> bool:
"""У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …».
Источник собран запросом с GUID-ом Москвы в параметре address и дополнительно
отфильтрован по bbox на стороне сборщика, так что областных карточек в сырье
и не должно быть. Фильтр здесь вторая линия: сменится GUID в сборщике или
появится второй город в той же таблице импорт не потащит его в Москву
молча. Замер по 5 024 карточкам первого прохода: первый компонент имеет ровно
одно значение, «Москва», областных нет ни одной.
Новая Москва отдельным значением НЕ приходит: «Москва, x. Ильичевка, »,
«Москва, пос. Птичное, » посёлок стоит ВТОРЫМ компонентом, первый всегда
город, что совпадает с кодом региона 77.
"""
parts = [part.strip() for part in (address or "").split(",")]
return bool(parts) and parts[0] == "Москва"
# Источники, у которых город виден в самой карточке. Ключ отсутствует — # Источники, у которых город виден в самой карточке. Ключ отсутствует —
# источник про город молчит, и без пред-геокода писать его нельзя (avito). # источник про город молчит, и без пред-геокода писать его нельзя (avito).
CITY_FILTERS = { CITY_FILTERS = {
"cian": is_moscow_address, "cian": is_moscow_address,
"yandex": is_moscow_yandex_address, "yandex": is_moscow_yandex_address,
"domclick": is_moscow_domclick_address,
} }

View file

@ -0,0 +1,59 @@
-- 301_web_support_message_idempotency_key.sql
-- Идемпотентность отправки веб-сообщения в поддержку (#3471 retry-storm):
-- канал Selectel -> api.telegram.org теряет заметную долю коротких запросов
-- (см. app/api/v1/support.py, _INTERACTIVE_SEND_TIMEOUT_S), поэтому повтор
-- клиента (fetch-ретрай/двойной клик/переотправка по таймауту) — обычное
-- дело. Раньше повтор создавал ВТОРУЮ строку в web_support_messages и ВТОРОЕ
-- зеркало в support-топике.
--
-- idempotency_key — client-provided (заголовок Idempotency-Key) ИЛИ
-- детерминированный fallback-отпечаток sha256(identity|текст|минутное окно)
-- для клиентов без заголовка (app/api/v1/support.py::_resolve_idempotency_key).
-- Всегда opaque-строка (клиентский токен или hex-хэш) — ТЕЛО СООБЩЕНИЯ сюда
-- никогда не попадает в открытом виде, только его отпечаток.
--
-- Scoped к (thread_id, direction='in'): идемпотентность осмысленна только для
-- inbound (клиентских) сообщений — direction='out' (ответ оператора) её не
-- требует, там уже есть своя partial-уникальность на topic_message_id (187).
-- thread_id, а не username/anon-token: таблица не хранит identity напрямую,
-- а тред уже гарантированно создан (`get_or_create_thread`) к моменту INSERT
-- (см. H1 в app/api/v1/support.py — тред создаётся ДО record_inbound).
--
-- Гонку двух одновременных запросов с одним ключом закрывает САМ уникальный
-- индекс + `INSERT ... ON CONFLICT DO NOTHING` в
-- web_support_storage.record_inbound (не read-then-write) — на конфликте
-- вызывающая сторона дочитывает уже вставленную строку и возвращает её тем
-- же ответом (тот же id), а не создаёт вторую строку.
--
-- IDEMPOTENCY: ADD COLUMN/CREATE INDEX IF NOT EXISTS — безопасный re-run.
-- Зависимости: 187_web_support_chat.sql (таблица web_support_messages).
BEGIN;
-- Блокирующий DDL не должен ждать чужую сессию бесконечно: без этого
-- ALTER встаёт в очередь за долгим запросом и уводит за собой ВСЕ
-- последующие обращения к таблице (#2752). Пять секунд — не успел взять
-- лок, деплой падает честно, а прод продолжает работать.
SET LOCAL lock_timeout = '5s';
ALTER TABLE web_support_messages
ADD COLUMN IF NOT EXISTS idempotency_key text;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'web_support_messages_idempotency_key_len_chk'
) THEN
ALTER TABLE web_support_messages
ADD CONSTRAINT web_support_messages_idempotency_key_len_chk
CHECK (idempotency_key IS NULL OR char_length(idempotency_key) BETWEEN 1 AND 160);
END IF;
END $$;
COMMENT ON COLUMN web_support_messages.idempotency_key IS 'Ключ идемпотентности inbound-отправки (#3471) — client-provided заголовок Idempotency-Key ("client:...") ИЛИ sha256-отпечаток thread+текст+минутное окно ("auto:...") для клиентов без заголовка. NULL для direction=''out'' и для строк до этой миграции.';
CREATE UNIQUE INDEX IF NOT EXISTS web_support_messages_thread_idempotency_uq
ON web_support_messages (thread_id, idempotency_key)
WHERE idempotency_key IS NOT NULL AND direction = 'in';
COMMIT;

View file

@ -461,3 +461,25 @@ def test_geocode_is_refused_for_yandex(captured: list[dict[str, Any]]) -> None:
mod.import_msk_raw( mod.import_msk_raw(
_FakeSession([{"id": 1, "payload": _yandex()}]), source="yandex", geocode=True _FakeSession([{"id": 1, "payload": _yandex()}]), source="yandex", geocode=True
) )
def test_domclick_moscow_is_recognised_without_geocode() -> None:
"""Регион ДомКлика — первый компонент адреса; Новая Москва тоже Москва."""
assert mod.is_moscow_domclick_address("Москва, улица Нижние Мнёвники, 16 к2")
assert mod.is_moscow_domclick_address("Москва, пос. Птичное, Лесная улица, 82")
assert mod.is_moscow_domclick_address("Москва, x. Ильичевка, хутор Ильичёвка, 1")
def test_domclick_non_moscow_is_rejected() -> None:
"""Вторая линия обороны: GUID в сборщике сменится — импорт не смолчит."""
assert not mod.is_moscow_domclick_address("Московская область, Химки, 9 Мая, 21к3")
assert not mod.is_moscow_domclick_address("Екатеринбург, улица Ясная, 2")
# «Москва» должна быть отдельным компонентом, а не префиксом другого слова.
assert not mod.is_moscow_domclick_address("Московский, 1-я улица, 5")
assert not mod.is_moscow_domclick_address("")
assert not mod.is_moscow_domclick_address(None)
def test_domclick_view_is_wired_into_source_views() -> None:
assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest"
assert mod.CITY_FILTERS["domclick"] is mod.is_moscow_domclick_address

View file

@ -76,6 +76,20 @@ class _FakeTelegramClient:
return _FakeTelegramClient._response return _FakeTelegramClient._response
@pytest.fixture(autouse=True)
def _no_existing_idempotent_message(monkeypatch: pytest.MonkeyPatch) -> None:
"""Дефолт для тестов, которые не проверяют идемпотентность (#3471) напрямую:
без этого автостаба pre-check в `support.py` дёргал бы РЕАЛЬНУЮ
storage-функцию против MagicMock `db` в каждом тесте, где замокан
`find_thread_id` для своих целей (rate-limit / DB-failure / anon-сценарии
и т.д.) падало бы на MagicMock-результате, никак не связанное с тем, что
тест на самом деле проверяет. Тесты идемпотентности переопределяют это
через `_install_fake_storage` ниже."""
monkeypatch.setattr(
support_module.storage, "find_inbound_by_idempotency_key", lambda *a, **kw: None
)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any: def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
_FakeTelegramClient.calls = [] _FakeTelegramClient.calls = []
@ -238,7 +252,9 @@ def test_send_message_happy_path_mirrors_with_website_marker(
monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake_get_or_create_thread) monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake_get_or_create_thread)
recorded = {} recorded = {}
def fake_record_inbound(db, *, thread_id, text_body, topic_message_id, support_chat_id): def fake_record_inbound(
db, *, thread_id, text_body, topic_message_id, support_chat_id, idempotency_key=None
):
recorded.update( recorded.update(
thread_id=thread_id, thread_id=thread_id,
text_body=text_body, text_body=text_body,
@ -1052,3 +1068,156 @@ def test_anon_send_failures_trigger_cooldown_per_ip(
blocked = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"}) blocked = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"})
assert blocked.status_code == 429 assert blocked.status_code == 429
assert len(_fake_telegram_client.calls) == calls_before # до Telegram не дошло assert len(_fake_telegram_client.calls) == calls_before # до Telegram не дошло
# ── idempotency (#3471) ──────────────────────────────────────────────────────
class _FakeIdempotentStorage:
"""In-memory stand-in для `web_support_storage`, достаточный чтобы точно
воспроизвести идемпотентный контракт (thread_id, idempotency_key) БЕЗ
настоящей БД: unique-конфликт на повторном ключе внутри `record_inbound`
(см. миграцию 301 и storage.record_inbound docstring)."""
def __init__(self) -> None:
self.threads: dict[str, int] = {}
self.messages: list[dict[str, Any]] = []
self._next_id = 1
def find_thread_id(self, db: Any, username: str) -> int | None:
return self.threads.get(username)
def get_or_create_thread(self, db: Any, username: str) -> int:
if username not in self.threads:
self.threads[username] = len(self.threads) + 1
return self.threads[username]
def find_inbound_by_idempotency_key(
self, db: Any, *, thread_id: int, idempotency_key: str
) -> dict[str, Any] | None:
for m in self.messages:
if m["_thread_id"] == thread_id and m["_idempotency_key"] == idempotency_key:
return {k: v for k, v in m.items() if not k.startswith("_")}
return None
def record_inbound(
self,
db: Any,
*,
thread_id: int,
text_body: str,
topic_message_id: int | None,
support_chat_id: int | None,
idempotency_key: str | None = None,
) -> dict[str, Any]:
# Тот же контракт, что и настоящий `INSERT ... ON CONFLICT DO NOTHING`:
# конфликт по (thread_id, idempotency_key) отдаёт УЖЕ существующую строку.
if idempotency_key is not None:
existing = self.find_inbound_by_idempotency_key(
db, thread_id=thread_id, idempotency_key=idempotency_key
)
if existing is not None:
return existing
row = {
"id": self._next_id,
"direction": "in",
"text_body": text_body,
"operator_tg_id": None,
"created_at": "2026-09-12T00:00:00+00:00",
"_thread_id": thread_id,
"_idempotency_key": idempotency_key,
}
self._next_id += 1
self.messages.append(row)
return {k: v for k, v in row.items() if not k.startswith("_")}
def _install_fake_storage(monkeypatch: pytest.MonkeyPatch) -> _FakeIdempotentStorage:
fake = _FakeIdempotentStorage()
monkeypatch.setattr(support_module.storage, "find_thread_id", fake.find_thread_id)
monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake.get_or_create_thread)
monkeypatch.setattr(
support_module.storage,
"find_inbound_by_idempotency_key",
fake.find_inbound_by_idempotency_key,
)
monkeypatch.setattr(support_module.storage, "record_inbound", fake.record_inbound)
return fake
def test_repeat_send_with_same_idempotency_header_returns_same_id_single_mirror(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Повтор с тем же `Idempotency-Key` — тот же id, ОДНО зеркало в Telegram
(не только одна строка в БД, см. #3471 требование п.3)."""
_install_fake_storage(monkeypatch)
headers = {**_auth("kopylov"), "Idempotency-Key": "retry-abc123"}
r1 = client.post(
"/api/v1/trade-in/support/messages", json={"text": "первое сообщение"}, headers=headers
)
r2 = client.post(
"/api/v1/trade-in/support/messages", json={"text": "первое сообщение"}, headers=headers
)
assert r1.status_code == 200, r1.text
assert r2.status_code == 200, r2.text
assert r1.json()["id"] == r2.json()["id"]
# Ключевая проверка: повтор НЕ ушёл в Telegram второй раз.
assert len(_fake_telegram_client.calls) == 1
def test_send_with_different_idempotency_keys_creates_different_messages(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
_install_fake_storage(monkeypatch)
r1 = client.post(
"/api/v1/trade-in/support/messages",
json={"text": "вопрос один"},
headers={**_auth("kopylov"), "Idempotency-Key": "key-one-111"},
)
r2 = client.post(
"/api/v1/trade-in/support/messages",
json={"text": "вопрос два"},
headers={**_auth("kopylov"), "Idempotency-Key": "key-two-222"},
)
assert r1.status_code == 200, r1.text
assert r2.status_code == 200, r2.text
assert r1.json()["id"] != r2.json()["id"]
assert len(_fake_telegram_client.calls) == 2
def test_repeat_send_without_idempotency_header_still_dedupes_via_fallback_window(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Требование п.5: отсутствие явного ключа не должно ломать старых клиентов —
сервер сам считает детерминированный fallback-отпечаток (thread+текст+минутное
окно), так что тот же повтор ТЕМ ЖЕ клиентом без заголовка тоже не создаёт
дубль. Время фиксируем, чтобы не зависеть от границы минутного окна в CI."""
_install_fake_storage(monkeypatch)
monkeypatch.setattr(support_module.time, "time", lambda: 1_800_000_000.0)
r1 = client.post(
"/api/v1/trade-in/support/messages", json={"text": "не могу войти"}, headers=_auth("bob")
)
r2 = client.post(
"/api/v1/trade-in/support/messages", json={"text": "не могу войти"}, headers=_auth("bob")
)
assert r1.status_code == 200, r1.text
assert r2.status_code == 200, r2.text
assert r1.json()["id"] == r2.json()["id"]
assert len(_fake_telegram_client.calls) == 1
def test_send_without_idempotency_header_first_call_still_succeeds(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Требование п.5 (не ломает старых клиентов): один запрос без заголовка
отрабатывает как раньше 200, тред создан, зеркало отправлено ровно раз."""
_install_fake_storage(monkeypatch)
r = client.post(
"/api/v1/trade-in/support/messages", json={"text": "просто вопрос"}, headers=_auth("carol")
)
assert r.status_code == 200, r.text
assert r.json()["persisted"] is True
assert len(_fake_telegram_client.calls) == 1

View file

@ -42,6 +42,7 @@
* setQueryData-merge path that would be pure incidental complexity here. * setQueryData-merge path that would be pure incidental complexity here.
*/ */
import { useRef } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch, HTTPError } from "@/lib/api"; import { apiFetch, HTTPError } from "@/lib/api";
@ -129,13 +130,36 @@ export function useSupportUnread(enabled: boolean, scope: SupportScope = "auth")
export function useSendSupportMessage(scope: SupportScope = "auth") { export function useSendSupportMessage(scope: SupportScope = "auth") {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Идемпотентность (#3471): ОДИН `Idempotency-Key` на все повторы ОДНОГО
// намерения отправить. Бэкенд пишет строку/ключ ТОЛЬКО ПОСЛЕ успешной
// доставки в Telegram, поэтому это не про потери на плече Selectel ->
// api.telegram.org (неудачный send там — законная первая попытка) — это
// про то, что доставка УЖЕ состоялась, а ответ до клиента не дошёл (обрыв
// на обратном пути / клиентский таймаут) или пользователь дважды нажал
// "отправить" по одной и той же ещё не отрисовавшейся отправке. Без
// заголовка единственная защита — backend-fallback (минутный
// hash-отпечаток, `_resolve_idempotency_key` в app/api/v1/support.py), а
// он ненадёжен для осмысленно повторяющихся коротких сообщений ("+", "ок",
// два "да" подряд). Намерение отождествляем с текстом: тот же текст подряд —
// это ретрай ЭТОГО намерения, переиспользуем ключ; новый текст — новое
// намерение, новый UUID.
const intentRef = useRef<{ text: string; key: string } | null>(null);
return useMutation<SupportMessage, Error, string>({ return useMutation<SupportMessage, Error, string>({
mutationFn: (text) => mutationFn: (text) => {
apiFetch<SupportMessage>(`${scopeBase(scope)}/messages`, { if (!intentRef.current || intentRef.current.text !== text) {
intentRef.current = { text, key: crypto.randomUUID() };
}
return apiFetch<SupportMessage>(`${scopeBase(scope)}/messages`, {
method: "POST", method: "POST",
headers: { "Idempotency-Key": intentRef.current.key },
body: JSON.stringify({ text }), body: JSON.stringify({ text }),
}), });
},
onSuccess: () => { onSuccess: () => {
// Успех закрывает намерение — следующая отправка (даже с тем же текстом)
// обязана получить НОВЫЙ ключ, иначе она молча схлопнется с этой.
intentRef.current = null;
queryClient.invalidateQueries({ queryKey: messagesKey(scope) }); queryClient.invalidateQueries({ queryKey: messagesKey(scope) });
}, },
}); });

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Локальный ручной сборщик SERP Авито/Циан/Яндекса по Москве и МО (эпик #2989, трек 1). """Локальный ручной сборщик SERP Авито/Циан/Яндекса/DomClick по Москве и МО (эпик #2989, трек 1).
Запускается ВРУЧНУЮ с машины владельца. Прод-скрейпер, его расписания и Запускается ВРУЧНУЮ с машины владельца. Прод-скрейпер, его расписания и
прокси-пул не задействованы вообще: браузер уже открытый Chrome владельца прокси-пул не задействованы вообще: браузер уже открытый Chrome владельца
@ -59,6 +59,21 @@ from scraper_kit.providers.yandex.serp import ( # noqa: E402
_parse_gate_json, _parse_gate_json,
) )
# DomClick — тоже JSON BFF-ответ (Layer A прод-скрейпера), а не HTML SERP, как
# и Яндекс выше. Берём из кита чистую функцию извлечения JSON (_extract_json)
# и сам класс DomClickScraper — не ради транспорта (тот же приём с браузером
# владельца), а ради DomClickScraper._map_item: маппинг offer-item → ScrapedLot
# дублировать своим кодом было бы регрессивно. Маркеры блока — единый источник
# Layer A/B кита, см. докстринг DOMCLICK_BLOCK_MARKERS.
from scraper_kit.domclick_exceptions import ( # noqa: E402
DOMCLICK_BLOCK_MARKERS,
DomClickBlockedError,
)
from scraper_kit.providers.domclick.serp import ( # noqa: E402
DomClickScraper,
_extract_json,
)
# Вкладка, открытая у владельца: вторичка, Москва + МО. # Вкладка, открытая у владельца: вторичка, Москва + МО.
DEFAULT_AVITO_BASE_URL = ( DEFAULT_AVITO_BASE_URL = (
"https://www.avito.ru/moskva_i_mo/kvartiry/prodam/vtorichka-ASgBAgICAkSSA8YQ5geMUg" "https://www.avito.ru/moskva_i_mo/kvartiry/prodam/vtorichka-ASgBAgICAkSSA8YQ5geMUg"
@ -138,6 +153,37 @@ YANDEX_MAX_PAGES = 25
# штатно «дорезает» до 1500 и каждый коридор уезжает в truncated. # штатно «дорезает» до 1500 и каждый коридор уезжает в truncated.
YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500 YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500
# DomClick: JSON BFF listing API, Москва (эпик #2989, трек 1).
#
# GUID Москвы проверен живым запросом 12.09: и region, и locality — один и тот
# же 1d1463ae-c80f-4d19-9331-a1b68a85b553 (для сравнения ЕКБ —
# 0d475b79-88de-4054-818c-37d8f9d0d440). Параметр aids (у ЕКБ 20561, сужает
# выдачу до конкретного агрегатора) для Москвы НЕ нужен — без него счётчик
# листинга точно совпадает с сайтом.
MSK_DOMCLICK_GUID = "1d1463ae-c80f-4d19-9331-a1b68a85b553"
DEFAULT_DOMCLICK_BASE_URL = (
"https://bff-search-web.domclick.ru/api/offers/v1"
f"?address={MSK_DOMCLICK_GUID}&deal_type=sale&category=living&offer_type=flat"
"&sort=qi&sort_dir=desc&limit=20&offset=0"
)
# Замерено живым запросом 12.09, не из документации: offset=1980 отдаёт полную
# страницу (20 items), offset=2000 отвечает HTTP 400
# {"statusCode":400,"error":"Bad Request"}. Значит жёсткий потолок пагинации —
# 100 страниц по 20 штук = 2000 офферов на один набор фильтров.
DOMCLICK_PAGE_SIZE = 20
DOMCLICK_MAX_PAGES = 100
# bbox Москвы с ТиНАО — единственный надёжный гео-гард для DomClick.
# offerRegionName использовать НЕЛЬЗЯ: часть офферов Новой Москвы приходит с
# именами вида "г. Говорово", а не "Москва" — гард по имени региона молча
# вырезал бы легитимные лоты. scraper._is_geo_ok() кита сюда тоже не подходит:
# он захардкожен на offerRegionName == "Екатеринбург" и отбросил бы буквально
# всю московскую выдачу.
_MSK_LAT_MIN, _MSK_LAT_MAX = 55.14, 56.02
_MSK_LON_MIN, _MSK_LON_MAX = 36.80, 37.97
PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽ PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽
PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы
PRICE_CEIL = 2_000_000_000 PRICE_CEIL = 2_000_000_000
@ -528,6 +574,197 @@ async def _yandex_wait_ready(page: Any) -> None:
pass pass
# --- DomClick: JSON BFF (конвейер как у Яндекса — payload вместо DOM) ---------
def _domclick_build_url(base_url: str, page: int, lo: int | None, hi: int | None) -> str:
"""URL коридора DomClick: sale_price__gte/__lte + offset (аналог _cian_build_url).
page 1-based, как у остальных платформ; offset у BFF 0-based, поэтому
offset = (page - 1) * DOMCLICK_PAGE_SIZE. offset проставляется ВСЕГДА, в
т.ч. на первой странице (как priceMin/priceMax/page у Яндекса): старые
offset/sale_price__gte/sale_price__lte из base_url выкидываются и ставятся
заново, дублей ключей не остаётся.
"""
parts = urlsplit(base_url)
q = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
if k not in {"offset", "sale_price__gte", "sale_price__lte"}]
if lo is not None:
q.append(("sale_price__gte", str(int(lo))))
if hi is not None:
q.append(("sale_price__lte", str(int(hi))))
q.append(("offset", str((page - 1) * DOMCLICK_PAGE_SIZE)))
return urlunsplit(
(parts.scheme, parts.netloc, parts.path, urlencode(q), parts.fragment)
)
def _unescape_pre_entities(html: str) -> str:
"""Снять HTML-экранирование текстового узла <pre>, в который Chrome заворачивает
ответ ручки.
`page.content()` отдаёт СЕРИАЛИЗОВАННЫЙ документ, а не тело ответа: сериализатор
экранирует в текстовом узле ровно четыре вещи `&`, `<`, `>` и неразрывный
пробел. Синтаксис JSON от этого не страдает (`&amp;` внутри строки валидная
строка), поэтому дефект тихий: `json.loads` проходит, а в описании и адресе
вместо «&» и неразрывного пробела оседают `&amp;` и `&nbsp;`. Замер на живой
выдаче Москвы: 29 `&nbsp;` на одной странице из двадцати карточек.
Кавычки в список не входят намеренно: сериализатор экранирует их только в
значениях атрибутов, а в текстовом узле `"` остаётся собой — и это важно, иначе
`\"` внутри JSON-строки превратился бы в невалидный escape и уронил разбор
страницы целиком.
Порядок фиксирован: `&amp;` разворачивается ПОСЛЕДНИМ, иначе уже развёрнутый
амперсанд склеится со следующей последовательностью и даст второй разбор.
"""
return (
html.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&nbsp;", " ")
.replace("&amp;", "&")
)
def _domclick_payload(scraper: Any, html: str) -> dict[str, Any] | None:
"""JSON BFF-ответ из содержимого вкладки, с мемоизацией на один HTML.
parse_page дёргает extract_total_count и parse_cards по одному и тому же
html разбираем один раз (тот же приём, что и _yandex_payload). Парсер не
свой: _extract_json та же функция, которой пользуется прод-скрейпер
(providers/domclick/serp.py), со своим корректным порядком «сначала JSON,
потом маркеры блока» (#3267 — маркер может встретиться в тексте самого
объявления, подстрочный поиск ДО попытки распарсить JSON уже забанил живой
узел на 6 часов в проде 30.08).
К моменту вызова этой функции _guard уже прогнал тот же html через
_domclick_detect_block, и настоящий блок остановил бы сбор раньше
DomClickBlockedError/ValueError здесь только тихо превращаются в None, как
и в yandex-ветке.
"""
if getattr(scraper, "_payload_html", None) is html:
return scraper._payload
try:
payload: dict[str, Any] | None = _extract_json(_unescape_pre_entities(html))
except (DomClickBlockedError, ValueError):
payload = None
scraper._payload_html = html
scraper._payload = payload
return payload
def _domclick_extract_total(scraper: Any, html: str) -> int | None:
"""result.pagination.total — а НЕ отдельная count-ручка.
Замер живым запросом 12.09: pagination.total по Москве без фильтра комнат
= 23692, и это число реально достижимо постраничным обходом (offset до
1980, см. DOMCLICK_MAX_PAGES). У отдельной count-ручки другое число
25898: оно включает дубли одного объекта у разных агентств, которые
пагинацией физически недостижимы. Если бы бисекция целилась в
count-ручку, она считала бы коридоры «недобранными» там, где добирать
нечего, и truncated/missed в notes врали бы.
"""
payload = _domclick_payload(scraper, html)
if not payload:
return None
result = payload.get("result")
if not isinstance(result, dict):
return None
pagination = result.get("pagination")
if not isinstance(pagination, dict):
return None
total = pagination.get("total")
return int(total) if total is not None else None
def _domclick_parse_cards(scraper: Any, html: str) -> list[Any]:
"""result.items → ScrapedLot через DomClickScraper._map_item (кит, без своего маппинга).
Гео-гард СВОЙ, по bbox Москвы+ТиНАО, а не scraper._is_geo_ok(): тот метод
кита захардкожен на offerRegionName == "Екатеринбург" и отбросил бы всю
московскую выдачу целиком. offerRegionName как критерий тоже не годится:
часть офферов Новой Москвы приходит с именами вида "г. Говорово", а не
"Москва" гард по названию региона молча резал бы легитимные лоты.
Координаты единственный признак, который не врёт.
last_raw_count фиксируется ДО геогарда (как raw-счётчик у Циан) иначе
страница, где все 20 items легитимно оказались вне bbox, была бы
неотличима от блока/пустого ответа в parse_page.
"""
payload = _domclick_payload(scraper, html)
scraper.last_raw_count = 0
if not payload:
return []
result = payload.get("result")
items = result.get("items") if isinstance(result, dict) else None
if not isinstance(items, list):
return []
scraper.last_raw_count = len(items)
lots = []
for item in items:
loc = item.get("location") or {}
# float() в try: прогон идёт часами без присмотра, и один оффер с мусором
# в координате уронил бы ВЕСЬ коллектор на середине коридора вместо того,
# чтобы выпасть одной карточкой. Нечисловая координата = лот без гео,
# пропускаем его так же, как отсутствующую.
try:
lat = float(loc["lat"])
lon = float(loc["lon"])
except (KeyError, TypeError, ValueError):
continue
if not (_MSK_LAT_MIN <= lat <= _MSK_LAT_MAX
and _MSK_LON_MIN <= lon <= _MSK_LON_MAX):
continue
lot = scraper._map_item(item)
if lot is not None:
lots.append(lot)
return lots
def _domclick_detect_block(html: str) -> tuple[str, str] | None:
"""QRATOR/капча DomClick: HTML вместо JSON BFF-ответа.
Порядок обязателен сперва пробуем распарсить JSON, и только если не
вышло, ищем маркеры (тот же порядок и та же причина, что в докстринге
_extract_json в ките, #3267): маркер вроде "система защиты" или "captcha"
может встретиться в тексте самого объявления, и подстрочный поиск ДО
попытки распарсить JSON забанил живой узел на 6 часов в проде 30.08.
Валидный JSON нужной формы блок-страницей быть не может QRATOR всегда
отдаёт HTML, поэтому успешный json.loads сам по себе доказывает «блока
нет», и маркеры уже не смотрим.
HTTP-400-подобное тело ({"statusCode":400,"error":"Bad Request"}, ответ на
offset за потолком пагинации) валидный JSON без result.pagination, а не
блок: возвращаем None, конец коридора отловит parse_page по правилу
«count is None и 0 карточек». В штатном прогоне это тело недостижимо
DOMCLICK_MAX_PAGES ограничивает offset числом ниже HTTP-400 порога.
"""
start = html.find("{")
end = html.rfind("}")
if start != -1 and end != -1 and end > start:
try:
json.loads(html[start:end + 1])
return None
except json.JSONDecodeError:
pass
html_lower = html.lower()
for marker in DOMCLICK_BLOCK_MARKERS:
if marker in html_lower:
return "challenge", f"маркер блока DomClick: {marker!r}"
return None
async def _domclick_wait_ready(page: Any) -> None:
"""BFF отдаёт JSON текстовым узлом в <pre> (как gate-API Яндекса) — ждём его
появления коротко; на капче его не будет вовсе, и через 3 с отработает
_domclick_detect_block.
"""
try:
await page.wait_for_selector("pre", timeout=3_000)
except Exception: # noqa: BLE001 — блок/пустая страница разбирается ниже
pass
@dataclass(frozen=True) @dataclass(frozen=True)
class PlatformAdapter: class PlatformAdapter:
"""Платформо-зависимые куски сбора. Всё общее (бисекция, guard по HTTP-статусу, """Платформо-зависимые куски сбора. Всё общее (бисекция, guard по HTTP-статусу,
@ -566,6 +803,11 @@ class PlatformAdapter:
SimpleNamespace(avito_serp_ekb_only=False), # type: ignore[arg-type] SimpleNamespace(avito_serp_ekb_only=False), # type: ignore[arg-type]
target_city_slug="moskva", target_city_slug="moskva",
) )
if self.name == "domclick":
# _map_item — чистая функция над dict (см. providers/domclick/serp.py),
# self._config/self._cookies там не читаются, поэтому пустой
# namespace достаточен — тот же приём, что и у CianScraper ниже.
return DomClickScraper(SimpleNamespace()) # type: ignore[arg-type]
# glitchtip_dsn=None — отключает попытку sentry-репорта schema-regression # glitchtip_dsn=None — отключает попытку sentry-репорта schema-regression
# из _report_schema_regression (нет реального DSN в локальном прогоне). # из _report_schema_regression (нет реального DSN в локальном прогоне).
return CianScraper(SimpleNamespace(glitchtip_dsn=None)) # type: ignore[arg-type] return CianScraper(SimpleNamespace(glitchtip_dsn=None)) # type: ignore[arg-type]
@ -609,6 +851,18 @@ ADAPTERS: dict[str, PlatformAdapter] = {
wait_ready=_yandex_wait_ready, wait_ready=_yandex_wait_ready,
default_target=YANDEX_TARGET_COUNT, default_target=YANDEX_TARGET_COUNT,
), ),
"domclick": PlatformAdapter(
name="domclick",
table="domclick_cards",
default_base_url=DEFAULT_DOMCLICK_BASE_URL,
page_size=DOMCLICK_PAGE_SIZE,
max_pages=DOMCLICK_MAX_PAGES,
build_url=_domclick_build_url,
extract_total_count=_domclick_extract_total,
parse_cards=_domclick_parse_cards,
detect_block=_domclick_detect_block,
wait_ready=_domclick_wait_ready,
),
} }
@ -1264,7 +1518,8 @@ async def collect(args: argparse.Namespace) -> int:
def parse_args(argv: list[str] | None = None) -> argparse.Namespace: def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p = argparse.ArgumentParser( p = argparse.ArgumentParser(
prog="collect.py", prog="collect.py",
description="Ручной сбор SERP Авито/Циан/Яндекса (вторичка, Москва+МО) в прод-схему msk_raw.", description="Ручной сбор SERP Авито/Циан/Яндекса/DomClick (вторичка, Москва+МО) в"
" прод-схему msk_raw.",
) )
p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito", p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito",
help="площадка сбора (дефолт avito)") help="площадка сбора (дефолт avito)")