Compare commits

..

No commits in common. "main" and "fix/3469-showcase-schedule" have entirely different histories.

21 changed files with 141 additions and 1794 deletions

View file

@ -233,16 +233,6 @@ jobs:
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше. # уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
- '.forgejo/workflows/deploy-metrics.yml' - '.forgejo/workflows/deploy-metrics.yml'
- 'docker-compose.metrics.yml' - 'docker-compose.metrics.yml'
# #3443: тот же класс, третий раз. Гейт
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
# все содержательные регрессии живут в самом скрипте, а не в
# deploy.yml. PR, правящий только ops/**, без этой строки давал бы
# backend=false: джоба пропускается, гейт не исполняется, и
# «пересоздавать всегда» (окно 67 с на всех доменах) или
# «не пересоздавать никогда» (правка конфига беззвучно не доезжает)
# уезжает в main зелёным.
- 'ops/**'
frontend: frontend:
- 'frontend/**' - 'frontend/**'
- '.forgejo/workflows/ci.yml' - '.forgejo/workflows/ci.yml'

View file

@ -1058,17 +1058,12 @@ jobs:
docker compose -p gendesign -f docker-compose.prod.yml up -d \ docker compose -p gendesign -f docker-compose.prod.yml up -d \
--force-recreate --no-deps $WORKER_SERVICES --force-recreate --no-deps $WORKER_SERVICES
# Caddy: пересоздание ТОЛЬКО когда без него правка не доедет (#3443). # Caddy: force-recreate чтобы подхватить изменения в Caddyfile
# Здесь стоял безусловный `up -d --force-recreate --no-deps caddy` — # И в особенности новые volume mounts из docker-compose.prod.yml
# то есть КАЖДЫЙ полный деплой сносил единственный процесс, слушающий # (`reload` не пересоздаёт container, поэтому новые binds не появляются —
# 80/443, и все домены хоста отдавали `code=000` (замер 05.09: 67 с). # был случай 2026-05-17 с PR #268 preview/ — потребовался manual SSH fix).
# Довод той правки (17.05, 11e78d73 — «иначе новые volume mounts не docker compose -p gendesign -f docker-compose.prod.yml up -d \
# появляются») не подтвердился: `up -d` БЕЗ флага пересоздаёт --force-recreate --no-deps caddy
# контейнер сам, как только меняется описание сервиса или образ.
# Разбор и проверки — в шапке ops/caddy-apply.sh; там же сверка
# пофайловых bind-маунтов (Caddyfile + 4 сниппета держат инод) и
# `caddy validate` до применения.
sh ops/caddy-apply.sh
# Forwarder: force-recreate чтобы новый image / новые env подхватывались. # Forwarder: force-recreate чтобы новый image / новые env подхватывались.
# Без --force-recreate обычный `up -d` НЕ recreate'ит при image rebuild # Без --force-recreate обычный `up -d` НЕ recreate'ит при image rebuild
@ -1295,13 +1290,14 @@ jobs:
# Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая # Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая
# страница = новая строка allowlist'а. # страница = новая строка allowlist'а.
# #
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Опечатка в конфиге на # ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Полный деплой
# пересоздании уводит контейнер в crash-loop и роняет ВСЕ домены сразу, а # осознанно пересоздаёт контейнер (комментарий в ci.yml: `reload` отказался бы
# `caddy reload` её просто не принимает: job краснеет, домены продолжают # принять битый конфиг и оставил бы работать старый — на общем деплое это
# обслуживаться прежним конфигом. С #3443 ровно тот же порядок действует и на # скрыло бы поломку). Здесь наоборот: правится ТОЛЬКО конфиг, и отказ
# полном деплое — оба пути зовут ops/caddy-apply.sh, который сперва проверяет # применить битый — ровно то, что нужно. `caddy reload` возвращает ненулевой
# конфиг одноразовым контейнером и пересоздаёт Caddy, только если правка иначе # код → job краснеет, а домены продолжают обслуживаться старым конфигом.
# не доедет (пофайловый bind-маунт держит инод). # Альтернатива (`--force-recreate`) на опечатке уводит контейнер в crash-loop
# и роняет ВСЕ домены сразу.
# #
# Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг — # Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг —
# вторая, уже против боевого файла после `git reset`. # вторая, уже против боевого файла после `git reset`.
@ -1404,16 +1400,11 @@ jobs:
exit 1 exit 1
fi fi
git reset --hard origin/main git reset --hard origin/main
# #3443: тот же скрипт, что и в полном деплое. Голый `exec caddy # Конфиг примонтирован read-only с хоста, пересборка не нужна —
# reload` здесь был ВЕРЕН только для каталогов (caddy/sites/**, # контейнер читает тот же файл, что только что обновил git.
# caddy/local/**). Caddyfile и четыре сниппета смонтированы docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
# ПОФАЙЛОВО, а `git reset --hard` выше пишет новый инод — контейнер caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
# остаётся на прежнем, и reload перечитывает СТАРЫЙ текст. Отказ echo "✓ конфиг прокси перезагружен без пересборки и без миграций"
# беззвучный: джоба зелёная, конфиг на диске новый, прокси работает
# по старому. Скрипт сверяет, что именно видит контейнер, и
# пересоздаёт его только в этом случае.
sh ops/caddy-apply.sh
echo "✓ быстрый путь завершён: без пересборки образов и без миграций"
# ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ────────────────── # ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ──────────────────
# #

View file

@ -17,22 +17,12 @@ Analyze-тесты с ПОЗИЦИОННЫМ DB-моком (``_make_db_for_analy
(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим (``test_analyze_zoning_regulation.py``), переопределяют этот же target своим
per-test ``patch`` он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так per-test ``patch`` он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так
что их ожидаемые значения резолвера сохраняются. что их ожидаемые значения резолвера сохраняются.
Perf-fix (2026-09-12): в конце ``analyze_parcel`` безусловный best-effort
``forecast_site_finder_report.delay(...)`` (§22-форсайт enqueue, см. app/api/v1/parcels.py).
В песочнице тестов Celery-брокер (Redis) недоступен ``.delay()`` синхронно ждёт
kombu-реконнект с растущим backoff (~69с) ДО того как try/except его проглотит
эта пауза оказалась внутри КАЖДОГО теста, который дергает ``POST /analyze`` и не
мокал форсайт-таску. Авто-фикстура ниже глушит ``.delay`` в no-op-мок для ВСЕХ
тестов каталога (как и с резолвером выше) тесты самого enqueue
(``test_parcels_forecast.py``, ``test_run_history_and_response_contract.py``)
переопределяют тот же target своим per-test ``patch`` поверх авто-фикстуры.
""" """
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterator from collections.abc import Iterator
from unittest.mock import MagicMock, patch from unittest.mock import patch
import pytest import pytest
@ -47,34 +37,3 @@ def _stub_zone_regulation_resolver() -> Iterator[None]:
""" """
with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None): with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None):
yield yield
@pytest.fixture(autouse=True)
def _stub_forecast_enqueue() -> Iterator[None]:
"""No-op форсайт-enqueue по умолчанию (без реального Celery/Redis round-trip).
``.delay(...)`` в проде fire-and-forget (best-effort, обёрнут в try/except в
``analyze_parcel``), тестам сам форсайт не нужен, а живой брокер в CI/локальной
песочнице недоступен и держит запрос ~69с на реконнект-backoff.
"""
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()):
yield
@pytest.fixture(autouse=True)
def _fast_inline_fetch_wait(monkeypatch: pytest.MonkeyPatch) -> None:
"""Схлопнуть inline-ожидание NSPD-фетча (#93 graceful fallback) до миллисекунд.
В ``analyze_parcel`` ветка «участка нет в БД» ждёт появления геометрии циклом
``sleep(_INLINE_FETCH_POLL_INTERVAL_S)`` до ``_INLINE_FETCH_WAIT_S`` (15с прод-
значение). В тестах фетч замокан и геометрия не появится никогда каждый такой
тест честно спал 16с (``test_market_price_invalid_cad_returns_404``,
``test_recent_permits_invalid_cad_no_regression``).
Оставляем цикл РАБОЧИМ (несколько итераций по 10мс), а не выключаем его нулём:
тесты, проверяющие сам fast-path «строка появилась на N-м опросе», продолжают
видеть опросы. Тесты с собственным ``patch`` того же имени (напр.
``test_run_history_and_response_contract.py``) переопределяют это поверх.
"""
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_WAIT_S", 0.05)
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_POLL_INTERVAL_S", 0.01)

View file

@ -1,463 +0,0 @@
"""Полный деплой не пересоздаёт Caddy без нужды (#3443).
ЧТО СЛУЧИЛОСЬ. Каждый полный деплой ПТИЦЫ делал `up -d --force-recreate
--no-deps caddy`, то есть сносил единственный процесс, слушающий 80/443.
Замер 05.09 (#3274): 67 с `code=000` на ВСЕХ доменах хоста — gendsgn.ru,
meraocenka.ru и зеркала. Не 502/503: принимающего процесса нет вовсе, поэтому
заглушка окна деплоя бессильна по построению её отдаёт тот же Caddy.
ЧТО УСТАНОВЛЕНО. Безусловный флаг появился 17.05 (11e78d73) ради нового
bind-маунта `./preview`, который «не появлялся в running container». Довод
неверен: `docker compose up -d` БЕЗ `--force-recreate` пересоздаёт контейнер
сам, как только меняется описание сервиса или образ (проверено на живом демоне
docker 28.4). Единственное, чего compose не видит, СОДЕРЖИМОЕ пофайлового
bind-маунта: `git reset --hard` пишет новый инод, контейнер держит прежний, и
`caddy reload` перечитывает старый текст. У Caddy так смонтированы Caddyfile и
четыре сниппета; каталоги (caddy/sites, caddy/local, preview) этим не страдают.
ЗАЧЕМ ЭТОТ ФАЙЛ. У правки нет отрицательного признака: вернуть `--force-recreate`
«на всякий случай» одна строка, все деплои останутся зелёными, а окно в минуту
увидит только тот, кто в этот момент держал непрерывную пробу. Проверки ниже
ИСПОЛНЯЮТ ops/caddy-apply.sh с подставным `docker` и смотрят на СОВЕРШЁННЫЕ
действия (пересоздал / перезагрузил / не тронул), а не на текст скрипта.
Отдельно проверяется проводка в deploy.yml что оба пути деплоя зовут именно
его.
"""
from __future__ import annotations
import re
import shutil
import stat
import subprocess
from pathlib import Path
import pytest
import yaml
# backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
SCRIPT = REPO_ROOT / "ops" / "caddy-apply.sh"
WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
DEPLOY = WORKFLOWS / "deploy.yml"
CID = "caddy-cid-0001"
# Маунты Caddy ровно как на проде (`docker inspect gendesign-caddy-1`, 12.09):
# пять ПОФАЙЛОВЫХ bind-маунтов и три каталога. Тома (caddy_data и соседи) в
# сверку не входят — их фильтрует `{{if eq .Type "bind"}}`.
FILE_MOUNTS = {
"Caddyfile": "/etc/caddy/Caddyfile",
"caddy/users.caddy.snippet": "/etc/caddy/caddy/users.caddy.snippet",
"caddy/metrics-ui.caddy.snippet": "/etc/caddy/caddy/metrics-ui.caddy.snippet",
"caddy/metrics-ingest.caddy.snippet": "/etc/caddy/caddy/metrics-ingest.caddy.snippet",
"caddy/deploy-window.caddy.snippet": "/etc/caddy/caddy/deploy-window.caddy.snippet",
}
DIR_MOUNTS = {
"caddy/sites": "/etc/caddy/caddy/sites",
"caddy/local": "/etc/caddy/caddy/local",
"preview": "/srv/preview",
}
# Подставной `docker`. Пишет каждый вызов в $FAKE_LOG и отвечает по сценарию:
# run — одноразовый `caddy validate`, код из $FAKE_VALIDATE_RC;
# inspect — список маунтов из $FAKE_MOUNTS, код из $FAKE_INSPECT_RC;
# exec — sha256sum ФАЙЛА, КОТОРЫЙ ВИДИТ КОНТЕЙНЕР ($FAKE_VIEW/<slug>);
# compose … ps — текущий id контейнера из $FAKE_CID_FILE;
# compose … up — при $FAKE_UP_RECREATES=1 подменяет id (compose пересоздал сам).
FAKE_DOCKER = r"""#!/bin/bash
printf '%s\n' "$*" >> "$FAKE_LOG"
cmd="$1"; shift
case "$cmd" in
run) exit "${FAKE_VALIDATE_RC:-0}" ;;
inspect)
if [ "${FAKE_INSPECT_RC:-0}" != "0" ]; then
echo "Error: No such object" >&2
exit "$FAKE_INSPECT_RC"
fi
cat "$FAKE_MOUNTS"
;;
exec)
dst="$3"
view="$FAKE_VIEW/$(printf '%s' "$dst" | tr '/' '_')"
[ -f "$view" ] || exit 1
sha256sum "$view"
;;
compose)
case " $* " in
*" ps "*) cat "$FAKE_CID_FILE" ;;
*--force-recreate*) echo "recreated caddy (forced)" ;;
*" up "*)
if [ "${FAKE_UP_RECREATES:-0}" = "1" ]; then
printf 'caddy-cid-NEW\n' > "$FAKE_CID_FILE"
echo "Container gendesign-caddy-1 Started"
else
echo "Container gendesign-caddy-1 Running"
fi
;;
*) echo "(compose $*)" ;;
esac
;;
esac
exit 0
"""
# macOS несёт shasum вместо sha256sum; на раннере (ubuntu) и на проде утилита
# настоящая. Шим ставится только при её отсутствии — иначе гейт не запускался бы
# локально вовсе.
SHA_SHIM = '#!/bin/sh\nexec shasum -a 256 "$@"\n'
def _write_exec(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@pytest.fixture
def prod_tree(tmp_path: Path) -> Path:
"""Копия боевого дерева: скрипт + конфиги + «взгляд контейнера»."""
tree = tmp_path / "opt" / "gendesign"
(tree / "ops").mkdir(parents=True)
shutil.copy(SCRIPT, tree / "ops" / SCRIPT.name)
for rel in [*FILE_MOUNTS, "caddy/sites/apps.caddy", "caddy/local/.gitignore"]:
path = tree / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"# {rel} версия НОВАЯ\n", encoding="utf-8")
(tree / "preview").mkdir()
view = tmp_path / "view"
view.mkdir()
# По умолчанию контейнер видит ровно то же, что лежит на диске.
for rel, dst in FILE_MOUNTS.items():
(view / dst.replace("/", "_")).write_text(
(tree / rel).read_text(encoding="utf-8"), encoding="utf-8"
)
mounts = tmp_path / "mounts"
mounts.write_text(
"".join(f"{tree / rel}|{dst}\n" for rel, dst in {**FILE_MOUNTS, **DIR_MOUNTS}.items()),
encoding="utf-8",
)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
_write_exec(bin_dir / "docker", FAKE_DOCKER)
if shutil.which("sha256sum") is None:
_write_exec(bin_dir / "sha256sum", SHA_SHIM)
(tmp_path / "cid").write_text(CID + "\n", encoding="utf-8")
(tmp_path / "log").write_text("", encoding="utf-8")
return tree
def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]:
root = tree.parent.parent
env = {
"PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin",
"FAKE_LOG": str(root / "log"),
"FAKE_CID_FILE": str(root / "cid"),
"FAKE_MOUNTS": str(root / "mounts"),
"FAKE_VIEW": str(root / "view"),
**env_extra,
}
proc = subprocess.run(
["sh", str(tree / "ops" / "caddy-apply.sh")],
cwd=str(tree),
env=env,
capture_output=True,
text=True,
)
calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c]
return proc.returncode, proc.stdout + proc.stderr, calls
def _stale(tree: Path, dst: str) -> None:
"""Контейнер остался на старом иноде этого маунта."""
view = tree.parent.parent / "view" / dst.replace("/", "_")
view.write_text("# версия СТАРАЯ\n", encoding="utf-8")
def _recreated(calls: list[str]) -> bool:
return any("--force-recreate" in c for c in calls)
def _reloaded(calls: list[str]) -> bool:
return any("caddy reload" in c for c in calls)
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
def test_nothing_changed_reloads_without_recreate(prod_tree: Path) -> None:
"""Обычный полный деплой (конфиг прокси не трогали): reload, без окна."""
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert not _recreated(calls), (
f"Caddy пересоздан, хотя ничего не изменилось: {calls}. "
"Это и есть #3443: 67 с code=000 на всех доменах при каждом деплое."
)
assert _reloaded(calls), f"конфиг не применён вовсе: {calls}"
def test_changed_file_mount_forces_recreate(prod_tree: Path) -> None:
"""Caddyfile правлен: reload перечитал бы старый инод — нужен recreate."""
_stale(prod_tree, "/etc/caddy/Caddyfile")
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert _recreated(calls), (
f"пересоздания нет: {calls}. Пофайловый bind-маунт держит инод — правка "
"Caddyfile не доехала бы до контейнера, а деплой ушёл бы зелёным."
)
assert "/etc/caddy/Caddyfile" in out, f"решение не названо в логе:\n{out}"
@pytest.mark.parametrize("dst", sorted(set(FILE_MOUNTS.values()) - {"/etc/caddy/Caddyfile"}))
def test_changed_snippet_forces_recreate(prod_tree: Path, dst: str) -> None:
"""Каждый из четырёх сниппетов — тот же класс, не только Caddyfile."""
_stale(prod_tree, dst)
_, _out, calls = _run(prod_tree)
assert _recreated(calls), f"{dst}: правка сниппета не доехала бы: {calls}"
def test_directory_mount_change_does_not_recreate(prod_tree: Path) -> None:
"""caddy/sites/apps.caddy — самый частый случай; каталог инод не держит.
Если сюда приползёт пересоздание «за компанию», окно недоступности вернётся
ровно на тех правках, ради которых заведён быстрый путь #2916.
"""
(prod_tree / "caddy" / "sites" / "apps.caddy").write_text("# новый блок\n", encoding="utf-8")
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert not _recreated(calls), f"правка в КАТАЛОГЕ вызвала пересоздание: {calls}"
assert _reloaded(calls), f"правка в каталоге не применена: {calls}"
def test_broken_config_touches_nothing(prod_tree: Path) -> None:
"""Битый конфиг: ни up, ни reload, ни пересоздания — прокси не тронут.
Иначе опечатка в Caddyfile уводит контейнер в crash-loop и роняет все
домены сразу (ровно то, чем опасен `--force-recreate` вслепую).
"""
rc, out, calls = _run(prod_tree, FAKE_VALIDATE_RC="1")
assert rc != 0, f"скрипт не упал на битом конфиге:\n{out}"
assert not _recreated(calls), f"битый конфиг поехал в пересоздание: {calls}"
assert not _reloaded(calls), f"битый конфиг поехал в reload: {calls}"
assert not any(" up " in f" {c} " for c in calls), f"был `up` при битом конфиге: {calls}"
def test_compose_recreate_is_not_doubled(prod_tree: Path) -> None:
"""compose пересоздал сам (сменилось описание сервиса/образ) — хватит.
Второй `--force-recreate` поверх ещё одно окно недоступности на ровном
месте, а новый контейнер и так читает свежие файлы.
"""
rc, out, calls = _run(prod_tree, FAKE_UP_RECREATES="1")
assert rc == 0, out
assert not _recreated(calls), f"пересоздание сделано дважды: {calls}"
assert not _reloaded(calls), f"reload поверх свежего контейнера: {calls}"
def test_unreadable_container_view_falls_back_to_recreate(prod_tree: Path) -> None:
"""Сверка не отработала (контейнер не отвечает) → прежнее поведение.
Fail-safe направлен в сторону пересоздания: лучше окно в секунды, чем
беззвучно не применённая правка конфига прокси.
"""
(prod_tree.parent.parent / "view" / "_etc_caddy_Caddyfile").unlink()
_, out, calls = _run(prod_tree)
assert _recreated(calls), f"непрочитанный маунт сочли доехавшим: {calls}\n{out}"
def test_unreadable_mount_list_falls_back_to_recreate(prod_tree: Path) -> None:
"""`docker inspect` не ответил → пересоздать, а не «расхождений нет».
Статус `$(docker inspect | while )` это статус `while`, то есть всегда
0, а `pipefail` в POSIX-sh не существует. Провал команды давал бы пустой
список маунтов, ветку «всё доехало» и зелёную строку «окна недоступности
нет» при прокси, работающем по СТАРОМУ конфигу тот самый беззвучный отказ,
ради которого написан скрипт.
"""
rc, out, calls = _run(prod_tree, FAKE_INSPECT_RC="1")
assert rc == 0, out
assert _recreated(calls), f"непрочитанный список маунтов сочли «всё доехало»: {calls}\n{out}"
assert not _reloaded(calls), f"reload вместо пересоздания: {calls}"
def test_no_file_mounts_is_not_silence(prod_tree: Path) -> None:
"""Ноль пофайловых маунтов — не «сверка прошла», а «сверять было нечем».
Так выглядит, например, перевод Caddyfile на именованный том: фильтр
`{{if eq .Type "bind"}}` перестаёт что-либо отбирать, и сверка становится
тавтологически успешной.
"""
root = prod_tree.parent.parent
(root / "mounts").write_text(
"".join(f"{prod_tree / rel}|{dst}\n" for rel, dst in DIR_MOUNTS.items()), encoding="utf-8"
)
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert _recreated(calls), f"пустая сверка сочтена успешной: {calls}\n{out}"
def test_missing_host_file_is_not_skipped_as_a_directory(prod_tree: Path) -> None:
"""Файла на хосте нет — это расхождение, а не «нечего сверять».
Пропуск по `[ -f "$src" ] || continue` склеивает два разных случая: каталог
(пропустить верно инод он не держит) и исчезнувший/нечитаемый файл, для
которого в контейнере как раз живёт старый инод со старым текстом. Файл
удаляется после проверки конфига (в тесте она подставная) проверяется
именно ветка сверки.
"""
(prod_tree / "Caddyfile").unlink()
_, out, calls = _run(prod_tree)
assert _recreated(calls), f"исчезнувший файл сочли доехавшим: {calls}\n{out}"
def test_log_says_how_many_mounts_were_compared(prod_tree: Path) -> None:
"""В логе должно быть ЧИСЛО сверенных файлов, а не только вердикт.
«Сверили пять» и «сверили ноль» обязаны различаться: иначе строка
«перезагружен без пересоздания» одинаково означает и проверку, и её
отсутствие.
"""
_, out, _ = _run(prod_tree)
assert re.search(r"сверено пофайловых маунтов[^\n]*: 5", out), (
f"скрипт не печатает число сверенных маунтов (их пять):\n{out}"
)
def test_validation_precedes_any_action(prod_tree: Path) -> None:
"""Проверка конфига идёт ПЕРВЫМ вызовом, до любого изменения состояния."""
_, out, calls = _run(prod_tree)
assert calls, f"скрипт не сделал ни одного вызова docker:\n{out}"
assert calls[0].startswith("run "), f"первым идёт не проверка конфига: {calls}"
assert "caddy validate" in calls[0], f"первый вызов — не validate: {calls[0]}"
# ── Проводка: оба пути деплоя зовут именно этот скрипт ───────────────────────
def _ssh_script(job: str) -> str:
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))]
assert len(steps) == 1, f"в job `{job}` нет ровно одного ssh-шага — гейт #3443 ослеп"
script = steps[0]["with"]["script"]
assert script.strip(), f"ssh-скрипт job `{job}` пуст"
return script
def _commands(script: str) -> str:
"""Только команды: комментарии выкинуты, продолжения строк склеены.
Комментарии потому что разбор дефекта живёт в тех же файлах и содержит
его формулировку дословно: гейт по голому тексту краснел бы от объяснения,
а не от кода. Склейка `\\` потому что `--force-recreate` и имя сервиса
легко оказываются на РАЗНЫХ физических строках, и построчный поиск такую
запись не увидел бы (зелено по построению).
"""
kept = [ln for ln in script.splitlines() if not ln.lstrip().startswith("#")]
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
def _forced_caddy_recreates(commands: str) -> list[str]:
"""Строки, которые пересоздают именно сервис caddy."""
return [
ln
for ln in commands.splitlines()
if "--force-recreate" in ln and re.search(r"\bcaddy\b", ln)
]
def test_script_exists_and_is_the_one_under_test() -> None:
"""Признак непустоты: без скрипта проверки выше проходили бы вхолостую."""
assert SCRIPT.is_file(), f"нет {SCRIPT} — проводка ниже проверяла бы пустоту"
@pytest.mark.parametrize("job", ["deploy", "deploy-caddy"])
def test_deploy_applies_caddy_config_through_the_script(job: str) -> None:
assert "ops/caddy-apply.sh" in _commands(_ssh_script(job)), (
f"job `{job}` не зовёт ops/caddy-apply.sh — конфиг прокси применяется "
"мимо разбора #3443 (или безусловным пересозданием, или reload'ом, "
"который на пофайловом маунте читает старый инод)"
)
def test_full_deploy_has_no_unconditional_caddy_recreate() -> None:
"""Главный инвариант: в полном деплое нет безусловного пересоздания Caddy.
Возврат одной строки `up -d --force-recreate --no-deps caddy` в job `deploy`
возвращает 67-секундное окно `code=000` на всех доменах и не краснит
ничего: деплой остаётся зелёным, а увидеть отказ может только непрерывная
проба, запущенная ровно в эту минуту.
"""
bad = _forced_caddy_recreates(_commands(_ssh_script("deploy")))
assert not bad, (
"в полный деплой вернулось безусловное пересоздание Caddy:\n "
+ "\n ".join(bad)
+ "\nПересоздание обязано быть УСЛОВНЫМ — см. ops/caddy-apply.sh: compose "
"сам пересоздаёт контейнер при смене описания сервиса или образа, а "
"вручную это нужно только когда до контейнера не доехал пофайловый "
"bind-маунт (#3443)."
)
def test_failures_are_not_swallowed() -> None:
"""Ни применение конфига, ни сам reload не гасятся `|| true`.
Строка `caddy reload || true` в репозитории уже живёт
(deploy-tradein.yml), то есть это не гипотеза: с ней отказ применения
перестаёт краснеть, и «конфиг доехал» становится неотличимо от «команда
упала, а мы продолжили». Проверяется и вызов скрипта из обеих джоб, и
строка reload внутри самого скрипта.
"""
swallow = re.compile(r"\|\|\s*(true|:)\s*$")
offenders = []
for where, text in [
("deploy", _commands(_ssh_script("deploy"))),
("deploy-caddy", _commands(_ssh_script("deploy-caddy"))),
(SCRIPT.name, SCRIPT.read_text(encoding="utf-8")),
]:
for line in text.splitlines():
code = line.split("#", 1)[0] if not line.lstrip().startswith("#") else ""
if ("caddy-apply.sh" in code or "caddy reload" in code) and swallow.search(code):
offenders.append(f"{where}: {line.strip()}")
assert not offenders, "отказ применения конфига проглочен:\n " + "\n ".join(offenders)
def test_gate_runs_on_changes_to_the_script_itself() -> None:
"""CI-фильтр обязан пускать backend-тесты на правку ops/**.
Все содержательные регрессии живут в ops/caddy-apply.sh: проверки выше его
ИСПОЛНЯЮТ. Без `ops/**` в фильтре PR, правящий только скрипт, даёт
backend=false джоба пропускается, гейт не исполняется, и «пересоздавать
всегда» уезжает в main зелёным. Тот же класс, что #2950/#3448/#3467.
"""
spec = yaml.safe_load((WORKFLOWS / "ci.yml").read_text(encoding="utf-8"))
steps = [
s
for job in spec["jobs"].values()
for s in job.get("steps") or []
if str(s.get("uses", "")).startswith("dorny/paths-filter")
]
assert steps, "в ci.yml не найден paths-filter — проверка прошла бы вхолостую"
patterns = [p for s in steps for p in yaml.safe_load(s["with"]["filters"]).get("backend") or []]
assert "ops/**" in patterns, (
f"фильтр backend не покрывает ops/** (сейчас: {patterns}) — гейт #3443 не "
"побежит на правке ops/caddy-apply.sh, то есть ровно на той правке, от "
"которой стережёт"
)
def test_gate_would_notice_the_regression() -> None:
"""Сам гейт обязан краснеть на возвращённом дефекте — проверка на себя.
Без этого «не нашли force-recreate» неотличимо от «искали не там»: маска
поиска, промахнувшаяся мимо строки, выглядит зелёной ровно так же.
"""
regressed = _commands(
" # безусловное пересоздание caddy вернулось сюда\n"
" docker compose -p gendesign -f docker-compose.prod.yml up -d \\\n"
" --force-recreate --no-deps caddy\n"
)
assert _forced_caddy_recreates(regressed), (
"маска поиска не видит дословно ту строку, ради которой заведён гейт"
)

View file

@ -1,138 +0,0 @@
#!/bin/sh
# Применить текущий конфиг прокси к работающему Caddy (#3443).
#
# ЗАЧЕМ. Полный деплой ПТИЦЫ пересоздавал сам Caddy БЕЗУСЛОВНО
# (`up -d --force-recreate --no-deps caddy`), а вместе с контейнером исчезал
# единственный процесс, слушающий 80/443. Замер 05.09 (#3274): 67 с `code=000`
# на ВСЕХ доменах хоста — gendsgn.ru, meraocenka.ru и зеркала, включая
# публичный лендинг МЕРЫ. Это не 502/503: принимающего процесса нет вовсе,
# поэтому заглушка окна деплоя (caddy/sites/deploy-window.caddy.snippet) здесь
# бессильна по построению — её отдаёт тот же Caddy.
#
# ЧТО НА САМОМ ДЕЛЕ ТРЕБУЕТ ПЕРЕСОЗДАНИЯ. Безусловный флаг появился 17.05
# (11e78d73) ради нового bind-маунта `./preview` из docker-compose.prod.yml,
# который «не появлялся в running container». Довод неверен: `docker compose
# up -d` БЕЗ `--force-recreate` пересоздаёт контейнер сам, как только меняется
# описание сервиса или образ. Проверено на живом демоне (docker 28.4):
# добавлен volume → `Container … Starting/Started`, id контейнера новый;
# тег указан на др. образ → id новый;
# не менялось ничего → `Container … Running`, id тот же.
#
# Остаётся ровно один класс изменений, которого compose не видит: СОДЕРЖИМОЕ
# пофайлового bind-маунта. `git reset --hard` не правит файл на месте, а пишет
# новый инод; контейнер держит примонтированным прежний и продолжает читать
# его — `caddy reload` перечитает ровно тот же старый инод. Тот же механизм уже
# ловили на Alertmanager (27.08, deploy-metrics.yml) и на Alloy (#3380). У Caddy
# так смонтированы пять путей: Caddyfile и четыре сниппета. Каталоги
# (caddy/sites, caddy/local, preview) этим не страдают — правка внутри каталога
# видна контейнеру сразу, поэтому самый частый случай (caddy/sites/apps.caddy)
# пересоздания НЕ требует.
#
# ОТСЮДА ПОРЯДОК: проверить конфиг → `up -d` без `--force-recreate` → если
# контейнер остался тем же, сверить, видит ли он текущее содержимое пофайловых
# маунтов → пересоздать ТОЛЬКО при расхождении, иначе `caddy reload`, который
# не рвёт соединения.
#
# ГРАНИЦА. Сверка по СОДЕРЖИМОМУ, а не по иноду: файл, переписанный тем же
# текстом, пересоздания не требует. Не прочиталось (контейнер не запущен, в
# образе нет sha256sum) — считаем расхождением: fail-safe в сторону прежнего
# поведения, то есть пересоздания.
set -eu
# Оба вызывающих (job `deploy` и job `deploy-caddy` в .forgejo/workflows/deploy.yml)
# работают в /opt/gendesign, но не зависеть от cwd дешевле, чем это помнить.
cd "$(dirname "$0")/.."
COMPOSE="docker compose -p gendesign -f docker-compose.prod.yml"
caddy_cid() { $COMPOSE ps -aq caddy 2>/dev/null | tail -n1; }
# ── 1. Проверка ДО применения ────────────────────────────────────────────────
# Одноразовый контейнер читает файлы С ХОСТА — то есть ровно то, что поедет в
# работающий Caddy. `exec caddy validate` здесь не годится: он проверил бы
# старый инод, то есть предыдущую версию конфига. Образ и парсер те же, что на
# PR-гейте (ci.yml «Guard: Caddyfile синтаксически валиден»).
#
# ГРАНИЦА ЭТОЙ ПРОВЕРКИ. Она обрывает применение до того, как конфиг попадёт в
# работающий Caddy, — но только на быстром пути. В полном деплое ВЫШЕ по
# скрипту (deploy.yml, `up -d $UP_SERVICES`) уже прошёл общий подъём всех
# сервисов, и если правка одновременно ломает Caddyfile И меняет блок caddy в
# docker-compose.prod.yml, контейнер пересоздастся там — с непроверенным
# конфигом и раньше этой строки. Первая линия против этого — гейт на PR (#2913).
echo "→ проверяю конфиг прокси одноразовым контейнером…"
if ! docker run --rm -v "$PWD:/work:ro" -w /work caddy:2 \
caddy validate --config /work/Caddyfile --adapter caddyfile; then
echo "ОШИБКА: конфиг прокси не применён — проверка не пройдена ЛИБО не удалось"
echo " запустить проверочный контейнер (нет образа caddy:2, занят демон,"
echo " недоступен реестр). Причина — в выводе выше, не гадать по этой строке."
echo " Работающий Caddy не тронут, домены живы."
exit 1
fi
# ── 2. Описание сервиса и образ ──────────────────────────────────────────────
before=$(caddy_cid)
$COMPOSE up -d --no-deps caddy
after=$(caddy_cid)
if [ -z "$after" ]; then
echo 'ОШИБКА: после `up -d` контейнера caddy нет — смотри вывод compose выше.'
exit 1
fi
if [ "$before" != "$after" ]; then
echo "✓ Caddy пересоздан compose'ом: изменилось описание сервиса или образ (${before:-нет}${after})."
exit 0
fi
# ── 3. Доехало ли содержимое пофайловых маунтов ──────────────────────────────
# Список маунтов читается ОТДЕЛЬНОЙ командой, а не в конвейере с циклом: в
# `$(docker inspect … | while …)` статус подстановки — это статус `while`, то
# есть всегда 0 (`pipefail` в POSIX-sh нет вовсе). Провал `docker inspect`
# давал бы пустой список → «расхождений нет» → `caddy reload` → зелёная джоба с
# надписью «окна недоступности нет», а прокси работал бы по СТАРОМУ конфигу.
# Это ровно тот беззвучный отказ, ради которого написан весь скрипт.
mounts=$(docker inspect "$after" \
--format '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}|{{.Destination}}{{println}}{{end}}{{end}}') \
|| mounts=''
if [ -z "$mounts" ]; then
echo "WARNING: список маунтов Caddy не прочитан (docker inspect молчит или упал) —"
echo " сверить нечем, считаю расхождением: fail-safe в прежнее поведение."
verdicts=""
stale="(маунты не прочитаны)"
else
verdicts=$(printf '%s\n' "$mounts" | while IFS='|' read -r src dst; do
[ -n "${dst:-}" ] || continue
# Каталог инод не держит — пропускаем. Именно `-d`, а не `-f`:
# отсутствующий/нечитаемый ФАЙЛ — не повод молча пропустить, в
# контейнере в этот момент живёт старый инод со старым текстом.
if [ -d "$src" ]; then continue; fi
host_sum=$(sha256sum "$src" 2>/dev/null | cut -d' ' -f1)
seen_sum=$(docker exec "$after" sha256sum "$dst" 2>/dev/null | cut -d' ' -f1)
if [ -n "$host_sum" ] && [ "$host_sum" = "${seen_sum:-НЕРОЧИТАНО}" ]; then
echo "OK $dst"
else
echo "STALE $dst"
fi
done)
# «Сверили пять файлов» и «сверили ноль» обязаны различаться в логе — иначе
# зелёная строка ниже одинаково означает и проверку, и её отсутствие.
checked=$(printf '%s\n' "$verdicts" | grep -c . || true)
echo "→ сверено пофайловых маунтов с тем, что видит контейнер: $checked"
stale=$(printf '%s\n' "$verdicts" | sed -n 's/^STALE //p' | tr '\n' ' ')
if [ "$checked" -eq 0 ]; then
echo "WARNING: ни одного пофайлового bind-маунта не найдено — у Caddy их пять"
echo " (Caddyfile + 4 сниппета). Считаю расхождением: fail-safe."
stale="(пофайловых маунтов не найдено)"
fi
fi
if [ -n "$stale" ]; then
echo "→ до контейнера НЕ доехали пофайловые маунты: $stale"
echo " (bind-маунт файла держит инод: reload перечитал бы старую версию — нужен recreate)"
$COMPOSE up -d --force-recreate --no-deps caddy
echo "✓ Caddy пересоздан — иначе правка осталась бы неприменённой."
exit 0
fi
# ── 4. Всё доехало — перезагрузка без разрыва соединений ─────────────────────
$COMPOSE exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
echo "✓ конфиг прокси перезагружен без пересоздания контейнера — окна недоступности нет."

View file

@ -47,7 +47,6 @@ from sqlalchemy.exc import ArgumentError
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings from app.core.config import settings
from app.core.db import DB_CONNECT_ARGS
class AuthDatabaseNotConfiguredError(RuntimeError): class AuthDatabaseNotConfiguredError(RuntimeError):
@ -102,18 +101,6 @@ def _build() -> tuple[Engine, sessionmaker[Session]]:
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением) # НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
# и сырые psycopg-подключения мимо движков — это отдельный класс. # и сырые psycopg-подключения мимо движков — это отдельный класс.
hide_parameters=True, hide_parameters=True,
# #3463. Те же потолки, что у продуктового движка, — ОДНОЙ константой на оба:
# потолок на одном движке и мина на втором это не починка, а половина.
# Этот движок живёт на ГОРЯЧЕМ пути: `core/rbac.py` резолвит session-cookie
# в middleware, синхронно на event loop'е, на КАЖДОМ запросе с cookie
# (на проде IDENTITY_STORE=auth во всех трёх сервисах образа — сверено 12.09,
# `printenv` в контейнерах). Без потолка `ACCESS EXCLUSIVE` на `auth.sessions`
# вешает не четыре слота `/estimate`, а весь uvicorn-воркер (он один, без
# --workers) — включая `/health`.
# Срабатывание потолка безопасно: вызов в rbac.py уже под `except Exception`
# с фолбэком на заголовочную аутентификацию, то есть отмена запроса даёт тот
# же путь, что и любой другой сбой реестра, а не 500.
connect_args=DB_CONNECT_ARGS,
) )
except (ArgumentError, ValueError): except (ArgumentError, ValueError):
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до # ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до

View file

@ -10,59 +10,10 @@ from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# #3463. Потолок ОДНОГО statement'а, секунды·1000. Ставится на КОННЕКТЕ (libpq
# `options`), а не в питоновской обёртке: обёртка (`run_db_thread` ниже) при
# отмене обязана ДОЖДАТЬСЯ потока, иначе поток остаётся сиротой в общей
# `Session` — ровно то, ради чего писался #3449. Значит верхняя граница ожидания
# = длительность самого запроса, и задать её может только сервер.
#
# 30 с выбраны так, чтобы потолок НИКОГДА не стал биндящим ограничением для
# честной работы, но остался конечным:
# * самый длинный ОБЪЯВЛЕННЫЙ бюджет на `/estimate` — 20 с (`estimate_avito_imv_timeout_s`,
# config.py:852); дальше 12 с геокод, 8 с Yandex/Cian/house_meta. 30 с = 1.5× от максимума;
# * ОСНОВНАЯ опора по планировщику — `scrape_runs` (длительности целых прогонов, они
# не вытесняются): самая долгая ЧИСТО-БД задача за 14 суток — listing_source_snapshot,
# 9.7 с ЦЕЛИКОМ (и у неё сверх того свой `SET LOCAL statement_timeout = 900000`,
# который перекрывает это значение — гейт tests/test_3463_db_timeouts.py);
# * самый длинный set-based statement ЧЕРЕЗ движок из замеренных — матч ГАР→houses
# (`services/gar_flats_loader._MATCH_SQL`): 2.07 с с городским фильтром и 6.46 с без
# него (`city_filter=None`, флаг CLI). Запас ~3×, и это СЧИТАЮЩИЙ запрос, а не ждущий.
#
# `pg_stat_statements` опорой по планировщику НЕ является: при `max = 5000` он вытесняет
# редкие записи (проверено 12.09 — `dealloc` вырос на единицу за десять минут, и из топа
# пропали ВСЕ записи с `calls = 1`, включая `REFRESH MATERIALIZED VIEW` 30.85 с и KNN
# `cadastral_geo_match` 2.45 с). Суточная задача до следующих суток там не доживает, так
# что «самый долгий запрос 4.27 с» верно только для ВЫСОКОЧАСТОТНЫХ запросов.
_STATEMENT_TIMEOUT_MS = 30_000
# Ожидание БЛОКИРОВКИ — заведомо меньше: ждать лок дольше секунд смысла нет, лучше
# деградировать. 5 с — та же величина, что у миграций проекта
# (`SET LOCAL lock_timeout = '5s'` в data/sql/250,251,260,272,277…), снизу ограничена
# deadlock_timeout (на проде 1 с — сверено 12.09). Именно этот потолок закрывает
# сценарий #3463: под `ACCESS EXCLUSIVE` на `geocode_cache` запрос ЖДЁТ лок, а не
# считает, — statement_timeout тут только страховка от «считает вечно».
_LOCK_TIMEOUT_MS = 5_000
# idle_in_transaction_session_timeout НАМЕРЕННО не трогаем: тем же движком живёт tgbot,
# и `services/tgbot/bridge.py` держит транзакцию открытой ПОВЕРХ long-poll Telegram
# (замер на проде 12.09, 3 пробы с шагом 7 с: одна и та же сессия, запрос
# `SELECT value FROM tg_support_state …`, возраст транзакции циклически растёт до ~29 с).
# Сессионный потолок на простой в транзакции ронял бы long-poll КАЖДЫЙ цикл —
# гарантированно, а не в редком случае.
DB_CONNECT_ARGS = {
"options": f"-c statement_timeout={_STATEMENT_TIMEOUT_MS} -c lock_timeout={_LOCK_TIMEOUT_MS}"
}
engine = create_engine( engine = create_engine(
settings.database_url, settings.database_url,
pool_pre_ping=True, pool_pre_ping=True,
future=True, future=True,
# #3463. Накрывает ВСЕ три сервиса образа (backend / scraper / tgbot — один и тот
# же `app.core.db`, см. docker-compose.prod.yml) и обе стороны: продуктовый путь
# `/estimate` и задачи планировщика. Миграции идут мимо (psql из
# .forgejo/workflows/deploy-tradein.yml, не этот движок) — их DDL под своим
# `SET LOCAL lock_timeout` и потолком не ограничен.
connect_args=DB_CONNECT_ARGS,
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError — # #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
# через них в GlitchTip уезжали ключ шифрования кук и сами куки # через них в GlitchTip уезжали ключ шифрования кук и сами куки
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все # (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все

View file

@ -328,13 +328,6 @@ SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд р
_SBER_REGION_SERIES: dict[int, str] = { _SBER_REGION_SERIES: dict[int, str] = {
66: SBER_TIME_ADJUST_REGION, 66: SBER_TIME_ADJUST_REGION,
77: "Москва", 77: "Москва",
# Московская область — свой ряд, а не фолбэк на «Россию». Загрузчик научился
# тянуть REF_AREA=50 (services/sber_index), и 12.09.2026 ряд приехал на прод:
# real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08 — та же глубина,
# что у Москвы и обл.66. До этой строки область считалась по общероссийскому
# ряду, то есть коридор ошибался на расхождение области со страной; теперь
# ошибка только внутрирегиональная.
50: "Московская область",
} }
# Ряд для региона ВНЕ карты. Выбран общероссийский, а не отказ от поправки (1.0): # Ряд для региона ВНЕ карты. Выбран общероссийский, а не отказ от поправки (1.0):

View file

@ -233,11 +233,9 @@ REGIONS: dict[int, Region] = {
), ),
# Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный # Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный
# индекс/кадастр/POI не заведены (проверено — frozenset() пуст # индекс/кадастр/POI не заведены (проверено — frozenset() пуст
# намеренно, не заглушка). Ряд Сбериндекса по области с 12.09.2026 в # намеренно, не заглушка). Ряд Сбериндекса по области загружен в
# карте _SBER_REGION_SERIES эстиматора есть, но тиром он от этого не # источник (#3498), но в карте _SBER_REGION_SERIES эстиматора кода 50
# становится: у Москвы набор тиров тоже пуст, а свой ряд она читает — # ЕЩЁ НЕТ — тир пока недоступен, добавляется отдельным решением.
# поправка по времени идёт мимо enrichment_tiers (TIER_SBER_INDEX нигде
# за пределами этого реестра не спрашивают).
enrichment_tiers=frozenset(), enrichment_tiers=frozenset(),
# Источники по области несут настоящий city (Химки, Балашиха — не # Источники по области несут настоящий city (Химки, Балашиха — не
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать # муниципальный округ/поселение, в отличие от Москвы) — перезаписывать

View file

@ -1,34 +1,22 @@
"""Импорт сырья `msk_raw.*_latest` в `listings` — Москва (77) и область (50). """Импорт московского сырья (`msk_raw.*_latest`) в `listings`.
Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
несёт `payload` сериализованный `ScrapedLot` один в один (те же 54 ключа, что и несёт `payload` сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=region)`. `ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=77)`.
Отбор региона (source=cian). Адрес карточки Циана города НЕ содержит, зато для Отбор Москвы (source=cian). Адрес карточки Циана города НЕ содержит, зато
Москвы начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
опознаётся байт-в-байт как раньше. Замер по проду (60 464 карточки): с округом опознаётся. Замер по проду (60 464 карточки): с округом 35 551, ВСЕ внутри
35 551, ВСЕ внутри bbox региона 77; без округа внутри bbox 17 576 (это bbox региона 77; без округа внутри bbox 17 576 (это Московская область, регион
Московская область, регион 50); без округа вне bbox 7 337. Отдельно 212 50, которого в реестре ещё нет, в этот импорт не берём); без округа вне bbox
карточек с адресом вида «Екатеринбург (Cian)» артефакт парсера, считаются 7 337. Отдельно 212 карточек с адресом вида «Екатеринбург (Cian)» артефакт
своим счётчиком, чтобы не растворяться в «не целевой регион». парсера, считаются своим счётчиком, чтобы не растворяться в «не Москва».
Область (регион 50) у Циана в адресе НЕ видна вовсе берём по ПОДДОМЕНУ
`source_url` (`https://<sub>.cian.ru/...`): `sub != "www"` область. Замер по
`msk_raw.cian_latest` 12.09.2026: `www` 38 030 карточек, из них 36 569 с
префиксом округа (это Москва); все прочие поддомены (krasnogorsk 2065,
balashikha 1828, vidnoye 1802, lyubertsy 1498, zvenigorod 1420, khimki 1306,
mytishchi 1288, podolsk 756, odintsovo 738, ) 0 карточек с префиксом округа,
итого не-www 24 784. Поддомен и префикс округа нигде не противоречат друг
другу, поэтому Москва остаётся на префиксе округа (не трогаем), а область
на поддомене. Поддомен не распознался (нет source_url / хост не `*.cian.ru`)
карточка НЕ область (консервативно, счётчик «не целевой регион»).
Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес голая улица с Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес голая улица с
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335). карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335).
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр.
для ЛЮБОГО целевого региона.
Два сигнала, и оба нужны ни один по отдельности не годится. Два сигнала, и оба нужны ни один по отдельности не годится.
@ -54,16 +42,16 @@ mytishchi 1288, podolsk 756, odintsovo 738, …) — 0 карточек с пр
qc_geo=0 у 97% найденных. qc_geo=0 у 97% найденных.
Что куда едет: Что куда едет:
* регион == `--region` в `listings`, С координатами (geom есть сразу, * регион 77 в `listings`, С координатами (geom есть сразу, radius-подбор
radius-подбор аналогов работает без ожидания `geocode_missing`); аналогов работает без ожидания `geocode_missing`);
* адрес разрешился, но регион ответа не совпал с `--region` не пишется, * регион 50 НЕ пишется, ждёт появления региона 50 в реестре; лежит не в
лежит не в воздухе, а строкой в `msk_raw.avito_geocode` прогон с другим воздухе, а строкой в `msk_raw.avito_geocode` (region_code=50) когда
`--region` подхватит её из кэша без единого внешнего вызова; регион появится, прогон по этой полке уже не потребует внешних вызовов;
* адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33») * адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33»)
свой счётчик, карточка не пишется. свой счётчик, карточка не пишется.
`--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет `--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет
целевой регион вперемешку с прочими и БЕЗ geom. Молча он по-прежнему не срабатывает. Москву вперемешку с областью и БЕЗ geom. Молча он по-прежнему не срабатывает.
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`, есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
@ -83,43 +71,28 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
одинаковы. одинаковы.
Отбор региона (source=yandex) стоит ноль вызовов: адрес приходит полным и Отбор Москвы (source=yandex) стоит ноль вызовов: адрес приходит полным и
нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается
вторым компонентом. Замер по 21 393 карточкам первого прохода ровно два вторым компонентом. Замер по 21 393 карточкам первого прохода ровно два
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны. заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны.
Отбор региона (source=domclick) стоит ноль вызовов, но таблица одна на ДВА Отбор Москвы (source=domclick) тоже стоит ноль вызовов, но по другой причине:
РАЗНЫХ прогона сборщика с разными GUID: московский (батч сырьё собрано запросом с GUID-ом Москвы и отфильтровано по bbox ещё на стороне
`msk-serp-domclick-20260912`) и областной (отдельный запуск, батч сборщика, поэтому областных карточек в нём нет по построению. Регион читается
`mo-serp-domclick-20260912`) оба пишут в один и тот же ПЕРВЫМ компонентом адреса («Москва, улица Нижние Мнёвники, 16 к2»), и фильтр
`msk_raw.domclick_cards`. Колонки региона в таблице НЕТ, а вью оставлен второй линией на случай, если в ту же таблицу когда-нибудь приедет
`msk_raw.domclick_latest` отдаёт обе партии вперемешку курсор по `id` не второй город. Замер по 5 024 карточкам первого прохода: единственное значение
различает, из какого прогона строка. Поэтому фильтр по адресу здесь не первого компонента «Москва». Новая Москва приходит как «Москва, пос. Птичное,
опциональная вторая линия, а единственный способ развести регионы. », то есть посёлок стоит вторым компонентом и регион 77 не ломает. Координаты
заполнены у 100% карточек.
Регион читается ПЕРВЫМ компонентом адреса. Замер живьём на API ДомКлика
12.09.2026: московские карточки «Москва, Генерала Дорохова проспект, 49» и
подобные, первый компонент «Москва» у всех 22 836 карточек прод-корпуса;
областные карточки «Московская область, Химки, 7-й м-н, проспект
Мельникова, 33», «Московская область, Одинцовский городской округ,
Звенигород, 3-й м-н, 28» и подобные, первый компонент «Московская область» у
всех 140 карточек выборки с семи разных смещений выдачи. Разделение полное и
симметричное `is_moscow_yandex_address`/`is_oblast_yandex_address`. Новая
Москва приходит как «Москва, пос. Птичное, » посёлок стоит вторым
компонентом, первый по-прежнему «Москва», регион 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 yandex --region 50
python -m app.tasks.msk_raw_import --source cian --region 50
python -m app.tasks.msk_raw_import --source domclick python -m app.tasks.msk_raw_import --source domclick
python -m app.tasks.msk_raw_import --source domclick --region 50
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 --geocode --region 50
python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
""" """
@ -129,7 +102,6 @@ import argparse
import asyncio import asyncio
import logging import logging
import re import re
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from urllib.parse import urlsplit from urllib.parse import urlsplit
@ -141,18 +113,15 @@ from sqlalchemy.orm import Session
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.services import dadata from app.services import dadata
from app.services.geocoder import normalize_address from app.services.geocoder import normalize_address
from app.services.regions import REGIONS, is_within_bbox
from app.services.scraper_adapters import RealMatcherAdapter from app.services.scraper_adapters import RealMatcherAdapter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MOSCOW_REGION_CODE = 77 MOSCOW_REGION_CODE = 77
MOSCOW_CITY = "Москва" MOSCOW_CITY = "Москва"
# Московская область в реестре `app.services.regions` заведена, но своего # Регион 50 (Московская область) в реестре `app.services.regions` ещё не заведён —
# единого города у неё нет (`canonical_city is None`) — в listings.city для # карточки области не пишутся, а откладываются (см. докстринг модуля).
# неё пишем None (см. `import_msk_raw`, `save_listings` его COALESCE'ит).
OBLAST_REGION_CODE = 50 OBLAST_REGION_CODE = 50
SUPPORTED_REGIONS = (MOSCOW_REGION_CODE, OBLAST_REGION_CODE)
DEFAULT_BATCH_SIZE = 500 DEFAULT_BATCH_SIZE = 500
# Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`. # Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`.
@ -458,16 +427,14 @@ class ImportCounters:
read: int = 0 read: int = 0
skipped_artifact: int = 0 skipped_artifact: int = 0
# Карточка сама говорит про другой регион (префикс округа / поддомен skipped_not_moscow: int = 0
# Циана / компонент адреса не совпал с целевым `--region`).
skipped_not_target_region: int = 0
skipped_invalid: int = 0 skipped_invalid: int = 0
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Другой # Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Область —
# регион — адрес разрешён, дом реальный, просто регион в ответе DaData не # адрес разрешён, дом реальный, просто регион 50 (ждёт реестра). Не
# совпал с целевым. Не разрешён — DaData дома не нашла ИЛИ кончился # разрешён — DaData дома не нашла ИЛИ кончился бюджет вызовов; всплеск
# бюджет вызовов; всплеск этого счётчика читается как «проверь квоту», а # этого счётчика читается как «проверь квоту», а не «в Москве стало меньше
# не «в целевом регионе стало меньше домов». # домов».
skipped_geo_other_region: int = 0 skipped_oblast: int = 0
skipped_ungeocoded: int = 0 skipped_ungeocoded: int = 0
selected: int = 0 selected: int = 0
inserted: int = 0 inserted: int = 0
@ -494,8 +461,8 @@ class ImportCounters:
self.read self.read
== self.selected == self.selected
+ self.skipped_artifact + self.skipped_artifact
+ self.skipped_not_target_region + self.skipped_not_moscow
+ self.skipped_geo_other_region + self.skipped_oblast
+ self.skipped_ungeocoded + self.skipped_ungeocoded
+ self.skipped_invalid + self.skipped_invalid
) )
@ -513,37 +480,6 @@ def is_moscow_address(address: str | None) -> bool:
return _MOSCOW_OKRUG_RE.match(address.strip()) is not None return _MOSCOW_OKRUG_RE.match(address.strip()) is not None
def cian_subdomain(payload: dict) -> str | None:
"""Поддомен `source_url` Циана: `https://<sub>.cian.ru/...` → `<sub>`.
Адрес карточки регион 50 не выдаёт вовсе (см. докстринг модуля), поэтому
область читается из URL. Поддомен и префикс округа не противоречат друг
другу ни в одной карточке (замер по `msk_raw.cian_latest`, 12.09.2026):
`www` 38 030 карточек (36 569 с префиксом округа, это Москва); все
прочие поддомены (krasnogorsk, balashikha, vidnoye, lyubertsy, zvenigorod,
khimki, mytishchi, podolsk, odintsovo, ) 0 карточек с префиксом округа.
Хост не `*.cian.ru` или `source_url` отсутствует None (консервативно:
региону не сопоставляем).
"""
url = payload.get("source_url")
if not isinstance(url, str) or not url:
return None
# `hostname`, а не `netloc`: он уже без порта и userinfo и в нижнем
# регистре — иначе гипотетический `www.cian.ru:443` промахнулся бы мимо
# суффикса и уехал в «не целевой регион».
host = urlsplit(url).hostname or ""
if not host.endswith(".cian.ru"):
return None
sub = host[: -len(".cian.ru")]
return sub or None
def is_cian_oblast_payload(payload: dict) -> bool:
"""Регион 50 у Циана: любой поддомен, кроме `www` (см. `cian_subdomain`)."""
sub = cian_subdomain(payload)
return sub is not None and sub != "www"
def is_moscow_yandex_address(address: str | None) -> bool: def is_moscow_yandex_address(address: str | None) -> bool:
"""У Яндекса регион — второй компонент полного адреса. """У Яндекса регион — второй компонент полного адреса.
@ -561,17 +497,6 @@ 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_oblast_yandex_address(address: str | None) -> bool:
"""Регион 50 у Яндекса: второй компонент адреса — «Московская область».
Симметрично `is_moscow_yandex_address`: во втором компоненте встречаются
ровно два значения (см. докстринг модуля), третьего нет среди карточек
Яндекса «не Москва» и означает «область».
"""
parts = [part.strip() for part in (address or "").split(",")]
return len(parts) > 1 and parts[1] == "Московская область"
def is_moscow_domclick_address(address: str | None) -> bool: def is_moscow_domclick_address(address: str | None) -> bool:
"""У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …». """У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …».
@ -590,70 +515,12 @@ def is_moscow_domclick_address(address: str | None) -> bool:
return bool(parts) and parts[0] == "Москва" return bool(parts) and parts[0] == "Москва"
def is_oblast_domclick_address(address: str | None) -> bool: # Источники, у которых город виден в самой карточке. Ключ отсутствует —
"""Регион 50 у ДомКлика: первый компонент адреса — «Московская область». # источник про город молчит, и без пред-геокода писать его нельзя (avito).
CITY_FILTERS = {
Симметрично `is_moscow_domclick_address`. Таблица `msk_raw.domclick_cards` "cian": is_moscow_address,
копит ДВА разных прогона сборщика (московский батч "yandex": is_moscow_yandex_address,
`msk-serp-domclick-20260912`, областной `mo-serp-domclick-20260912`) без "domclick": is_moscow_domclick_address,
своей колонки региона, а вью `msk_raw.domclick_latest` отдаёт обе партии
вперемешку фильтр по адресу обязателен, не опционален. Замер живьём на
API ДомКлика 12.09.2026: «Московская область, Химки, 7-й м-н, проспект
Мельникова, 33», «Московская область, Одинцовский городской округ,
Звенигород, 3-й м-н, 28» и подобные первый компонент «Московская
область» у всех 140 карточек выборки с семи разных смещений выдачи.
"""
parts = [part.strip() for part in (address or "").split(",")]
return bool(parts) and parts[0] == "Московская область"
def _payload_point(payload: dict) -> tuple[float, float] | None:
"""(lat, lon) из сырья ДомКлика, если сборщик их положил и они читаются."""
try:
return float(payload["lat"]), float(payload["lon"])
except (KeyError, TypeError, ValueError):
return None
def is_oblast_domclick_payload(payload: dict) -> bool:
"""Область у ДомКлика: префикс адреса ИЛИ координата внутри bbox области.
Одного префикса мало. Замер на собранном корпусе 12.09.2026 (2 961 карточка
областного батча): «Московская область» стоит первым компонентом у 2 960, а
у одной «Можайский муниципальный округ, д. Семёновское, 1», 55.5116/35.8293.
Это настоящая область (Можайск), и строгий префикс выбросил бы её молча.
Московский батч тем же замером даёт «Москва» первым компонентом у ВСЕХ
22 836 карточек, поэтому явный отказ Москве идёт раньше гео-ветки и bbox
Москвы (вложенный в областной) не может протащить столичную карточку в 50.
"""
address = payload.get("address")
if is_oblast_domclick_address(address):
return True
if is_moscow_domclick_address(address):
return False
point = _payload_point(payload)
if point is None:
return False
return is_within_bbox(point[0], point[1], REGIONS[OBLAST_REGION_CODE].bbox_region)
def _by_address(fn: Callable[[str | None], bool]) -> Callable[[dict], bool]:
"""Адаптер: фильтр по адресу → фильтр по всему payload'у (для реестра)."""
return lambda payload: fn(payload.get("address"))
# Реестр (source, целевой регион) → фильтр по ВСЕМУ payload'у, не только
# адресу: у Циана признак региона 50 лежит в `source_url`, адрес про него
# молчит. Ключа нет только для источника, который вообще не умеет отличать
# регион без пред-геокода (avito, для ЛЮБОГО региона) — для всех прочих пар
# фильтр обязан быть в реестре явно.
REGION_FILTERS: dict[tuple[str, int], Callable[[dict], bool]] = {
("cian", MOSCOW_REGION_CODE): _by_address(is_moscow_address),
("cian", OBLAST_REGION_CODE): is_cian_oblast_payload,
("yandex", MOSCOW_REGION_CODE): _by_address(is_moscow_yandex_address),
("yandex", OBLAST_REGION_CODE): _by_address(is_oblast_yandex_address),
("domclick", MOSCOW_REGION_CODE): _by_address(is_moscow_domclick_address),
("domclick", OBLAST_REGION_CODE): is_oblast_domclick_payload,
} }
@ -702,7 +569,6 @@ def import_msk_raw(
db: Session, db: Session,
*, *,
source: str = "cian", source: str = "cian",
region: int = MOSCOW_REGION_CODE,
batch_size: int = DEFAULT_BATCH_SIZE, batch_size: int = DEFAULT_BATCH_SIZE,
limit: int | None = None, limit: int | None = None,
dry_run: bool = False, dry_run: bool = False,
@ -711,38 +577,32 @@ def import_msk_raw(
geocode_limit: int = DEFAULT_GEOCODE_LIMIT, geocode_limit: int = DEFAULT_GEOCODE_LIMIT,
) -> ImportCounters: ) -> ImportCounters:
"""Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче.""" """Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче."""
if region not in SUPPORTED_REGIONS:
raise SystemExit(f"region={region}: регион не поддержан, доступны {SUPPORTED_REGIONS}")
view = SOURCE_VIEWS[source] view = SOURCE_VIEWS[source]
counters = ImportCounters() counters = ImportCounters()
matcher = RealMatcherAdapter() matcher = RealMatcherAdapter()
budget = GeocodeBudget(remaining=max(0, geocode_limit)) budget = GeocodeBudget(remaining=max(0, geocode_limit))
# Источник, который сам говорит про целевой регион: у Циана это префикс # Источник, который сам говорит про город: у Циана это префикс округа, у
# округа/поддомен, у Яндекса — второй компонент адреса, у ДомКлика — # Яндекса — второй компонент полного адреса. Авито не говорит ничего, ему
# первый. Авито не говорит ничего ни для какого региона, ему нужен # нужен пред-геокод, поэтому его в словаре нет.
# пред-геокод, поэтому в реестре его нет вовсе. city_filter = CITY_FILTERS.get(source)
city_filter = REGION_FILTERS.get((source, region))
if geocode and city_filter is not None: if geocode and city_filter is not None:
# Регион опознаётся даром и без ошибок — тратить на него внешнюю квоту # Город опознаётся даром и без ошибок — тратить на него внешнюю квоту
# незачем. # незачем.
raise SystemExit(f"source={source}: --geocode нужен только для avito") raise SystemExit(f"source={source}: --geocode нужен только для avito")
if city_filter is None and not geocode: if city_filter is None and not geocode:
# Без пред-геокода у Авито по-прежнему нечем отделить целевой регион от # Без пред-геокода у Авито по-прежнему нечем отделить Москву от области:
# прочих: ни города в адресе, ни координат. Пишем только по явному # ни города в адресе, ни координат. Пишем только по явному разрешению.
# разрешению.
if not (dry_run or allow_unfiltered): if not (dry_run or allow_unfiltered):
raise SystemExit( raise SystemExit(
f"source={source}: адрес не содержит признака города, регион " f"source={source}: адрес не содержит признака города, Москву от "
f"{region} от прочих не отличить. Нужен --geocode (штатный путь), " "области не отличить. Нужен --geocode (штатный путь), "
"--allow-unfiltered (аварийный) или --dry-run." "--allow-unfiltered (аварийный) или --dry-run."
) )
logger.warning( logger.warning(
"source=%s region=%d: пред-геокод ВЫКЛЮЧЕН — фильтра по региону нет " "source=%s: пред-геокод ВЫКЛЮЧЕН — фильтра по городу нет вовсе; "
"вовсе; строки лягут без geom и вперемешку с прочими регионами", "строки лягут без geom и вперемешку с областью",
source, source,
region,
) )
if geocode and not dry_run: if geocode and not dry_run:
db.execute(text(_GEO_CACHE_DDL)) db.execute(text(_GEO_CACHE_DDL))
@ -758,16 +618,16 @@ def import_msk_raw(
if is_artifact_address(address): if is_artifact_address(address):
counters.skipped_artifact += 1 counters.skipped_artifact += 1
continue continue
if city_filter is not None and not city_filter(payload): if city_filter is not None and not city_filter(address):
counters.skipped_not_target_region += 1 counters.skipped_not_moscow += 1
continue continue
if geocode: if geocode:
point = points.get(geo_cache_key(address or "", avito_city_slug(payload))) point = points.get(geo_cache_key(address or "", avito_city_slug(payload)))
if point is None: if point is None:
counters.skipped_ungeocoded += 1 counters.skipped_ungeocoded += 1
continue continue
if point.region_code != region: if point.region_code != MOSCOW_REGION_CODE:
counters.skipped_geo_other_region += 1 counters.skipped_oblast += 1
continue continue
# Координаты кладём в КОПИЮ payload'а: исходную строку сырья не # Координаты кладём в КОПИЮ payload'а: исходную строку сырья не
# трогаем, пересбор корпуса от этого не зависит. geom появляется # трогаем, пересбор корпуса от этого не зависит. geom появляется
@ -789,26 +649,19 @@ def import_msk_raw(
if dry_run or not lots: if dry_run or not lots:
continue continue
# У региона 50 своего единого города нет (`canonical_city is None` в
# реестре regions) — пишем city=None, `save_listings` его COALESCE'ит
# и существующее значение не затирает. Подбор аналогов не страдает:
# он радиусный (ST_DWithin), а не по городу; ценовая полоса ДКП
# ключуется на `deals.city`, а не на `listings.city`.
city = MOSCOW_CITY if region == MOSCOW_REGION_CODE else None
inserted, updated = save_listings( inserted, updated = save_listings(
db, db,
lots, lots,
matcher=matcher, matcher=matcher,
region_code=region, region_code=MOSCOW_REGION_CODE,
city=city, city=MOSCOW_CITY,
) )
counters.inserted += inserted counters.inserted += inserted
counters.updated += updated counters.updated += updated
db.commit() # батч зафиксирован — обрыв не отматывает всю работу db.commit() # батч зафиксирован — обрыв не отматывает всю работу
logger.info( logger.info(
"msk_raw %s region=%d: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)", "msk_raw %s: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
source, source,
region,
counters.read, counters.read,
counters.selected, counters.selected,
counters.written, counters.written,
@ -826,12 +679,11 @@ def import_msk_raw(
geocode_limit, geocode_limit,
) )
logger.info( logger.info(
"msk_raw %s region=%d ИТОГ%s: прочитано=%d отобрано=%d записано=%d " "msk_raw %s ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не целевой регион=%d " "(new=%d upd=%d, писатель пропустил=%d) | пропущено: не Москва=%d "
"геокод-другой-регион=%d не разрешён=%d артефакт=%d невалидный payload=%d | " "область(50)=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
"геокод-вызовов=%d | сходится=%s", "геокод-вызовов=%d | сходится=%s",
source, source,
region,
" (dry-run)" if dry_run else "", " (dry-run)" if dry_run else "",
counters.read, counters.read,
counters.selected, counters.selected,
@ -839,8 +691,8 @@ def import_msk_raw(
counters.inserted, counters.inserted,
counters.updated, counters.updated,
counters.writer_skipped if not dry_run else 0, counters.writer_skipped if not dry_run else 0,
counters.skipped_not_target_region, counters.skipped_not_moscow,
counters.skipped_geo_other_region, counters.skipped_oblast,
counters.skipped_ungeocoded, counters.skipped_ungeocoded,
counters.skipped_artifact, counters.skipped_artifact,
counters.skipped_invalid, counters.skipped_invalid,
@ -855,30 +707,21 @@ def main() -> None:
level=logging.INFO, level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s", format="%(asctime)s %(levelname)s %(name)s: %(message)s",
) )
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Импорт сырья msk_raw в listings (регион 77)")
description="Импорт сырья msk_raw в listings (регион задаётся --region, по умолчанию 77)"
)
parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian") parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian")
parser.add_argument(
"--region",
type=int,
choices=SUPPORTED_REGIONS,
default=MOSCOW_REGION_CODE,
help=f"целевой регион: {MOSCOW_REGION_CODE} — Москва, {OBLAST_REGION_CODE} — область",
)
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE) parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек") parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек")
parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики") parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики")
parser.add_argument( parser.add_argument(
"--allow-unfiltered", "--allow-unfiltered",
action="store_true", action="store_true",
help="АВАРИЙНЫЙ режим: писать avito без фильтра по региону и без geom", help="АВАРИЙНЫЙ режим: писать avito без фильтра по городу и без geom",
) )
parser.add_argument( parser.add_argument(
"--geocode", "--geocode",
action="store_true", action="store_true",
help="штатный путь для avito: пред-геокод адреса (слаг + DaData), " help="штатный путь для avito: пред-геокод адреса (слаг + DaData), "
"в listings уходит только целевой регион (--region), прочее пропускается", "в listings уходит только регион 77, область откладывается",
) )
parser.add_argument( parser.add_argument(
"--geocode-limit", "--geocode-limit",
@ -894,7 +737,6 @@ def main() -> None:
import_msk_raw( import_msk_raw(
db, db,
source=args.source, source=args.source,
region=args.region,
batch_size=args.batch_size, batch_size=args.batch_size,
limit=args.limit, limit=args.limit,
dry_run=args.dry_run, dry_run=args.dry_run,

View file

@ -1,52 +0,0 @@
-- 302_scrape_schedules_seed_rosreestr_dkp_50.sql
-- Seed-строка scrape_schedules для региона 50 (Московская область) — #3051, трек МО.
--
-- Dependencies: 289_rosreestr_fdw_msk_columns_seed77.sql (та же таблица, тот же
-- формат source/default_params).
-- Apply after: 301_web_support_message_idempotency_key.sql
--
-- WHY:
-- Код-часть импорта уже параметризована регионом (`scheduler.py::_job_rosreestr_dkp`
-- читает `region_code` из default_params и валидирует его по `app.services.regions`),
-- а wildcard-хендлер `rosreestr_dkp_import_*` резолвит любое имя с суффиксом кода.
-- Регион 50 заведён в реестре и уже на проде, поэтому включение области стоит ровно
-- одной строки расписания — новой логики не требуется.
--
-- У региона 50 `canonical_city IS NULL`, то есть он идёт по ветке региона 66:
-- `deals.city` берётся из источника (муниципалитет — Балашиха, Химки, Подольск),
-- `raw_payload` не заполняется, строки с пустым city отбрасываются. Для области это
-- и есть верное поведение: единого города у региона нет, подставлять нечего.
--
-- Замер по FDW (2026-09-12, прод): под полным WHERE импорта регион 50 даёт
-- 113 351 сделку ДКП с 2024-01-01 — корпус того же порядка, что московский.
--
-- Строка ВЫКЛЮЧЕНА (enabled=false) — ровно как seed 77 в миграции 289: миграция
-- заводит расписание, включение и первый прогон остаются отдельным решением
-- main-сессии. Окно 4-6 UTC совпадает с окнами регионов 66 и 77; прогоны
-- сериализуются планировщиком, а первая полная заливка 77 заняла 3 минуты на
-- 212 937 строк, так что третий регион в то же окно помещается с запасом.
--
-- ИДЕМПОТЕНТНОСТЬ: ON CONFLICT (source) DO NOTHING — повторный прогон no-op и,
-- что важнее, НЕ сбрасывает enabled обратно в false после ручного включения.
BEGIN;
SET LOCAL lock_timeout = '5s';
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
default_params
)
VALUES (
'rosreestr_dkp_import_50',
false,
4,
6,
'{"region_code": 50, "since": "2024-01-01", "batch_size": 2000}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -130,18 +130,3 @@ tests/test_revisit_floor_lateral_lookup.py::test_missing_history_before_anchor_s
# (postgres-сервис) — там прогон и был зелёным; в deploy-tradein.yml БД нет вовсе. # (postgres-сервис) — там прогон и был зелёным; в deploy-tradein.yml БД нет вовсе.
tests/test_3063_seller_fields_not_eroded.py::test_poor_rescrape_does_not_erase_seller_fields tests/test_3063_seller_fields_not_eroded.py::test_poor_rescrape_does_not_erase_seller_fields
tests/test_3063_seller_fields_not_eroded.py::test_real_change_still_overwrites tests/test_3063_seller_fields_not_eroded.py::test_real_change_still_overwrites
# Потолки БД на движке (#3463) — поведенческая половина: проверяют, что запрос
# длиннее потолка ОБРЫВАЕТСЯ (pg_sleep), что ожидание блокировки отваливается по
# lock_timeout, и что `SET LOCAL` задачи планировщика перекрывает сессионный
# потолок и не течёт за свою транзакцию. Всё это нельзя проверить без сервера:
# потолок применяет Postgres, а не питон. Статическая половина того же файла
# (test_engine_opens_connections_with_both_ceilings +
# test_ceilings_are_coherent_with_declared_estimate_budgets) идёт на ОБОИХ лэйнах
# и краснеет от снятия `connect_args` — проверено вручную 12.09. В ci-tradein.yml
# эти пять бегут по-настоящему (postgres-сервис, #2745).
tests/test_3463_db_timeouts.py::test_live_session_reports_both_ceilings
tests/test_3463_db_timeouts.py::test_statement_over_ceiling_is_cancelled_not_hung
tests/test_3463_db_timeouts.py::test_lock_wait_over_ceiling_is_aborted
tests/test_3463_db_timeouts.py::test_set_local_statement_timeout_overrides_session_ceiling
tests/test_3463_db_timeouts.py::test_set_local_is_scoped_to_its_transaction

View file

@ -342,9 +342,6 @@ def _yandex_with_pool(monkeypatch: pytest.MonkeyPatch, *, get: Any) -> _Counting
patch( patch(
"scraper_kit.providers.yandex.valuation._CurlCffiSession", _fake_curl_session(get=get) "scraper_kit.providers.yandex.valuation._CurlCffiSession", _fake_curl_session(get=get)
), ),
# На успехе fetch_house_history зовёт настоящий anti-ban sleep_between_requests
# (5с jitter) — тесту нужен только факт release-lease, не сама пауза.
patch("scraper_kit.base.asyncio.sleep", new_callable=AsyncMock),
): ):
_call_yandex(_db_cache_miss()) _call_yandex(_db_cache_miss())
return provider return provider

View file

@ -1,276 +0,0 @@
"""#3463 — у запросов к БД обязан быть потолок по времени и по ожиданию блокировки.
Отказ, который тут закрывается (см. issue): под `ACCESS EXCLUSIVE` на таблице шаг БД
на пути `/estimate` ЖДЁТ блокировку; бюджет источника истекает, обёртка `run_db_thread`
уходит ждать свой поток (иначе он останется сиротой в общей `Session` #3449), а
верхняя граница этого ожидания = длительность самого запроса. Границы у запроса не
было слот `_estimate_slots` не возвращался `_ESTIMATE_CONCURRENCY = 4` исчерпывался
и `/estimate` отдавал 429 всем остальным.
Потолок поэтому стоит на КОННЕКТЕ (libpq `options`), а не в питоновской обёртке:
таймаут в обёртке вернул бы ровно ту сироту, ради которой писался #3449.
Проверки по значению, а не по тексту:
* потолки реально доехали до параметров подключения движка и согласованы с
объявленными бюджетами `/estimate` (без БД падают от снятия `connect_args`);
* живая сессия этого движка сообщает оба потолка (без БД пропускается);
* запрос длиннее потолка ОБРЫВАЕТСЯ за отведённое время, а не висит;
* ожидание блокировки длиннее потолка обрывается это и есть сценарий #3463;
* задача со своим `SET LOCAL statement_timeout` (планировщик: 900 с в
`app/tasks/listing_source_snapshot.py`) новым сессионным потолком НЕ обрезается.
"""
from __future__ import annotations
import inspect
import os
import time
from collections.abc import Iterator
import psycopg
import pytest
from sqlalchemy import Engine, create_engine, text
from sqlalchemy.exc import OperationalError
from app.core.db import _LOCK_TIMEOUT_MS, _STATEMENT_TIMEOUT_MS, DB_CONNECT_ARGS, engine
# Потолки для ПОВЕДЕНЧЕСКИХ проверок — намеренно маленькие: проверяется механизм
# (потолок из `options` реально обрывает запрос / ожидание лока), а прод-ВЕЛИЧИНЫ
# проверяет `test_live_session_reports_both_ceilings` на самом прод-движке. Иначе
# каждый прогон сьюта стоил бы 30 с ожидания pg_sleep.
_PROBE_STATEMENT_TIMEOUT_MS = 1_000
_PROBE_LOCK_TIMEOUT_MS = 500
_LOCK_PROBE_TABLE = "t3463_lock_probe"
def _engine_connect_options(eng: Engine) -> str:
"""Строка libpq `options`, с которой движок РЕАЛЬНО открывает коннекты.
`connect_args` в движке не хранятся полем: `create_engine` вливает их в `cparams`
замыкания `pool._creator`. Читаем оттуда, а не из `DB_CONNECT_ARGS`, иначе тест
остался бы зелёным после снятия `connect_args=` у `create_engine`.
Наружу отдаём ТОЛЬКО `options`: в `cparams` лежит пароль роли, и текст
провалившегося assert'а уехал бы с ним в лог CI.
"""
creator = getattr(eng.pool, "_creator", None)
assert creator is not None, "у пула движка нет _creator — SQLAlchemy сменила устройство"
cparams = inspect.getclosurevars(creator).nonlocals.get("cparams")
assert cparams is not None, (
"в замыкании pool._creator нет cparams — SQLAlchemy сменила устройство, "
"проверку параметров подключения надо переписать, а не удалять"
)
return str(cparams.get("options", ""))
def _live_engine(connect_args: dict[str, str]) -> Engine | None:
"""Движок против живой Postgres с заданными `connect_args`, иначе None.
Тот же способ добыть DSN, что у `_live_session()` в tests/test_house_dedup_merge.py
и tests/test_purge_expired_trade_in_data.py: в CI Postgres есть (ci-tradein.yml),
на ноутбуке без БД тест пропускается (учтён в tests/skip_allowlist.txt).
"""
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
# Сначала проба БЕЗ connect_args: она отделяет «сервера нет» (честный пропуск)
# от «сервер есть, но наши `options` он не принял». Глушить второе нельзя —
# именно так испорченное значение (`statement_timeout=30000zz`) проходило
# зелёным: коннект падал, тест пропускался, запись в allowlist гасила сигнал,
# а на проде это FATAL на КАЖДОМ коннекте.
try:
probe = create_engine(dsn, future=True)
except Exception:
return None
try:
with probe.connect() as conn:
conn.execute(text("SELECT 1"))
except Exception:
return None
finally:
probe.dispose()
eng = create_engine(dsn, future=True, connect_args=connect_args)
with eng.connect() as conn: # НЕ под except: сервер живой, виноваты connect_args
conn.execute(text("SELECT 1"))
return eng
@pytest.fixture
def probe_engine() -> Iterator[Engine]:
"""Живой движок с МАЛЕНЬКИМИ потолками — проверка механизма, не прод-величин."""
eng = _live_engine(
{
"options": (
f"-c statement_timeout={_PROBE_STATEMENT_TIMEOUT_MS} "
f"-c lock_timeout={_PROBE_LOCK_TIMEOUT_MS}"
)
}
)
if eng is None:
pytest.skip("живой Postgres недоступен (DATABASE_URL-заглушка)")
try:
yield eng
finally:
eng.dispose()
# ── Проводка и согласованность величин (без БД) ───────────────────────────────
def test_engine_opens_connections_with_both_ceilings() -> None:
"""Оба потолка доехали до параметров подключения ПРОД-движка.
Фальсификация: убрать `connect_args=DB_CONNECT_ARGS` из `create_engine`
`options` станет пустой, тест краснеет.
"""
expected = f"-c statement_timeout={_STATEMENT_TIMEOUT_MS} -c lock_timeout={_LOCK_TIMEOUT_MS}"
options = _engine_connect_options(engine)
# РАВЕНСТВО, а не `in`: подстрочная проверка пропускала испорченный хвост
# (`…=30000zz` содержит `…=30000`), а Postgres на такое значение отвечает
# `FATAL: invalid value for parameter "statement_timeout"` — ни одного коннекта
# ни в одном из трёх сервисов образа, полный отказ продукта.
assert options == expected, (
f"движок открывает коннекты с options={options!r}, ожидалось {expected!r}: "
"либо потолка нет вовсе (заблокированный запрос снова висит и жжёт слот "
"/estimate, #3463), либо значение испорчено — тогда libpq отвергнет КАЖДЫЙ коннект"
)
assert DB_CONNECT_ARGS["options"] == options
def test_ceilings_are_coherent_with_declared_estimate_budgets() -> None:
"""Потолок выше САМОГО ДЛИННОГО объявленного бюджета `/estimate`, но конечен."""
from app.core.config import settings
declared_budgets_s = (
settings.estimate_avito_imv_timeout_s, # 20 с — самый длинный
settings.estimate_geocode_budget_s, # 12 с
settings.estimate_house_meta_timeout_s, # 8 с
settings.estimate_yandex_valuation_timeout_s, # 8 с
settings.estimate_cian_valuation_timeout_s, # 8 с
)
longest_ms = max(declared_budgets_s) * 1000
assert _STATEMENT_TIMEOUT_MS > longest_ms, (
f"потолок запроса {_STATEMENT_TIMEOUT_MS} мс не выше самого длинного объявленного "
f"бюджета {longest_ms:.0f} мс — потолок стал бы биндящим ограничением честной работы"
)
# Потолок сверху — иначе у проверки есть пол и нет крыши: `_STATEMENT_TIMEOUT_MS =
# 300_000` (пять минут) зеленел бы, а пять минут ожидания это тот же отказ, только
# медленнее: четыре таких запроса всё так же выедают `_ESTIMATE_CONCURRENCY`.
assert _STATEMENT_TIMEOUT_MS <= 2 * longest_ms, (
f"потолок запроса {_STATEMENT_TIMEOUT_MS} мс больше чем вдвое превышает самый "
f"длинный объявленный бюджет {longest_ms:.0f} мс — это уже не защита, а отсрочка: "
"слот /estimate держится всё это время"
)
assert 0 < _LOCK_TIMEOUT_MS < _STATEMENT_TIMEOUT_MS, (
"ожидание блокировки обязано обрываться РАНЬШЕ потолка на сам запрос: "
"деградировать лучше, чем держать слот"
)
# ── Поведение против живой Postgres ──────────────────────────────────────────
def test_live_session_reports_both_ceilings() -> None:
"""Прод-ВЕЛИЧИНЫ на живой сессии ПРОД-движка: сервер их принял, а не проигнорировал.
Коннект берётся у самого `app.core.db.engine`, а не у собранного здесь двойника:
двойник остался бы зелёным после снятия `connect_args=` в `create_engine`.
"""
if _live_engine(DB_CONNECT_ARGS) is None:
pytest.skip("живой Postgres недоступен (DATABASE_URL-заглушка)")
with engine.connect() as conn:
statement_timeout = conn.execute(
text("SELECT current_setting('statement_timeout')")
).scalar_one()
lock_timeout = conn.execute(text("SELECT current_setting('lock_timeout')")).scalar_one()
assert statement_timeout == "30s", (
f"сессия сообщает statement_timeout={statement_timeout!r}"
f"ожидалось 30s (_STATEMENT_TIMEOUT_MS={_STATEMENT_TIMEOUT_MS})"
)
assert lock_timeout == "5s", (
f"сессия сообщает lock_timeout={lock_timeout!r}"
f"ожидалось 5s (_LOCK_TIMEOUT_MS={_LOCK_TIMEOUT_MS})"
)
def test_statement_over_ceiling_is_cancelled_not_hung(probe_engine: Engine) -> None:
"""`pg_sleep` длиннее потолка обрывается ОТМЕНОЙ за отведённое время."""
sleep_s = _PROBE_STATEMENT_TIMEOUT_MS / 1000 * 5
started = time.monotonic()
with probe_engine.connect() as conn, pytest.raises(OperationalError) as excinfo:
conn.execute(text(f"SELECT pg_sleep({sleep_s})"))
elapsed = time.monotonic() - started
assert isinstance(excinfo.value.orig, psycopg.errors.QueryCanceled), (
f"запрос упал не отменой по таймауту, а {type(excinfo.value.orig).__name__}"
)
assert elapsed < sleep_s, (
f"запрос шёл {elapsed:.1f} с при потолке {_PROBE_STATEMENT_TIMEOUT_MS} мс — "
"потолок не сработал, он висел до конца pg_sleep"
)
def test_lock_wait_over_ceiling_is_aborted(probe_engine: Engine) -> None:
"""Сценарий #3463: под ACCESS EXCLUSIVE читатель ОТВАЛИВАЕТСЯ, а не ждёт вечно."""
with probe_engine.connect() as blocker:
blocker.execute(text(f"CREATE TABLE IF NOT EXISTS {_LOCK_PROBE_TABLE} (id int)"))
blocker.commit()
try:
blocker.execute(text(f"LOCK TABLE {_LOCK_PROBE_TABLE} IN ACCESS EXCLUSIVE MODE"))
started = time.monotonic()
with probe_engine.connect() as victim, pytest.raises(OperationalError) as excinfo:
victim.execute(text(f"SELECT count(*) FROM {_LOCK_PROBE_TABLE}"))
elapsed = time.monotonic() - started
finally:
blocker.rollback()
blocker.execute(text(f"DROP TABLE IF EXISTS {_LOCK_PROBE_TABLE}"))
blocker.commit()
assert isinstance(excinfo.value.orig, psycopg.errors.LockNotAvailable), (
f"читатель упал не по ожиданию блокировки, а {type(excinfo.value.orig).__name__}"
"сработал не тот потолок"
)
assert elapsed < _PROBE_STATEMENT_TIMEOUT_MS / 1000, (
f"ожидание блокировки длилось {elapsed:.2f} с при lock_timeout "
f"{_PROBE_LOCK_TIMEOUT_MS} мс — оборвал не lock_timeout"
)
def test_set_local_statement_timeout_overrides_session_ceiling(probe_engine: Engine) -> None:
"""Задача со своим `SET LOCAL` НЕ обрезается сессионным потолком.
Это и есть проверка обещания «задачи планировщика не пострадают»: у
`app/tasks/listing_source_snapshot.py:288` стоит `SET LOCAL statement_timeout = 900000`,
и он обязан ПЕРЕКРЫВАТЬ значение из `connect_args`, а не наоборот.
"""
own_budget_ms = _PROBE_STATEMENT_TIMEOUT_MS * 10
sleep_s = _PROBE_STATEMENT_TIMEOUT_MS / 1000 * 2 # заведомо больше сессионного потолка
with probe_engine.connect() as conn:
conn.execute(text(f"SET LOCAL statement_timeout = {own_budget_ms}"))
effective = conn.execute(text("SELECT current_setting('statement_timeout')")).scalar_one()
assert effective == "10s", f"SET LOCAL не применился: current_setting={effective!r}"
# Не просто current_setting: запрос длиннее СЕССИОННОГО потолка обязан дойти до конца.
conn.execute(text(f"SELECT pg_sleep({sleep_s})"))
conn.rollback()
def test_set_local_is_scoped_to_its_transaction(probe_engine: Engine) -> None:
"""Обратная сторона: чужой `SET LOCAL` не снимает потолок со всей сессии.
Иначе одна задача с 900-секундным бюджетом отключала бы защиту у всех, кому
достанется тот же коннект из пула.
"""
with probe_engine.connect() as conn:
conn.execute(text(f"SET LOCAL statement_timeout = {_PROBE_STATEMENT_TIMEOUT_MS * 10}"))
conn.rollback()
after = conn.execute(text("SELECT current_setting('statement_timeout')")).scalar_one()
assert after == "1s", (
f"после завершения транзакции statement_timeout={after!r}"
"SET LOCAL протёк за пределы своей транзакции, коннект вернулся в пул без потолка"
)

View file

@ -70,7 +70,7 @@ def glitchtip_events() -> Iterator[list[dict[str, Any]]]:
yield events yield events
finally: finally:
# Иначе на каждый тест остаётся фоновый поток транспорта. # Иначе на каждый тест остаётся фоновый поток транспорта.
client.close(timeout=0) client.close()
def event_texts(events: list[dict[str, Any]]) -> list[str]: def event_texts(events: list[dict[str, Any]]) -> list[str]:

View file

@ -215,8 +215,7 @@ async def test_backfill_yandex_addresses_error_on_non_200():
return_value=mock_session, return_value=mock_session,
), ),
): ):
# request_delay_sec=0: реальный анти-бот sleep(3с) тут не нужен, 1 item в батче. result = await backfill_yandex_addresses(MagicMock(), limit=10)
result = await backfill_yandex_addresses(MagicMock(), limit=10, request_delay_sec=0)
assert result.errors == 1 assert result.errors == 1
assert result.saved == 0 assert result.saved == 0

View file

@ -156,7 +156,7 @@ def test_only_moscow_rows_reach_the_writer(captured: list[dict[str, Any]]) -> No
assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"] assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"]
assert counters.read == 5 assert counters.read == 5
assert counters.selected == 2 assert counters.selected == 2
assert counters.skipped_not_target_region == 1 assert counters.skipped_not_moscow == 1
assert counters.skipped_artifact == 1 assert counters.skipped_artifact == 1
assert counters.skipped_invalid == 1 assert counters.skipped_invalid == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным" assert counters.check(), "счётчики обязаны сходиться с прочитанным"
@ -331,7 +331,7 @@ def test_geocode_lets_avito_through_without_allow_unfiltered(
_FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True _FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True
) )
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"] assert [lot.source_id for lot in captured[0]["lots"]] == ["1"]
assert counters.skipped_geo_other_region == 1 assert counters.skipped_oblast == 1
assert counters.skipped_ungeocoded == 1 assert counters.skipped_ungeocoded == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным" assert counters.check(), "счётчики обязаны сходиться с прочитанным"
@ -444,7 +444,7 @@ def test_yandex_oblast_rows_do_not_reach_the_writer(captured: list[dict[str, Any
] ]
counters = mod.import_msk_raw(_FakeSession(rows), source="yandex", batch_size=10) counters = mod.import_msk_raw(_FakeSession(rows), source="yandex", batch_size=10)
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"] assert [lot.source_id for lot in captured[0]["lots"]] == ["1"]
assert counters.skipped_not_target_region == 1 assert counters.skipped_not_moscow == 1
def test_yandex_keeps_its_own_coordinates(captured: list[dict[str, Any]]) -> None: def test_yandex_keeps_its_own_coordinates(captured: list[dict[str, Any]]) -> None:
@ -480,261 +480,6 @@ def test_domclick_non_moscow_is_rejected() -> None:
assert not mod.is_moscow_domclick_address(None) assert not mod.is_moscow_domclick_address(None)
def test_domclick_oblast_is_recognised_without_geocode() -> None:
"""Регион 50 у ДомКлика — первый компонент адреса «Московская область»."""
assert mod.is_oblast_domclick_address("Московская область, Химки, 9 Мая, 21к3")
assert mod.is_oblast_domclick_address(
"Московская область, Одинцовский городской округ, Звенигород, 3-й м-н, 28"
)
assert not mod.is_oblast_domclick_address("Москва, улица Нижние Мнёвники, 16 к2")
assert not mod.is_oblast_domclick_address("")
assert not mod.is_oblast_domclick_address(None)
def test_domclick_oblast_without_prefix_is_saved_by_its_coordinates() -> None:
"""Живой случай: областная карточка БЕЗ префикса «Московская область».
В собранном корпусе 12.09.2026 такая ровно одна на 2 961 Можайск, и
строгий префикс выбросил бы её молча. Координата внутри bbox области
вторая опора фильтра, а не замена первой.
"""
mozhaysk = _payload(
source="domclick",
address="Можайский муниципальный округ, д. Семёновское, 1",
lat=55.511625,
lon=35.829308,
)
assert mod.is_oblast_domclick_payload(mozhaysk)
# Строки вместо чисел (JSON сырья бывает и таким) читаются так же.
assert mod.is_oblast_domclick_payload({**mozhaysk, "lat": "55.511625", "lon": "35.829308"})
def test_domclick_oblast_geo_branch_cannot_swallow_moscow() -> None:
"""bbox Москвы вложен в областной — отказ по адресу обязан идти РАНЬШЕ гео."""
moscow = _payload(
source="domclick",
address="Москва, улица Нижние Мнёвники, 16 к2",
lat=55.7558,
lon=37.6173,
)
assert not mod.is_oblast_domclick_payload(moscow)
def test_domclick_oblast_geo_branch_needs_a_usable_point() -> None:
"""Нет префикса и нечего проверить координатой → карточка НЕ область."""
bare = _payload(source="domclick", address="Можайский муниципальный округ, д. Семёновское, 1")
bare.pop("lat", None)
bare.pop("lon", None)
assert not mod.is_oblast_domclick_payload(bare)
assert not mod.is_oblast_domclick_payload({**bare, "lat": None, "lon": None})
assert not mod.is_oblast_domclick_payload({**bare, "lat": "н/д", "lon": "н/д"})
# Координата за пределами области (Екатеринбург) тоже не проходит.
assert not mod.is_oblast_domclick_payload({**bare, "lat": 56.8300, "lon": 60.6000})
def test_domclick_view_is_wired_into_source_views() -> None: def test_domclick_view_is_wired_into_source_views() -> None:
assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest" assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest"
filt77 = mod.REGION_FILTERS[("domclick", 77)] assert mod.CITY_FILTERS["domclick"] is mod.is_moscow_domclick_address
assert filt77(_payload(source="domclick", address="Москва, улица Нижние Мнёвники, 16 к2"))
assert not filt77(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3"))
filt50 = mod.REGION_FILTERS[("domclick", 50)]
assert filt50 is mod.is_oblast_domclick_payload
assert filt50(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3"))
assert not filt50(_payload(source="domclick", address="Москва, улица Нижние Мнёвники, 16 к2"))
# ── регион 50 (Московская область) ───────────────────────────────────────────
def test_cian_subdomain_selects_oblast_regardless_of_okrug(
captured: list[dict[str, Any]],
) -> None:
"""khimki.cian.ru → область при --region 50, даже если адрес без округа."""
rows = [
{
"id": 1,
"payload": _payload(
source_id="khimki-1",
source_url="https://khimki.cian.ru/sale/flat/1/",
address="городской округ Химки, Олимпийский проспект, 29",
),
}
]
counters_50 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10)
assert counters_50.selected == 1
counters_77 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=77, batch_size=10)
assert counters_77.selected == 0
def test_cian_www_with_okrug_stays_moscow_only(captured: list[dict[str, Any]]) -> None:
"""www.cian.ru + префикс округа — Москва (77), не область (50)."""
rows = [
{
"id": 1,
"payload": _payload(
source_id="www-1",
source_url="https://www.cian.ru/sale/flat/330200428/",
address="ЦАО, р-н Арбат, улица Арбат, 10",
),
}
]
counters_77 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=77, batch_size=10)
assert counters_77.selected == 1
counters_50 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10)
assert counters_50.selected == 0
def test_yandex_oblast_region_50_is_selected(captured: list[dict[str, Any]]) -> None:
rows = [
{
"id": 1,
"payload": _yandex(
source_id="oblast-1",
address="Россия, Московская область, Химки, Ивановская улица, 5",
),
}
]
counters_50 = mod.import_msk_raw(_FakeSession(rows), source="yandex", region=50, batch_size=10)
assert counters_50.selected == 1
counters_77 = mod.import_msk_raw(_FakeSession(rows), source="yandex", region=77, batch_size=10)
assert counters_77.selected == 0
def test_domclick_oblast_region_50_is_selected(captured: list[dict[str, Any]]) -> None:
"""Областной прогон сборщика (батч mo-serp-domclick-20260912) отбирается по region=50."""
rows = [
{
"id": 1,
"payload": _payload(
source="domclick",
source_id="oblast-1",
address="Московская область, Химки, проспект Мельникова, 33",
),
}
]
counters_50 = mod.import_msk_raw(
_FakeSession(rows), source="domclick", region=50, batch_size=10
)
assert counters_50.selected == 1
counters_77 = mod.import_msk_raw(
_FakeSession(rows), source="domclick", region=77, batch_size=10
)
assert counters_77.selected == 0
def test_domclick_moscow_region_77_is_selected(captured: list[dict[str, Any]]) -> None:
"""Московский прогон (батч msk-serp-domclick-20260912) отбирается по region=77."""
rows = [
{
"id": 1,
"payload": _payload(
source="domclick",
source_id="moscow-1",
address="Москва, Генерала Дорохова проспект, 49",
),
}
]
counters_77 = mod.import_msk_raw(
_FakeSession(rows), source="domclick", region=77, batch_size=10
)
assert counters_77.selected == 1
counters_50 = mod.import_msk_raw(
_FakeSession(rows), source="domclick", region=50, batch_size=10
)
assert counters_50.selected == 0
def test_domclick_writer_gets_region_50_and_city_none(captured: list[dict[str, Any]]) -> None:
rows = [
{
"id": 1,
"payload": _payload(
source="domclick",
source_id="oblast-2",
address="Московская область, Химки, проспект Мельникова, 33",
),
}
]
mod.import_msk_raw(_FakeSession(rows), source="domclick", region=50, batch_size=10)
assert captured[0]["region_code"] == 50
assert captured[0]["city"] is None
def test_domclick_mixed_batches_are_told_apart_by_address(captured: list[dict[str, Any]]) -> None:
"""Вью отдаёт московский и областной прогоны вперемешку — региональный фильтр
обязан развести их сам, курсор по `id` тут не помощник."""
rows = [
{
"id": 1,
"payload": _payload(
source="domclick",
source_id="oblast-3",
address="Московская область, Одинцовский городской округ, Звенигород, 3-й м-н, 28",
),
},
{
"id": 2,
"payload": _payload(
source="domclick",
source_id="moscow-2",
address="Москва, Генерала Дорохова проспект, 49",
),
},
]
counters = mod.import_msk_raw(_FakeSession(rows), source="domclick", region=50, batch_size=10)
assert [lot.source_id for lot in captured[0]["lots"]] == ["oblast-3"]
assert counters.selected == 1
assert counters.skipped_not_target_region == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным"
def test_writer_gets_city_none_for_oblast(captured: list[dict[str, Any]]) -> None:
rows = [
{
"id": 1,
"payload": _payload(
source_id="khimki-1",
source_url="https://khimki.cian.ru/sale/flat/1/",
address="городской округ Химки, Олимпийский проспект, 29",
),
}
]
mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10)
assert captured[0]["region_code"] == 50
assert captured[0]["city"] is None
def test_moscow_region_77_numbers_unchanged(captured: list[dict[str, Any]]) -> None:
"""Анти-регресс: те же фикстуры, тот же region=77 → те же прежние числа."""
counters = mod.import_msk_raw(_FakeSession(_rows()), batch_size=10, region=77)
assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"]
assert counters.read == 5
assert counters.selected == 2
assert counters.skipped_not_target_region == 1
assert counters.skipped_artifact == 1
assert counters.skipped_invalid == 1
assert captured[0]["region_code"] == 77
assert captured[0]["city"] == "Москва"
def test_cian_subdomain_ignores_port_and_case() -> None:
"""Поддомен читается из `hostname`, а не из `netloc`: порт и регистр хоста
не должны превращать московскую карточку в «не целевой регион», а областную
в московскую. Хост не `*.cian.ru` (включая голый `cian.ru` и похожий
`fake-cian.ru`) остаётся None консервативно, ложной области не даёт."""
assert mod.cian_subdomain({"source_url": "https://WWW.CIAN.RU:443/sale/flat/1/"}) == "www"
assert (
mod.cian_subdomain({"source_url": "https://khimki.cian.ru:8443/sale/flat/1/"}) == "khimki"
)
assert mod.is_cian_oblast_payload({"source_url": "https://khimki.cian.ru:8443/x"}) is True
assert mod.is_cian_oblast_payload({"source_url": "https://WWW.CIAN.RU:443/x"}) is False
for bad in ("https://cian.ru/sale/flat/1/", "https://fake-cian.ru/x", "", None, 42):
assert mod.cian_subdomain({"source_url": bad}) is None
assert mod.cian_subdomain({}) is None

View file

@ -539,24 +539,16 @@ async def test_pull_sber_indices_asking_benchmark_logged(caplog: pytest.LogCaptu
) )
def test_moscow_oblast_ref_area_feeds_the_estimator_under_the_same_name() -> None: def test_moscow_oblast_ref_area_is_loaded_but_not_yet_used_by_estimator() -> None:
"""Имя ряда у загрузчика и у эстиматора обязано совпадать буква в букву. """Область грузится заранее: ряд наполняется месяцами, задним числом не берётся.
Загрузчик завёл REF_AREA=50 раньше (#3498), когда карта эстиматора кода 50 Одновременно проверяем, что заведение ряда НЕ включает область в оценку и НЕ
ещё не знала и область считалась по «России». 12.09.2026 ряд приехал на прод поднимает тревогу монитора свежести: обе величины считаются от карты
(real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08), и код 50 попал эстиматора (_SBER_REGION_SERIES), а не от карты загрузчика.
в `_SBER_REGION_SERIES`. С этого момента расхождение имён перестаёт быть
безобидным: эстиматор молча читал бы пустой ряд, а монитор свежести требовал
бы ряд, которого загрузчик не пишет. Тест сцепляет обе стороны по имени.
""" """
from app.services.estimator import ( from app.services.estimator import SBER_MONITORED_REGIONS, SBER_REQUIRED_REGIONS
SBER_MONITORED_REGIONS,
SBER_REQUIRED_REGIONS,
sber_region_series_name,
)
from app.services.sber_index import SBER_REF_AREAS from app.services.sber_index import SBER_REF_AREAS
assert SBER_REF_AREAS["50"] == "Московская область" assert SBER_REF_AREAS["50"] == "Московская область"
assert sber_region_series_name(50) == SBER_REF_AREAS["50"] assert "Московская область" not in SBER_REQUIRED_REGIONS
assert "Московская область" in SBER_REQUIRED_REGIONS assert "Московская область" not in SBER_MONITORED_REGIONS
assert "Московская область" in SBER_MONITORED_REGIONS

View file

@ -88,21 +88,6 @@ def test_series_names_agree_with_region_registry() -> None:
assert regions_mod.REGIONS[code].name == name assert regions_mod.REGIONS[code].name == name
def test_resolver_maps_50_to_moscow_oblast_not_russia() -> None:
"""Область получает СВОЙ ряд, а не общероссийский фолбэк.
Загрузчик научился тянуть REF_AREA=50 (#3498), и 12.09.2026 ряд приехал на
прод: real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08 та же
глубина, что у Москвы и обл.66. Пока кода 50 в карте не было, коридор
области считался по «России», то есть ошибался на всё расхождение области
со страной. Тест красный на прежней карте ПО ЗНАЧЕНИЮ: там вернулась бы
«Россия».
"""
assert est.sber_region_series_name(50) == "Московская область"
assert est.sber_region_series_name(50) != est.SBER_TIME_ADJUST_FALLBACK_REGION
assert "Московская область" in est.SBER_REQUIRED_REGIONS
def test_monitored_regions_cover_every_series_the_estimator_can_read() -> None: def test_monitored_regions_cover_every_series_the_estimator_can_read() -> None:
assert set(est.SBER_MONITORED_REGIONS) == set(est._SBER_REGION_SERIES.values()) | { assert set(est.SBER_MONITORED_REGIONS) == set(est._SBER_REGION_SERIES.values()) | {
est.SBER_TIME_ADJUST_FALLBACK_REGION est.SBER_TIME_ADJUST_FALLBACK_REGION
@ -290,15 +275,8 @@ def test_monitor_verdict_for_sverdlovsk_unchanged(monkeypatch: pytest.MonkeyPatc
def _without_moscow() -> _MonDB: def _without_moscow() -> _MonDB:
"""Источник переименовал «Москва» → ряда с прежним именем больше нет. """Источник переименовал «Москва» → ряда с прежним именем больше нет."""
return _MonDB({"Свердловская область": _JUN_2026, "Россия": _JUN_2026})
Остальные обязательные ряды на месте иначе тесты ниже считали бы пропажу
Москвы вместе с чужими пропажами. Строятся от карты эстиматора, чтобы новый
регион в ней не ронял эти тесты по счётчику (ровно так они и покраснели,
когда в карту приехала область).
"""
present = {r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS if r != "Москва"}
return _MonDB(present)
def test_missing_moscow_does_not_suppress_the_sverdlovsk_verdict( def test_missing_moscow_does_not_suppress_the_sverdlovsk_verdict(
@ -374,7 +352,7 @@ def test_missing_fallback_series_does_not_fail_the_monitor(
) -> None: ) -> None:
"""«Россия» — фолбэк: сегодня по нему не считается ни одна сделка → не сбой.""" """«Россия» — фолбэк: сегодня по нему не считается ни одна сделка → не сбой."""
calls = _patch_runs(monkeypatch) calls = _patch_runs(monkeypatch)
db = _MonDB({r: _JUN_2026 for r in est.SBER_REQUIRED_REGIONS}) db = _MonDB({"Свердловская область": _JUN_2026, "Москва": _JUN_2026})
out = mon.check_sber_freshness(db, run_id=4, params={}, now=_NOW) # type: ignore[arg-type] out = mon.check_sber_freshness(db, run_id=4, params={}, now=_NOW) # type: ignore[arg-type]
assert calls["failed"] is None assert calls["failed"] is None
assert out["regions_missing"] == 1 assert out["regions_missing"] == 1
@ -479,17 +457,15 @@ def test_duplicate_series_name_does_not_pin_regions_missing(
def test_early_exit_fills_the_new_counters(monkeypatch: pytest.MonkeyPatch) -> None: def test_early_exit_fills_the_new_counters(monkeypatch: pytest.MonkeyPatch) -> None:
"""КРАСНЫЙ ДО ПРАВКИ: в раннем выходе новые ключи отдавали нули. """КРАСНЫЙ ДО ПРАВКИ: в раннем выходе новые ключи отдавали нули.
Пропажа ВСЕХ обязательных рядов была неотличима от пропажи одного Пропажа ДВУХ обязательных рядов была неотличима от пропажи одного свердловского,
свердловского, а найденный ряд не попадал в age_days_max вовсе. Счётчик а найденный ряд не попадал в age_days_max вовсе.
сверяем с длиной карты, а не с литералом: новый регион в ней не повод
этому тесту краснеть.
""" """
calls = _patch_runs(monkeypatch) calls = _patch_runs(monkeypatch)
db = _MonDB({est.SBER_TIME_ADJUST_FALLBACK_REGION: _JUN_2026}) db = _MonDB({est.SBER_TIME_ADJUST_FALLBACK_REGION: _JUN_2026})
out = mon.check_sber_freshness(db, run_id=11, params={}, now=_NOW) # type: ignore[arg-type] out = mon.check_sber_freshness(db, run_id=11, params={}, now=_NOW) # type: ignore[arg-type]
assert calls["done"] is None assert calls["done"] is None
assert calls["failed"] is not None assert calls["failed"] is not None
assert out["regions_missing"] == len(est.SBER_REQUIRED_REGIONS) assert out["regions_missing"] == 2
assert out["alert_regions_missing"] == 1 # ДО ПРАВКИ: 0 assert out["alert_regions_missing"] == 1 # ДО ПРАВКИ: 0
assert out["age_days_max"] == 72 # ДО ПРАВКИ: 0 — найденный ряд был не виден assert out["age_days_max"] == 72 # ДО ПРАВКИ: 0 — найденный ряд был не виден
# прежняя семантика раннего выхода не тронута # прежняя семантика раннего выхода не тронута
@ -516,7 +492,9 @@ class _MonDBDriverFault(_MonDB):
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result: def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result:
if self._deactivated: if self._deactivated:
raise PendingRollbackError("Can't reconnect until invalid transaction is rolled back") raise PendingRollbackError(
"Can't reconnect until invalid transaction is rolled back"
)
params = params or {} params = params or {}
if params.get("city") == self._broken: if params.get("city") == self._broken:
self._deactivated = True self._deactivated = True

View file

@ -80,37 +80,18 @@ python .\collect.py --platform yandex --full --batch-id msk-serp-yandex-20260911
# 3) продолжить прерванный прогон по сохранённому плану коридоров # 3) продолжить прерванный прогон по сохранённому плану коридоров
python .\collect.py --full --resume --batch-id msk-serp-avito-20260908 python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
# 4) DomClick по Московской области (--region — только для domclick)
python .\collect.py --platform domclick --region 50 --full `
--batch-id mo-serp-domclick-20260912-1200
``` ```
Без аргументов скрипт работает в режиме `--measure 100` и полный проход **не начинает**. Без аргументов скрипт работает в режиме `--measure 100` и полный проход **не начинает**.
Дефолт — `--platform avito --region 77` (Москва). Дефолт — `--platform avito`.
`--region {77,50}` переключает регион **только для `--platform domclick`**: у DomClick Ключи: `--platform {avito,cian,yandex}` (дефолт avito), `--delay` (пауза между загрузками,
Москва и область — разные GUID листинга (`MSK_DOMCLICK_GUID` / `MO_DOMCLICK_GUID` в
`collect.py`) и разные прогоны. У Авито/Циана/Яндекса единый скоуп «Москва и МО»
собирается одним прогоном (`moskva_i_mo` / `region=-1` / `rgid=741964`) и делится на
регионы уже при импорте в `listings` — отдельного прогона по области у них нет, и
`--region 50` с любой площадкой кроме domclick падает `SystemExit` при разборе аргументов.
Скрипт также сверяет GUID, зашитый в фактический `base_url` (параметр `address`), с
GUID, ожидаемым для выбранного `--region` — расхождение (например, ручной `--base-url`
с московским GUID при `--region 50`) останавливает прогон до первого запроса, а не
заливает чужой регион под областной `batch_id`.
Ключи: `--platform {avito,cian,yandex,domclick}` (дефолт avito), `--region {77,50}`
(дефолт 77, только domclick), `--delay` (пауза между загрузками,
дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями
`request_delay_sec` 710 с), `--batch-size` (карточек в одной заливке, дефолт 1000), `request_delay_sec` 710 с), `--batch-size` (карточек в одной заливке, дефолт 1000),
`--target-count` (целевой размер коридора; дефолт зависит от площадки — 1500 у `--target-count` (целевой размер коридора; дефолт зависит от площадки — 1500 у
avito/cian, **500** у yandex, см. `PlatformAdapter.default_target`), `--base-url` (дефолт зависит avito/cian, **500** у yandex, см. `PlatformAdapter.default_target`), `--base-url` (дефолт зависит
от `--platform`/`--region`), `--batch-id` (дефолт `msk-serp-<platform>-<UTC>`, а при от `--platform`), `--batch-id` (дефолт `msk-serp-<platform>-<UTC>` — платформа в имени,
`--platform domclick --region 50``mo-serp-domclick-<UTC>`: платформа/регион в имени, чтобы avito- и cian-прогоны не затирали друг друга план/CSV), `--out-dir`,
чтобы прогоны разных площадок и регионов не затирали друг другу план/CSV — регион в
таблице `msk_raw.domclick_cards` иначе не выражается, отдельной колонки под него нет),
`--out-dir`,
`--ssh-host/--container/--db-user/--db-name`. `--ssh-host/--container/--db-user/--db-name`.
`AVITO_CDP` (адрес CDP, дефолт `http://localhost:9222`) общий для обеих платформ — `AVITO_CDP` (адрес CDP, дефолт `http://localhost:9222`) общий для обеих платформ —

View file

@ -6,19 +6,9 @@
(подключение по CDP), парсер импорт из scraper-kit, заливка поток в psql (подключение по CDP), парсер импорт из scraper-kit, заливка поток в psql
через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает. через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает.
Платформа выбирается ключом --platform {avito,cian,yandex,domclick} (дефолт Платформа выбирается ключом --platform {avito,cian,yandex} (дефолт avito) см.
avito) см. класс PlatformAdapter ниже. У каждой платформы свой потолок класс PlatformAdapter ниже. У каждой платформы свой потолок пагинации, свой
пагинации, свой билдер URL коридора и своя целевая таблица в msk_raw. билдер URL коридора и своя целевая таблица в msk_raw.
--region {77,50} (дефолт 77 Москва) переключает регион сбора и работает
ТОЛЬКО с --platform domclick: у DomClick Москва и область разные GUID и
разные прогоны. У Авито/Циана/Яндекса корпус Москва+МО собирается одним
прогоном (moskva_i_mo / region=-1 / rgid Москва+МО) и делится на регионы уже
при импорте, поэтому --region 50 с любой другой площадкой падает SystemExit.
Пример прогона по Московской области (DomClick, полный проход):
python collect.py --platform domclick --region 50 --full \
--batch-id mo-serp-domclick-20260912-1200
Дефолтный режим --measure 100 (замер): полный проход только по явному --full. Дефолтный режим --measure 100 (замер): полный проход только по явному --full.
""" """
@ -163,9 +153,7 @@ 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, Москва + отдельно Московская область # DomClick: JSON BFF listing API, Москва (эпик #2989, трек 1).
# (эпик #2989, трек 1). У DomClick, в отличие от Авито/Циана/Яндекса, Москва и
# область — РАЗНЫЕ прогоны с разными GUID (--region), а не один общий скоуп.
# #
# GUID Москвы проверен живым запросом 12.09: и region, и locality — один и тот # GUID Москвы проверен живым запросом 12.09: и region, и locality — один и тот
# же 1d1463ae-c80f-4d19-9331-a1b68a85b553 (для сравнения ЕКБ — # же 1d1463ae-c80f-4d19-9331-a1b68a85b553 (для сравнения ЕКБ —
@ -174,74 +162,28 @@ YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500
# листинга точно совпадает с сайтом. # листинга точно совпадает с сайтом.
MSK_DOMCLICK_GUID = "1d1463ae-c80f-4d19-9331-a1b68a85b553" MSK_DOMCLICK_GUID = "1d1463ae-c80f-4d19-9331-a1b68a85b553"
# GUID Московской области проверен живым запросом 12.09: тот же listing-эндпойнт DEFAULT_DOMCLICK_BASE_URL = (
# с этим address отдаёт result.pagination.total = 17572, aids тоже не нужен. "https://bff-search-web.domclick.ru/api/offers/v1"
MO_DOMCLICK_GUID = "9930cc20-32c6-4f6f-a55e-cd67086c5171" f"?address={MSK_DOMCLICK_GUID}&deal_type=sale&category=living&offer_type=flat"
"&sort=qi&sort_dir=desc&limit=20&offset=0"
)
def _domclick_base_url(guid: str) -> str:
"""Base_url DomClick BFF listing API под конкретный GUID региона.
Обе региональные константы (MSK/MO) обязаны строиться этой функцией, а не
двумя независимыми f-строками, иначе расхождение параметров (limit,
offset, sort) между Москвой и областью было бы легко внести незаметно.
"""
return (
"https://bff-search-web.domclick.ru/api/offers/v1"
f"?address={guid}&deal_type=sale&category=living&offer_type=flat"
"&sort=qi&sort_dir=desc&limit=20&offset=0"
)
DEFAULT_DOMCLICK_BASE_URL = _domclick_base_url(MSK_DOMCLICK_GUID)
MO_DOMCLICK_BASE_URL = _domclick_base_url(MO_DOMCLICK_GUID)
def _domclick_extract_guid(base_url: str) -> str | None:
"""GUID региона, зашитый в параметр address базового URL DomClick."""
return dict(parse_qsl(urlsplit(base_url).query)).get("address")
# Замерено живым запросом 12.09, не из документации: offset=1980 отдаёт полную # Замерено живым запросом 12.09, не из документации: offset=1980 отдаёт полную
# страницу (20 items), offset=2000 отвечает HTTP 400 # страницу (20 items), offset=2000 отвечает HTTP 400
# {"statusCode":400,"error":"Bad Request"}. Одинаково и для Москвы, и для # {"statusCode":400,"error":"Bad Request"}. Значит жёсткий потолок пагинации —
# области (проверено на обоих GUID) — значит жёсткий потолок пагинации общий:
# 100 страниц по 20 штук = 2000 офферов на один набор фильтров. # 100 страниц по 20 штук = 2000 офферов на один набор фильтров.
DOMCLICK_PAGE_SIZE = 20 DOMCLICK_PAGE_SIZE = 20
DOMCLICK_MAX_PAGES = 100 DOMCLICK_MAX_PAGES = 100
# bbox — единственный надёжный гео-гард для DomClick, отдельно для Москвы (с # bbox Москвы с ТиНАО — единственный надёжный гео-гард для DomClick.
# ТиНАО) и для Московской области. offerRegionName использовать НЕЛЬЗЯ: часть # offerRegionName использовать НЕЛЬЗЯ: часть офферов Новой Москвы приходит с
# офферов Новой Москвы приходит с именами вида "г. Говорово", а не "Москва" — # именами вида "г. Говорово", а не "Москва" — гард по имени региона молча
# гард по имени региона молча вырезал бы легитимные лоты. scraper._is_geo_ok() # вырезал бы легитимные лоты. scraper._is_geo_ok() кита сюда тоже не подходит:
# кита сюда тоже не подходит: он захардкожен на offerRegionName == # он захардкожен на offerRegionName == "Екатеринбург" и отбросил бы буквально
# "Екатеринбург" и отбросил бы буквально всю выдачу обоих регионов. # всю московскую выдачу.
_MSK_LAT_MIN, _MSK_LAT_MAX = 55.14, 56.02 _MSK_LAT_MIN, _MSK_LAT_MAX = 55.14, 56.02
_MSK_LON_MIN, _MSK_LON_MAX = 36.80, 37.97 _MSK_LON_MIN, _MSK_LON_MAX = 36.80, 37.97
# bbox Московской области — литерал, РАВНЫЙ bbox_region региона 50 в реестре
# tradein-mvp/backend/app/services/regions.py (сослались на реестр, значение
# впечатано сюда буквально: этот скрипт лежит вне пакета backend и не может
# его импортировать). Московский bbox область не накрывает: Серпухов
# (lat~54.9), Коломна (lon~39.4), Дубна (lat~56.7) все лежат вне _MSK_*.
_MO_LAT_MIN, _MO_LAT_MAX = 54.20, 56.96
_MO_LON_MIN, _MO_LON_MAX = 35.14, 40.21
_MSK_BBOX: tuple[float, float, float, float] = (
_MSK_LAT_MIN, _MSK_LAT_MAX, _MSK_LON_MIN, _MSK_LON_MAX,
)
_MO_BBOX: tuple[float, float, float, float] = (
_MO_LAT_MIN, _MO_LAT_MAX, _MO_LON_MIN, _MO_LON_MAX,
)
# Активный гео-гард, который реально читает _domclick_parse_cards. Модульная
# переменная, а НЕ аргумент функции: сигнатура parse_cards зафиксирована типом
# PlatformAdapter.parse_cards (Callable[[Any, str], list[Any]]) и завязана на
# него все остальные платформы, менять её ради одного DomClick нельзя. collect()
# выставляет это значение один раз при старте прогона, по --region, до первого
# запроса к сети.
_DOMCLICK_BBOX: tuple[float, float, float, float] = _MSK_BBOX
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
@ -770,8 +712,8 @@ def _domclick_parse_cards(scraper: Any, html: str) -> list[Any]:
lon = float(loc["lon"]) lon = float(loc["lon"])
except (KeyError, TypeError, ValueError): except (KeyError, TypeError, ValueError):
continue continue
lat_min, lat_max, lon_min, lon_max = _DOMCLICK_BBOX if not (_MSK_LAT_MIN <= lat <= _MSK_LAT_MAX
if not (lat_min <= lat <= lat_max and lon_min <= lon <= lon_max): and _MSK_LON_MIN <= lon <= _MSK_LON_MAX):
continue continue
lot = scraper._map_item(item) lot = scraper._map_item(item)
if lot is not None: if lot is not None:
@ -1469,31 +1411,6 @@ async def collect(args: argparse.Namespace) -> int:
) )
base_url = plan.base_url if plan is not None else args.base_url base_url = plan.base_url if plan is not None else args.base_url
# Гвард региона DomClick — САМОЕ ВАЖНОЕ в этом прогоне. --base-url можно
# передать вручную с любым GUID, а base_url резюме приходит из сохранённого
# плана в обход --region вовсе; расхождение GUID и --region залило бы
# Москву под областным batch_id (или наоборот) и отравило бы корпус.
# Проверка стоит здесь намеренно: этой точки достигают все три источника
# base_url — дефолтный (выставлен в parse_args по --region), переданный
# вручную через --base-url и восстановленный из плана при --resume — уже
# ПОСЛЕ сверки plan.base_url != args.base_url чуть выше, так что при
# --resume расхождение платформы/URL с CLI отловится раньше и с более
# точным сообщением.
if args.platform == "domclick":
expected_guid = MO_DOMCLICK_GUID if args.region == 50 else MSK_DOMCLICK_GUID
actual_guid = _domclick_extract_guid(base_url)
if actual_guid != expected_guid:
raise SystemExit(
f"--region {args.region} ожидает GUID {expected_guid!r} в base_url"
f" (параметр address), а фактический base_url несёт GUID"
f" {actual_guid!r}. Поправь --region либо --base-url — иначе"
f" прогон зальёт чужой регион под batch_id={args.batch_id}."
)
global _DOMCLICK_BBOX
_DOMCLICK_BBOX = _MO_BBOX if args.region == 50 else _MSK_BBOX
print(f"DomClick: --region={args.region}, guid={expected_guid}, "
f"bbox={_DOMCLICK_BBOX}", flush=True)
page_budget = None if args.full else args.measure page_budget = None if args.full else args.measure
mode = "FULL" if args.full else f"MEASURE<={page_budget}" mode = "FULL" if args.full else f"MEASURE<={page_budget}"
print(f"Режим: {mode}; platform={args.platform}; batch_id={args.batch_id}; " print(f"Режим: {mode}; platform={args.platform}; batch_id={args.batch_id}; "
@ -1606,11 +1523,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
) )
p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito", p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito",
help="площадка сбора (дефолт avito)") help="площадка сбора (дефолт avito)")
p.add_argument("--region", type=int, choices=(77, 50), default=77,
help="регион сбора: 77 Москва (дефолт), 50 Московская область"
" (поддерживается только --platform domclick)")
p.add_argument("--base-url", default=None, p.add_argument("--base-url", default=None,
help="базовый URL выдачи (дефолт зависит от --platform/--region)") help="базовый URL выдачи (дефолт зависит от --platform)")
p.add_argument("--measure", type=int, default=100, metavar="N", p.add_argument("--measure", type=int, default=100, metavar="N",
help="режим замера: не больше N загрузок страниц (дефолт 100)") help="режим замера: не больше N загрузок страниц (дефолт 100)")
p.add_argument("--full", action="store_true", p.add_argument("--full", action="store_true",
@ -1637,42 +1551,16 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p.add_argument("--db-user", default="tradein") p.add_argument("--db-user", default="tradein")
p.add_argument("--db-name", default="tradein") p.add_argument("--db-name", default="tradein")
args = p.parse_args(argv) args = p.parse_args(argv)
if args.region == 50 and args.platform != "domclick":
p.error(
"--region 50 поддерживается только для --platform domclick: корпус"
" Авито/Циана/Яндекса по Москве и области собирается ОДНИМ прогоном"
" (moskva_i_mo / region=-1 / rgid Москва+МО) и делится на регионы уже"
" при импорте — отдельного областного прогона у этих площадок нет."
)
if args.base_url is None: if args.base_url is None:
if args.platform == "domclick" and args.region == 50: args.base_url = ADAPTERS[args.platform].default_base_url
args.base_url = MO_DOMCLICK_BASE_URL
else:
args.base_url = ADAPTERS[args.platform].default_base_url
if args.target_count is None: if args.target_count is None:
args.target_count = ADAPTERS[args.platform].default_target args.target_count = ADAPTERS[args.platform].default_target
if args.batch_id is None: if args.batch_id is None:
prefix = ( args.batch_id = (
"mo-serp-domclick" if (args.platform == "domclick" and args.region == 50) f"msk-serp-{args.platform}-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
else f"msk-serp-{args.platform}"
) )
args.batch_id = prefix + "-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
if not _BATCH_ID_RE.match(args.batch_id): if not _BATCH_ID_RE.match(args.batch_id):
p.error("--batch-id: допустимы только символы [A-Za-z0-9._-]") p.error("--batch-id: допустимы только символы [A-Za-z0-9._-]")
# batch_id — ЕДИНСТВЕННАЯ метка региона в msk_raw.domclick_cards: колонки
# региона там нет, партии потом разделяются по префиксу имени батча. Имя,
# заданное руками вразрез с --region, сделало бы областную партию
# неотличимой от московской уже после заливки, когда чинить поздно. Поэтому
# префикс сверяется с регионом так же строго, как GUID в base_url.
if args.platform == "domclick":
want = "mo-serp-domclick" if args.region == 50 else "msk-serp-domclick"
other = "msk-serp-domclick" if args.region == 50 else "mo-serp-domclick"
if args.batch_id.startswith(other) or not args.batch_id.startswith(want):
p.error(
f"--batch-id должен начинаться с {want!r} при --region"
f" {args.region}: имя батча — единственный признак региона в"
f" msk_raw.domclick_cards, колонки региона там нет."
)
if args.measure < 1: if args.measure < 1:
p.error("--measure должен быть >= 1") p.error("--measure должен быть >= 1")
return args return args