Compare commits
No commits in common. "main" and "fix/3469-showcase-schedule" have entirely different histories.
main
...
fix/3469-s
21 changed files with 141 additions and 1794 deletions
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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) ──────────────────
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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), (
|
|
||||||
"маска поиска не видит дословно ту строку, ради которой заведён гейт"
|
|
||||||
)
|
|
||||||
|
|
@ -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 "✓ конфиг прокси перезагружен без пересоздания контейнера — окна недоступности нет."
|
|
||||||
|
|
@ -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 доходит до
|
||||||
|
|
|
||||||
|
|
@ -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)). Флаг на УРОВНЕ ДВИЖКА кроет все
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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 (Химки, Балашиха — не
|
||||||
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
|
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 протёк за пределы своей транзакции, коннект вернулся в пул без потолка"
|
|
||||||
)
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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` 7–10 с), `--batch-size` (карточек в одной заливке, дефолт 1000),
|
`request_delay_sec` 7–10 с), `--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`) общий для обеих платформ —
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue