Compare commits
No commits in common. "main" and "fix/3463-db-statement-timeout" have entirely different histories.
main
...
fix/3463-d
54 changed files with 298 additions and 4172 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), (
|
|
||||||
"маска поиска не видит дословно ту строку, ради которой заведён гейт"
|
|
||||||
)
|
|
||||||
|
|
@ -39,7 +39,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -73,16 +72,6 @@ GIT_ENV = {
|
||||||
"GIT_CONFIG_SYSTEM": "/dev/null",
|
"GIT_CONFIG_SYSTEM": "/dev/null",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Эти кейсы гоняют `bash -c <скрипт>` во временном git-репозитории (POSIX shell +
|
|
||||||
# /dev/null + жёсткий PATH=/usr/bin:/bin:/usr/local/bin). На Windows дочерний
|
|
||||||
# процесс не стартует (нет POSIX shell на этом PATH); покрытие обеспечивается в
|
|
||||||
# CI на Linux. Кейсы, которые читают только YAML/строки без subprocess, не
|
|
||||||
# помечены и обязаны выполняться локально.
|
|
||||||
posix_only = pytest.mark.skipif(
|
|
||||||
sys.platform == "win32",
|
|
||||||
reason="запускает bash-скрипт дочерним процессом; POSIX-only, покрытие в CI (Linux)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_script() -> str:
|
def _detect_script() -> str:
|
||||||
"""Тело шага, который считает изменённые файлы в job `changes`."""
|
"""Тело шага, который считает изменённые файлы в job `changes`."""
|
||||||
|
|
@ -192,7 +181,6 @@ def _run(
|
||||||
return _exec(repo, base_sha if before is None else before, event)
|
return _exec(repo, base_sha if before is None else before, event)
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
|
def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
|
||||||
"""Случай #3448 дословно: мерж, в диффе один файл под caddy/."""
|
"""Случай #3448 дословно: мерж, в диффе один файл под caddy/."""
|
||||||
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",))
|
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",))
|
||||||
|
|
@ -205,7 +193,6 @@ def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
|
||||||
assert outputs["frontend"] == "false"
|
assert outputs["frontend"] == "false"
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
|
def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
|
||||||
"""Обратное направление: быстрый путь НЕ должен красть обычный деплой."""
|
"""Обратное направление: быстрый путь НЕ должен красть обычный деплой."""
|
||||||
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy", "backend/app/main.py"))
|
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy", "backend/app/main.py"))
|
||||||
|
|
@ -216,7 +203,6 @@ def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
|
||||||
assert outputs["backend"] == "true"
|
assert outputs["backend"] == "true"
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
|
def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
|
||||||
"""База не разрешилась → полный деплой, а не пустой список.
|
"""База не разрешилась → полный деплой, а не пустой список.
|
||||||
|
|
||||||
|
|
@ -237,7 +223,6 @@ def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
|
||||||
assert "деплой полный" in log
|
assert "деплой полный" in log
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_decision_is_visible_in_the_log(tmp_path: Path) -> None:
|
def test_decision_is_visible_in_the_log(tmp_path: Path) -> None:
|
||||||
"""Решение печатается: и список файлов, и итоговые флаги.
|
"""Решение печатается: и список файлов, и итоговые флаги.
|
||||||
|
|
||||||
|
|
@ -306,13 +291,11 @@ def _run_guard(repo: Path) -> subprocess.CompletedProcess:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_fast_path_allowed_when_prod_lags_only_by_proxy_config(tmp_path: Path) -> None:
|
def test_fast_path_allowed_when_prod_lags_only_by_proxy_config(tmp_path: Path) -> None:
|
||||||
proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",)))
|
proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",)))
|
||||||
assert proc.returncode == 0, f"законный быстрый путь заблокирован:\n{proc.stdout}{proc.stderr}"
|
assert proc.returncode == 0, f"законный быстрый путь заблокирован:\n{proc.stdout}{proc.stderr}"
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_fast_path_refuses_when_prod_lags_by_code(tmp_path: Path) -> None:
|
def test_fast_path_refuses_when_prod_lags_by_code(tmp_path: Path) -> None:
|
||||||
"""Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена."""
|
"""Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена."""
|
||||||
proc = _run_guard(_prod_repo(tmp_path, ("backend/app/main.py", "caddy/sites/apps.caddy")))
|
proc = _run_guard(_prod_repo(tmp_path, ("backend/app/main.py", "caddy/sites/apps.caddy")))
|
||||||
|
|
@ -339,7 +322,6 @@ def test_fast_path_takes_the_same_host_lock() -> None:
|
||||||
assert "flock -w 900 9" in script, "лок открывается, но не захватывается"
|
assert "flock -w 900 9" in script, "лок открывается, но не захватывается"
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"changed", [("caddy-extra/x.txt",), ("Caddyfile.bak",), ("docs/caddy.md",)]
|
"changed", [("caddy-extra/x.txt",), ("Caddyfile.bak",), ("docs/caddy.md",)]
|
||||||
)
|
)
|
||||||
|
|
@ -356,7 +338,6 @@ def test_paths_that_merely_start_with_caddy_are_not_the_fast_path(
|
||||||
assert outputs["caddy_only"] == "false", f"{changed}: {outputs}"
|
assert outputs["caddy_only"] == "false", f"{changed}: {outputs}"
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
|
def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
|
||||||
"""Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy».
|
"""Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy».
|
||||||
|
|
||||||
|
|
@ -369,7 +350,6 @@ def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
|
||||||
assert "изменённых файлов: 0" in log
|
assert "изменённых файлов: 0" in log
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
|
def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
|
||||||
"""`data/sql/**` собирает backend-образ: миграции едут в нём."""
|
"""`data/sql/**` собирает backend-образ: миграции едут в нём."""
|
||||||
outputs, _ = _run(tmp_path, ("data/sql/002.sql",))
|
outputs, _ = _run(tmp_path, ("data/sql/002.sql",))
|
||||||
|
|
@ -377,7 +357,6 @@ def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
|
||||||
assert outputs["caddy_only"] == "false", outputs
|
assert outputs["caddy_only"] == "false", outputs
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
|
def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
|
||||||
"""Кириллица в пути не должна прятать файл от классификации.
|
"""Кириллица в пути не должна прятать файл от классификации.
|
||||||
|
|
||||||
|
|
@ -391,7 +370,6 @@ def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
|
||||||
assert outputs["backend"] == "true", f"кириллический путь потерян: {outputs}\n{log}"
|
assert outputs["backend"] == "true", f"кириллический путь потерян: {outputs}\n{log}"
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None:
|
def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None:
|
||||||
"""Push из нескольких коммитов разбирается целиком, а не по последнему.
|
"""Push из нескольких коммитов разбирается целиком, а не по последнему.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,29 +176,3 @@ tests/sql/test_2998_rosreestr_partition_horizon.py::test_schema_01_alone_is_red_
|
||||||
tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap
|
tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap
|
||||||
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter
|
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter
|
||||||
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead
|
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead
|
||||||
|
|
||||||
# ── POSIX-only: тесты запускают дочерний bash-скрипт ───────────────────────────
|
|
||||||
# На Windows нет POSIX shell на PATH, которым эти тесты исполняют /bin/bash -c
|
|
||||||
# <скрипт> или сам shell-скрипт напрямую (шебанг #!/usr/bin/env bash) — процесс
|
|
||||||
# не стартует. В CI ЭТИ ТЕСТЫ ИДУТ (ubuntu-раннер). Записи нужны только для
|
|
||||||
# локального прогона на Windows; чисто-логические проверки в тех же файлах
|
|
||||||
# (читают YAML/строки без subprocess) в этот список НЕ входят и обязаны
|
|
||||||
# исполняться локально.
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_merge_with_only_caddy_file_takes_the_fast_path
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_caddy_plus_backend_is_a_full_deploy
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_missing_base_falls_back_to_full_deploy
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_decision_is_visible_in_the_log
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_allowed_when_prod_lags_only_by_proxy_config
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_refuses_when_prod_lags_by_code
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_paths_that_merely_start_with_caddy_are_not_the_fast_path
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_empty_diff_is_not_the_fast_path
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_data_sql_counts_as_backend
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_non_ascii_path_is_classified
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_multi_commit_push_is_not_truncated
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_fresh_latest_passes
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_newer_revision_passes
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_stale_latest_fails_after_timeout
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_revision_from_other_component_does_not_cover
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_missing_label_fails
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_registry_error_fails
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_usage_errors_are_distinct
|
|
||||||
|
|
|
||||||
|
|
@ -15,20 +15,10 @@ from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# Каждый тест в файле запускает scripts/check-latest-image-revision.sh (POSIX shell,
|
|
||||||
# shebang #!/usr/bin/env bash) дочерним процессом. На Windows нет POSIX shell на PATH,
|
|
||||||
# чтобы это исполнить, — покрытие обеспечивается в CI на Linux.
|
|
||||||
pytestmark = pytest.mark.skipif(
|
|
||||||
sys.platform == "win32",
|
|
||||||
reason="запускает bash-скрипт (check-latest-image-revision.sh) дочерним процессом; "
|
|
||||||
"POSIX-only, покрытие в CI (Linux)",
|
|
||||||
)
|
|
||||||
|
|
||||||
КОРЕНЬ = Path(__file__).resolve().parents[2]
|
КОРЕНЬ = Path(__file__).resolve().parents[2]
|
||||||
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"
|
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,13 +184,10 @@ gendsgn.ru {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Static HTML mockups для review (audit alternatives).
|
# Static HTML mockups для review (audit alternatives).
|
||||||
# Public access — без auth (по запросу 2026-05-17) — это намеренно и
|
# Public access — без auth (по запросу 2026-05-17).
|
||||||
# менять НЕ надо. Но каталог не должен листингом отдавать список
|
|
||||||
# файлов случайному посетителю: без `browse` file_server продолжает
|
|
||||||
# отдавать сами файлы по прямой ссылке, просто без index-листинга.
|
|
||||||
handle_path /preview/* {
|
handle_path /preview/* {
|
||||||
root * /srv/preview
|
root * /srv/preview
|
||||||
file_server
|
file_server browse
|
||||||
}
|
}
|
||||||
|
|
||||||
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
|
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
|
||||||
|
|
@ -403,36 +400,6 @@ www.gendsgn.ru {
|
||||||
meraocenka.ru {
|
meraocenka.ru {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# #3471 (security-headers follow-up): публичный B2C-домен без базовых
|
|
||||||
# security-заголовков. Позиция директивы в файле не важна — Caddyfile
|
|
||||||
# применяет директивы в фиксированном порядке (`header` идёт раньше
|
|
||||||
# `reverse_proxy`), не по строке в исходнике.
|
|
||||||
#
|
|
||||||
# Проверено: ни tradein-mvp/frontend (обслуживает этот домен, отдаёт
|
|
||||||
# `/trade-in/mera-public*`), ни его next.config.ts/middleware не
|
|
||||||
# выставляют ни один из этих заголовков — дублирования нет.
|
|
||||||
header {
|
|
||||||
# 180 суток (15552000s). НЕ ставить includeSubDomains и НЕ ставить
|
|
||||||
# preload — оба флага практически необратимы (браузеры кэшируют
|
|
||||||
# preload-список надолго, includeSubDomains ломает любой будущий
|
|
||||||
# HTTP-поддомен), а не все поддомены meraocenka.ru гарантированно
|
|
||||||
# под нашим контролем. Если это когда-нибудь понадобится — отдельное
|
|
||||||
# осознанное решение, не «улучшение по умолчанию».
|
|
||||||
Strict-Transport-Security "max-age=15552000"
|
|
||||||
# Запрет встраивания во фрейм: X-Frame-Options — для старых
|
|
||||||
# клиентов, которые не понимают CSP; frame-ancestors — современный
|
|
||||||
# механизм. Оба вместе, не один вместо другого.
|
|
||||||
X-Frame-Options "DENY"
|
|
||||||
Content-Security-Policy "frame-ancestors 'none'"
|
|
||||||
X-Content-Type-Options "nosniff"
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
|
||||||
# Не даём стеку светиться в ответе. Полноценный CSP
|
|
||||||
# (default-src/script-src/...) сюда намеренно НЕ входит — отдельная
|
|
||||||
# работа с риском сломать фронт, здесь только периметр вокруг фрейма.
|
|
||||||
-Server
|
|
||||||
-X-Powered-By
|
|
||||||
}
|
|
||||||
|
|
||||||
log {
|
log {
|
||||||
output file /var/log/caddy/meraocenka.ru.log
|
output file /var/log/caddy/meraocenka.ru.log
|
||||||
}
|
}
|
||||||
|
|
@ -518,7 +485,7 @@ meraocenka.ru {
|
||||||
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
|
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
|
||||||
# robots.txt существует только в корне app/, не под mera-public/), эта
|
# robots.txt существует только в корне app/, не под mera-public/), эта
|
||||||
# форма под `{path}` не попадает — отсюда отдельный handle у него.
|
# форма под `{path}` не попадает — отсюда отдельный handle у него.
|
||||||
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /articles/skolko-stoit-metr-v-ekaterinburge /articles/pochemu-kvartira-ne-prodaetsya /docs /business /sitemap.xml
|
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business /sitemap.xml
|
||||||
handle @meraPages {
|
handle @meraPages {
|
||||||
rewrite * /trade-in/mera-public{path}
|
rewrite * /trade-in/mera-public{path}
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
|
|
@ -549,7 +516,7 @@ meraocenka.ru {
|
||||||
# переносит query как есть. Обёртка `route` обязательна по той же
|
# переносит query как есть. Обёртка `route` обязательна по той же
|
||||||
# причине, что у соседей — внутри `handle` порядок директив определяет
|
# причине, что у соседей — внутри `handle` порядок директив определяет
|
||||||
# Caddy, и без неё `redir` выполнился бы раньше `uri`.
|
# Caddy, и без неё `redir` выполнился бы раньше `uri`.
|
||||||
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|articles/skolko-stoit-metr-v-ekaterinburge|articles/pochemu-kvartira-ne-prodaetsya|docs|business)/$
|
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
|
||||||
handle @meraShortSlash {
|
handle @meraShortSlash {
|
||||||
route {
|
route {
|
||||||
uri strip_suffix /
|
uri strip_suffix /
|
||||||
|
|
@ -637,7 +604,7 @@ meraocenka.ru {
|
||||||
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
||||||
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
|
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
|
||||||
# (бесконечный цикл; поймано на локальном стенде).
|
# (бесконечный цикл; поймано на локальном стенде).
|
||||||
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge/ /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
|
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
|
||||||
handle @meraLongPages {
|
handle @meraLongPages {
|
||||||
route {
|
route {
|
||||||
uri strip_prefix /trade-in/mera-public
|
uri strip_prefix /trade-in/mera-public
|
||||||
|
|
|
||||||
|
|
@ -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 "✓ конфиг прокси перезагружен без пересоздания контейнера — окна недоступности нет."
|
|
||||||
|
|
@ -41,84 +41,17 @@ def scrub_query_secrets(text: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
class QuerySecretFilter(logging.Filter):
|
class QuerySecretFilter(logging.Filter):
|
||||||
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан.
|
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан."""
|
||||||
|
|
||||||
Скрабит `record.msg` и КАЖДЫЙ элемент `record.args` по отдельности — НЕ схлопывает
|
|
||||||
их в единую строку через `record.getMessage()` с последующим `args = ()`.
|
|
||||||
|
|
||||||
Прод-баг (#3471): схлопывание ломало `uvicorn.access` — там `record.args` это
|
|
||||||
структурный 5-tuple `(client_addr, method, full_path, http_version, status_code)`,
|
|
||||||
который `uvicorn.logging.AccessFormatter.formatMessage()` распаковывает напрямую
|
|
||||||
(`a, b, c, d, e = record.args`), в обход `record.getMessage()`. Как только фильтр
|
|
||||||
находил секрет (например `?secret=` в webhook-пути) и обнулял `args`, форматтер падал
|
|
||||||
с `ValueError: not enough values to unpack (expected 5, got 0)` — сама попытка
|
|
||||||
заскрабить секрет ломала запись лога целиком (`--- Logging error ---` в докер-логах).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
args = record.args
|
message = record.getMessage()
|
||||||
if isinstance(args, tuple) and args:
|
scrubbed = scrub_query_secrets(message)
|
||||||
# Есть позиционные args — скрабим КАЖДЫЙ элемент отдельно, arity не трогаем.
|
if scrubbed != message:
|
||||||
# `record.msg` (шаблон вида `"%s ..."`) не трогаем вовсе: в реальных вызовах
|
record.msg = scrubbed
|
||||||
# этого кодбейза секрет+query-контекст лежат САМОДОСТАТОЧНО внутри одного
|
record.args = ()
|
||||||
# аргумента (например body_preview в app/services/dadata.py), а не расщеплены
|
|
||||||
# между текстом шаблона и голым значением — трогать msg тут не нужно и опасно
|
|
||||||
# (шаблонный `%s` сам по себе мог бы ложно совпасть с чувствительным именем
|
|
||||||
# параметра прямо перед ним).
|
|
||||||
scrubbed_args = tuple(
|
|
||||||
scrub_query_secrets(a) if isinstance(a, str) else a for a in args
|
|
||||||
)
|
|
||||||
if scrubbed_args != args:
|
|
||||||
record.args = scrubbed_args
|
|
||||||
elif isinstance(record.msg, str):
|
|
||||||
# Args нет — вся запись уже готовым текстом в msg (f-string и т.п.).
|
|
||||||
scrubbed_msg = scrub_query_secrets(record.msg)
|
|
||||||
if scrubbed_msg != record.msg:
|
|
||||||
record.msg = scrubbed_msg
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# ── Секреты в произвольном тексте (тело ответа внешнего API) — #3471 ──────────
|
|
||||||
#
|
|
||||||
# DaData на HTTP 403 возвращает диагностику вида:
|
|
||||||
# "Feature 'CLEAN' disabled for token '<действующий 40-символьный токен>'. See ..."
|
|
||||||
# body_preview из этого текста уходит в WARNING/ERROR лог (app/services/dadata.py) →
|
|
||||||
# docker logs → потенциально breadcrumb к любой последующей ошибке в GlitchTip.
|
|
||||||
# Маскируем ДО логирования. Не завязываемся на конкретную формулировку вендора —
|
|
||||||
# она может измениться (см. generic-слой ниже).
|
|
||||||
|
|
||||||
_TOKEN_QUOTED = re.compile(r"(\btoken\s*['\"])([^'\"]+)(['\"])", re.IGNORECASE)
|
|
||||||
# Любая hex/base64-подобная последовательность 24+ символов — ловит секрет независимо
|
|
||||||
# от контекста (Authorization/X-Secret значения, если когда-нибудь попадут в текст как есть).
|
|
||||||
_LONG_SECRET_LIKE = re.compile(r"[A-Za-z0-9+/_-]{24,}")
|
|
||||||
|
|
||||||
|
|
||||||
def _mask_value(value: str) -> str:
|
|
||||||
"""`8d4e…(40)` — первые 4 символа + длина в скобках, остальное скрыто."""
|
|
||||||
if len(value) <= 4:
|
|
||||||
return "***"
|
|
||||||
return f"{value[:4]}…({len(value)})"
|
|
||||||
|
|
||||||
|
|
||||||
def scrub_body_secrets(text: str | None) -> str:
|
|
||||||
"""Маскирует токены/секреты в произвольном тексте (тело ответа внешнего API и т.п.).
|
|
||||||
|
|
||||||
Двухслойно: 1) явный ``token '<value>'`` (текущая формулировка DaData на 403),
|
|
||||||
2) generic — любая hex/base64-подобная последовательность 24+ символов, чтобы защита
|
|
||||||
не зависела от того, как вендор сформулирует сообщение завтра.
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return text or ""
|
|
||||||
|
|
||||||
def _replace_quoted(m: re.Match[str]) -> str:
|
|
||||||
return f"{m.group(1)}{_mask_value(m.group(2))}{m.group(3)}"
|
|
||||||
|
|
||||||
scrubbed = _TOKEN_QUOTED.sub(_replace_quoted, text)
|
|
||||||
scrubbed = _LONG_SECRET_LIKE.sub(lambda m: _mask_value(m.group(0)), scrubbed)
|
|
||||||
return scrubbed
|
|
||||||
|
|
||||||
|
|
||||||
def install_query_secret_filter(*logger_names: str) -> None:
|
def install_query_secret_filter(*logger_names: str) -> None:
|
||||||
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.
|
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ from typing import Any
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.log_scrub import scrub_body_secrets
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -174,7 +173,7 @@ async def clean_address(address: str) -> DadataAddressResult | None:
|
||||||
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
|
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
|
||||||
return None
|
return None
|
||||||
if status in (401, 403):
|
if status in (401, 403):
|
||||||
body_preview = scrub_body_secrets(response.text)[:200]
|
body_preview = (response.text or "")[:200]
|
||||||
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
|
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
|
||||||
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
|
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
|
||||||
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
|
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
|
||||||
|
|
@ -214,7 +213,7 @@ async def clean_address(address: str) -> DadataAddressResult | None:
|
||||||
logger.warning("dadata: HTTP %d — transient server error", status)
|
logger.warning("dadata: HTTP %d — transient server error", status)
|
||||||
return None
|
return None
|
||||||
if status >= 400:
|
if status >= 400:
|
||||||
body_preview = scrub_body_secrets(response.text)[:200]
|
body_preview = (response.text or "")[:200]
|
||||||
logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview)
|
logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -437,7 +436,7 @@ async def suggest_addresses(
|
||||||
logger.warning("dadata suggest: HTTP %d — transient server error", status)
|
logger.warning("dadata suggest: HTTP %d — transient server error", status)
|
||||||
return []
|
return []
|
||||||
if status >= 400:
|
if status >= 400:
|
||||||
body_preview = scrub_body_secrets(response.text)[:200]
|
body_preview = (response.text or "")[:200]
|
||||||
logger.warning("dadata suggest: HTTP %d — bad request: %r", status, body_preview)
|
logger.warning("dadata suggest: HTTP %d — bad request: %r", status, body_preview)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
@ -668,10 +661,8 @@ def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | No
|
||||||
# within-price-tier sold/asking НЕВЫЧИСЛИМ из asking-less ДКП-сделок; корректное
|
# within-price-tier sold/asking НЕВЫЧИСЛИМ из asking-less ДКП-сделок; корректное
|
||||||
# обусловливание дают per-rooms blend + захардженный hedonic (год+площадь).
|
# обусловливание дают per-rooms blend + захардженный hedonic (год+площадь).
|
||||||
_ASKING_SOLD_RATIO_CACHE_TTL_S = 300.0
|
_ASKING_SOLD_RATIO_CACHE_TTL_S = 300.0
|
||||||
# Cache key: (region_code, rooms bucket) — #3512. Регион — часть ключа с миграции 304
|
# Cache key: rooms bucket (единственный legacy per-rooms путь после #2002).
|
||||||
# (asking_to_sold_ratios ключуется по (region_code, rooms_bucket, district)); один
|
_asking_sold_ratio_cache: dict[int, tuple[float | None, str | None, float]] = {}
|
||||||
# бакет теперь несёт РАЗНЫЕ ratio для разных регионов, общий int-ключ смешал бы их.
|
|
||||||
_asking_sold_ratio_cache: dict[tuple[int, int], tuple[float | None, str | None, float]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_asking_sold_ratio(
|
def _get_asking_sold_ratio(
|
||||||
|
|
@ -679,7 +670,6 @@ def _get_asking_sold_ratio(
|
||||||
rooms: int | None,
|
rooms: int | None,
|
||||||
area_m2: float | None = None,
|
area_m2: float | None = None,
|
||||||
anchor_ppm2: float | None = None,
|
anchor_ppm2: float | None = None,
|
||||||
region_code: int = regions_mod.DEFAULT_REGION_CODE,
|
|
||||||
) -> tuple[float | None, str | None]:
|
) -> tuple[float | None, str | None]:
|
||||||
"""Возвращает (ratio, basis) asking→sold для area-бакета клиентской квартиры.
|
"""Возвращает (ratio, basis) asking→sold для area-бакета клиентской квартиры.
|
||||||
|
|
||||||
|
|
@ -695,24 +685,9 @@ def _get_asking_sold_ratio(
|
||||||
rooms-ключе vs area-ключе. area_bucket() — тот же Python-двойник, что и в
|
rooms-ключе vs area-ключе. area_bucket() — тот же Python-двойник, что и в
|
||||||
asking_to_sold_ratio.py (см. комментарий там, границы 30/44/62/85 = import-rosreestr.sh).
|
asking_to_sold_ratio.py (см. комментарий там, границы 30/44/62/85 = import-rosreestr.sh).
|
||||||
|
|
||||||
#3512: region_code — регион ЗАПРОСА (target_region_code резолвера, см.
|
Запрос к asking_to_sold_ratios (migration 080): per-rooms строка
|
||||||
_ratio_resolver ниже), НЕ дефолт 66 всегда. Раньше ratio считался ИСКЛЮЧИТЕЛЬНО
|
(WHERE rooms_bucket = bucket AND district = '') → fallback на global -1
|
||||||
по ЕКБ и применялся к любому адресу, включая Москву (region_code=77) — коэффициент
|
(basis='global_fallback'). Ничего нет → (None, None).
|
||||||
рынка на порядок дешевле (медиана ДКП: 66 — 90 278 ₽/м², 77 — 322 976 ₽/м²,
|
|
||||||
замер 13.09.2026) домножал московскую asking-цену. Дефолт-параметр (66) сохранён
|
|
||||||
ради обратной совместимости позиционных вызовов (тот же идиом, что
|
|
||||||
`_is_plausible_deal(region_code: int = regions_mod.DEFAULT_REGION_CODE)`).
|
|
||||||
|
|
||||||
Запрос к asking_to_sold_ratios (migration 080 + 304): per-rooms строка СВОЕГО
|
|
||||||
региона (WHERE region_code = :region_code AND rooms_bucket = bucket AND
|
|
||||||
district = '') → fallback на global -1 ТОГО ЖЕ региона (basis='global_fallback').
|
|
||||||
Ни одной строки для региона (регион ещё не накопил 30/30 сделок/listings, см.
|
|
||||||
app/tasks/asking_to_sold_ratio.py) → явная деградация (None, None) + INFO-лог —
|
|
||||||
та же идиома, что sber_region_series_name делает для отсутствующего в карте
|
|
||||||
региона, но БЕЗ подстановки чужого регионального ratio: значение другого рынка
|
|
||||||
здесь — не нейтральный фолбэк (в отличие от общероссийского sber-ряда), а именно
|
|
||||||
та ошибка калибровки, которую #3512 устраняет. ratio_basis в ответе честно
|
|
||||||
остаётся None — сайту не приписывается ЧУЖОЙ региональный коэффициент.
|
|
||||||
|
|
||||||
anchor_ppm2 сохранён в сигнатуре для совместимости с call-site (резолвер
|
anchor_ppm2 сохранён в сигнатуре для совместимости с call-site (резолвер
|
||||||
вызывается с ФИНАЛЬНЫМ headline ppm² после anchor/blend-мутаций — см.
|
вызывается с ФИНАЛЬНЫМ headline ppm² после anchor/blend-мутаций — см.
|
||||||
|
|
@ -720,12 +695,11 @@ def _get_asking_sold_ratio(
|
||||||
статистический артефакт (#2002, см. module-level комментарий выше).
|
статистический артефакт (#2002, см. module-level комментарий выше).
|
||||||
|
|
||||||
Таблицы нет / любая ошибка → (None, None), НЕ raise (graceful).
|
Таблицы нет / любая ошибка → (None, None), НЕ raise (graceful).
|
||||||
Кэшируется на ключ (region_code, bucket) с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S.
|
Кэшируется на ключ bucket с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S.
|
||||||
"""
|
"""
|
||||||
bucket = area_bucket(area_m2) if area_m2 else min(max(rooms or 0, 0), 4)
|
bucket = area_bucket(area_m2) if area_m2 else min(max(rooms or 0, 0), 4)
|
||||||
cache_key = (region_code, bucket)
|
|
||||||
|
|
||||||
cached = _asking_sold_ratio_cache.get(cache_key)
|
cached = _asking_sold_ratio_cache.get(bucket)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
ratio, basis, fetched = cached
|
ratio, basis, fetched = cached
|
||||||
if (time.monotonic() - fetched) < _ASKING_SOLD_RATIO_CACHE_TTL_S:
|
if (time.monotonic() - fetched) < _ASKING_SOLD_RATIO_CACHE_TTL_S:
|
||||||
|
|
@ -749,39 +723,24 @@ def _get_asking_sold_ratio(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT ratio, basis FROM asking_to_sold_ratios
|
SELECT ratio, basis FROM asking_to_sold_ratios
|
||||||
WHERE region_code = CAST(:region_code AS int)
|
WHERE rooms_bucket = CAST(:b AS int) AND district = ''
|
||||||
AND rooms_bucket = CAST(:b AS int) AND district = ''
|
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"region_code": region_code, "b": bucket},
|
{"b": bucket},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
# Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1)
|
# Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1).
|
||||||
# ТОГО ЖЕ региона (#3512 — никогда чужого региона).
|
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT ratio, basis FROM asking_to_sold_ratios
|
SELECT ratio, basis FROM asking_to_sold_ratios
|
||||||
WHERE region_code = CAST(:region_code AS int)
|
WHERE rooms_bucket = -1 AND district = ''
|
||||||
AND rooms_bucket = -1 AND district = ''
|
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"region_code": region_code},
|
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is not None and row.ratio is not None:
|
if row is not None and row.ratio is not None:
|
||||||
ratio = float(row.ratio)
|
ratio = float(row.ratio)
|
||||||
basis = row.basis
|
basis = row.basis
|
||||||
elif row is None:
|
|
||||||
# #3512: у региона нет НИ ОДНОЙ строки (ни per-rooms, ни global -1) —
|
|
||||||
# обычно потому, что регион ещё не набрал 30/30 сделок/listings
|
|
||||||
# (см. app/tasks/asking_to_sold_ratio.py). Явная деградация, не
|
|
||||||
# подмена чужим регионом.
|
|
||||||
logger.info(
|
|
||||||
"asking_to_sold_ratio: region_code=%d нет ни одной строки "
|
|
||||||
"(bucket=%d) — sold-коррекция не применяется для этого региона",
|
|
||||||
region_code,
|
|
||||||
bucket,
|
|
||||||
)
|
|
||||||
ok = True
|
ok = True
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
@ -810,7 +769,7 @@ def _get_asking_sold_ratio(
|
||||||
# Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет —
|
# Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет —
|
||||||
# стабильный факт БД, безопасно кэшировать на TTL); транзиентный None выше
|
# стабильный факт БД, безопасно кэшировать на TTL); транзиентный None выше
|
||||||
# уже вернулся ранним return и сюда не доходит.
|
# уже вернулся ранним return и сюда не доходит.
|
||||||
_asking_sold_ratio_cache[cache_key] = (ratio, basis, time.monotonic())
|
_asking_sold_ratio_cache[bucket] = (ratio, basis, time.monotonic())
|
||||||
return ratio, basis
|
return ratio, basis
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -5102,11 +5061,7 @@ async def estimate_quality(
|
||||||
appm2: float | None,
|
appm2: float | None,
|
||||||
) -> tuple[float | None, str | None]:
|
) -> tuple[float | None, str | None]:
|
||||||
# #2620-2: area-bucket key (payload.rooms — фолбэк только без площади).
|
# #2620-2: area-bucket key (payload.rooms — фолбэк только без площади).
|
||||||
# #3512: region_code — регион ЦЕЛИ (target_region_code, резолвлен по geo выше,
|
return _get_asking_sold_ratio(db, payload.rooms, payload.area_m2, anchor_ppm2=appm2)
|
||||||
# тот же код, что уже скоупит ДКП-коридор, #3051 PR-A) — НЕ дефолт 66 всегда.
|
|
||||||
return _get_asking_sold_ratio(
|
|
||||||
db, payload.rooms, payload.area_m2, anchor_ppm2=appm2, region_code=target_region_code
|
|
||||||
)
|
|
||||||
|
|
||||||
def _qi_lookup(q: str) -> tuple[float, int] | None:
|
def _qi_lookup(q: str) -> tuple[float, int] | None:
|
||||||
return _lookup_quarter_index(
|
return _lookup_quarter_index(
|
||||||
|
|
|
||||||
|
|
@ -142,8 +142,7 @@ class LocationIndexResult(BaseModel):
|
||||||
sample_size: int
|
sample_size: int
|
||||||
radius_m: int
|
radius_m: int
|
||||||
nearby_poi: list[NearbyPoi]
|
nearby_poi: list[NearbyPoi]
|
||||||
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена
|
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена)
|
||||||
# ДЛЯ ПРОДУКТ-ЯДРА РЕГИОНА точки — не глобальный count(*) по таблице)
|
|
||||||
|
|
||||||
|
|
||||||
def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
||||||
|
|
@ -208,27 +207,9 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
||||||
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
|
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
|
||||||
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
|
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
|
||||||
#
|
#
|
||||||
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО в продукт-ядре региона,
|
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
|
||||||
# даже если сам продукт уже скрейпит соседние города/области (city-sweep): географию
|
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
|
||||||
# location_index явно ограничил владелец продукта.
|
# явно ограничил владелец продукта.
|
||||||
#
|
|
||||||
# region_code (дефект Москвы, прод-замер 13.09.2026): bbox продукт-ядра — геометрический
|
|
||||||
# прямоугольник, а границы регионов НЕ прямоугольны — в московское ядро (55.55..55.95 /
|
|
||||||
# 37.30..37.90) попадают Химки/Реутов/Котельники/Люберцы (region_code=50). Внутри ядра 77
|
|
||||||
# лежало 16 801 лот региона 50 (медиана 251 295 ₽/м²) и 82 719 лотов региона 77 (медиана
|
|
||||||
# 458 208) — примесь заниженная городскую медиану до 422 222 (−7.9%). У областных строк
|
|
||||||
# `city IS NULL`, фильтровать по имени города нельзя — только по region_code.
|
|
||||||
#
|
|
||||||
# СТРОГОЕ равенство, БЕЗ `OR region_code IS NULL`: строка без региона не считается ни за
|
|
||||||
# один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля — is_active,
|
|
||||||
# price sanity, geo_precision, свежесть 14 дней, сегмент, bbox ядра 66): 15 858 строк
|
|
||||||
# проходят фильтры, из них 3 с NULL region_code; медиана с ними 151 101 ₽/м², без них
|
|
||||||
# 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), статистически ничто для 66. Взамен —
|
|
||||||
# `region_code IS NULL` НЕ постоянная лазейка того же класса, что и сам дефект: в
|
|
||||||
# `listings` 16 930 NULL-region строк Avito, 24 из них лежат в bbox Москвы и сегодня не
|
|
||||||
# проходят по свежести — но могут завтра. Мягкий `OR NULL` тихо открыл бы им дорогу
|
|
||||||
# обратно в московскую медиану ровно тем же путём, каким туда попадала Московская
|
|
||||||
# область. Три рубля на 66 — цена закрытия дыры, а не повод её оставить.
|
|
||||||
_MEDIAN_PPM2_LOCAL_SQL = text(
|
_MEDIAN_PPM2_LOCAL_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -242,7 +223,6 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
|
||||||
AND (geo_precision IS DISTINCT FROM 'city')
|
AND (geo_precision IS DISTINCT FROM 'city')
|
||||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||||
AND region_code = CAST(:region_code AS integer)
|
|
||||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||||
AND CAST(:bbox_north AS double precision)
|
AND CAST(:bbox_north AS double precision)
|
||||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||||
|
|
@ -268,7 +248,6 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
|
||||||
AND (geo_precision IS DISTINCT FROM 'city')
|
AND (geo_precision IS DISTINCT FROM 'city')
|
||||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||||
AND region_code = CAST(:region_code AS integer)
|
|
||||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||||
AND CAST(:bbox_north AS double precision)
|
AND CAST(:bbox_north AS double precision)
|
||||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||||
|
|
@ -276,17 +255,6 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
_POI_COUNT_IN_REGION_SQL = text(
|
|
||||||
"""
|
|
||||||
SELECT count(*) AS n
|
|
||||||
FROM osm_poi_ekb_local
|
|
||||||
WHERE lat BETWEEN CAST(:bbox_south AS double precision)
|
|
||||||
AND CAST(:bbox_north AS double precision)
|
|
||||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
|
||||||
AND CAST(:bbox_east AS double precision)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
_NEAREST_POI_SQL = text(
|
_NEAREST_POI_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -325,7 +293,6 @@ def _local_median_ppm2(
|
||||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||||
"region_code": region.code,
|
|
||||||
"bbox_south": lat_min,
|
"bbox_south": lat_min,
|
||||||
"bbox_north": lat_max,
|
"bbox_north": lat_max,
|
||||||
"bbox_west": lon_min,
|
"bbox_west": lon_min,
|
||||||
|
|
@ -350,7 +317,6 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
|
||||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||||
"region_code": region.code,
|
|
||||||
"bbox_south": lat_min,
|
"bbox_south": lat_min,
|
||||||
"bbox_north": lat_max,
|
"bbox_north": lat_max,
|
||||||
"bbox_west": lon_min,
|
"bbox_west": lon_min,
|
||||||
|
|
@ -367,38 +333,18 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
|
||||||
|
|
||||||
|
|
||||||
def _fetch_nearby_poi(
|
def _fetch_nearby_poi(
|
||||||
db: Any, lat: float, lon: float, radius_m: int, top_n: int, region: regions_mod.Region
|
db: Any, lat: float, lon: float, radius_m: int, top_n: int
|
||||||
) -> tuple[list[NearbyPoi], str]:
|
) -> tuple[list[NearbyPoi], str]:
|
||||||
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
|
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
|
||||||
|
|
||||||
Graceful fallback → ([], "unavailable") вместо 500 или сфабрикованного списка. Дефект
|
Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении)
|
||||||
(прод 13.09.2026): гейт раньше смотрел на count(*) ПО ВСЕЙ osm_poi_ekb_local (4 850
|
→ ([], "unavailable") вместо 500 или сфабрикованного списка.
|
||||||
строк, все екатеринбургские) — для Москвы это count > 0 (таблица не пуста), и статус
|
|
||||||
молча становился "ok" с пустым списком, хотя внутри московского продукт-ядра лежит 0
|
|
||||||
POI. Гейт обязан смотреть на наличие POI В РЕЛЕВАНТНОЙ области (bbox_product_core
|
|
||||||
региона точки), а не на размер таблицы целиком. Загрузка московских POI — отдельная
|
|
||||||
задача; здесь только честная деградация тем же статусом "unavailable" (терминология
|
|
||||||
не расширяется — trade_in.py и так трактует его как «нет данных», см. #2131).
|
|
||||||
"""
|
"""
|
||||||
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
|
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
|
||||||
in_region = (
|
if total == 0:
|
||||||
db.execute(
|
|
||||||
_POI_COUNT_IN_REGION_SQL,
|
|
||||||
{
|
|
||||||
"bbox_south": lat_min,
|
|
||||||
"bbox_north": lat_max,
|
|
||||||
"bbox_west": lon_min,
|
|
||||||
"bbox_east": lon_max,
|
|
||||||
},
|
|
||||||
).scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
if in_region == 0:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"location_index: osm_poi_ekb_local has no rows in region=%s product core "
|
"location_index: osm_poi_ekb_local is empty (refresh job not yet run on this "
|
||||||
"(empty or refresh job not yet run for this region) — nearby_poi unavailable, "
|
"environment) — nearby_poi unavailable, no fabricated factors"
|
||||||
"no fabricated factors",
|
|
||||||
region.code,
|
|
||||||
)
|
)
|
||||||
return [], "unavailable"
|
return [], "unavailable"
|
||||||
|
|
||||||
|
|
@ -477,7 +423,7 @@ def compute_location_index(
|
||||||
poi_status="unavailable",
|
poi_status="unavailable",
|
||||||
)
|
)
|
||||||
|
|
||||||
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n, region)
|
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n)
|
||||||
|
|
||||||
city_median, city_n = _citywide_median_ppm2(db, region)
|
city_median, city_n = _citywide_median_ppm2(db, region)
|
||||||
if city_median is None or city_n < MIN_SAMPLE_SIZE:
|
if city_median is None or city_n < MIN_SAMPLE_SIZE:
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,6 @@ from scraper_kit.orchestration.scheduler import (
|
||||||
from scraper_kit.orchestration.scheduler import (
|
from scraper_kit.orchestration.scheduler import (
|
||||||
_pick_resume as kit_pick_resume,
|
_pick_resume as kit_pick_resume,
|
||||||
)
|
)
|
||||||
from scraper_kit.orchestration.scheduler import (
|
|
||||||
_resolve_region_code as kit_resolve_region_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from scraper_kit.orchestration.scheduler import SchedulerContext
|
from scraper_kit.orchestration.scheduler import SchedulerContext
|
||||||
|
|
@ -328,34 +325,6 @@ async def _job_landing_stats(
|
||||||
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
|
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
|
||||||
|
|
||||||
|
|
||||||
# ── landing_showcase_deals — sync пересчёт витрины сделок в executor ─────────
|
|
||||||
async def _job_landing_showcase_deals(
|
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
|
||||||
) -> None:
|
|
||||||
"""Пересчёт витрины сделок публичного лэндинга (#3469).
|
|
||||||
|
|
||||||
ЛАЙФСАЙКЛ ПРОГОНА ВЕДЁТ HANDLER, а не задача. `refresh_landing_showcase_deals`
|
|
||||||
писалась под ручной запуск (`python -m app.tasks.landing_showcase_deals`) и про
|
|
||||||
`run_id` ничего не знает — тот же случай, что у `_job_refresh_search_matview`,
|
|
||||||
и решается так же: done/failed ставим здесь.
|
|
||||||
|
|
||||||
Параметры берём ИЗ РАСПИСАНИЯ только те, что в нём есть: дефолты живут в
|
|
||||||
сигнатуре задачи, и повтор их здесь дал бы два места, которые разъедутся.
|
|
||||||
"""
|
|
||||||
from app.tasks.landing_showcase_deals import refresh_landing_showcase_deals
|
|
||||||
|
|
||||||
kwargs = {k: params[k] for k in ("sample", "since", "limit", "city") if k in params}
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
try:
|
|
||||||
counters = await loop.run_in_executor(
|
|
||||||
None, lambda: refresh_landing_showcase_deals(db, **kwargs)
|
|
||||||
)
|
|
||||||
ctx.runs.mark_done(db, run_id, counters)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
|
|
||||||
ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})
|
|
||||||
|
|
||||||
|
|
||||||
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
|
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
|
||||||
async def _job_sber_freshness_monitor(
|
async def _job_sber_freshness_monitor(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
|
@ -518,7 +487,6 @@ async def _job_domclick_city_sweep(
|
||||||
rooms=params.get("rooms"),
|
rooms=params.get("rooms"),
|
||||||
pages=int(params.get("pages_per_anchor", 5)),
|
pages=int(params.get("pages_per_anchor", 5)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
|
||||||
region_code=kit_resolve_region_code(params),
|
|
||||||
resume_run_id=kit_pick_resume(db, run_id),
|
resume_run_id=kit_pick_resume(db, run_id),
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
)
|
)
|
||||||
|
|
@ -943,7 +911,6 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
||||||
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
|
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
|
||||||
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
|
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
|
||||||
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
|
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
|
||||||
"landing_showcase_deals": Handler(_job_landing_showcase_deals, "landing_showcase_deals"),
|
|
||||||
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
|
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
|
||||||
"yandex_newbuilding_sweep": Handler(
|
"yandex_newbuilding_sweep": Handler(
|
||||||
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
||||||
|
|
|
||||||
|
|
@ -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 (Химки, Балашиха — не
|
||||||
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
|
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
|
||||||
|
|
|
||||||
|
|
@ -67,11 +67,6 @@ _DEFAULT_MAX_RETRIES = 5
|
||||||
_DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE = 18
|
_DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE = 18
|
||||||
_RATE_LIMIT_WINDOW_S = 60.0
|
_RATE_LIMIT_WINDOW_S = 60.0
|
||||||
|
|
||||||
# Текст зондирующего сообщения `verify_chat_and_topic` — см. докстринг там.
|
|
||||||
# Живёт в чате доли секунды (удаляется сразу после отправки), но должен быть
|
|
||||||
# узнаваем в логах ретранслятора/дебаге, если удаление вдруг не отработает.
|
|
||||||
_TOPIC_PROBE_TEXT = "\U0001f50d startup topic check"
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramGroupRateLimiter:
|
class TelegramGroupRateLimiter:
|
||||||
"""Общий (per-`chat_id`, НЕ per-теме) ограничитель частоты отправки в группу.
|
"""Общий (per-`chat_id`, НЕ per-теме) ограничитель частоты отправки в группу.
|
||||||
|
|
@ -749,13 +744,13 @@ class TelegramClient:
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""sendChatAction — статус набора текста. Возвращает `True`/`False`, JSON-объекта нет.
|
"""sendChatAction — статус набора текста. Возвращает `True`/`False`, JSON-объекта нет.
|
||||||
|
|
||||||
⚠️ НЕ провалидировано для проверки `message_thread_id`: живой прод-замер
|
Используется НЕ по прямому назначению (индикация набора), а как способ
|
||||||
(#3471) показал, что Telegram принимает и мгновенно подтверждает
|
проверить существование `message_thread_id` (темы форума) — см.
|
||||||
`sendChatAction` с ЗАВЕДОМО несуществующим `message_thread_id`
|
`verify_chat_and_topic`. Это единственный метод Bot API, который
|
||||||
(`{"ok":true,"result":true}`) — метод молча принимает любую тему,
|
принимает `message_thread_id` и не создаёт message-объект: если тема
|
||||||
существующую или нет. Раньше здесь было обратное (неверное)
|
удалена/переименована в другую с иным id, Telegram отвечает `Bad
|
||||||
утверждение; см. `verify_chat_and_topic`, которая для проверки темы
|
Request: message thread not found` мгновенно, а в истории чата не
|
||||||
использует `sendMessage`+`deleteMessage`."""
|
остаётся ни строки (индикатор эфемерный и не персистится)."""
|
||||||
payload: dict[str, Any] = {"chat_id": chat_id, "action": action}
|
payload: dict[str, Any] = {"chat_id": chat_id, "action": action}
|
||||||
if message_thread_id:
|
if message_thread_id:
|
||||||
payload["message_thread_id"] = message_thread_id
|
payload["message_thread_id"] = message_thread_id
|
||||||
|
|
@ -764,22 +759,6 @@ class TelegramClient:
|
||||||
)
|
)
|
||||||
return bool(result)
|
return bool(result)
|
||||||
|
|
||||||
async def delete_message(self, *, chat_id: int, message_id: int) -> bool:
|
|
||||||
"""deleteMessage — удаляет сообщение бота.
|
|
||||||
|
|
||||||
Используется `verify_chat_and_topic` для зачистки зондирующего
|
|
||||||
`sendMessage`-пробника сразу после проверки темы: тема существует
|
|
||||||
тогда и только тогда, когда сообщение вообще удалось отправить —
|
|
||||||
поэтому к моменту вызова `deleteMessage` id уже гарантированно
|
|
||||||
валиден."""
|
|
||||||
result = await self._request(
|
|
||||||
"deleteMessage",
|
|
||||||
{"chat_id": chat_id, "message_id": message_id},
|
|
||||||
max_retries=1,
|
|
||||||
max_backoff=5.0,
|
|
||||||
)
|
|
||||||
return bool(result)
|
|
||||||
|
|
||||||
|
|
||||||
async def verify_chat_and_topic(
|
async def verify_chat_and_topic(
|
||||||
client: TelegramClient,
|
client: TelegramClient,
|
||||||
|
|
@ -797,23 +776,18 @@ async def verify_chat_and_topic(
|
||||||
отказами: тихий отказ хуже шума). Эта проверка переносит обнаружение с
|
отказами: тихий отказ хуже шума). Эта проверка переносит обнаружение с
|
||||||
«через сутки тишины» на «в первую секунду после старта/рестарта».
|
«через сутки тишины» на «в первую секунду после старта/рестарта».
|
||||||
|
|
||||||
Способ: `getChat(chat_id)` + `sendMessage`/`deleteMessage` в саму тему.
|
Способ намеренно НЕ `sendMessage`+`deleteMessage`:
|
||||||
- `getChat(chat_id)` подтверждает валидность чата и то, что бот не
|
- `getChat(chat_id)` подтверждает валидность чата и то, что бот не
|
||||||
выгнан/не заблокирован — чистый read, нулевой видимый след.
|
выгнан/не заблокирован — чистый read, нулевой видимый след.
|
||||||
- Раньше здесь стоял `send_chat_action` (typing-индикатор с
|
- `send_chat_action` (typing-индикатор с `message_thread_id`) —
|
||||||
`message_thread_id`) в расчёте на то, что Telegram обязан знать про
|
единственный способ провалидировать САМУ тему без создания
|
||||||
`message_thread_id`, чтобы показать «печатает...» именно в нужном
|
message-объекта: Telegram обязан знать про `message_thread_id`, чтобы
|
||||||
треде. Живой прод-замер (#3471) опроверг это: `sendChatAction`
|
показать «печатает...» именно в нужном треде, и явно отказывает, если
|
||||||
принимает и подтверждает ЗАВЕДОМО несуществующий
|
такой темы нет. `sendMessage`+`deleteMessage` тоже сработал бы, но
|
||||||
`message_thread_id` (`{"ok":true,"result":true}`) — проверка была
|
оставлял бы видимый (пусть на секунды) артефакт в истории треда при
|
||||||
ложно-зелёной ВСЕГДА, удалённая/переименованная тема проходила её
|
КАЖДОМ рестарте контейнера — на rolling-деплое это многократно в
|
||||||
так же, как живая, а реальная отправка потом падала на каждом
|
сутки; typing-индикатор того же результата достигает без единого
|
||||||
сообщении. `sendMessage` с тем же мусорным id вместо этого мгновенно
|
сообщения.
|
||||||
отвечает `400 Bad Request: message thread not found` (эмпирически
|
|
||||||
подтверждено на том же проде), а с валидным id создаёт сообщение —
|
|
||||||
которое здесь же стирается `deleteMessage`, так что в истории треда
|
|
||||||
не остаётся ничего, кроме микросекундного технического сообщения на
|
|
||||||
каждом старте/рестарте контейнера.
|
|
||||||
|
|
||||||
НЕ роняет процесс: любой `TelegramError` ловится здесь же и уходит в лог
|
НЕ роняет процесс: любой `TelegramError` ловится здесь же и уходит в лог
|
||||||
уровня error — задача явно требует шума в логе, а не падения воркера
|
уровня error — задача явно требует шума в логе, а не падения воркера
|
||||||
|
|
@ -828,16 +802,9 @@ async def verify_chat_and_topic(
|
||||||
try:
|
try:
|
||||||
await client.get_chat(chat_id)
|
await client.get_chat(chat_id)
|
||||||
if topic_id:
|
if topic_id:
|
||||||
probe = await client.send_message(
|
await client.send_chat_action(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id, action="typing", message_thread_id=topic_id
|
||||||
text=_TOPIC_PROBE_TEXT,
|
|
||||||
message_thread_id=topic_id,
|
|
||||||
max_retries=1,
|
|
||||||
max_backoff=5.0,
|
|
||||||
)
|
)
|
||||||
probe_message_id = probe.get("message_id")
|
|
||||||
if probe_message_id:
|
|
||||||
await client.delete_message(chat_id=chat_id, message_id=probe_message_id)
|
|
||||||
except TelegramError as exc:
|
except TelegramError as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"tg topic check [%s]: чат/тема недоступны для отправки "
|
"tg topic check [%s]: чат/тема недоступны для отправки "
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,6 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import LISTINGS_FRESH_DAYS, settings
|
from app.core.config import LISTINGS_FRESH_DAYS, settings
|
||||||
from app.services import regions as regions_mod
|
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
|
|
||||||
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
||||||
|
|
@ -69,29 +68,13 @@ _PPM2_MIN: int = 30_000
|
||||||
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
|
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
|
||||||
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
|
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
|
||||||
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
|
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
|
||||||
# ниже) — симметрично deal-стороне.
|
# ниже) — симметрично deal-стороне. Когда появится per-city ratio через зарезервированный
|
||||||
#
|
# столбец `district` (#647), эта константа станет per-city параметром для обеих сторон.
|
||||||
# #3512 (per-region ratio, migration 304): эта городская квота — костыль ИМЕННО региона
|
|
||||||
# 66 (asking-скрейп исторически покрывал только сам ЕКБ, см. #C2 выше), и трогать её
|
|
||||||
# нельзя — числа региона 66 обязаны остаться byte-for-byte прежними. Для ЛЮБОГО другого
|
|
||||||
# региона (deals.region_code / listings.region_code — обе таблицы несут колонку) городской
|
|
||||||
# квоты не было и не нужно: там достаточно `region_code = :region_code` симметрично на
|
|
||||||
# обеих сторонах (см. _REDERIVE_SQL_REGION ниже) — колонка `district` остаётся
|
|
||||||
# зарезервированной под #647 (гео-районы ВНУТРИ региона), per-region разрез теперь
|
|
||||||
# несёт `region_code`, а не `district`.
|
|
||||||
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
||||||
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
||||||
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
||||||
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
||||||
|
|
||||||
# #3512: регионы, для которых считается ОТДЕЛЬНАЯ (не-ЕКБ) деривация ниже —
|
|
||||||
# весь реестр покрытия (app.services.regions — ЕДИНСТВЕННЫЙ источник правды про регионы)
|
|
||||||
# минус 66 (у него своя историческая деривация выше). Появление нового региона в реестре
|
|
||||||
# автоматически включает его в пересчёт ratio, без правки этого файла.
|
|
||||||
_OTHER_REGION_CODES: tuple[int, ...] = tuple(
|
|
||||||
sorted(code for code in regions_mod.REGIONS if code != 66)
|
|
||||||
)
|
|
||||||
|
|
||||||
# #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ:
|
# #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ:
|
||||||
# tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms
|
# tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms
|
||||||
# синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы —
|
# синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы —
|
||||||
|
|
@ -125,26 +108,14 @@ def area_bucket(area_m2: float) -> int:
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── True-mirror cleanup: drop all #648 EKB rows before re-derivation ──────────
|
# ── True-mirror cleanup: drop all #648 rows before re-derivation ──────────────
|
||||||
# district = '' — все строки #648 (district зарезервирован под #647, пока всегда '').
|
# district = '' — все строки #648 (district зарезервирован под #647, пока всегда '').
|
||||||
# #3512: явный region_code=66 — эта DELETE трогает ТОЛЬКО ЕКБ-строки; остальные регионы
|
|
||||||
# чистит своя _DELETE_SQL_REGION в цикле ниже (иначе один бланкет-DELETE стирал бы
|
|
||||||
# только что вставленные строки другого региона на повторном прогоне того же transaction).
|
|
||||||
# Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались
|
# Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались
|
||||||
# stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT.
|
# stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT.
|
||||||
_DELETE_SQL = text(
|
_DELETE_SQL = text(
|
||||||
"""
|
"""
|
||||||
DELETE FROM asking_to_sold_ratios
|
DELETE FROM asking_to_sold_ratios
|
||||||
WHERE region_code = 66 AND district = ''
|
WHERE district = ''
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# #3512: та же true-mirror очистка, но per-region (используется в цикле для
|
|
||||||
# _OTHER_REGION_CODES) — CAST(:region_code AS int), никогда :region_code::int (psycopg v3).
|
|
||||||
_DELETE_SQL_REGION = text(
|
|
||||||
"""
|
|
||||||
DELETE FROM asking_to_sold_ratios
|
|
||||||
WHERE region_code = CAST(:region_code AS int) AND district = ''
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -295,133 +266,19 @@ _REDERIVE_SQL = text(
|
||||||
)
|
)
|
||||||
INSERT INTO asking_to_sold_ratios (
|
INSERT INTO asking_to_sold_ratios (
|
||||||
rooms_bucket, district, ratio, sold_median, ask_median,
|
rooms_bucket, district, ratio, sold_median, ask_median,
|
||||||
n_deals, n_listings, window_months, basis, region_code
|
n_deals, n_listings, window_months, basis
|
||||||
)
|
)
|
||||||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||||||
n_deals, n_listings, window_months, basis, 66 FROM global_row
|
n_deals, n_listings, window_months, basis FROM global_row
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||||||
n_deals, n_listings, window_months, basis, 66 FROM per_bucket
|
n_deals, n_listings, window_months, basis FROM per_bucket
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Generic per-region derivation (#3512) ─────────────────────────────────────
|
|
||||||
# Байт-в-байт та же деривация, что _REDERIVE_SQL выше (тот же 12-мес трейлинг,
|
|
||||||
# ppm²-полоса, novostroyki/freshness/area_m2-гарды, area-бакет ask-стороны, порог
|
|
||||||
# 30/30, global -1 fallback) — ЕДИНСТВЕННОЕ отличие: городская квота ЕКБ (city ILIKE /
|
|
||||||
# city IS NULL OR city ILIKE) заменена на симметричный `region_code = :region_code`
|
|
||||||
# на ОБЕИХ сторонах. Городской квоты здесь не было и не нужно: она была костылём
|
|
||||||
# конкретно исторического asking-покрытия ЕКБ (#C2), а не общим правилом.
|
|
||||||
_REDERIVE_SQL_REGION = text(
|
|
||||||
f"""
|
|
||||||
WITH
|
|
||||||
deal_side AS (
|
|
||||||
SELECT
|
|
||||||
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
|
|
||||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
|
|
||||||
COUNT(*) AS n_deals
|
|
||||||
FROM deals
|
|
||||||
WHERE source = 'rosreestr'
|
|
||||||
AND rooms IS NOT NULL
|
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
|
||||||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
|
||||||
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
|
||||||
),
|
|
||||||
ask_side AS (
|
|
||||||
SELECT
|
|
||||||
{_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket,
|
|
||||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
|
|
||||||
COUNT(*) AS n_listings
|
|
||||||
FROM listings
|
|
||||||
WHERE is_active
|
|
||||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
|
||||||
AND rooms IS NOT NULL
|
|
||||||
AND area_m2 IS NOT NULL
|
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
|
||||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
GROUP BY {_AREA_ROOMS_BUCKET_SQL}
|
|
||||||
),
|
|
||||||
per_bucket AS (
|
|
||||||
SELECT
|
|
||||||
d.rooms_bucket,
|
|
||||||
''::text AS district,
|
|
||||||
(d.sold_median / a.ask_median)::numeric AS ratio,
|
|
||||||
round(d.sold_median)::bigint AS sold_median,
|
|
||||||
round(a.ask_median)::bigint AS ask_median,
|
|
||||||
d.n_deals::int AS n_deals,
|
|
||||||
a.n_listings::int AS n_listings,
|
|
||||||
12 AS window_months,
|
|
||||||
'per_rooms'::text AS basis
|
|
||||||
FROM deal_side d
|
|
||||||
JOIN ask_side a USING (rooms_bucket)
|
|
||||||
WHERE d.n_deals >= 30
|
|
||||||
AND a.n_listings >= 30
|
|
||||||
AND a.ask_median IS NOT NULL
|
|
||||||
AND a.ask_median > 0
|
|
||||||
AND d.sold_median IS NOT NULL
|
|
||||||
AND d.sold_median > 0
|
|
||||||
),
|
|
||||||
deal_global AS (
|
|
||||||
SELECT
|
|
||||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
|
|
||||||
COUNT(*) AS n_deals
|
|
||||||
FROM deals
|
|
||||||
WHERE source = 'rosreestr'
|
|
||||||
AND rooms IS NOT NULL
|
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
|
||||||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
|
||||||
),
|
|
||||||
ask_global AS (
|
|
||||||
SELECT
|
|
||||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
|
|
||||||
COUNT(*) AS n_listings
|
|
||||||
FROM listings
|
|
||||||
WHERE is_active
|
|
||||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
|
||||||
AND rooms IS NOT NULL
|
|
||||||
AND area_m2 IS NOT NULL
|
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
|
||||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
),
|
|
||||||
global_row AS (
|
|
||||||
SELECT
|
|
||||||
-1 AS rooms_bucket,
|
|
||||||
''::text AS district,
|
|
||||||
(d.sold_median / a.ask_median)::numeric AS ratio,
|
|
||||||
round(d.sold_median)::bigint AS sold_median,
|
|
||||||
round(a.ask_median)::bigint AS ask_median,
|
|
||||||
d.n_deals::int AS n_deals,
|
|
||||||
a.n_listings::int AS n_listings,
|
|
||||||
12 AS window_months,
|
|
||||||
'global_fallback'::text AS basis
|
|
||||||
FROM deal_global d
|
|
||||||
CROSS JOIN ask_global a
|
|
||||||
WHERE a.ask_median IS NOT NULL
|
|
||||||
AND a.ask_median > 0
|
|
||||||
AND d.sold_median IS NOT NULL
|
|
||||||
AND d.sold_median > 0
|
|
||||||
)
|
|
||||||
INSERT INTO asking_to_sold_ratios (
|
|
||||||
rooms_bucket, district, ratio, sold_median, ask_median,
|
|
||||||
n_deals, n_listings, window_months, basis, region_code
|
|
||||||
)
|
|
||||||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
|
||||||
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM global_row
|
|
||||||
UNION ALL
|
|
||||||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
|
||||||
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM per_bucket
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Post-insert counters ──────────────────────────────────────────────────────
|
# ── Post-insert counters ──────────────────────────────────────────────────────
|
||||||
# Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк
|
# Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк
|
||||||
# записано всего, сколько per_rooms, был ли использован global -1 fallback.
|
# записано всего, сколько per_rooms, был ли использован global -1 fallback.
|
||||||
# #3512: считается ОДИН раз в самом конце, ПОСЛЕ всех регионов (ЕКБ + цикл по
|
|
||||||
# _OTHER_REGION_CODES) — district='' покрывает все регионы разом, счётчики суммарные.
|
|
||||||
_COUNTERS_SQL = text(
|
_COUNTERS_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -435,22 +292,13 @@ _COUNTERS_SQL = text(
|
||||||
|
|
||||||
|
|
||||||
def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
||||||
"""Пересчитать asking_to_sold_ratios per region (#3512, TRUE-MIRROR refresh #648 Stage 4).
|
"""Пересчитать asking_to_sold_ratios (TRUE-MIRROR refresh, #648 Stage 4).
|
||||||
|
|
||||||
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
||||||
В ОДНОЙ транзакции (атомарно — таблица никогда не пуста mid-refresh для уже
|
В ОДНОЙ транзакции (атомарно — таблица никогда не пуста mid-refresh):
|
||||||
посчитанных регионов):
|
1. DELETE FROM asking_to_sold_ratios WHERE district = '' — снести stale-строки.
|
||||||
1. Регион 66 (ЕКБ): DELETE (region_code=66, district='') → 080-derivation
|
2. Заново прогнать 080-derivation INSERT...SELECT (per_rooms при 30/30 + global -1).
|
||||||
INSERT...SELECT (byte-for-byte прежняя логика — городская квота ЕКБ, #C2/#2583).
|
Затем counters из таблицы, commit, mark_done. Семантика == re-seed миграции 080.
|
||||||
2. Каждый прочий регион реестра app.services.regions (минус 66): тот же
|
|
||||||
DELETE/INSERT цикл, но derivation скоупится по `region_code` симметрично
|
|
||||||
на sold- и asking-стороне (без городской квоты — она не применима вне
|
|
||||||
ЕКБ, см. комментарий у generic-derivation SQL ниже).
|
|
||||||
Регион без своих ДКП-сделок ИЛИ без активных listings просто не получает строк
|
|
||||||
(global_row/per_bucket WHERE-гарды отфильтровывают NULL-медианы) — это НЕ
|
|
||||||
ошибка, а честный признак «данных пока недостаточно», ловится потребителем
|
|
||||||
(estimator._get_asking_sold_ratio) через отсутствие строки → явная деградация.
|
|
||||||
Затем ОДИН общий counters-запрос по всей таблице, commit, mark_done.
|
|
||||||
|
|
||||||
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
||||||
|
|
||||||
|
|
@ -475,8 +323,7 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними —
|
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними —
|
||||||
# таблица не должна остаться пустой, если INSERT упадёт). Регион 66 —
|
# таблица не должна остаться пустой, если INSERT упадёт).
|
||||||
# прежняя ЕКБ-деривация байт-в-байт; остальные регионы — цикл ниже (#3512).
|
|
||||||
db.execute(_DELETE_SQL)
|
db.execute(_DELETE_SQL)
|
||||||
db.execute(
|
db.execute(
|
||||||
_REDERIVE_SQL,
|
_REDERIVE_SQL,
|
||||||
|
|
@ -487,17 +334,6 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
||||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
for region_code in _OTHER_REGION_CODES:
|
|
||||||
db.execute(_DELETE_SQL_REGION, {"region_code": region_code})
|
|
||||||
db.execute(
|
|
||||||
_REDERIVE_SQL_REGION,
|
|
||||||
{
|
|
||||||
"region_code": region_code,
|
|
||||||
"ppm2_min": _PPM2_MIN,
|
|
||||||
"ppm2_max": settings.asking_ratio_ppm2_max,
|
|
||||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
row = db.execute(_COUNTERS_SQL).mappings().first()
|
row = db.execute(_COUNTERS_SQL).mappings().first()
|
||||||
if row is not None:
|
if row is not None:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
-- 303_scrape_schedules_seed_landing_showcase_deals.sql
|
|
||||||
-- Расписание для пересчёта витрины сделок публичного лэндинга (issue #3469).
|
|
||||||
--
|
|
||||||
-- ЧТО БЫЛО. Задача `landing_showcase_deals` (миграции 276/277, таблицы
|
|
||||||
-- landing_showcase_deals + landing_showcase_runs) в scrape_schedules НЕ СТОЯЛА:
|
|
||||||
-- `SELECT * FROM scrape_schedules WHERE source LIKE '%showcase%'` — 0 строк
|
|
||||||
-- (замер на проде 12.09.2026). Пересчёт был ручным шагом, и за всё время его
|
|
||||||
-- запускали четырежды; на 12.09 лэндинг показывал прогон от 30.08 — тринадцать
|
|
||||||
-- суток. Handler в реестре тоже отсутствовал, то есть строка расписания без
|
|
||||||
-- него не помогла бы: обе половины регистрации задачи (Handler в
|
|
||||||
-- app/services/product_handlers.py + вот эта строка) едут одним PR.
|
|
||||||
--
|
|
||||||
-- ТАКТ — СУТКИ, И СЧИТАЕТСЯ ОН НЕ ОТ ДАННЫХ, А ОТ КОДА.
|
|
||||||
-- Вход витрины — ДКП-сделки Росреестра, они приезжают ПОКВАРТАЛЬНО, и по
|
|
||||||
-- входу хватило бы такта в квартал. Но витрина показывает не сделки, а
|
|
||||||
-- РАСХОЖДЕНИЕ прогноза МЕРЫ с ценой сделки, а прогноз пересчитывается тем же
|
|
||||||
-- спайном оценщика, что и боевой расчёт: любая правка оценщика, коэффициентов
|
|
||||||
-- СберИндекса, набора активных объявлений или правила отбора (миграция 276,
|
|
||||||
-- полоса −5..+20 % от 12.09.2026) меняет ЧИСЛА на странице, не трогая ни одной
|
|
||||||
-- сделки. Деплой у продукта чаще, чем квартал, — поэтому такт суточный: столько
|
|
||||||
-- живёт окно «код уже другой, а витрина ещё прежняя». Прогон дешёвый и без
|
|
||||||
-- внешних вызовов (200 сделок через спайн + запись 20 строк, ~минуты CPU
|
|
||||||
-- ночью), так что цена суточного такта — та же, что у соседнего
|
|
||||||
-- landing_stats_refresh (миграция 275).
|
|
||||||
--
|
|
||||||
-- ЭТА ЖЕ СТРОКА ЗАВОДИТ ВИТРИНУ В МОНИТОР СВЕЖЕСТИ. Сводка просроченных
|
|
||||||
-- источников (`emit_stale_digest`, scraper_kit/orchestration/scheduler.py,
|
|
||||||
-- #2670) ходит по ВКЛЮЧЁННЫМ расписаниям и бьёт тревогу (logger.error →
|
|
||||||
-- GlitchTip), когда источник не приносил данных дольше
|
|
||||||
-- STALE_DIGEST_INTERVAL_FACTOR × его такта — то есть здесь дольше ТРЁХ СУТОК.
|
|
||||||
-- Отдельного монитора для витрины не заводится намеренно: её молчание было
|
|
||||||
-- невидимо ровно потому, что источника не существовало для сводки, а не потому,
|
|
||||||
-- что сводка не умеет про него говорить (живой пример с прода 12.09.2026:
|
|
||||||
-- «1 источников не собирают дольше 3× своего такта — avito_newbuilding_sweep
|
|
||||||
-- 3.5d/1d»). interval_days в default_params стоит ЯВНО — им же сводка считает
|
|
||||||
-- порог (`_schedule_interval_days`), и умолчание «1» лучше не подразумевать.
|
|
||||||
--
|
|
||||||
-- ОКНО 06:00–07:00 UTC (11:00–12:00 по Екатеринбургу): после импорта сделок
|
|
||||||
-- Росреестра (rosreestr_dkp_import, окно 04–06) и после landing_stats_refresh
|
|
||||||
-- (05–06) — витрина считается по уже обновлённым за ночь данным; и за два часа
|
|
||||||
-- до deals_freshness_monitor (08–09), так что утренний пересчёт успевает
|
|
||||||
-- сняться с просрочки до утренней же проверки.
|
|
||||||
--
|
|
||||||
-- enabled=true — как у landing_stats_refresh: задача только читает базу и
|
|
||||||
-- перезаписывает две свои маленькие таблицы, внешних вызовов нет, цена ошибки —
|
|
||||||
-- минуты CPU. Дожидаться ручного включения тут значило бы оставить дефект
|
|
||||||
-- #3469 на месте, просто под другой причиной.
|
|
||||||
--
|
|
||||||
-- next_run_at на завтра 06:00 UTC — прогон не выстреливает в момент деплоя
|
|
||||||
-- (образец: 162_seed_deals_freshness_monitor.sql, 275_landing_stats.sql).
|
|
||||||
--
|
|
||||||
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 276/277
|
|
||||||
-- (таблицы витрины), Handler 'landing_showcase_deals' в product_handlers.py.
|
|
||||||
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
-- Конвенция проекта (#2752): блокирующий DDL/DML под lock_timeout.
|
|
||||||
SET LOCAL lock_timeout = '5s';
|
|
||||||
|
|
||||||
INSERT INTO scrape_schedules (
|
|
||||||
source,
|
|
||||||
enabled,
|
|
||||||
window_start_hour,
|
|
||||||
window_end_hour,
|
|
||||||
next_run_at,
|
|
||||||
default_params
|
|
||||||
)
|
|
||||||
VALUES
|
|
||||||
(
|
|
||||||
'landing_showcase_deals',
|
|
||||||
true,
|
|
||||||
6,
|
|
||||||
7,
|
|
||||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
|
|
||||||
'{"interval_days": 1, "sample": 200, "limit": 20}'::jsonb
|
|
||||||
)
|
|
||||||
ON CONFLICT (source) DO NOTHING;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
-- 304_asking_to_sold_ratios_region.sql
|
|
||||||
-- asking_to_sold_ratios: ключ (region_code, rooms_bucket, district) — #3512.
|
|
||||||
--
|
|
||||||
-- ПРОБЛЕМА. asking_to_sold_ratios (миграция 080) несёт РОВНО 6 строк (per area-бакет
|
|
||||||
-- 0..4 + global -1), все с district='' — колонки региона в таблице НЕТ ВООБЩЕ (проверено
|
|
||||||
-- на проде 13.09.2026: `\d asking_to_sold_ratios` без region_code). Деривация
|
|
||||||
-- (app/tasks/asking_to_sold_ratio.py) скоупит ОБЕ стороны расчёта на
|
|
||||||
-- `city ILIKE '%Екатеринбург%'` — ratio физически посчитан только по рынку ЕКБ. Но
|
|
||||||
-- estimator.py (_get_asking_sold_ratio) читает строку по `rooms_bucket AND district=''`
|
|
||||||
-- БЕЗ единого понятия региона запроса — тот же коэффициент домножает asking-цену ЛЮБОГО
|
|
||||||
-- адреса, включая Москву (region_code=77) и Московскую обл. (region_code=50). Рынки
|
|
||||||
-- разные на порядок: медиана ДКП за 12 мес по region_code — 66: 90 278 ₽/м²,
|
|
||||||
-- 50: 161 176 ₽/м², 77: 322 976 ₽/м² (замер 13.09.2026) — екатеринбургский sold/asking
|
|
||||||
-- коэффициент, применённый к московской asking-цене, calibration mismatch, а не оценка.
|
|
||||||
--
|
|
||||||
-- ЧТО ДЕЛАЕТ ЭТА МИГРАЦИЯ (только схема — derivation региона 66 остаётся байт-в-байт
|
|
||||||
-- прежней, деривация ДРУГИХ регионов теперь считается отдельным SQL-путём в
|
|
||||||
-- app/tasks/asking_to_sold_ratio.py, сама эта миграция ничего не пересчитывает):
|
|
||||||
-- 1. ADD COLUMN region_code int NOT NULL DEFAULT 66 — существующие 6 строк ВСЕ
|
|
||||||
-- екатеринбургские (единственная деривация на момент 080/098/109), DEFAULT
|
|
||||||
-- backfill'ит их на 66 автоматически при ALTER (Postgres 11+ — DEFAULT на
|
|
||||||
-- NOT NULL столбце без DEFAULT ранее вычисляется один раз, без rewrite всей
|
|
||||||
-- таблицы, но здесь таблица микроскопическая — 6 строк, cost неощутим в любом
|
|
||||||
-- случае).
|
|
||||||
-- 2. PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district) — строки
|
|
||||||
-- теперь живут per (регион, area-бакет, district). district остаётся зарезервирован
|
|
||||||
-- под #647 (гео-районы ВНУТРИ региона), всегда ''.
|
|
||||||
--
|
|
||||||
-- ПОЧЕМУ НЕ НОВАЯ ПАРАЛЛЕЛЬНАЯ ТАБЛИЦА (в отличие от 098 tiered-подхода). Там
|
|
||||||
-- параллельная таблица защищала fetchone()-safety при flag-OFF откате (#928). Здесь
|
|
||||||
-- отката не предполагается: region_code — обязательный параметр запроса везде, где
|
|
||||||
-- ratio читается (estimator._get_asking_sold_ratio), и ADD COLUMN NOT NULL DEFAULT 66
|
|
||||||
-- не меняет СУЩЕСТВУЮЩИЕ 6 строк по значению (только добавляет им явный регион) —
|
|
||||||
-- старый flag-OFF путь `WHERE rooms_bucket=B AND district=''` для региона 66
|
|
||||||
-- продолжает возвращать ТУ ЖЕ строку, если добавить `AND region_code=66` (что и
|
|
||||||
-- делает estimator.py в этом же PR).
|
|
||||||
--
|
|
||||||
-- ИДЕМПОТЕНТНОСТЬ: ADD COLUMN IF NOT EXISTS — повторный прогон no-op на колонке.
|
|
||||||
-- PK-guard (как 298_deal_city_price_bands_region.sql) смотрит ТЕКУЩЕЕ определение PK
|
|
||||||
-- через pg_get_constraintdef и меняет его ТОЛЬКО если это ещё старый
|
|
||||||
-- PK(rooms_bucket, district) — повторный прогон видит PK(region_code, rooms_bucket,
|
|
||||||
-- district) и ничего не трогает. strict/exit-1 на деплое — без SET LOCAL lock_timeout
|
|
||||||
-- ALTER TABLE мог бы зависнуть под конкурентным чтением; таблица in-memory-масштаба
|
|
||||||
-- (6 строк), но конвенция проекта (#2752) требует потолок на любой блокирующий DDL.
|
|
||||||
--
|
|
||||||
-- ЗАВИСИМОСТИ: 080_asking_to_sold_ratios.sql (таблица, PK(rooms_bucket, district)),
|
|
||||||
-- 002_core_tables.sql (deals.region_code, listings.region_code — обе уже существуют).
|
|
||||||
-- Apply after: 303_scrape_schedules_seed_landing_showcase_deals.sql
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
SET LOCAL lock_timeout = '5s';
|
|
||||||
|
|
||||||
ALTER TABLE asking_to_sold_ratios
|
|
||||||
ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN asking_to_sold_ratios.region_code IS
|
|
||||||
'Регион коэффициента (deals.region_code / listings.region_code, #3512). '
|
|
||||||
'Существующие строки (посчитаны деривацией, скоупленной на ЕКБ, #C2) — 66. '
|
|
||||||
'Часть составного PK (region_code, rooms_bucket, district).';
|
|
||||||
|
|
||||||
-- PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district), идемпотентно:
|
|
||||||
-- смотрим ТЕКУЩЕЕ определение PK и меняем его только если это ещё старый PK без региона.
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
v_pk_def text;
|
|
||||||
BEGIN
|
|
||||||
SELECT pg_get_constraintdef(oid) INTO v_pk_def
|
|
||||||
FROM pg_constraint
|
|
||||||
WHERE conrelid = 'asking_to_sold_ratios'::regclass
|
|
||||||
AND contype = 'p';
|
|
||||||
|
|
||||||
IF v_pk_def = 'PRIMARY KEY (rooms_bucket, district)' THEN
|
|
||||||
ALTER TABLE asking_to_sold_ratios DROP CONSTRAINT asking_to_sold_ratios_pkey;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_constraint
|
|
||||||
WHERE conrelid = 'asking_to_sold_ratios'::regclass
|
|
||||||
AND contype = 'p'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE asking_to_sold_ratios
|
|
||||||
ADD CONSTRAINT asking_to_sold_ratios_pkey
|
|
||||||
PRIMARY KEY (region_code, rooms_bucket, district);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
@ -134,10 +134,6 @@ USAGE
|
||||||
|
|
||||||
# oblast D: per-city validation (exact deals.city name, not a slug):
|
# oblast D: per-city validation (exact deals.city name, not a slug):
|
||||||
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
|
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
|
||||||
|
|
||||||
# #3520 любой регион (choices come from app.services.regions.REGIONS):
|
|
||||||
# Москва (region_code=77, 212 937 ДКП, 69 138 за последние 12 мес.):
|
|
||||||
python -m scripts.backtest_estimator --region 77 --since 2024-09-01 --sample 300
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -160,15 +156,6 @@ from typing import Any
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.regions import REGIONS, region_for_point
|
|
||||||
|
|
||||||
# Регион по умолчанию — Свердловская обл., прежнее (единственное до #3520)
|
|
||||||
# поведение скрипта. Список допустимых значений — РЕЕСТР REGIONS
|
|
||||||
# (app/services/regions.py), а не отдельный литерал: регион появляется в
|
|
||||||
# реестре один раз и автоматически становится доступен и здесь, и в
|
|
||||||
# msk_raw_import, и в самом эстиматоре.
|
|
||||||
DEFAULT_BACKTEST_REGION_CODE = 66
|
|
||||||
|
|
||||||
|
|
||||||
def _import_estimator() -> tuple[Any, Any]:
|
def _import_estimator() -> tuple[Any, Any]:
|
||||||
"""Lazy import of the estimator's pure funcs (_filter_outliers, _percentile).
|
"""Lazy import of the estimator's pure funcs (_filter_outliers, _percentile).
|
||||||
|
|
@ -1166,7 +1153,6 @@ _SAMPLE_SQL = text(
|
||||||
house_type
|
house_type
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
AND geom IS NOT NULL
|
AND geom IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
|
@ -1218,7 +1204,6 @@ _SAMPLE_SQL_SCATTERED = text(
|
||||||
house_type
|
house_type
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
AND geom IS NOT NULL
|
AND geom IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
|
@ -1248,7 +1233,6 @@ _SAMPLE_SQL_SCATTERED_CITY = text(
|
||||||
house_type
|
house_type
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
AND geom IS NOT NULL
|
AND geom IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
|
@ -1287,9 +1271,10 @@ _CANDIDATES_SQL = text(
|
||||||
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
|
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
|
||||||
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
|
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
|
||||||
# _resolve_city_ppm2_band); the default (city=None) path never issues this
|
# _resolve_city_ppm2_band); the default (city=None) path never issues this
|
||||||
# query. region_code (#3520 "любой регион") is threaded from the CLI --region
|
# query. region_code defaults to 66 (Свердловская обл.) — this script has no
|
||||||
# flag end-to-end now (default 66, Свердловская обл. — unchanged behaviour for
|
# region CLI flag yet, so every call is scoped to the oblast, matching every
|
||||||
# every pre-existing invocation, which never passed --region).
|
# existing --city invocation (byte-identical to the pre-298 unscoped lookup,
|
||||||
|
# which only ever saw region-66 rows since the table was oblast-only before).
|
||||||
_CITY_PPM2_BAND_SQL = text(
|
_CITY_PPM2_BAND_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT ppm2_min, ppm2_max
|
SELECT ppm2_min, ppm2_max
|
||||||
|
|
@ -1303,13 +1288,10 @@ _CITY_PPM2_BAND_SQL = text(
|
||||||
def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
||||||
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
|
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
|
||||||
|
|
||||||
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object every
|
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
|
||||||
time — literal identity, not just equal text (asserted by
|
before this change — literal identity, not just equal text — so the
|
||||||
``test_sample_sql_city_none_returns_original_object``) — so callers that
|
default CLI invocation (and the frozen EKB regression gate, which never
|
||||||
cache/compare the query object see a stable reference. All four SQL
|
calls this path at all) see byte-identical SQL.
|
||||||
variants (unscoped/scattered × city/no-city) carry a ``region_code``
|
|
||||||
predicate (#3520 "любой регион") bound from ``_load_sample``'s
|
|
||||||
``region_code`` param, default 66.
|
|
||||||
|
|
||||||
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
|
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
|
||||||
scopes the sample to that ``deals.city`` value — populated oblast-wide by
|
scopes the sample to that ``deals.city`` value — populated oblast-wide by
|
||||||
|
|
@ -1339,7 +1321,6 @@ def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
||||||
house_type
|
house_type
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
AND geom IS NOT NULL
|
AND geom IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
|
@ -1367,9 +1348,9 @@ def _resolve_city_ppm2_band(
|
||||||
mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB
|
mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB
|
||||||
error → the globals; read-only best-effort, never raises.
|
error → the globals; read-only best-effort, never raises.
|
||||||
|
|
||||||
``region_code`` defaults to 66 (Свердловская обл.) for backward-compat
|
``region_code`` defaults to 66 (Свердловская обл.) — this script has no
|
||||||
direct callers; ``_load_sample`` (#3520) always passes the actual
|
region CLI flag yet (out of scope, #3051 sub-PR B); every existing
|
||||||
``--region`` value through explicitly.
|
--city caller keeps its byte-identical lookup.
|
||||||
"""
|
"""
|
||||||
if city is None:
|
if city is None:
|
||||||
return float(PPM2_MIN), float(PPM2_MAX)
|
return float(PPM2_MIN), float(PPM2_MAX)
|
||||||
|
|
@ -1395,32 +1376,22 @@ def _load_sample(
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
scattered: bool = False,
|
scattered: bool = False,
|
||||||
seed: str = "mera",
|
seed: str = "mera",
|
||||||
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
|
|
||||||
) -> list[DealSample]:
|
) -> list[DealSample]:
|
||||||
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
||||||
|
|
||||||
``region_code`` (#3520 "любой регион", default 66 — Свердловская обл.)
|
|
||||||
scopes the sample to one ``deals.region_code`` value — REQUIRED once
|
|
||||||
``deals`` carries more than one region (212 937 Москва + 113 351
|
|
||||||
Московская обл. rows alongside 108 623 ЕКБ), otherwise the "unscoped"
|
|
||||||
query would silently mix regions together. Must be a key of
|
|
||||||
``app.services.regions.REGIONS`` — validated by ``main()``/``_parse_args``
|
|
||||||
(CLI ``choices``) so this function only ever sees a supported code.
|
|
||||||
|
|
||||||
``city`` (oblast D, default None) scopes the sample to one ``deals.city``
|
``city`` (oblast D, default None) scopes the sample to one ``deals.city``
|
||||||
value via ``_sample_sql`` and sources the PPM2 sanity band from
|
value via ``_sample_sql`` and sources the PPM2 sanity band from
|
||||||
``deal_city_price_bands`` for that (region_code, city) pair
|
``deal_city_price_bands`` for that city (``_resolve_city_ppm2_band``,
|
||||||
(``_resolve_city_ppm2_band``, falls back to the module globals). Default
|
falls back to the module globals). Default None is byte-identical to the
|
||||||
None is byte-identical to the pre-oblast-D behaviour: same SQL object,
|
pre-oblast-D behaviour: same SQL object, same PPM2_MIN/PPM2_MAX globals,
|
||||||
same PPM2_MIN/PPM2_MAX globals, no extra query.
|
no extra query.
|
||||||
"""
|
"""
|
||||||
if city is None:
|
if city is None:
|
||||||
ppm2_min: float = PPM2_MIN
|
ppm2_min: float = PPM2_MIN
|
||||||
ppm2_max: float = PPM2_MAX
|
ppm2_max: float = PPM2_MAX
|
||||||
else:
|
else:
|
||||||
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city, region_code=region_code)
|
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city)
|
||||||
params: dict[str, Any] = {
|
params: dict[str, Any] = {
|
||||||
"region_code": region_code,
|
|
||||||
"ppm2_min": ppm2_min,
|
"ppm2_min": ppm2_min,
|
||||||
"ppm2_max": ppm2_max,
|
"ppm2_max": ppm2_max,
|
||||||
"since": since,
|
"since": since,
|
||||||
|
|
@ -1976,21 +1947,8 @@ def _predict_full_spine(
|
||||||
# harness measures the SAME corridor prod actually computes today, not the
|
# harness measures the SAME corridor prod actually computes today, not the
|
||||||
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
|
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
|
||||||
target_city = m._resolve_target_city(deal.address)
|
target_city = m._resolve_target_city(deal.address)
|
||||||
# #3520 "любой регион": mirror estimate_quality's OWN region_code resolution
|
|
||||||
# (estimator.py ~4868-4870, region_for_point(geo.lat, geo.lon) with a
|
|
||||||
# DEFAULT_REGION_CODE fallback for points outside every registered bbox) —
|
|
||||||
# without this, _fetch_dkp_corridor's default (region 66) would scope a
|
|
||||||
# Москва/Московская обл. deal's corridor query to the WRONG region and
|
|
||||||
# silently return no rows (or, worse, an unrelated same-named street in 66).
|
|
||||||
target_region = region_for_point(deal.lat, deal.lon)
|
|
||||||
target_region_code = target_region.code if target_region else DEFAULT_BACKTEST_REGION_CODE
|
|
||||||
dkp_raw = m._fetch_dkp_corridor(
|
dkp_raw = m._fetch_dkp_corridor(
|
||||||
db,
|
db, address=deal.address, rooms=deal.rooms, area=deal.area_m2, city=target_city
|
||||||
address=deal.address,
|
|
||||||
rooms=deal.rooms,
|
|
||||||
area=deal.area_m2,
|
|
||||||
city=target_city,
|
|
||||||
region_code=target_region_code,
|
|
||||||
)
|
)
|
||||||
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
|
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
|
||||||
# estimate_quality — no-area / no-address → ([], None) instead of an
|
# estimate_quality — no-area / no-address → ([], None) instead of an
|
||||||
|
|
@ -2211,7 +2169,6 @@ def run_backtest(
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
scattered: bool = False,
|
scattered: bool = False,
|
||||||
seed: str = "mera",
|
seed: str = "mera",
|
||||||
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the full read-only backtest and return a metrics dict.
|
"""Drive the full read-only backtest and return a metrics dict.
|
||||||
|
|
||||||
|
|
@ -2227,29 +2184,12 @@ def run_backtest(
|
||||||
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
|
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
|
||||||
to fit on even-id deals and evaluate on the odd-id half for an honest number.
|
to fit on even-id deals and evaluate on the odd-id half for an honest number.
|
||||||
|
|
||||||
``region_code`` (#3520, default 66) scopes the deal sample to one
|
|
||||||
``deals.region_code`` — see ``_load_sample``.
|
|
||||||
|
|
||||||
``city`` (oblast D, default None) scopes the deal sample to one
|
``city`` (oblast D, default None) scopes the deal sample to one
|
||||||
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||||
(byte-identical to the pre-oblast-D behaviour).
|
(byte-identical to the pre-oblast-D behaviour).
|
||||||
"""
|
"""
|
||||||
deals = _load_sample(
|
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
|
||||||
db,
|
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
|
||||||
sample=sample,
|
|
||||||
since=since,
|
|
||||||
city=city,
|
|
||||||
scattered=scattered,
|
|
||||||
seed=seed,
|
|
||||||
region_code=region_code,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"loaded sample: %d ДКП deals (since=%s, city=%s, region=%d)",
|
|
||||||
len(deals),
|
|
||||||
since,
|
|
||||||
city,
|
|
||||||
region_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
matched_rows: list[tuple[float, float, int]] = []
|
matched_rows: list[tuple[float, float, int]] = []
|
||||||
matched_ids: list[int] = []
|
matched_ids: list[int] = []
|
||||||
|
|
@ -2324,7 +2264,6 @@ def run_backtest_full(
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
scattered: bool = False,
|
scattered: bool = False,
|
||||||
seed: str = "mera",
|
seed: str = "mera",
|
||||||
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
||||||
|
|
||||||
|
|
@ -2350,14 +2289,6 @@ def run_backtest_full(
|
||||||
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
|
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
|
||||||
attached to the returned metrics. Default False → byte-identical prior output.
|
attached to the returned metrics. Default False → byte-identical prior output.
|
||||||
|
|
||||||
``region_code`` (#3520, default 66) scopes the deal sample to one
|
|
||||||
``deals.region_code`` — see ``_load_sample``. Independently of this flag,
|
|
||||||
``_predict_full_spine`` ALWAYS resolves each deal's OWN region from its
|
|
||||||
lat/lon via ``regions.region_for_point`` (mirroring prod's
|
|
||||||
``estimate_quality`` region resolution) before calling
|
|
||||||
``_fetch_dkp_corridor``, so the ДКП corridor is scoped correctly even if a
|
|
||||||
sample somehow mixed regions.
|
|
||||||
|
|
||||||
``city`` (oblast D, default None) scopes the deal sample to one
|
``city`` (oblast D, default None) scopes the deal sample to one
|
||||||
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||||
(byte-identical to the pre-oblast-D behaviour). Independently of this flag,
|
(byte-identical to the pre-oblast-D behaviour). Independently of this flag,
|
||||||
|
|
@ -2365,21 +2296,9 @@ def run_backtest_full(
|
||||||
``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring.
|
``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring.
|
||||||
"""
|
"""
|
||||||
est = _import_estimator_full()
|
est = _import_estimator_full()
|
||||||
deals = _load_sample(
|
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
|
||||||
db,
|
|
||||||
sample=sample,
|
|
||||||
since=since,
|
|
||||||
city=city,
|
|
||||||
scattered=scattered,
|
|
||||||
seed=seed,
|
|
||||||
region_code=region_code,
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"loaded sample: %d ДКП deals (since=%s, city=%s, region=%d) [full spine]",
|
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
|
||||||
len(deals),
|
|
||||||
since,
|
|
||||||
city,
|
|
||||||
region_code,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
predictions: list[Prediction] = []
|
predictions: list[Prediction] = []
|
||||||
|
|
@ -2563,17 +2482,6 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два "
|
help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два "
|
||||||
"прогона подряд отличаются только правкой, а не составом выборки.",
|
"прогона подряд отличаются только правкой, а не составом выборки.",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
|
||||||
"--region",
|
|
||||||
type=int,
|
|
||||||
default=DEFAULT_BACKTEST_REGION_CODE,
|
|
||||||
choices=sorted(REGIONS),
|
|
||||||
help="#3520 'любой регион': deals.region_code to backtest against (default "
|
|
||||||
f"{DEFAULT_BACKTEST_REGION_CODE} — Свердловская обл., unchanged behaviour). "
|
|
||||||
"Supported values come from the app.services.regions.REGIONS registry — "
|
|
||||||
+ ", ".join(f"{code} ({REGIONS[code].name})" for code in sorted(REGIONS))
|
|
||||||
+ ". An unsupported code is rejected by argparse before any DB call.",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--city",
|
"--city",
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -2691,10 +2599,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
raise SystemExit("--calibrate-segments is only supported with --engine full")
|
raise SystemExit("--calibrate-segments is only supported with --engine full")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"backtest start: engine=%s region=%d sample=%d since=%s radius=%dm "
|
"backtest start: engine=%s sample=%d since=%s radius=%dm "
|
||||||
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
|
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
|
||||||
args.engine,
|
args.engine,
|
||||||
args.region,
|
|
||||||
args.sample,
|
args.sample,
|
||||||
args.since,
|
args.since,
|
||||||
args.radius,
|
args.radius,
|
||||||
|
|
@ -2717,7 +2624,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
city=args.city,
|
city=args.city,
|
||||||
scattered=(args.spread == "scattered"),
|
scattered=(args.spread == "scattered"),
|
||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
region_code=args.region,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
metrics = run_backtest(
|
metrics = run_backtest(
|
||||||
|
|
@ -2730,7 +2636,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
city=args.city,
|
city=args.city,
|
||||||
scattered=(args.spread == "scattered"),
|
scattered=(args.spread == "scattered"),
|
||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
region_code=args.region,
|
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -829,46 +829,6 @@ async def test_clean_address_throttles_repeated_feature_disabled_warning(caplog)
|
||||||
assert dadata._clean_disabled_warned is True
|
assert dadata._clean_disabled_warned is True
|
||||||
|
|
||||||
|
|
||||||
# Вымышленный токен ТОЙ ЖЕ формы, что настоящий DaData API-ключ (40 hex-символов) — НЕ
|
|
||||||
# реальный секрет. Прод-факт: DaData на 403 кладёт действующий токен в тело ответа
|
|
||||||
# открытым текстом, и body_preview копирует его в WARNING/ERROR лог как есть (#3471).
|
|
||||||
FAKE_LEAKED_TOKEN = "deadbeef1234deadbeef1234deadbeef12345678"
|
|
||||||
|
|
||||||
CLEAN_TOKEN_LEAK_BODY = {
|
|
||||||
"timestamp": "2026-09-13T10:00:00.000+00:00",
|
|
||||||
"status": 403,
|
|
||||||
"error": "Forbidden",
|
|
||||||
"message": (
|
|
||||||
f"Feature 'CLEAN' disabled for token '{FAKE_LEAKED_TOKEN}'. "
|
|
||||||
"See https://dadata.userecho.com/topics/7784 for help."
|
|
||||||
),
|
|
||||||
"path": "/api/v1/clean/address",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_clean_address_masks_token_in_feature_disabled_log(caplog) -> None:
|
|
||||||
"""403 body в реалистичной прод-форме (40-символьный токен) — токен НЕ должен светиться в логе.
|
|
||||||
|
|
||||||
Отличие от test_clean_address_logs_feature_disabled_distinctly: тот тест использует
|
|
||||||
укороченный токен 'xxx' и не проверяет утечку. Здесь — токен длины настоящего DaData-ключа,
|
|
||||||
именно такая форма ушла в docker logs на проде (#3471).
|
|
||||||
"""
|
|
||||||
from app.services import dadata
|
|
||||||
|
|
||||||
dadata._clean_disabled_warned = False # изоляция от порядка тестов (module-level throttle)
|
|
||||||
|
|
||||||
transport = _mock_transport_returning(403, CLEAN_TOKEN_LEAK_BODY)
|
|
||||||
with _patch_settings(), _patch_async_client(transport):
|
|
||||||
with caplog.at_level(_logging.WARNING, logger="app.services.dadata"):
|
|
||||||
result = await dadata.clean_address("Екатеринбург, Малышева 4")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
text = caplog.text
|
|
||||||
assert FAKE_LEAKED_TOKEN not in text, "токен утёк в лог-запись открытым текстом"
|
|
||||||
# Диагностика (какая услуга выключена) обязана сохраниться несмотря на маскирование.
|
|
||||||
assert "Стандартизация" in text or "выключена" in text
|
|
||||||
|
|
||||||
|
|
||||||
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
|
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
|
||||||
"""401 (или 403 без 'disabled') → сообщение про креды."""
|
"""401 (или 403 без 'disabled') → сообщение про креды."""
|
||||||
from app.services import dadata
|
from app.services import dadata
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,6 @@ from app.services import location_index as lc
|
||||||
# A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area).
|
# A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area).
|
||||||
_LAT_IN_EKB = 56.838
|
_LAT_IN_EKB = 56.838
|
||||||
_LON_IN_EKB = 60.605
|
_LON_IN_EKB = 60.605
|
||||||
_REGION_66 = lc.regions_mod.REGIONS[66]
|
|
||||||
|
|
||||||
# A point well inside Moscow's product core (within MKAD) — #3051 region 77.
|
|
||||||
_LAT_IN_MSK = 55.75
|
|
||||||
_LON_IN_MSK = 37.62
|
|
||||||
_REGION_77 = lc.regions_mod.REGIONS[77]
|
|
||||||
|
|
||||||
# ── Pure functions ──────────────────────────────────────────────────────────
|
# ── Pure functions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -124,7 +118,6 @@ def test_no_psycopg_v3_colon_colon_cast() -> None:
|
||||||
lc._MEDIAN_PPM2_LOCAL_SQL,
|
lc._MEDIAN_PPM2_LOCAL_SQL,
|
||||||
lc._MEDIAN_PPM2_CITYWIDE_SQL,
|
lc._MEDIAN_PPM2_CITYWIDE_SQL,
|
||||||
lc._NEAREST_POI_SQL,
|
lc._NEAREST_POI_SQL,
|
||||||
lc._POI_COUNT_IN_REGION_SQL,
|
|
||||||
):
|
):
|
||||||
assert not re.search(r":\w+::", str(sql.text))
|
assert not re.search(r":\w+::", str(sql.text))
|
||||||
|
|
||||||
|
|
@ -149,51 +142,6 @@ def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None:
|
||||||
assert "bbox_west" in sql_text and "bbox_east" in sql_text
|
assert "bbox_west" in sql_text and "bbox_east" in sql_text
|
||||||
|
|
||||||
|
|
||||||
# ── #msk-defect-1: медианы обязаны фильтроваться по региону, не только по bbox ──
|
|
||||||
|
|
||||||
|
|
||||||
def test_median_queries_filter_by_region_code() -> None:
|
|
||||||
"""Прод-замер 13.09.2026: bbox продукт-ядра Москвы геометрически захватывает соседние
|
|
||||||
region_code=50 (Химки/Реутов/Котельники/Люберцы) — без фильтра по региону городская
|
|
||||||
медиана Москвы считалась с примесью 16 801 подмосковного лота и была занижена на 7.9%
|
|
||||||
(422 222 вместо 458 208 ₽/м²). У областных строк city IS NULL — фильтровать можно
|
|
||||||
только по region_code, не по имени города."""
|
|
||||||
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
|
||||||
sql_text = str(sql.text)
|
|
||||||
assert "region_code = CAST(:region_code AS integer)" in sql_text
|
|
||||||
|
|
||||||
|
|
||||||
def test_median_queries_do_not_admit_null_region_code() -> None:
|
|
||||||
"""Строгое равенство, БЕЗ `OR region_code IS NULL` — строка без региона не считается
|
|
||||||
ни за один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля, bbox
|
|
||||||
ядра 66): 15 858 строк проходят фильтры, 3 из них с NULL region_code; медиана с ними
|
|
||||||
151 101 ₽/м², без них 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), для 66
|
|
||||||
статистически ничто. Мягкий `OR NULL` был бы лазейкой того же класса, что и сам
|
|
||||||
дефект: в `listings` 16 930 NULL-region строк Avito, 24 из них уже лежат в bbox
|
|
||||||
Москвы (сегодня не проходят по свежести, но могут завтра) — `OR NULL` тихо открыл бы
|
|
||||||
им дорогу обратно в московскую медиану."""
|
|
||||||
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
|
||||||
sql_text = str(sql.text)
|
|
||||||
assert "region_code IS NULL" not in sql_text
|
|
||||||
assert "OR region_code" not in sql_text
|
|
||||||
|
|
||||||
|
|
||||||
def test_local_and_citywide_median_bind_region_code_of_the_given_region() -> None:
|
|
||||||
"""region_code реально долетает bind-параметром и берётся ИЗ переданного региона, а не
|
|
||||||
захардкожен — иначе фикс работал бы только для 66 и молча ломался на следующем регионе."""
|
|
||||||
db66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000})])
|
|
||||||
lc._citywide_median_ppm2(db66, _REGION_66)
|
|
||||||
assert db66.executed[0][1]["region_code"] == 66
|
|
||||||
|
|
||||||
db77 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 458_208.0, "n": 82_719})])
|
|
||||||
lc._citywide_median_ppm2(db77, _REGION_77)
|
|
||||||
assert db77.executed[0][1]["region_code"] == 77
|
|
||||||
|
|
||||||
local66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25})])
|
|
||||||
lc._local_median_ppm2(local66, _LAT_IN_EKB, _LON_IN_EKB, 800, _REGION_66)
|
|
||||||
assert local66.executed[0][1]["region_code"] == 66
|
|
||||||
|
|
||||||
|
|
||||||
# ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ─────────────
|
# ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -298,9 +246,7 @@ class _FakeDB:
|
||||||
|
|
||||||
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
|
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
|
||||||
db = _FakeDB([_FakeResult(scalar_value=0)])
|
db = _FakeDB([_FakeResult(scalar_value=0)])
|
||||||
poi, status = lc._fetch_nearby_poi(
|
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
|
||||||
db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66
|
|
||||||
)
|
|
||||||
assert poi == []
|
assert poi == []
|
||||||
assert status == "unavailable"
|
assert status == "unavailable"
|
||||||
assert len(db.executed) == 1 # only the count probe ran
|
assert len(db.executed) == 1 # only the count probe ran
|
||||||
|
|
@ -308,9 +254,7 @@ def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
|
||||||
|
|
||||||
def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None:
|
def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None:
|
||||||
db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])])
|
db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])])
|
||||||
poi, status = lc._fetch_nearby_poi(
|
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
|
||||||
db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66
|
|
||||||
)
|
|
||||||
assert poi == []
|
assert poi == []
|
||||||
assert status == "ok"
|
assert status == "ok"
|
||||||
|
|
||||||
|
|
@ -323,7 +267,7 @@ def test_fetch_nearby_poi_ranks_by_weight_not_distance_only() -> None:
|
||||||
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
|
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
|
||||||
]
|
]
|
||||||
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
|
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
|
||||||
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66)
|
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
|
||||||
assert status == "ok"
|
assert status == "ok"
|
||||||
assert len(poi) == 4
|
assert len(poi) == 4
|
||||||
# metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not
|
# metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not
|
||||||
|
|
@ -337,7 +281,7 @@ def test_fetch_nearby_poi_limits_to_top_n() -> None:
|
||||||
for i in range(20)
|
for i in range(20)
|
||||||
]
|
]
|
||||||
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
|
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
|
||||||
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66)
|
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
|
||||||
assert len(poi) == 7
|
assert len(poi) == 7
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -463,64 +407,3 @@ def test_compute_location_index_poi_unavailable_does_not_block_index() -> None:
|
||||||
assert result.poi_status == "unavailable"
|
assert result.poi_status == "unavailable"
|
||||||
assert result.nearby_poi == []
|
assert result.nearby_poi == []
|
||||||
assert result.location_index_pct == 15.0
|
assert result.location_index_pct == 15.0
|
||||||
|
|
||||||
|
|
||||||
# ── #msk-defect-1/2: Москва (region 77) — не течёт в EKB, честная POI-деградация ──
|
|
||||||
|
|
||||||
|
|
||||||
def test_covered_region_resolves_moscow_center_to_region_77() -> None:
|
|
||||||
"""МКАД-центр Москвы должен попадать в продукт-ядро 77, а не в 50 (см. комментарий у
|
|
||||||
regions._POINT_LOOKUP_ORDER — компактный bbox_region проверяется раньше объемлющего)."""
|
|
||||||
region = lc._covered_region(_LAT_IN_MSK, _LON_IN_MSK)
|
|
||||||
assert region is not None and region.code == 77
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_nearby_poi_gate_uses_regions_own_bbox_not_whole_table() -> None:
|
|
||||||
"""Дефект (прод 13.09.2026): гейт раньше делал `count(*)` по ВСЕЙ osm_poi_ekb_local
|
|
||||||
(4 850 екатеринбургских строк, всегда > 0) — Москва получала бы poi_status="ok" с
|
|
||||||
пустым списком. Теперь count скоупится в bbox_product_core региона точки: для 77
|
|
||||||
внутри его ядра POI нет (0), для 66 — те же 4 850 строк, что и раньше."""
|
|
||||||
db_msk = _FakeDB([_FakeResult(scalar_value=0)])
|
|
||||||
poi, status = lc._fetch_nearby_poi(
|
|
||||||
db_msk, _LAT_IN_MSK, _LON_IN_MSK, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_77
|
|
||||||
)
|
|
||||||
assert poi == []
|
|
||||||
assert status == "unavailable" # честная деградация, ТА ЖЕ терминология, что и раньше
|
|
||||||
# bbox в запросе — Москвы, не Екатеринбурга (это и есть суть фикса).
|
|
||||||
poi_count_params = db_msk.executed[0][1]
|
|
||||||
lat_min, lat_max, lon_min, lon_max = _REGION_77.bbox_product_core
|
|
||||||
assert poi_count_params["bbox_south"] == lat_min
|
|
||||||
assert poi_count_params["bbox_north"] == lat_max
|
|
||||||
assert poi_count_params["bbox_west"] == lon_min
|
|
||||||
assert poi_count_params["bbox_east"] == lon_max
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_nearby_poi_gate_still_scopes_to_ekb_bbox_for_region_66() -> None:
|
|
||||||
"""Regression: для 66 гейт обязан продолжать спрашивать про bbox ЕКБ — byte-for-byte,
|
|
||||||
просто теперь явно скоупенный, а не «случайно совпадающий» с count(*) по всей таблице."""
|
|
||||||
db = _FakeDB([_FakeResult(scalar_value=4850), _FakeResult(mapping_rows=[])])
|
|
||||||
lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66)
|
|
||||||
poi_count_params = db.executed[0][1]
|
|
||||||
lat_min, lat_max, lon_min, lon_max = _REGION_66.bbox_product_core
|
|
||||||
assert poi_count_params["bbox_south"] == lat_min
|
|
||||||
assert poi_count_params["bbox_north"] == lat_max
|
|
||||||
assert poi_count_params["bbox_west"] == lon_min
|
|
||||||
assert poi_count_params["bbox_east"] == lon_max
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_location_index_moscow_point_isolates_region_and_degrades_honestly() -> None:
|
|
||||||
"""End-to-end #3051 Москва: citywide/local запросы обязаны нести region_code=77 (не 66,
|
|
||||||
не отсутствие фильтра), а poi_status обязан быть честным "unavailable" — НЕ "ok" с
|
|
||||||
пустым списком, как было до фикса дефекта 2."""
|
|
||||||
db = _FakeDB(
|
|
||||||
[
|
|
||||||
_FakeResult(scalar_value=0), # poi-count in Moscow core: 0
|
|
||||||
_FakeResult(mapping_one={"median_ppm2": None, "n": 0}), # citywide: no MSK data yet
|
|
||||||
]
|
|
||||||
)
|
|
||||||
result = lc.compute_location_index(db, lat=_LAT_IN_MSK, lon=_LON_IN_MSK)
|
|
||||||
assert result.status == "insufficient_data" # честно: листингов Москвы пока нет
|
|
||||||
assert result.poi_status == "unavailable"
|
|
||||||
assert result.nearby_poi == []
|
|
||||||
citywide_params = db.executed[1][1]
|
|
||||||
assert citywide_params["region_code"] == 77
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ NEVER calls real Telegram API — httpx.MockTransport only, тот же патт
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
|
@ -52,19 +51,12 @@ def _stop_patches():
|
||||||
# ── verify_chat_and_topic ────────────────────────────────────────────────────
|
# ── verify_chat_and_topic ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def test_verify_chat_and_topic_success_uses_send_message_and_delete_message() -> None:
|
async def test_verify_chat_and_topic_success_calls_get_chat_and_send_chat_action() -> None:
|
||||||
"""Happy path: getChat + зондирующий sendMessage в тему + его deleteMessage.
|
"""Happy path: getChat + typing-индикатор в тему, никакого видимого сообщения."""
|
||||||
|
|
||||||
`sendChatAction` тут больше не используется: живой прод-замер (#3471)
|
|
||||||
показал, что она подтверждает даже несуществующий `message_thread_id`
|
|
||||||
(`{"ok":true,"result":true}`) — проверка была ложно-зелёной."""
|
|
||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
method = request.url.path.rsplit("/", 1)[-1]
|
calls.append(request.url.path.rsplit("/", 1)[-1])
|
||||||
calls.append(method)
|
|
||||||
if method == "sendMessage":
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 42}})
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": True})
|
return httpx.Response(200, json={"ok": True, "result": True})
|
||||||
|
|
||||||
_install_transport(handler)
|
_install_transport(handler)
|
||||||
|
|
@ -73,31 +65,24 @@ async def test_verify_chat_and_topic_success_uses_send_message_and_delete_messag
|
||||||
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
|
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
|
||||||
|
|
||||||
assert ok is True
|
assert ok is True
|
||||||
assert calls == ["getChat", "sendMessage", "deleteMessage"]
|
assert calls == ["getChat", "sendChatAction"]
|
||||||
|
|
||||||
|
|
||||||
async def test_verify_chat_and_topic_deletes_probe_message_leaving_no_trace() -> None:
|
async def test_verify_chat_and_topic_no_visible_message_method_used() -> None:
|
||||||
"""Зонд `sendMessage` не остаётся видимым — `deleteMessage` бьёт по тому же
|
"""Ни один из вызовов не бьёт в sendMessage/copyMessage — не мусорим в чат."""
|
||||||
`message_id`, что вернул `sendMessage` (не мусорим в истории треда)."""
|
methods: list[str] = []
|
||||||
delete_payloads: list[dict[str, object]] = []
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
method = request.url.path.rsplit("/", 1)[-1]
|
methods.append(request.url.path.rsplit("/", 1)[-1])
|
||||||
if method == "sendMessage":
|
return httpx.Response(200, json={"ok": True, "result": True})
|
||||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 986}})
|
|
||||||
if method == "deleteMessage":
|
|
||||||
delete_payloads.append(json.loads(request.content))
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": True})
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": {}})
|
|
||||||
|
|
||||||
_install_transport(handler)
|
_install_transport(handler)
|
||||||
client = TelegramClient(token="fake-token")
|
client = TelegramClient(token="fake-token")
|
||||||
|
|
||||||
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
|
await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
|
||||||
|
|
||||||
assert ok is True
|
assert "sendMessage" not in methods
|
||||||
assert len(delete_payloads) == 1
|
assert "copyMessage" not in methods
|
||||||
assert delete_payloads[0]["message_id"] == 986
|
|
||||||
|
|
||||||
|
|
||||||
async def test_verify_chat_and_topic_logs_error_and_does_not_raise(caplog) -> None:
|
async def test_verify_chat_and_topic_logs_error_and_does_not_raise(caplog) -> None:
|
||||||
|
|
@ -150,50 +135,6 @@ async def test_verify_chat_and_topic_skips_api_call_when_chat_id_is_zero() -> No
|
||||||
assert calls == []
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
async def test_verify_chat_and_topic_is_red_when_topic_deleted_even_though_send_chat_action_lies(
|
|
||||||
caplog,
|
|
||||||
) -> None:
|
|
||||||
"""Регрессия #3471: удалённая/переименованная тема ДОЛЖНА красить проверку.
|
|
||||||
|
|
||||||
Handler воспроизводит РЕАЛЬНО замеренное на проде поведение Bot API:
|
|
||||||
`sendChatAction` подтверждает даже несуществующий `message_thread_id`
|
|
||||||
(`{"ok":true,"result":true}` — живой прод-замер, #3471), а `sendMessage`
|
|
||||||
с тем же id честно отвечает `400 Bad Request: message thread not found`.
|
|
||||||
|
|
||||||
На коде ДО правки (`verify_chat_and_topic` звала `send_chat_action`) этот
|
|
||||||
тест падает: `ok` был бы `True` — проверка молчаливо считала мёртвую тему
|
|
||||||
живой. Явно проверено при разработке (#3471): `git checkout HEAD --
|
|
||||||
.../client.py` (до-фиксная версия) + прогон только этого теста дал
|
|
||||||
`assert True is False`; после возврата фикса — зелёный."""
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
method = request.url.path.rsplit("/", 1)[-1]
|
|
||||||
if method == "getChat":
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": {"id": -100123}})
|
|
||||||
if method == "sendChatAction":
|
|
||||||
# Реальный прод-баг: sendChatAction НЕ валидирует message_thread_id.
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": True})
|
|
||||||
if method == "sendMessage":
|
|
||||||
return httpx.Response(
|
|
||||||
400,
|
|
||||||
json={
|
|
||||||
"ok": False,
|
|
||||||
"error_code": 400,
|
|
||||||
"description": "Bad Request: message thread not found",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return httpx.Response(200, json={"ok": True, "result": True})
|
|
||||||
|
|
||||||
_install_transport(handler)
|
|
||||||
client = TelegramClient(token="fake-token")
|
|
||||||
|
|
||||||
with caplog.at_level("ERROR", logger="app.services.tgbot.client"):
|
|
||||||
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=987654, label="support")
|
|
||||||
|
|
||||||
assert ok is False
|
|
||||||
assert any(record.levelname == "ERROR" for record in caplog.records)
|
|
||||||
|
|
||||||
|
|
||||||
# ── TelegramGroupRateLimiter ─────────────────────────────────────────────────
|
# ── TelegramGroupRateLimiter ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,6 @@ tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard
|
||||||
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
|
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
|
||||||
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
|
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
|
||||||
tests/test_user_events.py::test_real_record_event_inserts_row
|
tests/test_user_events.py::test_real_record_event_inserts_row
|
||||||
tests/test_3469_showcase_schedule.py::test_live_migration_puts_showcase_into_schedules_and_digest
|
|
||||||
|
|
||||||
# Приватность/ретеншн (#2547) — тот же `_live_session()`. Приехали в main
|
# Приватность/ретеншн (#2547) — тот же `_live_session()`. Приехали в main
|
||||||
# параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и
|
# параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и
|
||||||
|
|
|
||||||
|
|
@ -64,14 +64,7 @@ def test_uvicorn_access_line_masks_secret_query_param() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_app_logger_via_root_handler_masks_secret() -> None:
|
def test_app_logger_via_root_handler_masks_secret() -> None:
|
||||||
"""Фильтр стоит и на handler'ах корня — прикладные логгеры тоже закрыты.
|
"""Фильтр стоит и на handler'ах корня — прикладные логгеры тоже закрыты."""
|
||||||
|
|
||||||
Секрет + query-контекст (`?token=...`) передаются ОДНИМ %s-аргументом — как реально
|
|
||||||
устроены вызовы в этом кодбейзе (см. body_preview в app/services/dadata.py), а не
|
|
||||||
расщеплены между msg-шаблоном и голым значением в args. Фильтр (#3471) скрабит
|
|
||||||
ЭЛЕМЕНТЫ `record.args` по отдельности, не трогая arity/`record.msg`-шаблон — поэтому
|
|
||||||
секрет обязан быть самодостаточным (с контекстом) внутри одного аргумента.
|
|
||||||
"""
|
|
||||||
stream = io.StringIO()
|
stream = io.StringIO()
|
||||||
handler = logging.StreamHandler(stream)
|
handler = logging.StreamHandler(stream)
|
||||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
|
@ -80,7 +73,7 @@ def test_app_logger_via_root_handler_masks_secret() -> None:
|
||||||
root.addHandler(handler)
|
root.addHandler(handler)
|
||||||
try:
|
try:
|
||||||
logging.getLogger("app.some.module").warning(
|
logging.getLogger("app.some.module").warning(
|
||||||
"retry callback %s", f"https://gendsgn.ru/hook?token={_SECRET}"
|
"retry callback https://gendsgn.ru/hook?token=%s", _SECRET
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
root.removeHandler(handler)
|
root.removeHandler(handler)
|
||||||
|
|
@ -140,46 +133,3 @@ def test_install_is_idempotent() -> None:
|
||||||
install_query_secret_filter("uvicorn.access")
|
install_query_secret_filter("uvicorn.access")
|
||||||
filters = logging.getLogger("uvicorn.access").filters
|
filters = logging.getLogger("uvicorn.access").filters
|
||||||
assert sum(isinstance(f, QuerySecretFilter) for f in filters) == 1, filters
|
assert sum(isinstance(f, QuerySecretFilter) for f in filters) == 1, filters
|
||||||
|
|
||||||
|
|
||||||
def test_uvicorn_access_formatter_survives_secret_scrub() -> None:
|
|
||||||
"""Прод-баг: скраб секрета в access-логе ронял сам процесс логирования (docker logs).
|
|
||||||
|
|
||||||
`uvicorn.logging.AccessFormatter.formatMessage()` распаковывает `record.args` НАПРЯМУЮ
|
|
||||||
как 5-tuple (`client_addr, method, full_path, http_version, status_code = record.args`),
|
|
||||||
в обход `record.getMessage()`. Старая реализация фильтра при найденном секрете обнуляла
|
|
||||||
`record.args = ()` → `ValueError: not enough values to unpack (expected 5, got 0)` →
|
|
||||||
`--- Logging error ---` в docker-логах ТОЛЬКО в момент, когда фильтр пытался защитить
|
|
||||||
секрет (см. трассу через starlette/middleware/errors.py `_send` — это просто цепочка
|
|
||||||
вложенных ASGI `send()`, ведущая к access-log вызову внутри uvicorn).
|
|
||||||
"""
|
|
||||||
from uvicorn.logging import AccessFormatter
|
|
||||||
|
|
||||||
stream = io.StringIO()
|
|
||||||
handler = logging.StreamHandler(stream)
|
|
||||||
handler.setFormatter(AccessFormatter("%(client_addr)s - %(request_line)s %(status_code)s"))
|
|
||||||
logger = logging.getLogger("uvicorn.access.formatter_test")
|
|
||||||
logger.addHandler(handler)
|
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
logger.propagate = False
|
|
||||||
install_query_secret_filter("uvicorn.access.formatter_test")
|
|
||||||
try:
|
|
||||||
# НЕ должно бросить ValueError при обработке handler'ом (падение уходит в
|
|
||||||
# handleError → stderr, а не наверх — поэтому проверяем результат, а не raises).
|
|
||||||
logger.info(
|
|
||||||
'%s - "%s %s HTTP/%s" %d',
|
|
||||||
"172.18.0.3:60322",
|
|
||||||
"POST",
|
|
||||||
f"/api/v1/trade-in/ops/glitchtip-webhook?secret={_SECRET}",
|
|
||||||
"1.1",
|
|
||||||
200,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
logger.removeHandler(handler)
|
|
||||||
logger.filters.clear()
|
|
||||||
|
|
||||||
out = stream.getvalue()
|
|
||||||
assert out, "форматтер не выдал ни строки — запись эмита провалилась (см. handleError)"
|
|
||||||
assert _SECRET not in out, out
|
|
||||||
assert "secret=***" in out, out
|
|
||||||
assert "200" in out and "POST" in out
|
|
||||||
|
|
|
||||||
|
|
@ -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,324 +0,0 @@
|
||||||
"""Витрина сделок лэндинга попадает в расписание и в монитор свежести (#3469).
|
|
||||||
|
|
||||||
ЧТО БЫЛО СЛОМАНО. `landing_showcase_deals` считает витрину публичного лэндинга
|
|
||||||
(«МЕРА сказала X — продали за Y»), но в `scrape_schedules` строки для неё не было
|
|
||||||
вовсе (`WHERE source LIKE '%showcase%'` — 0 строк на проде 12.09.2026), а в
|
|
||||||
реестре `product_handlers` — обработчика. То есть планировщик про задачу не знал
|
|
||||||
ни с какой стороны, пересчёт был ручным, и страница показывала прогон
|
|
||||||
тринадцатисуточной давности. Заметить это было неоткуда: под таблицей печатались
|
|
||||||
счётчики прогона, но не его дата, а сводка просроченных источников
|
|
||||||
(`emit_stale_digest`, #2670) ходит по ВКЛЮЧЁННЫМ РАСПИСАНИЯМ — источника, которого
|
|
||||||
в таблице нет, для неё не существует.
|
|
||||||
|
|
||||||
ЧТО ПРОВЕРЯЕТСЯ ЗДЕСЬ, И ПОЧЕМУ ИМЕННО ЭТО.
|
|
||||||
|
|
||||||
1. Обработчик резолвится ТЕМ ЖЕ `resolve_handler`, которым его ищет боевой
|
|
||||||
`_dispatch`. Одной строки расписания мало: без обработчика планировщик
|
|
||||||
нашёл бы задачу и не смог её запустить.
|
|
||||||
2. Миграция 303 сеет строку, и сеет её ВКЛЮЧЁННОЙ с явным `interval_days` —
|
|
||||||
сводка считает порог просрочки из этого же числа.
|
|
||||||
3. Сводка краснеет, когда витрина не пересчитывалась дольше ТРЁХ тактов
|
|
||||||
(приёмка #3469), и молчит на двух. Число тактов здесь — литерал, а такт
|
|
||||||
читается из миграции: ожидание, взятое из той же настройки, которую
|
|
||||||
проверяешь, уезжает вместе с ней — см. комментарий при _ACCEPTANCE_CYCLES.
|
|
||||||
4. Живой Postgres (само-скип): миграция реально вставляет строку в таблицу,
|
|
||||||
повторное применение её не задваивает, и настоящий запрос сводки
|
|
||||||
`_STALE_SOURCES_SQL` видит эту строку и отдаёт витрину просроченной.
|
|
||||||
|
|
||||||
ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет ту же идемпотентную
|
|
||||||
миграцию, что применяет деплой (ON CONFLICT DO NOTHING). В CI он ИДЁТ —
|
|
||||||
ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL; на машине без
|
|
||||||
базы само-скипается (запись в tests/skip_allowlist.txt).
|
|
||||||
|
|
||||||
ГОНЯТЬ ЕГО НАДО НА ПОЛНОЙ СХЕМЕ, А НЕ НА ПУСТОЙ БАЗЕ. На чистой базе он был
|
|
||||||
зелёным и при этом падал в CI: повтор `015_scrape_runs.sql` (его комментарий к
|
|
||||||
колонке, снесённой миграцией 214) на полной схеме валится, а на пустой — нет.
|
|
||||||
Поэтому зависимости применяются только когда таблицы ещё нет, а проверять надо
|
|
||||||
тем же путём, каким гоняет CI:
|
|
||||||
|
|
||||||
docker exec tradein-postgres psql -U tradein -d postgres -c 'CREATE DATABASE t3469full'
|
|
||||||
docker exec -i tradein-postgres psql -U tradein -d t3469full -c \\
|
|
||||||
'CREATE EXTENSION postgis; CREATE EXTENSION pg_trgm; CREATE ROLE gendesign_reader;'
|
|
||||||
for f in $(ls -1 data/sql/*.sql | sort); do docker exec -i tradein-postgres \\
|
|
||||||
psql -U tradein -d t3469full -v ON_ERROR_STOP=on -q < "$f"; done
|
|
||||||
DATABASE_URL="postgresql+psycopg://tradein:tradein@127.0.0.1:5433/t3469full" \\
|
|
||||||
uv run python -m pytest tests/test_3469_showcase_schedule.py -q
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
||||||
|
|
||||||
from scraper_kit.orchestration import scheduler as sched
|
|
||||||
|
|
||||||
from app.services.product_handlers import _job_landing_showcase_deals, build_product_handlers
|
|
||||||
|
|
||||||
SOURCE = "landing_showcase_deals"
|
|
||||||
|
|
||||||
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
|
||||||
_MIGRATION = _SQL_DIR / "303_scrape_schedules_seed_landing_showcase_deals.sql"
|
|
||||||
# Таблицы, без которых строку расписания некуда класть (FK scrape_schedules →
|
|
||||||
# scrape_runs), — живой тест применяет их в том же порядке, что и деплой.
|
|
||||||
_DEPS = [
|
|
||||||
_SQL_DIR / "015_scrape_runs.sql",
|
|
||||||
# counters jsonb — по нему сводка судит, принёс ли прогон данные.
|
|
||||||
_SQL_DIR / "051_scrape_runs_extend.sql",
|
|
||||||
_SQL_DIR / "052_scrape_schedules.sql",
|
|
||||||
]
|
|
||||||
|
|
||||||
NOW = datetime(2026, 9, 12, 8, 0, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def _migration_sql() -> str:
|
|
||||||
return _MIGRATION.read_text("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _seeded_interval_days() -> int:
|
|
||||||
"""Такт из САМОЙ миграции — порог сводки считается из него, не из литерала."""
|
|
||||||
m = re.search(r'"interval_days"\s*:\s*(\d+)', _migration_sql())
|
|
||||||
assert m is not None, "в default_params миграции 303 нет interval_days"
|
|
||||||
return int(m.group(1))
|
|
||||||
|
|
||||||
|
|
||||||
# ── 1. Планировщик видит задачу ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_handler_resolves_for_scheduler() -> None:
|
|
||||||
"""`resolve_handler` находит витрину — тем же вызовом, что и боевой _dispatch.
|
|
||||||
|
|
||||||
Ломать так: убрать ключ из реестра в product_handlers — тест покраснеет, а
|
|
||||||
планировщик на проде заклеймил бы прогон и не нашёл, чем его выполнить.
|
|
||||||
"""
|
|
||||||
registry = build_product_handlers(ctx=None) # type: ignore[arg-type]
|
|
||||||
handler = sched.resolve_handler(SOURCE, registry)
|
|
||||||
assert handler is not None, f"{SOURCE} не резолвится реестром — задача невидима"
|
|
||||||
# СРАВНИВАЕМ САМ JOB, А НЕ `log_name`: имя — второй литерал конструктора
|
|
||||||
# Handler, и правильный ключ с чужим телом (`_job_landing_stats` под ключом
|
|
||||||
# витрины) проходил проверку по имени насквозь. Резолв ведёт к пересчёту
|
|
||||||
# витрины или не ведёт — это свойство функции, а не подписи в логе.
|
|
||||||
assert handler.job is _job_landing_showcase_deals, (
|
|
||||||
f"под ключом {SOURCE} стоит чужой job: {handler.job.__name__}"
|
|
||||||
)
|
|
||||||
assert handler.log_name == SOURCE
|
|
||||||
|
|
||||||
|
|
||||||
# ── 2. Миграция сеет строку ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_303_exists() -> None:
|
|
||||||
assert _MIGRATION.is_file(), f"missing migration: {_MIGRATION}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_303_seeds_source_enabled() -> None:
|
|
||||||
sql = _migration_sql()
|
|
||||||
assert f"'{SOURCE}'" in sql
|
|
||||||
assert "INSERT INTO scrape_schedules" in sql
|
|
||||||
# enabled=true — иначе сводка просроченных источников строку не увидит
|
|
||||||
# (_STALE_SOURCES_SQL: WHERE sch.enabled), и монитор молчал бы как раньше.
|
|
||||||
assert re.search(rf"'{SOURCE}',\s*\n\s*true", sql), "расписание засеяно выключенным"
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_303_is_idempotent_and_transactional() -> None:
|
|
||||||
sql = _migration_sql()
|
|
||||||
assert "ON CONFLICT (source) DO NOTHING" in sql
|
|
||||||
assert "BEGIN;" in sql
|
|
||||||
assert "COMMIT;" in sql
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_303_no_psycopg_trap() -> None:
|
|
||||||
assert not re.search(r":\w+::", _migration_sql())
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_303_interval_days_is_daily() -> None:
|
|
||||||
"""Такт суточный: витрина устаревает от КОДА (деплой), а не от квартальных сделок."""
|
|
||||||
assert _seeded_interval_days() == 1
|
|
||||||
|
|
||||||
|
|
||||||
# ── 3. Сводка свежести краснеет на молчащей витрине ──────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _row(age_days: float, *, status: str | None = "done") -> Any:
|
|
||||||
"""Строка `_STALE_SOURCES_SQL`: прогон витрины `age_days` суток назад.
|
|
||||||
|
|
||||||
`status=None` (LEFT JOIN не нашёл прогонов) — витрину не пересчитывали ни разу
|
|
||||||
с момента появления расписания; тогда возраст считается от created_at строки.
|
|
||||||
"""
|
|
||||||
finished = None if status is None else NOW - timedelta(days=age_days)
|
|
||||||
return SimpleNamespace(
|
|
||||||
source=SOURCE,
|
|
||||||
interval_days=str(_seeded_interval_days()),
|
|
||||||
created_at=NOW - timedelta(days=age_days),
|
|
||||||
finished_at=finished,
|
|
||||||
status=status,
|
|
||||||
counters={"considered": 200, "eligible": 161, "written": 20},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _stale_now(rows: list[Any]) -> list[sched.StaleSource]:
|
|
||||||
return sched.stale_sources(sched.freshness_rows(rows), NOW)
|
|
||||||
|
|
||||||
|
|
||||||
# Приёмка issue #3469 дословно: «отсутствие прогона дольше 3× такта даёт тревогу».
|
|
||||||
# ЧИСЛО ЗДЕСЬ ЛИТЕРАЛ, А НЕ `sched.STALE_DIGEST_INTERVAL_FACTOR`. Взятое из той же
|
|
||||||
# настройки, которую проверяем, ожидание уезжает вместе с ней: при факторе 3650
|
|
||||||
# ЭТИ ЖЕ тесты оставались зелёными (проверено руками), то есть проверяли ровно
|
|
||||||
# ничего. Такт (`interval_days`) при этом читается из миграции — правило «3×»
|
|
||||||
# и задано в тактах, а не в сутках.
|
|
||||||
_ACCEPTANCE_CYCLES = 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_digest_flags_showcase_after_three_cycles() -> None:
|
|
||||||
"""Нет пересчёта дольше 3× такта → витрина в сводке просроченных."""
|
|
||||||
lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
|
|
||||||
stale = _stale_now([_row(lag)])
|
|
||||||
assert [s.source for s in stale] == [SOURCE], (
|
|
||||||
f"витрина молчит {lag} суток при такте {_seeded_interval_days()} и не в тревоге"
|
|
||||||
)
|
|
||||||
assert stale[0].interval_days == _seeded_interval_days()
|
|
||||||
|
|
||||||
|
|
||||||
def test_digest_silent_within_cycle() -> None:
|
|
||||||
"""Контроль: два такта — ещё норма, иначе тревога кричала бы всегда."""
|
|
||||||
lag = 2 * _seeded_interval_days()
|
|
||||||
assert _stale_now([_row(lag)]) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_digest_flags_showcase_that_never_ran() -> None:
|
|
||||||
"""Расписание есть, прогонов нет — самый частый вид молчания (#3469 и был им)."""
|
|
||||||
lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 1
|
|
||||||
stale = _stale_now([_row(lag, status=None)])
|
|
||||||
assert [s.source for s in stale] == [SOURCE]
|
|
||||||
assert stale[0].never_ok is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_showcase_counters_do_not_fake_freshness() -> None:
|
|
||||||
"""Свежесть даёт ПРОГОН, а не его счётчики.
|
|
||||||
|
|
||||||
`run_brought_data` судит по результатным ключам kit'а, а у витрины их нет
|
|
||||||
(`considered`/`eligible`/`written` — свой словарь). Значит мерой остаётся
|
|
||||||
успешный статус: прогон, свалившийся в failed, свежести не даёт.
|
|
||||||
"""
|
|
||||||
assert sched.run_brought_data("done", {"considered": 200, "written": 20}) is True
|
|
||||||
assert sched.run_brought_data("failed", {"considered": 200, "written": 0}) is False
|
|
||||||
|
|
||||||
|
|
||||||
# ── 4. Живая БД: строка реально ложится в таблицу ────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _live_session() -> Any | None:
|
|
||||||
"""Session на живой Postgres — та же проба, что у соседних живых тестов.
|
|
||||||
|
|
||||||
`TEST_DATABASE_URL` имеет приоритет; `localhost:5432/test` — заглушка модулей,
|
|
||||||
её не считаем базой. В CI сюда приезжает DSN поднятого в job'е контейнера
|
|
||||||
(ci-tradein.yml), поэтому проверка там ИДЁТ, а не тихо скипается.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
|
||||||
if not dsn or "localhost:5432/test" in dsn:
|
|
||||||
return None
|
|
||||||
engine = create_engine(dsn, future=True)
|
|
||||||
with engine.connect() as conn:
|
|
||||||
conn.execute(text("SELECT 1"))
|
|
||||||
return sessionmaker(bind=engine, future=True)()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _apply(db: Any, path: Path) -> None:
|
|
||||||
"""Прогнать файл миграции целиком, одним куском — как `psql -f` на деплое.
|
|
||||||
|
|
||||||
Через ДРАЙВЕРНОЕ соединение, а не `exec_driver_sql`: последний отдаёт текст
|
|
||||||
psycopg вместе с пустым набором параметров, и тот начинает искать в нём
|
|
||||||
плейсхолдеры — любой процент в комментарии миграции («полоса −5..+20 %»)
|
|
||||||
роняет запуск ошибкой про `%`. psql такого разбора не делает, так что это
|
|
||||||
артефакт теста, а не свойство файла.
|
|
||||||
"""
|
|
||||||
db.connection().connection.driver_connection.execute(path.read_text("utf-8"))
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
|
||||||
def test_live_migration_puts_showcase_into_schedules_and_digest() -> None:
|
|
||||||
"""Миграция кладёт строку в scrape_schedules, и сводка видит витрину просроченной.
|
|
||||||
|
|
||||||
Значение, а не текст файла: применяем 303 на живой базе (дважды — дублей быть
|
|
||||||
не должно), читаем строку обратно и прогоняем настоящий `_STALE_SOURCES_SQL` —
|
|
||||||
тот же запрос, которым сводка судит на проде.
|
|
||||||
|
|
||||||
Ничего не удаляем: обе миграции идемпотентны (CREATE TABLE IF NOT EXISTS /
|
|
||||||
ON CONFLICT DO NOTHING), то есть повтор здесь — ровно то же действие, что и
|
|
||||||
повторный деплой.
|
|
||||||
"""
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
db = _live_session()
|
|
||||||
assert db is not None
|
|
||||||
try:
|
|
||||||
# Зависимости — ТОЛЬКО на пустой базе. На базе, прошедшей всю цепочку
|
|
||||||
# (CI и прод), повтор 015 падает: `CREATE TABLE IF NOT EXISTS` — no-op,
|
|
||||||
# а `COMMENT ON COLUMN scrape_runs.returning_count` внизу того же файла
|
|
||||||
# обращается к колонке, которую снесла 214. Файл идемпотентен
|
|
||||||
# относительно себя, но не относительно схемы, прошедшей 214, — и
|
|
||||||
# прогон на чистой базе этого не видит по построению.
|
|
||||||
if db.execute(text("SELECT to_regclass('public.scrape_schedules')")).scalar() is None:
|
|
||||||
for dep in _DEPS:
|
|
||||||
_apply(db, dep)
|
|
||||||
_apply(db, _MIGRATION)
|
|
||||||
# ИДЕМПОТЕНТНОСТЬ МЕРЯЕТСЯ ПО СОСТОЯНИЮ СТРОКИ, А НЕ ПО ЧИСЛУ СТРОК.
|
|
||||||
# «DELETE + INSERT» тоже оставляет ровно одну строку, но на КАЖДОМ
|
|
||||||
# деплое стирает last_run_at/next_run_at и взводит расписание заново —
|
|
||||||
# счёт строк такую замену не отличает, а created_at отличает.
|
|
||||||
first_created_at = db.execute(
|
|
||||||
text("SELECT created_at FROM scrape_schedules WHERE source = :s"), {"s": SOURCE}
|
|
||||||
).scalar()
|
|
||||||
_apply(db, _MIGRATION)
|
|
||||||
|
|
||||||
rows = db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT enabled, window_start_hour, window_end_hour, created_at, "
|
|
||||||
" (next_run_at > now()) AS next_run_ahead, "
|
|
||||||
" default_params->>'interval_days' AS interval_days "
|
|
||||||
"FROM scrape_schedules WHERE source = :s"
|
|
||||||
),
|
|
||||||
{"s": SOURCE},
|
|
||||||
).fetchall()
|
|
||||||
assert len(rows) == 1, f"ожидалась одна строка расписания, получено {len(rows)}"
|
|
||||||
row = rows[0]
|
|
||||||
assert row.created_at == first_created_at, (
|
|
||||||
"повторное применение пересоздало строку расписания — на каждом деплое "
|
|
||||||
"это стирало бы состояние прогонов (last_run_at/next_run_at)"
|
|
||||||
)
|
|
||||||
assert row.enabled is True
|
|
||||||
assert (row.window_start_hour, row.window_end_hour) == (6, 7)
|
|
||||||
assert int(row.interval_days) == _seeded_interval_days()
|
|
||||||
# next_run_at в БУДУЩЕМ: сев расписания не должен выстреливать прогоном
|
|
||||||
# в момент деплоя (образец — 162/275). Стоит в приёмке, значит и здесь.
|
|
||||||
assert row.next_run_ahead is True, "next_run_at в прошлом — прогон стартует на деплое"
|
|
||||||
|
|
||||||
# Сводка: прогонов у витрины нет, возраст считается от created_at строки.
|
|
||||||
digest_rows = list(db.execute(sched._STALE_SOURCES_SQL).fetchall())
|
|
||||||
assert SOURCE in {r.source for r in digest_rows}, "сводка не видит витрину"
|
|
||||||
|
|
||||||
overdue_at = row.created_at + timedelta(
|
|
||||||
days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
|
|
||||||
)
|
|
||||||
stale = sched.stale_sources(sched.freshness_rows(digest_rows), overdue_at)
|
|
||||||
assert SOURCE in {s.source for s in stale}, "витрина без прогонов не попала в тревогу"
|
|
||||||
|
|
||||||
fresh_at = row.created_at + timedelta(hours=1)
|
|
||||||
fresh = sched.stale_sources(sched.freshness_rows(digest_rows), fresh_at)
|
|
||||||
assert SOURCE not in {s.source for s in fresh}, "тревога сразу после сева — ложная"
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -81,27 +81,26 @@ def test_transient_error_returns_none_and_does_not_cache() -> None:
|
||||||
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type]
|
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type]
|
||||||
assert (ratio, basis) == (None, None)
|
assert (ratio, basis) == (None, None)
|
||||||
assert db.rolled_back is True
|
assert db.rolled_back is True
|
||||||
# #3512: ключ кэша — (region_code, bucket); region_code не передан → дефолт 66.
|
# bucket=2 НЕ должен попасть в кэш — иначе следующая оценка застрянет на None.
|
||||||
# (66, 2) НЕ должен попасть в кэш — иначе следующая оценка застрянет на None.
|
assert 2 not in estimator._asking_sold_ratio_cache
|
||||||
assert (66, 2) not in estimator._asking_sold_ratio_cache
|
|
||||||
|
|
||||||
|
|
||||||
def test_transient_error_then_success_retries_not_poisoned() -> None:
|
def test_transient_error_then_success_retries_not_poisoned() -> None:
|
||||||
# 1) транзиентный сбой — None, кэш не отравлен
|
# 1) транзиентный сбой — None, кэш не отравлен
|
||||||
r1, _ = estimator._get_asking_sold_ratio(_BoomDB(), rooms=2) # type: ignore[arg-type]
|
r1, _ = estimator._get_asking_sold_ratio(_BoomDB(), rooms=2) # type: ignore[arg-type]
|
||||||
assert r1 is None
|
assert r1 is None
|
||||||
assert (66, 2) not in estimator._asking_sold_ratio_cache
|
assert 2 not in estimator._asking_sold_ratio_cache
|
||||||
# 2) следующий вызов с рабочей БД должен РЕТРАИТЬ и вернуть реальный ratio
|
# 2) следующий вызов с рабочей БД должен РЕТРАИТЬ и вернуть реальный ratio
|
||||||
r2, basis2 = estimator._get_asking_sold_ratio(_OkDB(0.84), rooms=2) # type: ignore[arg-type]
|
r2, basis2 = estimator._get_asking_sold_ratio(_OkDB(0.84), rooms=2) # type: ignore[arg-type]
|
||||||
assert r2 == pytest.approx(0.84)
|
assert r2 == pytest.approx(0.84)
|
||||||
assert basis2 == "per_rooms"
|
assert basis2 == "per_rooms"
|
||||||
# теперь успешный результат закэширован
|
# теперь успешный результат закэширован
|
||||||
assert estimator._asking_sold_ratio_cache[(66, 2)][0] == pytest.approx(0.84)
|
assert estimator._asking_sold_ratio_cache[2][0] == pytest.approx(0.84)
|
||||||
|
|
||||||
|
|
||||||
def test_successful_lookup_is_cached() -> None:
|
def test_successful_lookup_is_cached() -> None:
|
||||||
r, basis = estimator._get_asking_sold_ratio(_OkDB(0.77), rooms=1) # type: ignore[arg-type]
|
r, basis = estimator._get_asking_sold_ratio(_OkDB(0.77), rooms=1) # type: ignore[arg-type]
|
||||||
assert r == pytest.approx(0.77)
|
assert r == pytest.approx(0.77)
|
||||||
assert basis == "per_rooms"
|
assert basis == "per_rooms"
|
||||||
cached = estimator._asking_sold_ratio_cache.get((66, 1))
|
cached = estimator._asking_sold_ratio_cache.get(1)
|
||||||
assert cached is not None and cached[0] == pytest.approx(0.77)
|
assert cached is not None and cached[0] == pytest.approx(0.77)
|
||||||
|
|
|
||||||
|
|
@ -34,20 +34,8 @@ _MIGRATION_082 = _SQL_DIR / "082_scrape_schedules_seed_ratio_refresh.sql"
|
||||||
# Emitted SQL text (SQLAlchemy text() clause → .text gives the raw string).
|
# Emitted SQL text (SQLAlchemy text() clause → .text gives the raw string).
|
||||||
_DELETE_SQL = str(ratio_mod._DELETE_SQL.text)
|
_DELETE_SQL = str(ratio_mod._DELETE_SQL.text)
|
||||||
_REDERIVE_SQL = str(ratio_mod._REDERIVE_SQL.text)
|
_REDERIVE_SQL = str(ratio_mod._REDERIVE_SQL.text)
|
||||||
_DELETE_SQL_REGION = str(ratio_mod._DELETE_SQL_REGION.text)
|
|
||||||
_REDERIVE_SQL_REGION = str(ratio_mod._REDERIVE_SQL_REGION.text)
|
|
||||||
_COUNTERS_SQL = str(ratio_mod._COUNTERS_SQL.text)
|
_COUNTERS_SQL = str(ratio_mod._COUNTERS_SQL.text)
|
||||||
_ALL_SQL = (
|
_ALL_SQL = _DELETE_SQL + "\n" + _REDERIVE_SQL + "\n" + _COUNTERS_SQL
|
||||||
_DELETE_SQL
|
|
||||||
+ "\n"
|
|
||||||
+ _REDERIVE_SQL
|
|
||||||
+ "\n"
|
|
||||||
+ _DELETE_SQL_REGION
|
|
||||||
+ "\n"
|
|
||||||
+ _REDERIVE_SQL_REGION
|
|
||||||
+ "\n"
|
|
||||||
+ _COUNTERS_SQL
|
|
||||||
)
|
|
||||||
_TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)
|
_TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -55,17 +43,14 @@ _TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_deletes_district_rows_first() -> None:
|
def test_refresh_deletes_district_rows_first() -> None:
|
||||||
"""True-mirror: DELETE FROM asking_to_sold_ratios WHERE region=66/district='' first.
|
"""True-mirror: DELETE FROM asking_to_sold_ratios WHERE district='' before the INSERT.
|
||||||
|
|
||||||
The 080 seed used ON CONFLICT DO UPDATE which leaves stale per-rooms rows for buckets
|
The 080 seed used ON CONFLICT DO UPDATE which leaves stale per-rooms rows for buckets
|
||||||
that drop below 30/30 on a later run. The refresh must DELETE first instead.
|
that drop below 30/30 on a later run. The refresh must DELETE first instead.
|
||||||
|
|
||||||
#3512: the EKB delete is now scoped to `region_code = 66` too (region-aware ratios) —
|
|
||||||
the other-region loop uses its own _DELETE_SQL_REGION, tested separately below.
|
|
||||||
"""
|
"""
|
||||||
flat_del = re.sub(r"\s+", " ", _DELETE_SQL).strip()
|
flat_del = re.sub(r"\s+", " ", _DELETE_SQL).strip()
|
||||||
assert "DELETE FROM asking_to_sold_ratios" in flat_del
|
assert "DELETE FROM asking_to_sold_ratios" in flat_del
|
||||||
assert "WHERE region_code = 66 AND district = ''" in flat_del
|
assert "WHERE district = ''" in flat_del
|
||||||
# The re-derivation is an INSERT (no ON CONFLICT — DELETE precedes it).
|
# The re-derivation is an INSERT (no ON CONFLICT — DELETE precedes it).
|
||||||
assert "INSERT INTO asking_to_sold_ratios" in _REDERIVE_SQL
|
assert "INSERT INTO asking_to_sold_ratios" in _REDERIVE_SQL
|
||||||
assert "ON CONFLICT" not in _REDERIVE_SQL
|
assert "ON CONFLICT" not in _REDERIVE_SQL
|
||||||
|
|
@ -293,24 +278,12 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
|
||||||
"""Remove the #2656 freshness predicate (absent in the 080 seed)."""
|
"""Remove the #2656 freshness predicate (absent in the 080 seed)."""
|
||||||
return re.sub(r"AND\s+scraped_at\s*>\s*NOW\(\)\s*-\s*\(:fresh_days[^\n]*?interval", "", s)
|
return re.sub(r"AND\s+scraped_at\s*>\s*NOW\(\)\s*-\s*\(:fresh_days[^\n]*?interval", "", s)
|
||||||
|
|
||||||
def _drop_region_code_column(s: str) -> str:
|
|
||||||
"""Remove the #3512 region_code column/literal (absent in the 080 seed).
|
|
||||||
|
|
||||||
Migration 304 adds region_code to the table; the refresh INSERT now writes it
|
|
||||||
explicitly (region_code, ... ) / ( ..., 66) — the 080 seed predates the column.
|
|
||||||
"""
|
|
||||||
s = re.sub(r"basis,\s*region_code", "basis", s)
|
|
||||||
s = re.sub(r"basis,\s*66\s+FROM", "basis FROM", s)
|
|
||||||
return s
|
|
||||||
|
|
||||||
def _norm(s: str) -> str:
|
def _norm(s: str) -> str:
|
||||||
return _strip_sql(
|
return _strip_sql(
|
||||||
_normalise_ppm2(
|
_normalise_ppm2(
|
||||||
_normalise_bucket_expr(
|
_normalise_bucket_expr(
|
||||||
_drop_freshness_guard(
|
_drop_freshness_guard(
|
||||||
_drop_region_code_column(
|
_drop_area_not_null_guard(_drop_city_guard(_drop_segment_guard(s)))
|
||||||
_drop_area_not_null_guard(_drop_city_guard(_drop_segment_guard(s)))
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -433,10 +406,10 @@ class _FakeMappingResult:
|
||||||
class _FakeDB:
|
class _FakeDB:
|
||||||
"""Minimal stand-in for a SQLAlchemy Session — records execute() calls.
|
"""Minimal stand-in for a SQLAlchemy Session — records execute() calls.
|
||||||
|
|
||||||
#3512: execute call order is now EKB (DELETE + INSERT) followed by a
|
Execute call order:
|
||||||
DELETE+INSERT pair per _OTHER_REGION_CODES region, then ONE counters SELECT
|
1. DELETE asking_to_sold_ratios
|
||||||
at the very end — identified by statement IDENTITY (`stmt is ratio_mod._COUNTERS_SQL`),
|
2. REDERIVE INSERT asking_to_sold_ratios
|
||||||
not by a hardcoded call index, so the fake stays correct if the region registry grows.
|
3. COUNTERS SELECT (returns counters_row)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -450,7 +423,8 @@ class _FakeDB:
|
||||||
|
|
||||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeMappingResult:
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeMappingResult:
|
||||||
self.executed.append((stmt, params))
|
self.executed.append((stmt, params))
|
||||||
if stmt is ratio_mod._COUNTERS_SQL:
|
# 3rd call is the counters SELECT (asking_to_sold_ratios).
|
||||||
|
if len(self.executed) == 3:
|
||||||
return _FakeMappingResult(self._counters_row)
|
return _FakeMappingResult(self._counters_row)
|
||||||
return _FakeMappingResult(None)
|
return _FakeMappingResult(None)
|
||||||
|
|
||||||
|
|
@ -464,8 +438,8 @@ class _FakeDB:
|
||||||
def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""recompute_asking_to_sold_ratios maps the counters row into its return + marks done.
|
"""recompute_asking_to_sold_ratios maps the counters row into its return + marks done.
|
||||||
|
|
||||||
#3512: FakeDB now sees 2 statements for EKB + 2 per _OTHER_REGION_CODES region +
|
#2002: tiered refresh removed — counters are the 3 legacy keys, FakeDB has 3
|
||||||
1 final COUNTERS SELECT (counters are still the 3 legacy keys, #2002).
|
execute() calls (DELETE + REDERIVE INSERT + COUNTERS SELECT).
|
||||||
"""
|
"""
|
||||||
marked: dict[str, Any] = {}
|
marked: dict[str, Any] = {}
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -487,10 +461,8 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
}
|
}
|
||||||
assert out == expected
|
assert out == expected
|
||||||
assert db.committed is True
|
assert db.committed is True
|
||||||
# EKB (DELETE+INSERT) + 2 per other region + 1 COUNTERS SELECT.
|
# Three statements: DELETE + REDERIVE INSERT + COUNTERS SELECT (legacy per-rooms).
|
||||||
expected_calls = 2 + 2 * len(ratio_mod._OTHER_REGION_CODES) + 1
|
assert len(db.executed) == 3
|
||||||
assert len(db.executed) == expected_calls
|
|
||||||
assert db.executed[-1][0] is ratio_mod._COUNTERS_SQL
|
|
||||||
assert marked["run_id"] == 99
|
assert marked["run_id"] == 99
|
||||||
assert marked["counters"] == expected
|
assert marked["counters"] == expected
|
||||||
|
|
||||||
|
|
@ -579,90 +551,3 @@ def test_area_bucket_matches_sql_boundaries() -> None:
|
||||||
for i, bound in enumerate(sql_bounds):
|
for i, bound in enumerate(sql_bounds):
|
||||||
assert ratio_mod.area_bucket(bound - 0.01) == i
|
assert ratio_mod.area_bucket(bound - 0.01) == i
|
||||||
assert ratio_mod.area_bucket(bound) == i + 1
|
assert ratio_mod.area_bucket(bound) == i + 1
|
||||||
|
|
||||||
|
|
||||||
# ── #3512: per-region ratio (migration 304) ───────────────────────────────────
|
|
||||||
|
|
||||||
_MIGRATION_304 = _SQL_DIR / "304_asking_to_sold_ratios_region.sql"
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_304_exists() -> None:
|
|
||||||
assert _MIGRATION_304.is_file(), f"missing migration: {_MIGRATION_304}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_304_adds_region_code_and_composite_pk() -> None:
|
|
||||||
sql = _MIGRATION_304.read_text("utf-8")
|
|
||||||
assert "ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66" in sql
|
|
||||||
assert "PRIMARY KEY (region_code, rooms_bucket, district)" in sql
|
|
||||||
# PK-guard идемпотентен (тот же паттерн, что 298_deal_city_price_bands_region.sql).
|
|
||||||
assert "pg_get_constraintdef" in sql
|
|
||||||
assert "DROP CONSTRAINT asking_to_sold_ratios_pkey" in sql
|
|
||||||
assert "BEGIN;" in sql and "COMMIT;" in sql
|
|
||||||
assert "SET LOCAL lock_timeout" in sql
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_304_uses_psycopg_safe_sql() -> None:
|
|
||||||
sql = _MIGRATION_304.read_text("utf-8")
|
|
||||||
assert not re.search(r":\w+::", sql)
|
|
||||||
|
|
||||||
|
|
||||||
def test_other_region_codes_matches_registry_minus_ekb() -> None:
|
|
||||||
"""_OTHER_REGION_CODES — весь реестр регионов (regions_mod.REGIONS) минус 66.
|
|
||||||
|
|
||||||
Новый регион в реестре автоматически подхватывается пересчётом ratio (#3512) —
|
|
||||||
без правки этого файла.
|
|
||||||
"""
|
|
||||||
from app.services import regions as regions_mod
|
|
||||||
|
|
||||||
expected = tuple(sorted(code for code in regions_mod.REGIONS if code != 66))
|
|
||||||
assert ratio_mod._OTHER_REGION_CODES == expected
|
|
||||||
assert 66 not in ratio_mod._OTHER_REGION_CODES
|
|
||||||
assert 77 in ratio_mod._OTHER_REGION_CODES # Москва — уже в реестре (#3051)
|
|
||||||
|
|
||||||
|
|
||||||
def test_rederive_sql_region_scopes_by_region_code_not_city() -> None:
|
|
||||||
"""_REDERIVE_SQL_REGION скоупит ОБЕ стороны по region_code, БЕЗ городской квоты ЕКБ.
|
|
||||||
|
|
||||||
В отличие от _REDERIVE_SQL (byte-identical historical EKB derivation), генерик-путь
|
|
||||||
для остальных регионов не несёт `city ILIKE` — эта квота была костылём конкретно
|
|
||||||
ЕКБ-исторического asking-покрытия (#C2), не общим правилом (#3512).
|
|
||||||
"""
|
|
||||||
assert "city ILIKE" not in _REDERIVE_SQL_REGION
|
|
||||||
assert _REDERIVE_SQL_REGION.count("region_code = CAST(:region_code AS int)") >= 4
|
|
||||||
assert "FROM deals" in _REDERIVE_SQL_REGION
|
|
||||||
assert "FROM listings" in _REDERIVE_SQL_REGION
|
|
||||||
# Тот же порог 30/30 и то же 12-мес окно, что и у ЕКБ-деривации (переиспользуется,
|
|
||||||
# не изобретается заново — требование задачи).
|
|
||||||
assert "d.n_deals >= 30" in _REDERIVE_SQL_REGION
|
|
||||||
assert "a.n_listings >= 30" in _REDERIVE_SQL_REGION
|
|
||||||
assert "deal_date >= CURRENT_DATE - INTERVAL '12 months'" in _REDERIVE_SQL_REGION
|
|
||||||
# INSERT пишет параметризованный регион, а не литерал 66.
|
|
||||||
assert "CAST(:region_code AS int) FROM global_row" in _REDERIVE_SQL_REGION
|
|
||||||
assert "CAST(:region_code AS int) FROM per_bucket" in _REDERIVE_SQL_REGION
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_sql_region_scopes_by_region_code() -> None:
|
|
||||||
flat = re.sub(r"\s+", " ", _DELETE_SQL_REGION).strip()
|
|
||||||
assert "DELETE FROM asking_to_sold_ratios" in flat
|
|
||||||
assert "WHERE region_code = CAST(:region_code AS int) AND district = ''" in flat
|
|
||||||
|
|
||||||
|
|
||||||
def test_rederive_sql_writes_region_code_66_literal() -> None:
|
|
||||||
"""_REDERIVE_SQL (ЕКБ-путь) теперь пишет region_code=66 явно в новую колонку."""
|
|
||||||
assert "region_code" in _REDERIVE_SQL
|
|
||||||
assert "basis, 66 FROM global_row" in _REDERIVE_SQL
|
|
||||||
assert "basis, 66 FROM per_bucket" in _REDERIVE_SQL
|
|
||||||
# Числа для 66 не должны были поменяться: городская квота ЕКБ (_ASKING_CITY_PATTERN)
|
|
||||||
# осталась нетронутой в обоих deal-CTE и обоих ask-CTE.
|
|
||||||
assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2
|
|
||||||
assert _REDERIVE_SQL.count("AND (city IS NULL OR city ILIKE :asking_city)") == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_task_loops_other_regions_between_ekb_and_counters() -> None:
|
|
||||||
"""recompute_asking_to_sold_ratios: EKB derive -> other-regions loop -> counters."""
|
|
||||||
ekb_pos = _TASK_SRC.index("_REDERIVE_SQL,")
|
|
||||||
loop_pos = _TASK_SRC.index("_OTHER_REGION_CODES")
|
|
||||||
region_delete_pos = _TASK_SRC.index("_DELETE_SQL_REGION")
|
|
||||||
region_insert_pos = _TASK_SRC.index("_REDERIVE_SQL_REGION,")
|
|
||||||
counters_pos = _TASK_SRC.index("_COUNTERS_SQL")
|
|
||||||
assert ekb_pos < loop_pos < region_delete_pos < region_insert_pos < counters_pos
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
"""Tests for per-region asking→sold ratio resolution (#3512).
|
|
||||||
|
|
||||||
ПРОБЛЕМА: asking_to_sold_ratios (migration 080) считался ИСКЛЮЧИТЕЛЬНО по ЕКБ
|
|
||||||
(city ILIKE '%Екатеринбург%') и применялся estimator'ом к ЛЮБОМУ адресу, включая
|
|
||||||
Москву — коэффициент рынка на порядок дешевле домножал московскую asking-цену.
|
|
||||||
Migration 304 добавила region_code в таблицу; этот файл проверяет, что
|
|
||||||
_get_asking_sold_ratio (app/services/estimator.py) теперь:
|
|
||||||
1. Скоупит ОБА SELECT (per-bucket + global -1 fallback) по region_code запроса.
|
|
||||||
2. Регион 66 без явного region_code — прежнее поведение (дефолт DEFAULT_REGION_CODE).
|
|
||||||
3. Регион без единой строки (ни per-bucket, ни global -1) деградирует ЯВНО:
|
|
||||||
(None, None), без подмены чужим регионом.
|
|
||||||
4. Кэш ключуется на (region_code, bucket), не на голый bucket — иначе Москва и ЕКБ
|
|
||||||
смешались бы в одном кэш-слоте.
|
|
||||||
|
|
||||||
No DB / network: fake Session (тот же паттерн, что
|
|
||||||
test_estimator_ratio_savepoint_retry_2265.py). DATABASE_URL нужен только для импорта
|
|
||||||
app.core.config.Settings.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
||||||
|
|
||||||
from app.services import estimator
|
|
||||||
from app.services import regions as regions_mod
|
|
||||||
|
|
||||||
|
|
||||||
class _Row:
|
|
||||||
def __init__(self, ratio: float | None, basis: str | None) -> None:
|
|
||||||
self.ratio = ratio
|
|
||||||
self.basis = basis
|
|
||||||
|
|
||||||
|
|
||||||
class _Result:
|
|
||||||
def __init__(self, row: _Row | None) -> None:
|
|
||||||
self._row = row
|
|
||||||
|
|
||||||
def fetchone(self) -> _Row | None:
|
|
||||||
return self._row
|
|
||||||
|
|
||||||
|
|
||||||
class _RegionAwareFakeSession:
|
|
||||||
"""Fake Session: отвечает по params['region_code'], как настоящая БД per-region.
|
|
||||||
|
|
||||||
rows: {(region_code, rooms_bucket): _Row} — rooms_bucket=-1 для global fallback.
|
|
||||||
Записывает все (sql_text, params) в self.calls для инспекции bind-параметров.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, rows: dict[tuple[int, int], _Row]) -> None:
|
|
||||||
self._rows = rows
|
|
||||||
self.calls: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def begin_nested(self): # type: ignore[no-untyped-def]
|
|
||||||
yield None
|
|
||||||
|
|
||||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
|
||||||
params = params or {}
|
|
||||||
self.calls.append(dict(params))
|
|
||||||
region_code = params.get("region_code")
|
|
||||||
bucket = params.get("b", -1)
|
|
||||||
return _Result(self._rows.get((region_code, bucket)))
|
|
||||||
|
|
||||||
def rollback(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_cache() -> None:
|
|
||||||
estimator._asking_sold_ratio_cache.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def test_ratio_query_scoped_by_region_code_of_request() -> None:
|
|
||||||
"""Оба SELECT (per-bucket + global -1) несут region_code запроса как bind-параметр."""
|
|
||||||
_clear_cache()
|
|
||||||
db = _RegionAwareFakeSession({(77, 1): _Row(0.95, "per_rooms")})
|
|
||||||
|
|
||||||
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=1, region_code=77) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
assert ratio == 0.95
|
|
||||||
assert basis == "per_rooms"
|
|
||||||
assert len(db.calls) == 1 # per-bucket строка нашлась сразу, global -1 не запрошен
|
|
||||||
assert db.calls[0]["region_code"] == 77
|
|
||||||
assert db.calls[0]["b"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_and_ekb_get_different_ratios_for_same_bucket() -> None:
|
|
||||||
"""Тот же rooms-бакет (1), два региона — РАЗНЫЕ коэффициенты, не смешиваются."""
|
|
||||||
_clear_cache()
|
|
||||||
db = _RegionAwareFakeSession(
|
|
||||||
{
|
|
||||||
(66, 1): _Row(0.84, "per_rooms"),
|
|
||||||
(77, 1): _Row(0.97, "per_rooms"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ratio_ekb, basis_ekb = estimator._get_asking_sold_ratio(db, rooms=1, region_code=66) # type: ignore[arg-type]
|
|
||||||
ratio_msk, basis_msk = estimator._get_asking_sold_ratio(db, rooms=1, region_code=77) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
assert (ratio_ekb, basis_ekb) == (0.84, "per_rooms")
|
|
||||||
assert (ratio_msk, basis_msk) == (0.97, "per_rooms")
|
|
||||||
assert ratio_ekb != ratio_msk
|
|
||||||
# Оба закэшированы РАЗДЕЛЬНО — (region_code, bucket) ключ, не голый bucket (#3512).
|
|
||||||
assert estimator._asking_sold_ratio_cache[(66, 1)][0] == 0.84
|
|
||||||
assert estimator._asking_sold_ratio_cache[(77, 1)][0] == 0.97
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_region_code_is_ekb_previous_behaviour_preserved() -> None:
|
|
||||||
"""Вызов без region_code (легаси call-site) — дефолт 66, прежнее поведение."""
|
|
||||||
_clear_cache()
|
|
||||||
assert regions_mod.DEFAULT_REGION_CODE == 66
|
|
||||||
db = _RegionAwareFakeSession({(66, 2): _Row(0.81, "global_fallback")})
|
|
||||||
|
|
||||||
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
assert (ratio, basis) == (0.81, "global_fallback")
|
|
||||||
assert db.calls[0]["region_code"] == 66
|
|
||||||
|
|
||||||
|
|
||||||
def test_region_without_any_row_degrades_explicit_not_silent() -> None:
|
|
||||||
"""Регион без per-bucket И без global -1 строки → (None, None), не чужой регион.
|
|
||||||
|
|
||||||
Falsifiable: до фикса резолвер не нёс region_code вообще, и запрос без фильтра
|
|
||||||
подхватил бы ЕКБ-глобальную строку молча. Здесь region_code=50 (Московская обл.)
|
|
||||||
не имеет НИ ОДНОЙ строки в rows — обе попытки (per-bucket + global -1) промахиваются.
|
|
||||||
"""
|
|
||||||
_clear_cache()
|
|
||||||
db = _RegionAwareFakeSession({(66, 3): _Row(0.9, "per_rooms")}) # только ЕКБ
|
|
||||||
|
|
||||||
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=3, region_code=50) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
assert (ratio, basis) == (None, None)
|
|
||||||
# Обе попытки (per-bucket=3, global=-1) реально ушли под region_code=50 —
|
|
||||||
# НЕ переключились молча на ЕКБ-данные, которые в rows есть.
|
|
||||||
assert len(db.calls) == 2
|
|
||||||
assert db.calls[0]["region_code"] == 50
|
|
||||||
assert db.calls[1]["region_code"] == 50
|
|
||||||
# Стабильный факт БД — кэшируется как обычный None-результат (см. #2175 идиома).
|
|
||||||
assert estimator._asking_sold_ratio_cache[(50, 3)][0] is None
|
|
||||||
|
|
@ -71,8 +71,7 @@ def test_ratio_retry_recovers_after_first_lookup_fails() -> None:
|
||||||
assert basis == "per_rooms"
|
assert basis == "per_rooms"
|
||||||
assert db.begin_nested_count == 2 # первый заход упал, второй прошёл
|
assert db.begin_nested_count == 2 # первый заход упал, второй прошёл
|
||||||
assert db.rollback_count >= 1 # чистка tx перед retry
|
assert db.rollback_count >= 1 # чистка tx перед retry
|
||||||
# #3512: ключ кэша (region_code, bucket); region_code не передан → дефолт 66.
|
assert 1 in estimator._asking_sold_ratio_cache # успех кэшируется
|
||||||
assert (66, 1) in estimator._asking_sold_ratio_cache # успех кэшируется
|
|
||||||
|
|
||||||
|
|
||||||
def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None:
|
def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None:
|
||||||
|
|
@ -83,8 +82,7 @@ def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None:
|
||||||
|
|
||||||
assert ratio is None
|
assert ratio is None
|
||||||
assert basis is None
|
assert basis is None
|
||||||
# транзиент НЕ отравляет кэш (#2175); ключ (region_code, bucket) — #3512.
|
assert 2 not in estimator._asking_sold_ratio_cache # транзиент НЕ отравляет кэш (#2175)
|
||||||
assert (66, 2) not in estimator._asking_sold_ratio_cache
|
|
||||||
assert db.begin_nested_count == 2
|
assert db.begin_nested_count == 2
|
||||||
assert db.rollback_count >= 2
|
assert db.rollback_count >= 2
|
||||||
|
|
||||||
|
|
@ -99,7 +97,7 @@ def test_ratio_happy_path_single_attempt_no_rollback() -> None:
|
||||||
assert basis == "global_fallback"
|
assert basis == "global_fallback"
|
||||||
assert db.begin_nested_count == 1 # ни одного retry
|
assert db.begin_nested_count == 1 # ни одного retry
|
||||||
assert db.rollback_count == 0
|
assert db.rollback_count == 0
|
||||||
cached = estimator._asking_sold_ratio_cache.get((66, 0))
|
cached = estimator._asking_sold_ratio_cache.get(0)
|
||||||
assert cached is not None
|
assert cached is not None
|
||||||
assert cached[0] == 0.9
|
assert cached[0] == 0.9
|
||||||
assert cached[1] == "global_fallback"
|
assert cached[1] == "global_fallback"
|
||||||
|
|
|
||||||
|
|
@ -107,23 +107,9 @@ def _run_estimate_with_ratio_spy(
|
||||||
|
|
||||||
ratio_calls: list[Any] = []
|
ratio_calls: list[Any] = []
|
||||||
|
|
||||||
def _spy_ratio(
|
def _spy_ratio(db_inner: Any, rooms: Any, area_m2: Any = None, anchor_ppm2: Any = None) -> Any:
|
||||||
db_inner: Any,
|
|
||||||
rooms: Any,
|
|
||||||
area_m2: Any = None,
|
|
||||||
anchor_ppm2: Any = None,
|
|
||||||
region_code: Any = None,
|
|
||||||
) -> Any:
|
|
||||||
# #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2.
|
# #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2.
|
||||||
# #3512: region_code — регион цели, теперь тоже часть call-site сигнатуры.
|
ratio_calls.append({"rooms": rooms, "area_m2": area_m2, "anchor_ppm2": anchor_ppm2})
|
||||||
ratio_calls.append(
|
|
||||||
{
|
|
||||||
"rooms": rooms,
|
|
||||||
"area_m2": area_m2,
|
|
||||||
"anchor_ppm2": anchor_ppm2,
|
|
||||||
"region_code": region_code,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return ratio_return
|
return ratio_return
|
||||||
|
|
||||||
async def _run() -> Any:
|
async def _run() -> Any:
|
||||||
|
|
@ -183,15 +169,8 @@ def test_ratio_tier_uses_final_headline_after_anchor() -> None:
|
||||||
# _get_asking_sold_ratio должен получить anchor_ppm2 ≈ 300k (не 105k).
|
# _get_asking_sold_ratio должен получить anchor_ppm2 ≈ 300k (не 105k).
|
||||||
captured_anchor_ppm2: list[float | None] = []
|
captured_anchor_ppm2: list[float | None] = []
|
||||||
|
|
||||||
def _spy(
|
def _spy(db_inner: Any, rooms: Any, area_m2: Any = None, anchor_ppm2: Any = None) -> tuple:
|
||||||
db_inner: Any,
|
|
||||||
rooms: Any,
|
|
||||||
area_m2: Any = None,
|
|
||||||
anchor_ppm2: Any = None,
|
|
||||||
region_code: Any = None,
|
|
||||||
) -> tuple:
|
|
||||||
# #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2.
|
# #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2.
|
||||||
# #3512: region_code — регион цели, теперь тоже часть call-site сигнатуры.
|
|
||||||
captured_anchor_ppm2.append(anchor_ppm2)
|
captured_anchor_ppm2.append(anchor_ppm2)
|
||||||
return (0.78, "per_rooms")
|
return (0.78, "per_rooms")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,10 @@ import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
from scraper_kit.orchestration import scheduler as kit_sched
|
from scraper_kit.orchestration import scheduler as kit_sched
|
||||||
from scraper_kit.orchestration.pipeline import CITY_ANCHORS, DEFAULT_REGION_CODE
|
from scraper_kit.orchestration.pipeline import CITY_ANCHORS
|
||||||
from scraper_kit.orchestration.scheduler import (
|
from scraper_kit.orchestration.scheduler import (
|
||||||
Handler,
|
Handler,
|
||||||
SchedulerContext,
|
SchedulerContext,
|
||||||
|
|
@ -40,10 +38,7 @@ from scraper_kit.orchestration.scheduler import (
|
||||||
_job_avito_full_load,
|
_job_avito_full_load,
|
||||||
_job_avito_full_load_exhaustive,
|
_job_avito_full_load_exhaustive,
|
||||||
_job_cian_city_sweep,
|
_job_cian_city_sweep,
|
||||||
_job_cian_full_load,
|
|
||||||
_job_domclick_city_sweep,
|
|
||||||
_job_yandex_city_sweep,
|
_job_yandex_city_sweep,
|
||||||
_resolve_region_code,
|
|
||||||
build_registry,
|
build_registry,
|
||||||
reap_zombies,
|
reap_zombies,
|
||||||
reschedule_after_minutes,
|
reschedule_after_minutes,
|
||||||
|
|
@ -628,101 +623,3 @@ def test_wildcard_resolves_oblast_city_sweep_sources() -> None:
|
||||||
# EKB source (no city) — по-прежнему резолвится на exact non-wildcard entry.
|
# EKB source (no city) — по-прежнему резолвится на exact non-wildcard entry.
|
||||||
assert resolve_handler("avito_city_sweep", registry) is registry["avito_city_sweep"]
|
assert resolve_handler("avito_city_sweep", registry) is registry["avito_city_sweep"]
|
||||||
assert registry["avito_city_sweep"] is not registry["avito_city_sweep_*"]
|
assert registry["avito_city_sweep"] is not registry["avito_city_sweep_*"]
|
||||||
|
|
||||||
|
|
||||||
# ── region_code: default_params → run_*-функции pipeline.py ─────────────────────────
|
|
||||||
#
|
|
||||||
# Планировщик кита раньше НЕ читал default_params["region_code"] ни разу — все sweep'ы
|
|
||||||
# писали листings регионом 66 (DEFAULT_REGION_CODE) независимо от того, что лежит в
|
|
||||||
# scrape_schedules.default_params. Это единственная причина, по которой московский обход
|
|
||||||
# нельзя было завести конфигом — pipeline.py уже принимает region_code параметром,
|
|
||||||
# scheduler.py его просто не прокидывал. Ниже: (1) отсутствие ключа — БЕЗ изменений
|
|
||||||
# поведения для всех существующих строк расписания (default 66); (2) валидный код (77)
|
|
||||||
# доезжает до run_*; (3) мусорное значение — явный отказ ДО сетевого похода, а не тихая
|
|
||||||
# запись в listings.region_code.
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_region_code_missing_key_defaults_to_66() -> None:
|
|
||||||
"""Нет ключа в default_params → DEFAULT_REGION_CODE (66) — прежнее поведение всех
|
|
||||||
123 существующих строк расписания не меняется."""
|
|
||||||
assert _resolve_region_code({}) == DEFAULT_REGION_CODE == 66
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_region_code_known_value_passes_through() -> None:
|
|
||||||
assert _resolve_region_code({"region_code": 77}) == 77
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_region_code_string_digit_coerces_to_int() -> None:
|
|
||||||
"""jsonb default_params может прислать региона строкой — тот же int(...), что и
|
|
||||||
у остальных числовых параметров этого файла (params.get("pages", ...) и т.п.)."""
|
|
||||||
assert _resolve_region_code({"region_code": "77"}) == 77
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_region_code_unknown_value_raises() -> None:
|
|
||||||
"""Регион вне реестра — явный ValueError, а не тихая запись мусора в listings."""
|
|
||||||
with pytest.raises(ValueError, match="реестре регионов"):
|
|
||||||
_resolve_region_code({"region_code": 999})
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_region_code_non_numeric_raises() -> None:
|
|
||||||
with pytest.raises(ValueError, match="не приводится к int"):
|
|
||||||
_resolve_region_code({"region_code": "москва"})
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_avito_city_sweep_region_code_absent_defaults() -> None:
|
|
||||||
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
|
||||||
await _job_avito_city_sweep(MagicMock(), 1, {}, _oblast_ctx())
|
|
||||||
|
|
||||||
_args, kwargs = mock_run.call_args
|
|
||||||
assert kwargs["region_code"] == DEFAULT_REGION_CODE
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_avito_city_sweep_region_code_from_params() -> None:
|
|
||||||
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
|
||||||
await _job_avito_city_sweep(MagicMock(), 1, {"region_code": 77}, _oblast_ctx())
|
|
||||||
|
|
||||||
_args, kwargs = mock_run.call_args
|
|
||||||
assert kwargs["region_code"] == 77
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_avito_city_sweep_region_code_garbage_raises_before_run() -> None:
|
|
||||||
"""Мусорный region_code роняет job ДО вызова run_* — сеть/скрапинг не трогаются."""
|
|
||||||
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
await _job_avito_city_sweep(MagicMock(), 1, {"region_code": 12345}, _oblast_ctx())
|
|
||||||
|
|
||||||
mock_run.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_avito_full_load_region_code_from_params() -> None:
|
|
||||||
with patch.object(kit_sched, "run_avito_full_load", AsyncMock()) as mock_run:
|
|
||||||
await _job_avito_full_load(MagicMock(), 1, {"region_code": 77}, _oblast_ctx())
|
|
||||||
|
|
||||||
_args, kwargs = mock_run.call_args
|
|
||||||
assert kwargs["region_code"] == 77
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_domclick_city_sweep_region_code_absent_defaults() -> None:
|
|
||||||
"""Регионный проброс не должен ломать уже читаемый city_id (тот же params dict)."""
|
|
||||||
with patch.object(kit_sched, "run_domclick_city_sweep", AsyncMock()) as mock_run:
|
|
||||||
await _job_domclick_city_sweep(MagicMock(), 1, {"city_id": 9}, _oblast_ctx())
|
|
||||||
|
|
||||||
_args, kwargs = mock_run.call_args
|
|
||||||
assert kwargs["region_code"] == DEFAULT_REGION_CODE
|
|
||||||
assert kwargs["city_id"] == 9
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_domclick_city_sweep_region_code_from_params() -> None:
|
|
||||||
with patch.object(kit_sched, "run_domclick_city_sweep", AsyncMock()) as mock_run:
|
|
||||||
await _job_domclick_city_sweep(MagicMock(), 1, {"region_code": 77}, _oblast_ctx())
|
|
||||||
|
|
||||||
_args, kwargs = mock_run.call_args
|
|
||||||
assert kwargs["region_code"] == 77
|
|
||||||
|
|
||||||
|
|
||||||
async def test_job_cian_full_load_region_code_absent_defaults() -> None:
|
|
||||||
with patch.object(kit_sched, "run_cian_full_load", AsyncMock()) as mock_run:
|
|
||||||
await _job_cian_full_load(MagicMock(), 1, {}, _oblast_ctx())
|
|
||||||
|
|
||||||
_args, kwargs = mock_run.call_args
|
|
||||||
assert kwargs["region_code"] == DEFAULT_REGION_CODE
|
|
||||||
|
|
|
||||||
|
|
@ -330,8 +330,6 @@ describe("гейт витринных чисел (#2904)", () => {
|
||||||
"estimate/page.tsx",
|
"estimate/page.tsx",
|
||||||
"articles/page.tsx",
|
"articles/page.tsx",
|
||||||
"articles/kak-ocenit-kvartiru/page.tsx",
|
"articles/kak-ocenit-kvartiru/page.tsx",
|
||||||
"articles/skolko-stoit-metr-v-ekaterinburge/page.tsx",
|
|
||||||
"articles/pochemu-kvartira-ne-prodaetsya/page.tsx",
|
|
||||||
"docs/page.tsx",
|
"docs/page.tsx",
|
||||||
"business/page.tsx",
|
"business/page.tsx",
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -97,41 +97,6 @@ describe("витрина лэндинга v3 без данных", () => {
|
||||||
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
|
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* ДАТА ПРОГОНА — НА СТРАНИЦЕ, РЯДОМ СО СЧЁТЧИКАМИ (#3469).
|
|
||||||
*
|
|
||||||
* Счётчики («рассмотрено 200, показано 20») не стареют на вид, а строки под
|
|
||||||
* ними стареют: на проде задачи не было в расписании, и витрина тринадцать
|
|
||||||
* суток показывала прогон от 30.08 — неотличимо от вчерашнего. Проверяется
|
|
||||||
* ПО ЗНАЧЕНИЮ: дата берётся из `computed_at` фикстуры, вписать её в разметку
|
|
||||||
* руками и остаться зелёным нельзя — второй кейс отдаёт другую дату.
|
|
||||||
*/
|
|
||||||
it("«Точность»: под таблицей стоит дата пересчёта витрины, и она из данных", () => {
|
|
||||||
const { unmount } = render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
|
|
||||||
expect(screen.getByText(/Витрина пересчитана 29\.08\.2026/u)).toBeTruthy();
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<AccuracyV3
|
|
||||||
stats={STATS}
|
|
||||||
showcase={{ ...SHOWCASE, computed_at: "2026-09-12T06:12:03+00:00" }}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText(/Витрина пересчитана 12\.09\.2026/u)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Даты нет — нет и предложения про неё: то же правило «нет величины — нет
|
|
||||||
* подписи», что у всего блока. «Invalid Date» или «null» на публичной
|
|
||||||
* странице хуже отсутствия даты, а «сегодня» на её месте — враньё.
|
|
||||||
*/
|
|
||||||
it("«Точность»: без computed_at подпись не выдумывает дату", () => {
|
|
||||||
render(<AccuracyV3 stats={STATS} showcase={{ ...SHOWCASE, computed_at: null }} />);
|
|
||||||
const note = screen.getByText(/рассмотрено сделок: 4 000/u);
|
|
||||||
expect(note.textContent).not.toMatch(/Витрина пересчитана/u);
|
|
||||||
expect(note.textContent).not.toMatch(/Invalid Date|null|undefined/u);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Снятая величина не должна остаться на экране «по инерции»: её плитку занял
|
* Снятая величина не должна остаться на экране «по инерции»: её плитку занял
|
||||||
* свежий замер, и оговорка про уверенность без своей плитки объясняла бы
|
* свежий замер, и оговорка про уверенность без своей плитки объясняла бы
|
||||||
|
|
@ -171,7 +136,7 @@ describe("витрина лэндинга v3 без данных", () => {
|
||||||
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ ПРОВЕРЯЕТ САМО СЕБЯ ПО ПОКАЗАННЫМ СТРОКАМ.
|
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ ПРОВЕРЯЕТ САМО СЕБЯ ПО ПОКАЗАННЫМ СТРОКАМ.
|
||||||
*
|
*
|
||||||
* Границы стоят в коде фронта, строки приходят из БД от последнего прогона
|
* Границы стоят в коде фронта, строки приходят из БД от последнего прогона
|
||||||
* задачи, а такт пересчёта — сутки (#3469). Между выкатом фронта и пересчётом
|
* задачи, а задача в расписании не стоит. Между выкатом фронта и пересчётом
|
||||||
* витрины на странице лежат СТАРЫЕ строки: на проде 12 из 20 вне полосы,
|
* витрины на странице лежат СТАРЫЕ строки: на проде 12 из 20 вне полосы,
|
||||||
* худшая +75,71 %. Утверждение «показана полоса −5…+20 %» и строка +75,7 %
|
* худшая +75,71 %. Утверждение «показана полоса −5…+20 %» и строка +75,7 %
|
||||||
* под ним — хуже, чем отсутствие утверждения.
|
* под ним — хуже, чем отсутствие утверждения.
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,10 @@
|
||||||
* обещала бы точность, которой никто не мерил.
|
* обещала бы точность, которой никто не мерил.
|
||||||
*
|
*
|
||||||
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ САМОПРОВЕРЯЕМОЕ. Границы стоят в коде фронта, строки
|
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ САМОПРОВЕРЯЕМОЕ. Границы стоят в коде фронта, строки
|
||||||
* приходят из БД от последнего прогона задачи, и прогон отстаёт от выката:
|
* приходят из БД от последнего прогона задачи, а задача в расписании не стоит
|
||||||
* с #3469 задача стоит в расписании (суточный такт), но между деплоем фронта
|
* — её запускают руками. В окне «фронт выкачен, витрина не пересчитана»
|
||||||
* и ближайшим ночным пересчётом окно всё равно есть. В нём страница утверждала
|
* страница утверждала бы полосу над строками прежнего правила, а под
|
||||||
* бы полосу над строками прежнего правила, а под утверждением стояла бы
|
* утверждением стояла бы строка +75,7 % (на проде сейчас 12 таких из 20).
|
||||||
* строка +75,7 % (так на проде и было — 12 таких из 20).
|
|
||||||
* Поэтому обе подписи спрашивают сами строки (`allWithinBand` в `deal-view`):
|
* Поэтому обе подписи спрашивают сами строки (`allWithinBand` в `deal-view`):
|
||||||
* не соответствуют — про полосу не говорим, называем то, что есть, а правило
|
* не соответствуют — про полосу не говорим, называем то, что есть, а правило
|
||||||
* прогона и так печатается рядом и приезжает из ТОГО ЖЕ прогона, что строки.
|
* прогона и так печатается рядом и приезжает из ТОГО ЖЕ прогона, что строки.
|
||||||
|
|
@ -88,7 +87,6 @@ import {
|
||||||
dealTitle,
|
dealTitle,
|
||||||
errPct,
|
errPct,
|
||||||
rub,
|
rub,
|
||||||
runDate,
|
|
||||||
shownSpread,
|
shownSpread,
|
||||||
} from "./deal-view";
|
} from "./deal-view";
|
||||||
|
|
||||||
|
|
@ -176,9 +174,6 @@ export function AccuracyV3({
|
||||||
const deals = showcase?.deals ?? [];
|
const deals = showcase?.deals ?? [];
|
||||||
const showcaseStats = showcase?.stats ?? null;
|
const showcaseStats = showcase?.stats ?? null;
|
||||||
const spread = shownSpread(deals);
|
const spread = shownSpread(deals);
|
||||||
// Дата прогона, давшего эти строки. Стоит в той же подписи, что и счётчики:
|
|
||||||
// «рассмотрено 200, показано 20» не стареет на вид, а строки — стареют.
|
|
||||||
const computedOn = runDate(showcase?.computed_at ?? null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|
@ -276,26 +271,16 @@ export function AccuracyV3({
|
||||||
(`rejection_rule`) приходит из того же прогона и стоит в этой же
|
(`rejection_rule`) приходит из того же прогона и стоит в этой же
|
||||||
подписи — счётчики и правило порознь не показываются.
|
подписи — счётчики и правило порознь не показываются.
|
||||||
*/}
|
*/}
|
||||||
{/*
|
|
||||||
ДАТА ПРОГОНА СТОИТ РЯДОМ СО СЧЁТЧИКАМИ (#3469). Счётчики описывают
|
|
||||||
прогон, но не говорят, КОГДА он был, — и замороженная витрина
|
|
||||||
выглядела ровно так же, как вчера пересчитанная. На проде это и
|
|
||||||
случилось: задачи не было в расписании, и страница тринадцать
|
|
||||||
суток показывала строки от 30.08 без единого признака возраста.
|
|
||||||
Даты нет (ручка отдала null) — предложения тоже нет: подставлять
|
|
||||||
на её место «сегодня» нельзя.
|
|
||||||
*/}
|
|
||||||
<p className={styles.accFootnote}>
|
<p className={styles.accFootnote}>
|
||||||
{showcaseStats
|
{showcaseStats
|
||||||
? `${computedOn ? `Витрина пересчитана ${computedOn}. ` : ""}Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} собранных прогоном, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
|
? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} собранных прогоном, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
|
||||||
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
|
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
|
||||||
</p>
|
</p>
|
||||||
{spread && (
|
{spread && (
|
||||||
// УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
|
// УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
|
||||||
// фронта, а строки приходят из БД, из последнего прогона задачи;
|
// фронта, а строки приходят из БД, из последнего прогона задачи;
|
||||||
// такт пересчёта — сутки (#3469), то есть строки старше выката
|
// задача в расписании не стоит и запускается руками. Между
|
||||||
// фронта почти всегда. Между выкатом и пересчётом страница
|
// выкатом и пересчётом страница утверждала бы полосу над
|
||||||
// утверждала бы полосу над
|
|
||||||
// строками, собранными до неё (на проде сейчас 12 из 20 вне
|
// строками, собранными до неё (на проде сейчас 12 из 20 вне
|
||||||
// полосы, худшая +75,7 %) — утверждение и его опровержение в
|
// полосы, худшая +75,7 %) — утверждение и его опровержение в
|
||||||
// одном экране. Поэтому про полосу говорим, только если ни одна
|
// одном экране. Поэтому про полосу говорим, только если ни одна
|
||||||
|
|
|
||||||
|
|
@ -29,24 +29,6 @@ export const count = (value: number): string => RUB.format(value);
|
||||||
/** Отклонение прогноза от факта — со знаком: плюс = МЕРА назвала дороже. */
|
/** Отклонение прогноза от факта — со знаком: плюс = МЕРА назвала дороже. */
|
||||||
export const errPct = (value: number): string => `${PCT.format(value)} %`;
|
export const errPct = (value: number): string => `${PCT.format(value)} %`;
|
||||||
|
|
||||||
/**
|
|
||||||
* Дата пересчёта витрины — «30.08.2026» из `computed_at`.
|
|
||||||
*
|
|
||||||
* РАЗБОР СТРОКИ, А НЕ `new Date().toLocaleDateString()`: подпись рендерит
|
|
||||||
* серверный компонент, и локальная дата там считалась бы по часовому поясу
|
|
||||||
* КОНТЕЙНЕРА — то есть дата на странице зависела бы от того, где её собрали.
|
|
||||||
* Дальше того же правила держится и формат: `landing-facts` печатает свои
|
|
||||||
* даты замеров так же, разбором ISO.
|
|
||||||
*
|
|
||||||
* `null` на неразобранном входе — та же дисциплина, что у всей витрины: нет
|
|
||||||
* величины, нет подписи. «Invalid Date» на публичной странице хуже её
|
|
||||||
* отсутствия.
|
|
||||||
*/
|
|
||||||
export const runDate = (iso: string | null): string | null => {
|
|
||||||
const m = iso === null ? null : /^(\d{4})-(\d{2})-(\d{2})/u.exec(iso);
|
|
||||||
return m === null ? null : `${m[3]}.${m[2]}.${m[1]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** «2-к, 52 м²» — всё, что про объект известно наверняка. */
|
/** «2-к, 52 м²» — всё, что про объект известно наверняка. */
|
||||||
/**
|
/**
|
||||||
* Заголовок сделки. `rooms = 0` — это СТУДИЯ, а не «ноль комнат»: так её
|
* Заголовок сделки. `rooms = 0` — это СТУДИЯ, а не «ноль комнат»: так её
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export interface ArticleCardMeta {
|
||||||
* второй статьи не может увести её на первую (так и было, пока
|
* второй статьи не может увести её на первую (так и было, пока
|
||||||
* `to="articleHowTo"` стоял константой в JSX).
|
* `to="articleHowTo"` стоял константой в JSX).
|
||||||
*/
|
*/
|
||||||
readonly route: "articleHowTo" | "articlePrices" | "articleWaiting" | null;
|
readonly route: "articleHowTo" | null;
|
||||||
readonly rubric: string;
|
readonly rubric: string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
/** Подводка карточки — дословно из макета. */
|
/** Подводка карточки — дословно из макета. */
|
||||||
|
|
@ -61,16 +61,16 @@ export const ARTICLE_CARDS: readonly ArticleCardMeta[] = [
|
||||||
lead: "Почему цена соседа — не ориентир, и как считать по аналогам.",
|
lead: "Почему цена соседа — не ориентир, и как считать по аналогам.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
route: "articlePrices",
|
route: null,
|
||||||
rubric: "ЦЕНЫ СДЕЛОК · 5 МИН",
|
rubric: "СРОКИ · 5 МИН",
|
||||||
title: "Сколько стоит метр в Екатеринбурге: цены сделок, а не объявлений",
|
title: "Сколько продаётся квартира в 2026 году и что «подвешивает» объявление",
|
||||||
lead: "Медиана по 24 535 сделкам Росреестра и почему метр в двушке самый дешёвый.",
|
lead: "Средние сроки по типам квартир и главные причины простоя.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
route: "articleWaiting",
|
route: null,
|
||||||
rubric: "ЦЕНА И ОЖИДАНИЕ · 5 МИН",
|
rubric: "ТОРГ · 7 МИН",
|
||||||
title: "Почему квартира не продаётся: что происходит с ценой, пока вы ждёте",
|
title: "Завысить и торговаться или поставить в рынок: что выгоднее",
|
||||||
lead: "Кто снижает цену, на сколько и за какой срок — по 49 620 объявлениям.",
|
lead: "Считаем на данных, сколько теряет «висящая» квартира каждый месяц.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -181,262 +181,3 @@ export const HOWTO = {
|
||||||
summary:
|
summary:
|
||||||
"Смотрите на цены сделок, а не объявлений. Берите 5–14 действительно взаимозаменяемых аналогов, считайте медиану цены за метр, применяйте поправки и обязательно проверяйте цену сроком продажи. Если считать некогда — это ровно то, что МЕРА делает автоматически.",
|
"Смотрите на цены сделок, а не объявлений. Берите 5–14 действительно взаимозаменяемых аналогов, считайте медиану цены за метр, применяйте поправки и обязательно проверяйте цену сроком продажи. Если считать некогда — это ровно то, что МЕРА делает автоматически.",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Статья «Сколько стоит метр в Екатеринбурге» (13.09.2026).
|
|
||||||
*
|
|
||||||
* ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. Таблица `deals` — договоры купли-продажи
|
|
||||||
* Росреестра, регион 66. Взяты четыре последних доступных квартала
|
|
||||||
* (`deal_date` 2025-07-01, 2025-10-01, 2026-01-01, 2026-04-01 — дата в данных
|
|
||||||
* штампуется началом квартала, отдельного дня сделки в выгрузке нет), то есть
|
|
||||||
* июль 2025 — июнь 2026. Отсечены цены за метр вне коридора 20 000–500 000 ₽
|
|
||||||
* как заведомый мусор выгрузки. По Екатеринбургу осталось 24 535 сделок.
|
|
||||||
*
|
|
||||||
* ГРАНИЦА, КОТОРУЮ НАЗЫВАЕМ В ТЕКСТЕ: Росреестр публикует цену договора. Она
|
|
||||||
* не всегда равна сумме, которую покупатель отдал на руки, и связки со
|
|
||||||
* сделавшим её объявлением в данных нет — `cadastral_number` пуст и у сделок,
|
|
||||||
* и у объявлений. Поэтому «разрыв между ценой объявления и ценой сделки» в
|
|
||||||
* статье не считается: считать его нечем.
|
|
||||||
*
|
|
||||||
* ПОЧЕМУ ДИНАМИКА ДАНА ПОКВАРТАЛЬНО: в выгрузке ровно десять различных
|
|
||||||
* значений `deal_date`, все на первое число квартала. Месячного разрешения в
|
|
||||||
* данных нет вовсе.
|
|
||||||
*/
|
|
||||||
export const PRICES = {
|
|
||||||
rubric: "ЦЕНЫ СДЕЛОК · 5 МИН ЧТЕНИЯ",
|
|
||||||
title: "Сколько стоит метр в Екатеринбурге: цены сделок, а не объявлений",
|
|
||||||
lead: [
|
|
||||||
"Когда вы смотрите объявления, вы видите, сколько продавцы хотят получить. Сколько покупатели заплатили на самом деле, видно в другом месте — в договорах купли-продажи, которые проходят через Росреестр. Эти два числа не совпадают, и ориентироваться стоит на второе.",
|
|
||||||
"Ниже — медианы по реальным сделкам Екатеринбурга и области за последние четыре квартала. Без округлений до красивого и без слова «примерно».",
|
|
||||||
],
|
|
||||||
toc: [
|
|
||||||
{ id: "s1", title: "Сколько стоит метр по сделкам" },
|
|
||||||
{ id: "s2", title: "Почему метр в двушке самый дешёвый" },
|
|
||||||
{ id: "s3", title: "Что делала цена последние два с половиной года" },
|
|
||||||
{ id: "s4", title: "Города области: где метр дешевле" },
|
|
||||||
{ id: "s5", title: "Пять способов неправильно прочитать эти цифры" },
|
|
||||||
],
|
|
||||||
s1: {
|
|
||||||
id: "s1",
|
|
||||||
heading: "1. Сколько стоит метр по сделкам",
|
|
||||||
body: "Медианная цена квадратного метра в сделках по Екатеринбургу — 123 626 ₽. Медиана, а не среднее: среднее в недвижимости всегда тянут вверх несколько дорогих объектов, и к типичной квартире оно отношения не имеет. Четверть сделок прошла дешевле 100 000 ₽ за метр, четверть дороже 151 288 ₽ — вот настоящая ширина рынка, и она больше, чем кажется по объявлениям. Если ваша цена попадает в эту вилку, вы в рынке.",
|
|
||||||
source:
|
|
||||||
"ЗАМЕР 13.09.2026 · 24 535 ДОГОВОРОВ КУПЛИ-ПРОДАЖИ РОСРЕЕСТРА ПО ЕКАТЕРИНБУРГУ ЗА ИЮЛЬ 2025 — ИЮНЬ 2026 · ЭТО ЦЕНА ДОГОВОРА, А НЕ ЦЕНА ОБЪЯВЛЕНИЯ",
|
|
||||||
memo: "Цена объявления и цена договора — разные величины. Насколько именно разные, не скажет никто: Росреестр публикует сделки без привязки к объявлению, сопоставить одно с другим нечем.",
|
|
||||||
},
|
|
||||||
s2: {
|
|
||||||
id: "s2",
|
|
||||||
heading: "2. Почему метр в двушке самый дешёвый",
|
|
||||||
body: "«Чем больше квартира, тем дешевле метр» — самое живучее заблуждение о ценах. По сделкам кривая не падает, а прогибается: дешевле всего метр в двухкомнатной, а дальше он снова дорожает.",
|
|
||||||
table: {
|
|
||||||
head: ["КОМНАТНОСТЬ", "МЕДИАНА ЗА МЕТР"],
|
|
||||||
rows: [
|
|
||||||
{ name: "Студия · медиана 25 м² · 2 967 сделок", value: "134 507 ₽" },
|
|
||||||
{ name: "Однокомнатная · 38 м² · 8 342 сделки", value: "124 256 ₽" },
|
|
||||||
{ name: "Двухкомнатная · 51 м² · 6 548 сделок", value: "116 910 ₽" },
|
|
||||||
{ name: "Трёхкомнатная · 70 м² · 3 861 сделка", value: "121 835 ₽" },
|
|
||||||
{
|
|
||||||
name: "Четыре комнаты и больше · 108 м² · 2 817 сделок",
|
|
||||||
value: "132 122 ₽",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
footnote:
|
|
||||||
"ТОТ ЖЕ ЗАМЕР · РЯДОМ С КАЖДОЙ СТРОКОЙ — МЕДИАННАЯ ПЛОЩАДЬ И ЧИСЛО СДЕЛОК, НА КОТОРЫХ ПОСЧИТАНА МЕДИАНА",
|
|
||||||
after:
|
|
||||||
"Объяснение простое: у студий и у больших квартир метр продаётся вместе с чем-то ещё. У студии — с низким порогом входа, её берут те, кому важна сумма целиком, а не цена метра. У четырёхкомнатной — с домом и районом, потому что такие квартиры почти не строят в дешёвых сериях. Двушка же самый массовый и самый конкурентный товар на рынке, и цена метра в ней прижата плотнее всего.",
|
|
||||||
},
|
|
||||||
s3: {
|
|
||||||
id: "s3",
|
|
||||||
heading: "3. Что делала цена последние два с половиной года",
|
|
||||||
body: "Рынок Екатеринбурга не падал ни одного квартала из десяти доступных. Медиана росла непрерывно, но по-разному: в 2024 году прибавка держалась около полутора процентов за квартал, в 2025-м и 2026-м ускорилась. Для продавца отсюда следует практический вывод: цена, посчитанная по сделкам полугодовой давности, сегодня занижена.",
|
|
||||||
tiles: [
|
|
||||||
{ value: "106 727 ₽", label: "метр в первом квартале 2024 года" },
|
|
||||||
{ value: "128 756 ₽", label: "метр во втором квартале 2026 года" },
|
|
||||||
{ value: "+20,6%", label: "рост медианы за два с половиной года" },
|
|
||||||
],
|
|
||||||
source:
|
|
||||||
"ТОТ ЖЕ ИСТОЧНИК, ВСЕ ДЕСЯТЬ ДОСТУПНЫХ КВАРТАЛОВ · ДАТА СДЕЛКИ В ВЫГРУЗКЕ РОСРЕЕСТРА ШТАМПУЕТСЯ НАЧАЛОМ КВАРТАЛА, МЕСЯЧНОГО РАЗРЕШЕНИЯ В ДАННЫХ НЕТ",
|
|
||||||
},
|
|
||||||
s4: {
|
|
||||||
id: "s4",
|
|
||||||
heading: "4. Города области: где метр дешевле",
|
|
||||||
body: "Внутри Свердловской области разброс больше, чем между комнатностями. Города-спутники Екатеринбурга идут почти по областной столице, дальние промышленные города — втрое дешевле.",
|
|
||||||
kv: [
|
|
||||||
{ k: "ВЕРХНЯЯ ПЫШМА", v: "102 888 ₽ за метр · 1 193 сделки" },
|
|
||||||
{ k: "ЗАРЕЧНЫЙ", v: "95 865 ₽ · 393 сделки" },
|
|
||||||
{ k: "БЕРЁЗОВСКИЙ", v: "95 745 ₽ · 858 сделок" },
|
|
||||||
{ k: "ПЕРВОУРАЛЬСК", v: "66 384 ₽ · 1 458 сделок" },
|
|
||||||
{ k: "КАМЕНСК-УРАЛЬСКИЙ", v: "63 763 ₽ · 1 994 сделки" },
|
|
||||||
{ k: "НИЖНИЙ ТАГИЛ", v: "62 637 ₽ · 3 799 сделок" },
|
|
||||||
{ k: "АСБЕСТ", v: "46 429 ₽ · 642 сделки" },
|
|
||||||
{ k: "НОВОУРАЛЬСК", v: "40 527 ₽ · 816 сделок" },
|
|
||||||
],
|
|
||||||
footnote:
|
|
||||||
"ТОТ ЖЕ ПЕРИОД · ПОКАЗАНЫ ГОРОДА, ГДЕ ЗА ГОД НАБРАЛОСЬ НЕ МЕНЬШЕ СТА СДЕЛОК · ГДЕ ИХ МЕНЬШЕ, МЕДИАНА НЕУСТОЙЧИВА И МЫ ЕЁ НЕ ПУБЛИКУЕМ",
|
|
||||||
},
|
|
||||||
cta: {
|
|
||||||
eyebrow: "МЕДИАНА ПО ГОРОДУ — ЭТО ЕЩЁ НЕ ВАША ЦЕНА",
|
|
||||||
title: "МЕРА считает по вашему дому, а не по городу",
|
|
||||||
body: `Между двумя домами в одном районе разница бывает больше, чем между районами. Мы находим аналоги вокруг вашего адреса, считаем медиану по ним и показываем, на чём она построена. Рынок вокруг — бесплатно, ваша цена и список аналогов — за ${SERVICE_PRICE_RUB} ₽.`,
|
|
||||||
},
|
|
||||||
s5: {
|
|
||||||
id: "s5",
|
|
||||||
heading: "5. Пять способов неправильно прочитать эти цифры",
|
|
||||||
mistakes: [
|
|
||||||
{
|
|
||||||
title: "Умножить медиану города на свою площадь",
|
|
||||||
body: "Получится цена абстрактной квартиры в абстрактном районе. Медиана по городу — ориентир порядка величины, а не оценка объекта.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Решить, что раз метр в двушке дешевле, двушку продавать невыгодно",
|
|
||||||
body: "Дешевле метр, а не квартира. Двушка всё равно стоит дороже однушки: 6 015 000 ₽ против 4 650 000 ₽ по медиане сделок.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Считать цену договора суммой на руки",
|
|
||||||
body: "Росреестр публикует то, что написано в договоре. Совпадает ли это с фактическим расчётом между сторонами, из данных не видно.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Брать медиану полугодовой давности",
|
|
||||||
body: "За последние четыре квартала медиана прибавляла около двух процентов за квартал. Полгода отставания — это несколько процентов мимо вашей цены.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Сравнивать свою квартиру с городом, а не с соседями",
|
|
||||||
body: "Между квартилями Екатеринбурга разброс от 100 000 до 151 288 ₽ за метр. Где внутри этой вилки стоит ваш дом, медиана города не скажет.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
summary:
|
|
||||||
"Медианный метр в Екатеринбурге стоит 123 626 ₽ по 24 535 сделкам за год. Дешевле всего метр в двухкомнатной, дороже всего в студии. Рынок рос все десять доступных кварталов и прибавил 20,6% с начала 2024 года. Города области идут от 40 527 ₽ в Новоуральске до 102 888 ₽ в Верхней Пышме. Всё это ориентиры порядка, а не оценка вашей квартиры: её считают по соседним домам.",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Статья «Почему квартира не продаётся» (13.09.2026).
|
|
||||||
*
|
|
||||||
* ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. Таблица `offer_price_history` — история цены
|
|
||||||
* объявления, которую ведут сами площадки (cian, yandex, domklik, avito),
|
|
||||||
* склеенная с `listings` по региону 66. Срез 13.09.2026:
|
|
||||||
*
|
|
||||||
* 49 620 объявлений области, у которых есть хоть одна запись о цене
|
|
||||||
* 21 608 из них цена менялась хотя бы раз (43,5%)
|
|
||||||
* 13 009 итоговая цена ниже стартовой (60,2% от менявших)
|
|
||||||
* 8 212 итоговая цена ВЫШЕ стартовой (38,0% от менявших)
|
|
||||||
*
|
|
||||||
* Медиана по снизившим: −3,85%, −291 000 ₽, 30 дней между первой и последней
|
|
||||||
* записью о цене.
|
|
||||||
*
|
|
||||||
* ГРАНИЦЫ, КОТОРЫЕ НАЗЫВАЕМ В ТЕКСТЕ. (1) Это движение ЗАПРАШИВАЕМОЙ цены, а
|
|
||||||
* не скидка на сделке: связки объявления со сделкой в данных нет. (2) Знаменатель
|
|
||||||
* 21 608 — объявления, где цена двигалась; те, у кого запись одна, в долю не
|
|
||||||
* входят, потому что у них неподвижная цена и отсутствие наблюдения
|
|
||||||
* неразличимы. (3) «30 дней» — срок между первой и последней записью о цене, а
|
|
||||||
* не срок продажи. Срока продажи в данных нет вовсе: поле `days_on_market` в
|
|
||||||
* сделках Росреестра пусто у всех 435 тысяч записей, а исчезновение объявления
|
|
||||||
* означает только исчезновение из выдачи, но не продажу.
|
|
||||||
*
|
|
||||||
* ПОЧЕМУ В СТАТЬЕ НЕТ СРЕДНЕГО СРОКА ПРОДАЖИ. Он не измеряется. Снятие
|
|
||||||
* объявления и продажа в наших данных неразличимы, а даты «снятий» частично
|
|
||||||
* совпадают с провалами собственного обхода — то есть измеряли бы мы свой
|
|
||||||
* сборщик, а не рынок.
|
|
||||||
*/
|
|
||||||
export const WAITING = {
|
|
||||||
rubric: "ЦЕНА И ОЖИДАНИЕ · 5 МИН ЧТЕНИЯ",
|
|
||||||
title: "Почему квартира не продаётся: что происходит с ценой, пока вы ждёте",
|
|
||||||
lead: [
|
|
||||||
"Квартира висит третий месяц, звонков нет, и первый совет от всех вокруг — «подожди, твой покупатель найдётся». Иногда это правда. Чаще ожидание заканчивается тем же, чем закончилось у большинства: продавец снижает цену, только на несколько месяцев позже и на большую сумму.",
|
|
||||||
"Мы посмотрели, что на самом деле происходит с ценой в объявлениях Свердловской области, пока они висят. Ниже — что видно в данных и, не менее важно, чего в них не видно.",
|
|
||||||
],
|
|
||||||
toc: [
|
|
||||||
{ id: "s1", title: "Что вообще можно измерить, а что нельзя" },
|
|
||||||
{ id: "s2", title: "Кто двигает цену и в какую сторону" },
|
|
||||||
{ id: "s3", title: "На сколько снижают те, кто снижает" },
|
|
||||||
{ id: "s4", title: "Что делать, если звонков нет" },
|
|
||||||
{ id: "s5", title: "Пять причин простоя, которые не в цене" },
|
|
||||||
],
|
|
||||||
s1: {
|
|
||||||
id: "s1",
|
|
||||||
heading: "1. Что вообще можно измерить, а что нельзя",
|
|
||||||
body: "Честно о границах, прежде чем показывать числа. Среднего срока продажи мы вам не назовём, и никто честный не назовёт: чтобы его посчитать, нужно знать день, когда квартиру выставили, и день, когда её купили. Второго нет ни у кого, кроме сторон сделки. Росреестр публикует договоры без срока экспозиции, а исчезновение объявления с площадки означает только исчезновение с площадки — продал, передумал или переподал заново, из данных не отличить. Зато прекрасно видно другое: как продавец сам двигает цену, пока ждёт.",
|
|
||||||
source:
|
|
||||||
"ЗАМЕР 13.09.2026 · 49 620 ОБЪЯВЛЕНИЙ СВЕРДЛОВСКОЙ ОБЛАСТИ, У КОТОРЫХ ПЛОЩАДКА ВЕДЁТ ИСТОРИЮ ЦЕНЫ · ЭТО ЗАПРАШИВАЕМАЯ ЦЕНА, А НЕ ЦЕНА СДЕЛКИ",
|
|
||||||
memo: "Любая статья, которая называет вам «средний срок продажи квартиры», считает его по снятиям объявлений. Снятие — это не продажа. Спросите, как отличали одно от другого.",
|
|
||||||
},
|
|
||||||
s2: {
|
|
||||||
id: "s2",
|
|
||||||
heading: "2. Кто двигает цену и в какую сторону",
|
|
||||||
body: "Из 49 620 объявлений цена хотя бы раз сдвинулась у 21 608. Дальше начинается неожиданное: вниз ходят не все. Почти четыре из десяти движений — вверх.",
|
|
||||||
tiles: [
|
|
||||||
{ value: "43,5%", label: "объявлений, где цена вообще двигалась" },
|
|
||||||
{ value: "60,2%", label: "из двигавших — снизили" },
|
|
||||||
{ value: "38,0%", label: "из двигавших — подняли" },
|
|
||||||
],
|
|
||||||
source:
|
|
||||||
"ТОТ ЖЕ ЗАМЕР · ЗНАМЕНАТЕЛЬ ДОЛЕЙ — 21 608 ОБЪЯВЛЕНИЙ, У КОТОРЫХ ЦЕНА МЕНЯЛАСЬ · У ОСТАЛЬНЫХ ЗАПИСЬ ОДНА, И НЕПОДВИЖНУЮ ЦЕНУ ОТ ОТСУТСТВИЯ НАБЛЮДЕНИЯ НЕ ОТЛИЧИТЬ",
|
|
||||||
after:
|
|
||||||
"Подъём цены обычно означает не жадность, а переоценку: продавец начал слишком низко, получил шквал звонков в первую неделю и понял это. Ошибка в меньшую сторону чинится легко и быстро. Ошибка в большую сторону чинится месяцами — и это ровно та асимметрия, ради которой стоит потратить вечер на расчёт до публикации.",
|
|
||||||
},
|
|
||||||
s3: {
|
|
||||||
id: "s3",
|
|
||||||
heading: "3. На сколько снижают те, кто снижает",
|
|
||||||
body: "Медианное снижение выглядит скромно в процентах и совсем не скромно в рублях. И это не одна большая уступка в конце, а обычно две-три ступеньки подряд.",
|
|
||||||
table: {
|
|
||||||
head: ["ЧТО ИЗМЕРЕНО", "МЕДИАНА ПО 13 009 ОБЪЯВЛЕНИЯМ"],
|
|
||||||
rows: [
|
|
||||||
{ name: "Снижение от стартовой цены", value: "−3,85%" },
|
|
||||||
{ name: "Снижение в рублях", value: "−291 000 ₽" },
|
|
||||||
{ name: "Срок между первой и последней ценой", value: "30 дней" },
|
|
||||||
{ name: "Нижний квартиль снижения", value: "−7,7%" },
|
|
||||||
{ name: "Верхний квартиль снижения", value: "−2,4%" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
footnote:
|
|
||||||
"ТОТ ЖЕ ЗАМЕР · «30 ДНЕЙ» — СРОК МЕЖДУ ПЕРВОЙ И ПОСЛЕДНЕЙ ЗАПИСЬЮ О ЦЕНЕ, А НЕ СРОК ПРОДАЖИ · СРОК ПРОДАЖИ ИЗМЕРИТЬ НЕЧЕМ",
|
|
||||||
},
|
|
||||||
s4: {
|
|
||||||
id: "s4",
|
|
||||||
heading: "4. Что делать, если звонков нет",
|
|
||||||
body: "Ожидание само по себе не аргумент. Рынок отвечает на цену быстро, и молчание — тоже ответ. Порядок действий такой:",
|
|
||||||
kv: [
|
|
||||||
{ k: "ПЕРВАЯ НЕДЕЛЯ", v: "считаем показы и звонки, а не дни. Ноль звонков за неделю при живом объявлении — цена выше рынка" },
|
|
||||||
{ k: "ВТОРАЯ НЕДЕЛЯ", v: "пересчитываем по аналогам заново: что сейчас стоит рядом и что из этого реально ушло" },
|
|
||||||
{ k: "ПЕРЕД СНИЖЕНИЕМ", v: "проверяем фото, описание и этаж — часть простоя не в цене вовсе, см. раздел 5" },
|
|
||||||
{ k: "ЕСЛИ СНИЖАЕМ", v: "один заметный шаг лучше пяти незаметных: снижение на 1% рынок не видит" },
|
|
||||||
{ k: "ЧЕГО НЕ ДЕЛАТЬ", v: "снимать и публиковать заново ради «свежести» — история цены на площадках всё равно сохраняется" },
|
|
||||||
],
|
|
||||||
footnote:
|
|
||||||
"ЭТО ПОРЯДОК ДЕЙСТВИЙ, А НЕ ЗАМЕР · ИЗ ДАННЫХ ЗДЕСЬ ТОЛЬКО ОДНО: СНИЖАЮЩИЕ ДЕЛАЮТ ЭТО В МЕДИАНЕ ЗА ТРИ СТУПЕНЬКИ ЦЕНЫ",
|
|
||||||
},
|
|
||||||
cta: {
|
|
||||||
eyebrow: "ПЕРЕД ТЕМ КАК СНИЖАТЬ",
|
|
||||||
title: "Проверьте, действительно ли цена выше рынка",
|
|
||||||
body: `Снижать вслепую — это отдавать сотни тысяч на всякий случай. МЕРА найдёт аналоги вокруг вашего адреса и покажет, где ваша цена относительно них. Рынок вокруг — бесплатно, разбор вашей цены — за ${SERVICE_PRICE_RUB} ₽.`,
|
|
||||||
},
|
|
||||||
s5: {
|
|
||||||
id: "s5",
|
|
||||||
heading: "5. Пять причин простоя, которые не в цене",
|
|
||||||
mistakes: [
|
|
||||||
{
|
|
||||||
title: "Четыре фотографии, снятые вечером",
|
|
||||||
body: "Объявление конкурирует в ленте миниатюр, а не в тексте. Если карточку не открыли, цена значения не имела.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Описание из характеристик",
|
|
||||||
body: "«2/5, панель, 44 м²» покупатель уже видит в фильтре. В описании должно быть то, чего в фильтре нет.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Показы только по будням до шести",
|
|
||||||
body: "Ваш покупатель в это время на работе. Часть простоя — это не отказ рынка, а несовпадение расписаний.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Обременение, о котором вы скажете позже",
|
|
||||||
body: "Ипотека, доли, несовершеннолетний собственник. Всплывает на этапе аванса и отправляет вас в начало.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Продажа «в никуда»",
|
|
||||||
body: "Если вы не знаете, куда переезжаете, вы неосознанно тормозите сделку сами. Покупатель это чувствует раньше вас.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
summary:
|
|
||||||
"Срок продажи никто честно не измерит: снятие объявления и продажа в данных неразличимы. Зато видно движение цены. Она двигалась у 43,5% объявлений, и в 60,2% случаев вниз, в медиане на 3,85% и 291 000 ₽. Прежде чем снижать, проверьте цену по аналогам и проверьте само объявление: часть простоя не в цене.",
|
|
||||||
} as const;
|
|
||||||
|
|
|
||||||
|
|
@ -202,19 +202,8 @@ export default function HowToArticlePage() {
|
||||||
</PublicLink>
|
</PublicLink>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.artGrid}>
|
<div className={styles.artGrid}>
|
||||||
{ARTICLE_CARDS.filter((c) => c.route !== "articleHowTo").map((c) =>
|
{ARTICLE_CARDS.filter((c) => c.route !== "articleHowTo").map(
|
||||||
c.route ? (
|
(c) => (
|
||||||
<PublicLink
|
|
||||||
key={c.title}
|
|
||||||
to={c.route}
|
|
||||||
className={styles.artCardLink}
|
|
||||||
>
|
|
||||||
<div className={styles.artRubric}>{c.rubric}</div>
|
|
||||||
<div className={styles.artCardTitle}>{c.title}</div>
|
|
||||||
<div className={styles.artCardLead}>{c.lead}</div>
|
|
||||||
<div className={styles.artReadMore}>Читать →</div>
|
|
||||||
</PublicLink>
|
|
||||||
) : (
|
|
||||||
<article key={c.title} className={styles.artCard}>
|
<article key={c.title} className={styles.artCard}>
|
||||||
<div className={styles.artRubric}>{c.rubric}</div>
|
<div className={styles.artRubric}>{c.rubric}</div>
|
||||||
<div className={styles.artCardTitle}>{c.title}</div>
|
<div className={styles.artCardTitle}>{c.title}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
import type { Metadata } from "next";
|
|
||||||
|
|
||||||
import { InnerHeader } from "../../_components/InnerHeader";
|
|
||||||
import { JsonLd } from "../../_components/JsonLd";
|
|
||||||
import { PublicLink } from "../../_components/PublicLink";
|
|
||||||
import { FooterV3 } from "../../_components/v3/FooterV3";
|
|
||||||
import { breadcrumbNode, ORGANIZATION_ID } from "../../_lib/schema";
|
|
||||||
import { PUBLIC_ORIGIN, PUBLIC_ROUTES } from "../../content";
|
|
||||||
import styles from "../../landing-v3.module.css";
|
|
||||||
import { ARTICLE_CARDS, WAITING } from "../articles-content";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Статья «Почему квартира не продаётся: что происходит с ценой, пока вы ждёте».
|
|
||||||
* Текст и все величины — в `articles-content.ts` (там же замер, на котором они
|
|
||||||
* стоят, и его границы). В этом файле не должно быть ни одного числа: папка
|
|
||||||
* попадает в `SUBPAGES` гейта чисел.
|
|
||||||
*
|
|
||||||
* Серверный компонент без клиентского JS: содержание — якорные ссылки,
|
|
||||||
* секции — обычные заголовки с id.
|
|
||||||
*
|
|
||||||
* ИНДЕКСАЦИЯ ОТКРЫТА (10.09.2026, см. layout.tsx).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PUBLIC_WAITING_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articleWaiting}`;
|
|
||||||
const DESCRIPTION = WAITING.lead[0];
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: `${WAITING.title} — МЕРА`,
|
|
||||||
description: DESCRIPTION,
|
|
||||||
alternates: { canonical: PUBLIC_WAITING_URL },
|
|
||||||
openGraph: {
|
|
||||||
type: "article",
|
|
||||||
title: `${WAITING.title} — МЕРА`,
|
|
||||||
description: DESCRIPTION,
|
|
||||||
url: PUBLIC_WAITING_URL,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON-LD `Article` для сниппетов поисковика. Без `datePublished`/
|
|
||||||
* `dateModified`: достоверной даты публикации в коде нет, а выдуманная дата —
|
|
||||||
* то самое нечестное утверждение, которое запрещает шапка content.ts.
|
|
||||||
*/
|
|
||||||
const ARTICLE_JSON_LD = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Article",
|
|
||||||
headline: WAITING.title,
|
|
||||||
description: DESCRIPTION,
|
|
||||||
author: { "@type": "Organization", name: "МЕРА" },
|
|
||||||
publisher: { "@id": ORGANIZATION_ID },
|
|
||||||
mainEntityOfPage: { "@type": "WebPage", "@id": PUBLIC_WAITING_URL },
|
|
||||||
};
|
|
||||||
|
|
||||||
const BREADCRUMB_JSON_LD = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
...breadcrumbNode([
|
|
||||||
{ name: "Главная", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.home}` },
|
|
||||||
{ name: "Статьи", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articles}` },
|
|
||||||
{ name: WAITING.title, url: PUBLIC_WAITING_URL },
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function WaitingArticlePage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={ARTICLE_JSON_LD} />
|
|
||||||
<JsonLd data={BREADCRUMB_JSON_LD} />
|
|
||||||
<InnerHeader />
|
|
||||||
<main id="content" className={styles.artPage} tabIndex={-1}>
|
|
||||||
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">
|
|
||||||
<PublicLink to="home" className={styles.artCrumbLink}>
|
|
||||||
Главная
|
|
||||||
</PublicLink>
|
|
||||||
<span aria-hidden="true">/</span>
|
|
||||||
<PublicLink to="articles" className={styles.artCrumbLink}>
|
|
||||||
Статьи
|
|
||||||
</PublicLink>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<header className={styles.artPageHead}>
|
|
||||||
<div className={styles.repEyebrow}>{WAITING.rubric}</div>
|
|
||||||
<h1 className={styles.artPageTitle}>{WAITING.title}</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav className={styles.artToc} aria-label="Содержание">
|
|
||||||
<div className={styles.artTocLabel}>СОДЕРЖАНИЕ</div>
|
|
||||||
<ol className={styles.artTocList}>
|
|
||||||
{WAITING.toc.map((t) => (
|
|
||||||
<li key={t.id}>
|
|
||||||
<a href={`#${t.id}`}>{t.title}</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className={styles.artBody}>
|
|
||||||
{WAITING.lead.map((p) => (
|
|
||||||
<p key={p.slice(0, 24)} className={styles.artP}>
|
|
||||||
{p}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<h2 id={WAITING.s1.id} className={styles.artH2}>
|
|
||||||
{WAITING.s1.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{WAITING.s1.body}</p>
|
|
||||||
<div className={styles.artFootnote}>{WAITING.s1.source}</div>
|
|
||||||
<aside className={styles.artMemo}>
|
|
||||||
<div className={styles.artMemoLabel}>ЗАПОМНИТЬ</div>
|
|
||||||
<p>{WAITING.s1.memo}</p>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<h2 id={WAITING.s2.id} className={styles.artH2}>
|
|
||||||
{WAITING.s2.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{WAITING.s2.body}</p>
|
|
||||||
<div className={styles.artTiles}>
|
|
||||||
{WAITING.s2.tiles.map((t) => (
|
|
||||||
<div key={t.label} className={styles.artTile}>
|
|
||||||
<div className={styles.artTileValue}>{t.value}</div>
|
|
||||||
<div className={styles.artTileLabel}>{t.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.artFootnote}>{WAITING.s2.source}</div>
|
|
||||||
<p className={styles.artP}>{WAITING.s2.after}</p>
|
|
||||||
|
|
||||||
<h2 id={WAITING.s3.id} className={styles.artH2}>
|
|
||||||
{WAITING.s3.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{WAITING.s3.body}</p>
|
|
||||||
<div className={styles.artTable} role="table">
|
|
||||||
<div className={styles.artTableHead} role="row">
|
|
||||||
<span role="columnheader">{WAITING.s3.table.head[0]}</span>
|
|
||||||
<span role="columnheader" className={styles.artTableRight}>
|
|
||||||
{WAITING.s3.table.head[1]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{WAITING.s3.table.rows.map((r) => (
|
|
||||||
<div key={r.name} className={styles.artTableRow} role="row">
|
|
||||||
<span role="cell">{r.name}</span>
|
|
||||||
<span role="cell" className={styles.artTableRight}>
|
|
||||||
{r.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.artFootnote}>{WAITING.s3.footnote}</div>
|
|
||||||
|
|
||||||
<h2 id={WAITING.s4.id} className={styles.artH2}>
|
|
||||||
{WAITING.s4.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{WAITING.s4.body}</p>
|
|
||||||
<div className={styles.artKv}>
|
|
||||||
{WAITING.s4.kv.map((row) => (
|
|
||||||
<div key={row.k} className={styles.artKvRow}>
|
|
||||||
<span className={styles.artKvKey}>{row.k}</span>
|
|
||||||
<span className={styles.artKvVal}>{row.v}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.artFootnote}>{WAITING.s4.footnote}</div>
|
|
||||||
|
|
||||||
<div className={styles.artCta}>
|
|
||||||
<div className={styles.artCtaEyebrow}>{WAITING.cta.eyebrow}</div>
|
|
||||||
<div className={styles.artCtaTitle}>{WAITING.cta.title}</div>
|
|
||||||
<p className={styles.artCtaBody}>{WAITING.cta.body}</p>
|
|
||||||
<PublicLink to="estimate" className={styles.gameBtnAccent}>
|
|
||||||
Проверить квартиру
|
|
||||||
</PublicLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 id={WAITING.s5.id} className={styles.artH2}>
|
|
||||||
{WAITING.s5.heading}
|
|
||||||
</h2>
|
|
||||||
<ol className={styles.artMistakes}>
|
|
||||||
{WAITING.s5.mistakes.map((m, i) => (
|
|
||||||
<li key={m.title} className={styles.artMistake}>
|
|
||||||
<span className={styles.artMistakeNum}>
|
|
||||||
{String(i + 1).padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<div className={styles.artMistakeTitle}>{m.title}</div>
|
|
||||||
<div className={styles.artMistakeBody}>{m.body}</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className={styles.artSummary}>
|
|
||||||
<div className={styles.artTocLabel}>КОРОТКО</div>
|
|
||||||
<p>{WAITING.summary}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className={styles.artMore} aria-label="Читайте дальше">
|
|
||||||
<div className={styles.artHead}>
|
|
||||||
<h2 className={styles.artTitle}>Читайте дальше</h2>
|
|
||||||
<PublicLink to="articles" className={styles.artAllLink}>
|
|
||||||
Все статьи →
|
|
||||||
</PublicLink>
|
|
||||||
</div>
|
|
||||||
<div className={styles.artGrid}>
|
|
||||||
{ARTICLE_CARDS.filter((c) => c.route !== "articleWaiting").map((c) =>
|
|
||||||
c.route ? (
|
|
||||||
<PublicLink
|
|
||||||
key={c.title}
|
|
||||||
to={c.route}
|
|
||||||
className={styles.artCardLink}
|
|
||||||
>
|
|
||||||
<div className={styles.artRubric}>{c.rubric}</div>
|
|
||||||
<div className={styles.artCardTitle}>{c.title}</div>
|
|
||||||
<div className={styles.artCardLead}>{c.lead}</div>
|
|
||||||
<div className={styles.artReadMore}>Читать →</div>
|
|
||||||
</PublicLink>
|
|
||||||
) : (
|
|
||||||
<article key={c.title} className={styles.artCard}>
|
|
||||||
<div className={styles.artRubric}>{c.rubric}</div>
|
|
||||||
<div className={styles.artCardTitle}>{c.title}</div>
|
|
||||||
<div className={styles.artCardLead}>{c.lead}</div>
|
|
||||||
<div className={styles.artSoon}>СКОРО</div>
|
|
||||||
</article>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<FooterV3 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
import type { Metadata } from "next";
|
|
||||||
|
|
||||||
import { InnerHeader } from "../../_components/InnerHeader";
|
|
||||||
import { JsonLd } from "../../_components/JsonLd";
|
|
||||||
import { PublicLink } from "../../_components/PublicLink";
|
|
||||||
import { FooterV3 } from "../../_components/v3/FooterV3";
|
|
||||||
import { breadcrumbNode, ORGANIZATION_ID } from "../../_lib/schema";
|
|
||||||
import { PUBLIC_ORIGIN, PUBLIC_ROUTES } from "../../content";
|
|
||||||
import styles from "../../landing-v3.module.css";
|
|
||||||
import { ARTICLE_CARDS, PRICES } from "../articles-content";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Статья «Сколько стоит метр в Екатеринбурге: цены сделок, а не объявлений».
|
|
||||||
* Текст и все величины — в `articles-content.ts` (там же замер, на котором они
|
|
||||||
* стоят, и его границы). В этом файле не должно быть ни одного числа: папка
|
|
||||||
* попадает в `SUBPAGES` гейта чисел.
|
|
||||||
*
|
|
||||||
* Серверный компонент без клиентского JS: содержание — якорные ссылки,
|
|
||||||
* секции — обычные заголовки с id.
|
|
||||||
*
|
|
||||||
* ИНДЕКСАЦИЯ ОТКРЫТА (10.09.2026, см. layout.tsx).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PUBLIC_PRICES_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articlePrices}`;
|
|
||||||
const DESCRIPTION = PRICES.lead[0];
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: `${PRICES.title} — МЕРА`,
|
|
||||||
description: DESCRIPTION,
|
|
||||||
alternates: { canonical: PUBLIC_PRICES_URL },
|
|
||||||
openGraph: {
|
|
||||||
type: "article",
|
|
||||||
title: `${PRICES.title} — МЕРА`,
|
|
||||||
description: DESCRIPTION,
|
|
||||||
url: PUBLIC_PRICES_URL,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON-LD `Article` для сниппетов поисковика. Без `datePublished`/
|
|
||||||
* `dateModified`: достоверной даты публикации в коде нет, а выдуманная дата —
|
|
||||||
* то самое нечестное утверждение, которое запрещает шапка content.ts.
|
|
||||||
*/
|
|
||||||
const ARTICLE_JSON_LD = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Article",
|
|
||||||
headline: PRICES.title,
|
|
||||||
description: DESCRIPTION,
|
|
||||||
author: { "@type": "Organization", name: "МЕРА" },
|
|
||||||
publisher: { "@id": ORGANIZATION_ID },
|
|
||||||
mainEntityOfPage: { "@type": "WebPage", "@id": PUBLIC_PRICES_URL },
|
|
||||||
};
|
|
||||||
|
|
||||||
const BREADCRUMB_JSON_LD = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
...breadcrumbNode([
|
|
||||||
{ name: "Главная", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.home}` },
|
|
||||||
{ name: "Статьи", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articles}` },
|
|
||||||
{ name: PRICES.title, url: PUBLIC_PRICES_URL },
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PricesArticlePage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={ARTICLE_JSON_LD} />
|
|
||||||
<JsonLd data={BREADCRUMB_JSON_LD} />
|
|
||||||
<InnerHeader />
|
|
||||||
<main id="content" className={styles.artPage} tabIndex={-1}>
|
|
||||||
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">
|
|
||||||
<PublicLink to="home" className={styles.artCrumbLink}>
|
|
||||||
Главная
|
|
||||||
</PublicLink>
|
|
||||||
<span aria-hidden="true">/</span>
|
|
||||||
<PublicLink to="articles" className={styles.artCrumbLink}>
|
|
||||||
Статьи
|
|
||||||
</PublicLink>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<header className={styles.artPageHead}>
|
|
||||||
<div className={styles.repEyebrow}>{PRICES.rubric}</div>
|
|
||||||
<h1 className={styles.artPageTitle}>{PRICES.title}</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav className={styles.artToc} aria-label="Содержание">
|
|
||||||
<div className={styles.artTocLabel}>СОДЕРЖАНИЕ</div>
|
|
||||||
<ol className={styles.artTocList}>
|
|
||||||
{PRICES.toc.map((t) => (
|
|
||||||
<li key={t.id}>
|
|
||||||
<a href={`#${t.id}`}>{t.title}</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className={styles.artBody}>
|
|
||||||
{PRICES.lead.map((p) => (
|
|
||||||
<p key={p.slice(0, 24)} className={styles.artP}>
|
|
||||||
{p}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<h2 id={PRICES.s1.id} className={styles.artH2}>
|
|
||||||
{PRICES.s1.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{PRICES.s1.body}</p>
|
|
||||||
<div className={styles.artFootnote}>{PRICES.s1.source}</div>
|
|
||||||
<aside className={styles.artMemo}>
|
|
||||||
<div className={styles.artMemoLabel}>ЗАПОМНИТЬ</div>
|
|
||||||
<p>{PRICES.s1.memo}</p>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<h2 id={PRICES.s2.id} className={styles.artH2}>
|
|
||||||
{PRICES.s2.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{PRICES.s2.body}</p>
|
|
||||||
<div className={styles.artTable} role="table">
|
|
||||||
<div className={styles.artTableHead} role="row">
|
|
||||||
<span role="columnheader">{PRICES.s2.table.head[0]}</span>
|
|
||||||
<span role="columnheader" className={styles.artTableRight}>
|
|
||||||
{PRICES.s2.table.head[1]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{PRICES.s2.table.rows.map((r) => (
|
|
||||||
<div key={r.name} className={styles.artTableRow} role="row">
|
|
||||||
<span role="cell">{r.name}</span>
|
|
||||||
<span role="cell" className={styles.artTableRight}>
|
|
||||||
{r.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.artFootnote}>{PRICES.s2.footnote}</div>
|
|
||||||
<p className={styles.artP}>{PRICES.s2.after}</p>
|
|
||||||
|
|
||||||
<h2 id={PRICES.s3.id} className={styles.artH2}>
|
|
||||||
{PRICES.s3.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{PRICES.s3.body}</p>
|
|
||||||
<div className={styles.artTiles}>
|
|
||||||
{PRICES.s3.tiles.map((t) => (
|
|
||||||
<div key={t.label} className={styles.artTile}>
|
|
||||||
<div className={styles.artTileValue}>{t.value}</div>
|
|
||||||
<div className={styles.artTileLabel}>{t.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.artFootnote}>{PRICES.s3.source}</div>
|
|
||||||
|
|
||||||
<h2 id={PRICES.s4.id} className={styles.artH2}>
|
|
||||||
{PRICES.s4.heading}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.artP}>{PRICES.s4.body}</p>
|
|
||||||
<div className={styles.artKv}>
|
|
||||||
{PRICES.s4.kv.map((row) => (
|
|
||||||
<div key={row.k} className={styles.artKvRow}>
|
|
||||||
<span className={styles.artKvKey}>{row.k}</span>
|
|
||||||
<span className={styles.artKvVal}>{row.v}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.artFootnote}>{PRICES.s4.footnote}</div>
|
|
||||||
|
|
||||||
<div className={styles.artCta}>
|
|
||||||
<div className={styles.artCtaEyebrow}>{PRICES.cta.eyebrow}</div>
|
|
||||||
<div className={styles.artCtaTitle}>{PRICES.cta.title}</div>
|
|
||||||
<p className={styles.artCtaBody}>{PRICES.cta.body}</p>
|
|
||||||
<PublicLink to="estimate" className={styles.gameBtnAccent}>
|
|
||||||
Проверить квартиру
|
|
||||||
</PublicLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 id={PRICES.s5.id} className={styles.artH2}>
|
|
||||||
{PRICES.s5.heading}
|
|
||||||
</h2>
|
|
||||||
<ol className={styles.artMistakes}>
|
|
||||||
{PRICES.s5.mistakes.map((m, i) => (
|
|
||||||
<li key={m.title} className={styles.artMistake}>
|
|
||||||
<span className={styles.artMistakeNum}>
|
|
||||||
{String(i + 1).padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<div className={styles.artMistakeTitle}>{m.title}</div>
|
|
||||||
<div className={styles.artMistakeBody}>{m.body}</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className={styles.artSummary}>
|
|
||||||
<div className={styles.artTocLabel}>КОРОТКО</div>
|
|
||||||
<p>{PRICES.summary}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className={styles.artMore} aria-label="Читайте дальше">
|
|
||||||
<div className={styles.artHead}>
|
|
||||||
<h2 className={styles.artTitle}>Читайте дальше</h2>
|
|
||||||
<PublicLink to="articles" className={styles.artAllLink}>
|
|
||||||
Все статьи →
|
|
||||||
</PublicLink>
|
|
||||||
</div>
|
|
||||||
<div className={styles.artGrid}>
|
|
||||||
{ARTICLE_CARDS.filter((c) => c.route !== "articlePrices").map((c) =>
|
|
||||||
c.route ? (
|
|
||||||
<PublicLink
|
|
||||||
key={c.title}
|
|
||||||
to={c.route}
|
|
||||||
className={styles.artCardLink}
|
|
||||||
>
|
|
||||||
<div className={styles.artRubric}>{c.rubric}</div>
|
|
||||||
<div className={styles.artCardTitle}>{c.title}</div>
|
|
||||||
<div className={styles.artCardLead}>{c.lead}</div>
|
|
||||||
<div className={styles.artReadMore}>Читать →</div>
|
|
||||||
</PublicLink>
|
|
||||||
) : (
|
|
||||||
<article key={c.title} className={styles.artCard}>
|
|
||||||
<div className={styles.artRubric}>{c.rubric}</div>
|
|
||||||
<div className={styles.artCardTitle}>{c.title}</div>
|
|
||||||
<div className={styles.artCardLead}>{c.lead}</div>
|
|
||||||
<div className={styles.artSoon}>СКОРО</div>
|
|
||||||
</article>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<FooterV3 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -195,8 +195,6 @@ export const PUBLIC_ROUTES = {
|
||||||
// Макет 29.08: раздел статей и хаб документов.
|
// Макет 29.08: раздел статей и хаб документов.
|
||||||
articles: "/articles",
|
articles: "/articles",
|
||||||
articleHowTo: "/articles/kak-ocenit-kvartiru",
|
articleHowTo: "/articles/kak-ocenit-kvartiru",
|
||||||
articlePrices: "/articles/skolko-stoit-metr-v-ekaterinburge",
|
|
||||||
articleWaiting: "/articles/pochemu-kvartira-ne-prodaetsya",
|
|
||||||
docs: "/docs",
|
docs: "/docs",
|
||||||
// Страница B2B-контура: связаться и оставить заявку. Заведена 31.08.2026
|
// Страница B2B-контура: связаться и оставить заявку. Заведена 31.08.2026
|
||||||
// взамен ссылки на `B2B_URL` в шапке и подвале — та вела в закрытый контур
|
// взамен ссылки на `B2B_URL` в шапке и подвале — та вела в закрытый контур
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import { PUBLIC_ORIGIN, PUBLIC_ROUTES } from "./content";
|
||||||
* актуальным целиком. Двигать дату вперёд нужно вручную, вместе с реальным
|
* актуальным целиком. Двигать дату вперёд нужно вручную, вместе с реальным
|
||||||
* изменением состава/контента страниц.
|
* изменением состава/контента страниц.
|
||||||
*/
|
*/
|
||||||
const LAST_MODIFIED = new Date("2026-09-13T00:00:00Z");
|
const LAST_MODIFIED = new Date("2026-09-10T00:00:00Z");
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
const url = (path: string) => `${PUBLIC_ORIGIN}${path}`;
|
const url = (path: string) => `${PUBLIC_ORIGIN}${path}`;
|
||||||
|
|
@ -52,18 +52,6 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
url: url(PUBLIC_ROUTES.articlePrices),
|
|
||||||
lastModified: LAST_MODIFIED,
|
|
||||||
changeFrequency: "monthly",
|
|
||||||
priority: 0.6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: url(PUBLIC_ROUTES.articleWaiting),
|
|
||||||
lastModified: LAST_MODIFIED,
|
|
||||||
changeFrequency: "monthly",
|
|
||||||
priority: 0.6,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
url: url(PUBLIC_ROUTES.business),
|
url: url(PUBLIC_ROUTES.business),
|
||||||
lastModified: LAST_MODIFIED,
|
lastModified: LAST_MODIFIED,
|
||||||
|
|
|
||||||
|
|
@ -102,8 +102,6 @@ const PUBLIC_SHORT_PATHS = [
|
||||||
"/privacy",
|
"/privacy",
|
||||||
"/articles",
|
"/articles",
|
||||||
"/articles/kak-ocenit-kvartiru",
|
"/articles/kak-ocenit-kvartiru",
|
||||||
"/articles/skolko-stoit-metr-v-ekaterinburge",
|
|
||||||
"/articles/pochemu-kvartira-ne-prodaetsya",
|
|
||||||
"/docs",
|
"/docs",
|
||||||
"/business",
|
"/business",
|
||||||
// Машинные артефакты для поисковых роботов, а не страницы. React на них не
|
// Машинные артефакты для поисковых роботов, а не страницы. React на них не
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ except ImportError: # pragma: no cover - в проде backend-env sentry_sdk
|
||||||
|
|
||||||
from scraper_kit.orchestration import runs as _kit_runs
|
from scraper_kit.orchestration import runs as _kit_runs
|
||||||
from scraper_kit.orchestration.pipeline import (
|
from scraper_kit.orchestration.pipeline import (
|
||||||
DEFAULT_REGION_CODE,
|
|
||||||
get_city_anchors,
|
get_city_anchors,
|
||||||
run_avito_city_sweep,
|
run_avito_city_sweep,
|
||||||
run_avito_full_load,
|
run_avito_full_load,
|
||||||
|
|
@ -881,37 +880,6 @@ def _interval_minutes(params: dict[str, Any], *, param: str = "interval_minutes"
|
||||||
return None if raw is None else int(raw)
|
return None if raw is None else int(raw)
|
||||||
|
|
||||||
|
|
||||||
# Kit не импортирует `app.*` (contracts.py — граница strangler'а, см. её docstring),
|
|
||||||
# поэтому реестр `app.services.regions.REGIONS` сюда не дотянуть напрямую. Список
|
|
||||||
# ниже — суженная копия ЕГО КЛЮЧЕЙ только для отказа мусорного region_code из
|
|
||||||
# default_params; она не заменяет REGIONS и не должна расти самостоятельно — при
|
|
||||||
# добавлении региона в REGIONS обновить и здесь.
|
|
||||||
_KNOWN_REGION_CODES: frozenset[int] = frozenset({50, 66, 77})
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_region_code(params: dict[str, Any]) -> int:
|
|
||||||
"""default_params["region_code"] → валидированный int для run_*.
|
|
||||||
|
|
||||||
Ключа нет → DEFAULT_REGION_CODE (66, тот же дефолт что и у run_*-функций
|
|
||||||
pipeline.py) — прежнее поведение всех 123 существующих строк расписания не
|
|
||||||
меняется. Значение не парсится в int ИЛИ отсутствует в _KNOWN_REGION_CODES →
|
|
||||||
ValueError: явный отказ прогона (падает сквозь job в `_dispatch`'s
|
|
||||||
`except Exception` → `logger.exception`, тот же путь, что и любой другой
|
|
||||||
crash хендлера), а не тихая запись мусора в listings.region_code.
|
|
||||||
"""
|
|
||||||
raw = params.get("region_code", DEFAULT_REGION_CODE)
|
|
||||||
try:
|
|
||||||
region_code = int(raw)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError(f"region_code={raw!r} из default_params не приводится к int") from exc
|
|
||||||
if region_code not in _KNOWN_REGION_CODES:
|
|
||||||
raise ValueError(
|
|
||||||
f"region_code={region_code} отсутствует в реестре регионов "
|
|
||||||
f"(известны: {sorted(_KNOWN_REGION_CODES)})"
|
|
||||||
)
|
|
||||||
return region_code
|
|
||||||
|
|
||||||
|
|
||||||
# Нижний порог defer'а (#1522): пропуск обязан пережить несколько get_due_schedules,
|
# Нижний порог defer'а (#1522): пропуск обязан пережить несколько get_due_schedules,
|
||||||
# иначе pre-check снова гоняется каждый тик. 3 тика = минимум один промах даже при
|
# иначе pre-check снова гоняется каждый тик. 3 тика = минимум один промах даже при
|
||||||
# рассинхроне часов планировщика и БД на такт в любую сторону.
|
# рассинхроне часов планировщика и БД на такт в любую сторону.
|
||||||
|
|
@ -1079,7 +1047,6 @@ async def _job_avito_city_sweep(
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
||||||
enrich_houses=bool(params.get("enrich_houses", True)),
|
enrich_houses=bool(params.get("enrich_houses", True)),
|
||||||
radius_m=int(params.get("radius_m", 1500)),
|
radius_m=int(params.get("radius_m", 1500)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
# #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта —
|
# #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта —
|
||||||
# имя якоря, оно не зависит от количества якорей, поэтому в отличие от
|
# имя якоря, оно не зависит от количества якорей, поэтому в отличие от
|
||||||
# combo-чекпоинта yandex-свипа гарда по числу якорей здесь не требуется.
|
# combo-чекпоинта yandex-свипа гарда по числу якорей здесь не требуется.
|
||||||
|
|
@ -1099,7 +1066,6 @@ async def _job_avito_newbuilding_sweep(
|
||||||
proxy_provider=ctx.proxy_provider,
|
proxy_provider=ctx.proxy_provider,
|
||||||
pages=int(params.get("pages", 20)),
|
pages=int(params.get("pages", 20)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
# #3074: подхват страниц у оборванного предшественника — см.
|
# #3074: подхват страниц у оборванного предшественника — см.
|
||||||
# _job_avito_city_sweep выше.
|
# _job_avito_city_sweep выше.
|
||||||
resume_run_id=_pick_resume(db, run_id),
|
resume_run_id=_pick_resume(db, run_id),
|
||||||
|
|
@ -1144,7 +1110,6 @@ async def _job_avito_full_load(
|
||||||
concurrency=int(params.get("concurrency", 5)),
|
concurrency=int(params.get("concurrency", 5)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
||||||
secondary_only=bool(params.get("secondary_only", True)),
|
secondary_only=bool(params.get("secondary_only", True)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
resume_run_id=_pick_resume(db, run_id),
|
resume_run_id=_pick_resume(db, run_id),
|
||||||
incremental_days=incremental_days,
|
incremental_days=incremental_days,
|
||||||
)
|
)
|
||||||
|
|
@ -1165,7 +1130,6 @@ async def _job_avito_full_load_exhaustive(
|
||||||
concurrency=int(params.get("concurrency", 5)),
|
concurrency=int(params.get("concurrency", 5)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
||||||
secondary_only=bool(params.get("secondary_only", True)),
|
secondary_only=bool(params.get("secondary_only", True)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
resume_run_id=_pick_resume(db, run_id),
|
resume_run_id=_pick_resume(db, run_id),
|
||||||
incremental_days=None,
|
incremental_days=None,
|
||||||
)
|
)
|
||||||
|
|
@ -1195,7 +1159,6 @@ async def _job_yandex_city_sweep(
|
||||||
radius_m=int(params.get("radius_m", 1500)),
|
radius_m=int(params.get("radius_m", 1500)),
|
||||||
enrich_address=bool(params.get("enrich_address", True)),
|
enrich_address=bool(params.get("enrich_address", True)),
|
||||||
segments=params.get("segments"),
|
segments=params.get("segments"),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
resume_run_id=_pick_resume(db, run_id),
|
resume_run_id=_pick_resume(db, run_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1221,7 +1184,6 @@ async def _job_cian_city_sweep(
|
||||||
detail_top_n=int(params.get("detail_top_n", 10)),
|
detail_top_n=int(params.get("detail_top_n", 10)),
|
||||||
enrich_houses=bool(params.get("enrich_houses", True)),
|
enrich_houses=bool(params.get("enrich_houses", True)),
|
||||||
newbuilding_only=bool(params.get("newbuilding_only", True)),
|
newbuilding_only=bool(params.get("newbuilding_only", True)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
# #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта —
|
# #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта —
|
||||||
# имя якоря, от их количества не зависит, поэтому гарда как у combo-
|
# имя якоря, от их количества не зависит, поэтому гарда как у combo-
|
||||||
# чекпоинта яндекса здесь не требуется.
|
# чекпоинта яндекса здесь не требуется.
|
||||||
|
|
@ -1244,7 +1206,6 @@ async def _job_cian_full_load(
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 4.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 4.0)),
|
||||||
enrich_detail=bool(params.get("enrich_detail", False)),
|
enrich_detail=bool(params.get("enrich_detail", False)),
|
||||||
detail_top_n=int(params.get("detail_top_n", 0)),
|
detail_top_n=int(params.get("detail_top_n", 0)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
resume_run_id=_pick_resume(db, run_id),
|
resume_run_id=_pick_resume(db, run_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1263,7 +1224,6 @@ async def _job_domclick_city_sweep(
|
||||||
rooms=params.get("rooms"),
|
rooms=params.get("rooms"),
|
||||||
pages=int(params.get("pages_per_anchor", 5)),
|
pages=int(params.get("pages_per_anchor", 5)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
|
||||||
region_code=_resolve_region_code(params),
|
|
||||||
resume_run_id=_pick_resume(db, run_id),
|
resume_run_id=_pick_resume(db, run_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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