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
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:
commit
acbfdf0a18
8 changed files with 795 additions and 9 deletions
|
|
@ -62,6 +62,7 @@ import hashlib
|
|||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
|
|
@ -170,6 +171,14 @@ _SEND_UNAVAILABLE_DETAIL = "Telegram сейчас недоступен. Попр
|
|||
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
|
||||
_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:
|
||||
"""Достаёт 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)
|
||||
|
||||
|
||||
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)
|
||||
async def send_support_message(
|
||||
request: Request,
|
||||
payload: SupportMessageInput,
|
||||
username: Annotated[str, Depends(_require_username)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
|
|
@ -380,6 +432,37 @@ async def send_support_message(
|
|||
if cooldown is not None:
|
||||
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): на каждый запрос
|
||||
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
|
||||
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
||||
|
|
@ -438,6 +521,7 @@ async def send_support_message(
|
|||
text_body=payload.text,
|
||||
topic_message_id=topic_message_id,
|
||||
support_chat_id=settings.telegram_support_chat_id,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
|
|
@ -608,6 +692,31 @@ async def send_anon_support_message(
|
|||
raise _too_many_failures_error(cooldown)
|
||||
|
||||
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): на каждый запрос
|
||||
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
|
||||
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
||||
|
|
@ -657,6 +766,7 @@ async def send_anon_support_message(
|
|||
text_body=payload.text,
|
||||
topic_message_id=topic_message_id,
|
||||
support_chat_id=settings.telegram_support_chat_id,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
|
|
|
|||
|
|
@ -61,6 +61,36 @@ def get_or_create_thread(db: Session, username: str) -> int:
|
|||
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(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -68,6 +98,7 @@ def record_inbound(
|
|||
text_body: str,
|
||||
topic_message_id: int | None,
|
||||
support_chat_id: int | None,
|
||||
idempotency_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` —
|
||||
id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора.
|
||||
|
|
@ -75,18 +106,103 @@ def record_inbound(
|
|||
review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ
|
||||
support-группе — если группу когда-нибудь сменят/пересоздадут, Telegram
|
||||
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 = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO web_support_messages
|
||||
(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
|
||||
(CAST(:thread_id AS bigint), 'in', :text_body,
|
||||
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
|
||||
"""
|
||||
),
|
||||
|
|
|
|||
|
|
@ -77,10 +77,21 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це
|
|||
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
|
||||
заполнены у 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 --limit 500
|
||||
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 --allow-unfiltered # аварийный
|
||||
"""
|
||||
|
|
@ -160,6 +171,7 @@ SOURCE_VIEWS = {
|
|||
"cian": "msk_raw.cian_latest",
|
||||
"avito": "msk_raw.avito_latest",
|
||||
"yandex": "msk_raw.yandex_latest",
|
||||
"domclick": "msk_raw.domclick_latest",
|
||||
}
|
||||
|
||||
_PAGE_SQL = """
|
||||
|
|
@ -485,11 +497,30 @@ def is_moscow_yandex_address(address: str | None) -> bool:
|
|||
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).
|
||||
CITY_FILTERS = {
|
||||
"cian": is_moscow_address,
|
||||
"yandex": is_moscow_yandex_address,
|
||||
"domclick": is_moscow_domclick_address,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -461,3 +461,25 @@ def test_geocode_is_refused_for_yandex(captured: list[dict[str, Any]]) -> None:
|
|||
mod.import_msk_raw(
|
||||
_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
|
||||
|
|
|
|||
|
|
@ -76,6 +76,20 @@ class _FakeTelegramClient:
|
|||
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)
|
||||
def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
|
||||
_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)
|
||||
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(
|
||||
thread_id=thread_id,
|
||||
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"})
|
||||
assert blocked.status_code == 429
|
||||
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
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
* setQueryData-merge path that would be pure incidental complexity here.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch, HTTPError } from "@/lib/api";
|
||||
|
|
@ -129,13 +130,36 @@ export function useSupportUnread(enabled: boolean, scope: SupportScope = "auth")
|
|||
|
||||
export function useSendSupportMessage(scope: SupportScope = "auth") {
|
||||
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>({
|
||||
mutationFn: (text) =>
|
||||
apiFetch<SupportMessage>(`${scopeBase(scope)}/messages`, {
|
||||
mutationFn: (text) => {
|
||||
if (!intentRef.current || intentRef.current.text !== text) {
|
||||
intentRef.current = { text, key: crypto.randomUUID() };
|
||||
}
|
||||
return apiFetch<SupportMessage>(`${scopeBase(scope)}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Idempotency-Key": intentRef.current.key },
|
||||
body: JSON.stringify({ text }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Успех закрывает намерение — следующая отправка (даже с тем же текстом)
|
||||
// обязана получить НОВЫЙ ключ, иначе она молча схлопнется с этой.
|
||||
intentRef.current = null;
|
||||
queryClient.invalidateQueries({ queryKey: messagesKey(scope) });
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Локальный ручной сборщик SERP Авито/Циан/Яндекса по Москве и МО (эпик #2989, трек 1).
|
||||
"""Локальный ручной сборщик SERP Авито/Циан/Яндекса/DomClick по Москве и МО (эпик #2989, трек 1).
|
||||
|
||||
Запускается ВРУЧНУЮ с машины владельца. Прод-скрейпер, его расписания и
|
||||
прокси-пул не задействованы вообще: браузер — уже открытый Chrome владельца
|
||||
|
|
@ -59,6 +59,21 @@ from scraper_kit.providers.yandex.serp import ( # noqa: E402
|
|||
_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 = (
|
||||
"https://www.avito.ru/moskva_i_mo/kvartiry/prodam/vtorichka-ASgBAgICAkSSA8YQ5geMUg"
|
||||
|
|
@ -138,6 +153,37 @@ YANDEX_MAX_PAGES = 25
|
|||
# штатно «дорезает» до 1500 и каждый коридор уезжает в truncated.
|
||||
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_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы
|
||||
PRICE_CEIL = 2_000_000_000
|
||||
|
|
@ -528,6 +574,197 @@ async def _yandex_wait_ready(page: Any) -> None:
|
|||
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 от этого не страдает (`&` внутри строки — валидная
|
||||
строка), поэтому дефект тихий: `json.loads` проходит, а в описании и адресе
|
||||
вместо «&» и неразрывного пробела оседают `&` и ` `. Замер на живой
|
||||
выдаче Москвы: 29 ` ` на одной странице из двадцати карточек.
|
||||
|
||||
Кавычки в список не входят намеренно: сериализатор экранирует их только в
|
||||
значениях атрибутов, а в текстовом узле `"` остаётся собой — и это важно, иначе
|
||||
`\"` внутри JSON-строки превратился бы в невалидный escape и уронил разбор
|
||||
страницы целиком.
|
||||
|
||||
Порядок фиксирован: `&` разворачивается ПОСЛЕДНИМ, иначе уже развёрнутый
|
||||
амперсанд склеится со следующей последовательностью и даст второй разбор.
|
||||
"""
|
||||
return (
|
||||
html.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
class PlatformAdapter:
|
||||
"""Платформо-зависимые куски сбора. Всё общее (бисекция, guard по HTTP-статусу,
|
||||
|
|
@ -566,6 +803,11 @@ class PlatformAdapter:
|
|||
SimpleNamespace(avito_serp_ekb_only=False), # type: ignore[arg-type]
|
||||
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
|
||||
# из _report_schema_regression (нет реального DSN в локальном прогоне).
|
||||
return CianScraper(SimpleNamespace(glitchtip_dsn=None)) # type: ignore[arg-type]
|
||||
|
|
@ -609,6 +851,18 @@ ADAPTERS: dict[str, PlatformAdapter] = {
|
|||
wait_ready=_yandex_wait_ready,
|
||||
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:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="collect.py",
|
||||
description="Ручной сбор SERP Авито/Циан/Яндекса (вторичка, Москва+МО) в прод-схему msk_raw.",
|
||||
description="Ручной сбор SERP Авито/Циан/Яндекса/DomClick (вторичка, Москва+МО) в"
|
||||
" прод-схему msk_raw.",
|
||||
)
|
||||
p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito",
|
||||
help="площадка сбора (дефолт avito)")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue