Merge branch 'main' into fix/eesk-bind-in-comment
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m24s
CI / backend-tests (pull_request) Successful in 17m40s
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m24s
CI / backend-tests (pull_request) Successful in 17m40s
This commit is contained in:
commit
6eb1441508
223 changed files with 23962 additions and 2101 deletions
|
|
@ -419,11 +419,22 @@ jobs:
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml up -d
|
-f docker-compose.metrics-agent.yml up -d
|
||||||
|
|
||||||
|
# Конфиг Alloy — бинд-маунт ОДНОГО файла, а `git reset --hard` выше пишет
|
||||||
|
# его новым инодом: `up -d` изменения не видит, контейнер держит старый.
|
||||||
|
METRICS_ROLE=apps \
|
||||||
|
METRICS_ALLOY_CONFIG=alloy-apps.alloy \
|
||||||
|
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
|
docker compose -p gendesign-metrics-agent \
|
||||||
|
-f docker-compose.metrics-agent.yml up -d --force-recreate alloy
|
||||||
|
|
||||||
sleep 10
|
sleep 10
|
||||||
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml ps
|
-f docker-compose.metrics-agent.yml ps
|
||||||
|
|
||||||
|
[ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
||||||
|
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
|
||||||
|
|
||||||
agent-infra:
|
agent-infra:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: server
|
needs: server
|
||||||
|
|
@ -478,7 +489,18 @@ jobs:
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml up -d
|
-f docker-compose.metrics-agent.yml up -d
|
||||||
|
|
||||||
|
# Конфиг Alloy — бинд-маунт ОДНОГО файла, а `git reset --hard` (джоба server)
|
||||||
|
# пишет его новым инодом: `up -d` изменения не видит, контейнер держит старый.
|
||||||
|
METRICS_ROLE=infra \
|
||||||
|
METRICS_ALLOY_CONFIG=alloy-infra.alloy \
|
||||||
|
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
|
docker compose -p gendesign-metrics-agent \
|
||||||
|
-f docker-compose.metrics-agent.yml up -d --force-recreate alloy
|
||||||
|
|
||||||
sleep 10
|
sleep 10
|
||||||
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml ps
|
-f docker-compose.metrics-agent.yml ps
|
||||||
|
|
||||||
|
[ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
||||||
|
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
|
||||||
|
|
|
||||||
|
|
@ -596,6 +596,11 @@ jobs:
|
||||||
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
|
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
|
||||||
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
|
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
|
||||||
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||||||
|
# #3324: дефолт appleboy/ssh-action — command_timeout 10m, а worst-case
|
||||||
|
# гейта в конце скрипта ~17 мин (4 сервиса × 240s ожидания healthy +
|
||||||
|
# фронт + diagnose). Сессию убило бы посреди печати диагноза, и авария
|
||||||
|
# выглядела бы обрывом связи, а не мёртвым контейнером.
|
||||||
|
command_timeout: 30m
|
||||||
envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS,WORKER_RECREATE_GUARD,WORKER_GUARD_MAX_SKIP_H
|
envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS,WORKER_RECREATE_GUARD,WORKER_GUARD_MAX_SKIP_H
|
||||||
script: |
|
script: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
@ -1037,6 +1042,171 @@ jobs:
|
||||||
fi
|
fi
|
||||||
echo "→ backend healthy на /health."
|
echo "→ backend healthy на /health."
|
||||||
|
|
||||||
|
# ── Гейт деплоя (#3324) ────────────────────────────────────────────
|
||||||
|
# ДО этого блока весь гейт ПТИЦЫ = один `curl backend /health` выше:
|
||||||
|
# worker/beat не проверялись вообще (у них и healthcheck'а в compose не
|
||||||
|
# было), у frontend была только TCP-проба внутри контейнера, которую
|
||||||
|
# деплой не читал. То есть crash-loop воркера, вставший beat и фронт,
|
||||||
|
# отдающий 500, уезжали ЗЕЛЁНЫМ деплоем. Дисциплина перенесена из
|
||||||
|
# deploy-tradein.yml (health каждого сервиса + HTTP фронта + сверка
|
||||||
|
# образов), сюда добавлено чтение docker-health, потому что у ПТИЦЫ
|
||||||
|
# пробы теперь описаны в compose.
|
||||||
|
# `up -d --wait` НЕ используется намеренно: подъём здесь разбит на
|
||||||
|
# несколько `up` (bulk без worker'а → guard #3029 → caddy → forwarder),
|
||||||
|
# и общий --wait ждал бы ещё и профильные/инфраструктурные сервисы,
|
||||||
|
# ломая порядок «миграции до подъёма кода». Читаем состояние явно.
|
||||||
|
# Все проверки выполняются ДО выхода (не падаем на первой) — один
|
||||||
|
# прогон обязан показать ВСЕ поломанные сервисы, а не первый по списку.
|
||||||
|
# tail -n1: у сервиса может остаться залежавшийся exited-контейнер, и
|
||||||
|
# тогда `ps -aq` вернёт НЕСКОЛЬКО id через \n — `docker inspect` с таким
|
||||||
|
# аргументом падает, статус приходит пустым и гейт краснеет на ровном
|
||||||
|
# месте. Последний id — самый свежий контейнер сервиса.
|
||||||
|
cid() { docker compose -p gendesign -f docker-compose.prod.yml ps -aq "$1" 2>/dev/null | tail -n1 || true; }
|
||||||
|
|
||||||
|
diagnose() { # $1 сервис, $2 id контейнера (может быть пустым)
|
||||||
|
local svc="$1" c="$2"
|
||||||
|
echo "── ДИАГНОЗ $svc ──"
|
||||||
|
if [ -z "$c" ]; then
|
||||||
|
echo " контейнера нет вообще (docker compose ps -aq $svc пусто)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
docker inspect -f ' state={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}<healthcheck не сконфигурирован>{{end}} restarts={{.RestartCount}} exit_code={{.State.ExitCode}} image={{.Image}}' "$c" || true
|
||||||
|
echo " healthcheck log:"
|
||||||
|
docker inspect -f '{{json .State.Health}}' "$c" 2>/dev/null | head -c 2000 || true
|
||||||
|
echo ""
|
||||||
|
echo " последние 40 строк логов $svc:"
|
||||||
|
docker logs --tail 40 "$c" 2>&1 | sed 's/^/ /' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_healthy() { # $1 сервис, $2 таймаут, с
|
||||||
|
local svc="$1" deadline="$2" c="" status="" alive="" r0="" r1="" waited=0
|
||||||
|
while [ "$waited" -lt "$deadline" ]; do
|
||||||
|
c="$(cid "$svc")"
|
||||||
|
if [ -n "$c" ]; then
|
||||||
|
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none:{{.State.Status}}{{end}}' "$c" 2>/dev/null || echo '')"
|
||||||
|
case "$status" in
|
||||||
|
healthy)
|
||||||
|
echo "→ $svc healthy (за ${waited}s)"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
none:running)
|
||||||
|
# Контейнер без healthcheck-конфига = создан ДО этой правки
|
||||||
|
# compose и в этом прогоне не пересоздавался (штатный случай —
|
||||||
|
# worker, пропущенный guard'ом #3029). Валить деплой за это
|
||||||
|
# нельзя, но и молчать нельзя: падаем на «стабильный running»
|
||||||
|
# (двойное чтение, как tgbot/scraper в deploy-tradein.yml).
|
||||||
|
# Одного `running` дважды НЕДОСТАТОЧНО: crash-loop с временем
|
||||||
|
# жизни больше паузы читается как «стабилен» — контейнер оба
|
||||||
|
# раза running, просто это разные его жизни. Поэтому вместе со
|
||||||
|
# статусом сверяем RestartCount: изменился за окно = именно
|
||||||
|
# тот дефект, ради которого этот гейт и писался.
|
||||||
|
r0="$(docker inspect -f '{{.RestartCount}}' "$c" 2>/dev/null || echo '')"
|
||||||
|
sleep 15
|
||||||
|
alive="$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo unknown)"
|
||||||
|
r1="$(docker inspect -f '{{.RestartCount}}' "$c" 2>/dev/null || echo '')"
|
||||||
|
if [ "$alive" = "running" ] && [ -n "$r0" ] && [ "$r0" = "$r1" ]; then
|
||||||
|
echo "→ $svc: healthcheck не сконфигурирован (контейнер не пересоздавался), running стабилен (RestartCount=$r0 не изменился за 15s)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# waited растёт на длину ЭТОЙ паузы тоже — иначе таймаут
|
||||||
|
# 240s превратился бы в ~24 минуты реального ожидания и упёрся
|
||||||
|
# бы в command_timeout SSH-сессии.
|
||||||
|
waited=$((waited + 15))
|
||||||
|
echo " $svc: running нестабилен — status='$alive', RestartCount ${r0:-<нет>}→${r1:-<нет>} (контейнер перезапускался внутри окна наблюдения); продолжаю ждать"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
waited=$((waited + 3))
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
echo "ERROR (#3324): $svc не стал healthy за ${deadline}s (последний статус: '${status:-<контейнера нет>}') — деплой FAILED"
|
||||||
|
diagnose "$svc" "$c"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
health_rc=0
|
||||||
|
for gate_svc in backend worker beat frontend; do
|
||||||
|
wait_healthy "$gate_svc" 240 || health_rc=$?
|
||||||
|
done
|
||||||
|
|
||||||
|
# Фронт: HTTP-СТАТУС, а не только «порт слушает». Compose-проба фронта
|
||||||
|
# намеренно TCP-only (в node:alpine нет ни curl, ни wget), и она не
|
||||||
|
# отличает живой Next.js от процесса, отдающего 500 на каждый запрос.
|
||||||
|
# Тянем с хоста через опубликованный 127.0.0.1:3000. basePath у ПТИЦЫ
|
||||||
|
# нет (frontend/next.config.*), корень — настоящий маршрут приложения.
|
||||||
|
# Годным считаем 2xx/3xx: редирект middleware'а на логин — это живой
|
||||||
|
# роутинг, а не поломка (тот же критерий, что `curl -f` в tradein).
|
||||||
|
fe_rc=0
|
||||||
|
fe_code=000
|
||||||
|
for i in $(seq 1 5); do
|
||||||
|
fe_code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://localhost:3000/ || echo 000)"
|
||||||
|
case "$fe_code" in
|
||||||
|
2*|3*) break ;;
|
||||||
|
esac
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
case "$fe_code" in
|
||||||
|
2*|3*) echo "→ frontend отвечает HTTP $fe_code на /." ;;
|
||||||
|
*)
|
||||||
|
echo "ERROR (#3324): frontend на http://localhost:3000/ вернул '$fe_code' (000 = соединения нет) — деплой FAILED"
|
||||||
|
diagnose frontend "$(cid frontend)"
|
||||||
|
fe_rc=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Сверка образов (приём #2679 из deploy-tradein.yml, адаптирован под
|
||||||
|
# ПТИЦУ). Здесь не одно «backend-семейство»: backend и beat бегут один
|
||||||
|
# образ gendesign-backend, worker и frontend — свои. Поэтому эталон не
|
||||||
|
# «образ backend'а», а то, что реально лежит локально под тегом
|
||||||
|
# $IMAGE_TAG после pull'а: контейнер, оставшийся на другом id, работает
|
||||||
|
# на старом коде при зелёном деплое.
|
||||||
|
# Гард свежести самого :latest в registry — отдельный шаг выше
|
||||||
|
# (scripts/check-latest-image-revision.sh, #2950); здесь проверяется
|
||||||
|
# следующее звено: доехал ли уже скачанный образ до контейнера.
|
||||||
|
check_image() { # $1 сервис, $2 репозиторий образа
|
||||||
|
local svc="$1" repo="$2" want run c
|
||||||
|
want="$(docker image inspect -f '{{.Id}}' "$repo:$IMAGE_TAG" 2>/dev/null || echo '')"
|
||||||
|
c="$(cid "$svc")"
|
||||||
|
run="$(docker inspect -f '{{.Image}}' "$c" 2>/dev/null || echo '')"
|
||||||
|
if [ -z "$want" ]; then
|
||||||
|
echo "ERROR (#3324): локально нет образа $repo:$IMAGE_TAG — сверять не с чем (pull не отработал?)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -z "$run" ]; then
|
||||||
|
echo "ERROR (#3324): контейнера сервиса $svc НЕТ — это не «отставший образ», а неполный стек"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ "$want" != "$run" ]; then
|
||||||
|
echo "ERROR (#3324): $svc ОТСТАЛ: работает на $run, а $repo:$IMAGE_TAG — это $want"
|
||||||
|
echo " лечение: docker compose -p gendesign -f docker-compose.prod.yml up -d --force-recreate --no-deps $svc"
|
||||||
|
diagnose "$svc" "$c"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "→ $svc на свежем $repo:$IMAGE_TAG ($run)"
|
||||||
|
}
|
||||||
|
|
||||||
|
image_rc=0
|
||||||
|
check_image backend ghcr.io/lekss361/gendesign-backend || image_rc=$?
|
||||||
|
check_image beat ghcr.io/lekss361/gendesign-backend || image_rc=$?
|
||||||
|
check_image frontend ghcr.io/lekss361/gendesign-frontend || image_rc=$?
|
||||||
|
# worker сверяем ТОЛЬКО если этот прогон его пересоздавал: guard #3029
|
||||||
|
# намеренно оставляет worker на старом образе, пока идёт живой прогон
|
||||||
|
# скрейпа, и это уже отражено WARNING'ом выше. Падать здесь означало бы
|
||||||
|
# красить деплой за штатное поведение guard'а.
|
||||||
|
case " $WORKER_SERVICES " in
|
||||||
|
*" worker "*) check_image worker ghcr.io/lekss361/gendesign-worker || image_rc=$? ;;
|
||||||
|
*) echo "→ сверка образа worker'а пропущена: guard #3029 не пересоздавал его в этом прогоне (см. WARNING выше)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Явный rc: «зелёная сводка» ниже печатается ДО выхода, поэтому итог
|
||||||
|
# обязан быть числом в логе, а не выводом из отсутствия ERROR-строк.
|
||||||
|
gate_rc=0
|
||||||
|
[ "$health_rc" = 0 ] || gate_rc=1
|
||||||
|
[ "$fe_rc" = 0 ] || gate_rc=1
|
||||||
|
[ "$image_rc" = 0 ] || gate_rc=1
|
||||||
|
echo "Гейт деплоя (#3324): health_rc=$health_rc frontend_rc=$fe_rc image_rc=$image_rc → rc=$gate_rc"
|
||||||
|
exit "$gate_rc"
|
||||||
|
|
||||||
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
|
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
|
||||||
# молча (result=skipped), когда build падает (например, битый blob в
|
# молча (result=skipped), когда build падает (например, битый blob в
|
||||||
# buildcache роняет `docker/build-push-action` — до ретрая выше, #2841).
|
# buildcache роняет `docker/build-push-action` — до ретрая выше, #2841).
|
||||||
|
|
|
||||||
785
_wt-mskcol/tradein-mvp/scripts/local-avito-msk/collect.py
Normal file
785
_wt-mskcol/tradein-mvp/scripts/local-avito-msk/collect.py
Normal file
|
|
@ -0,0 +1,785 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Локальный ручной сборщик SERP Авито по Москве и МО (эпик #2989, трек 1).
|
||||||
|
|
||||||
|
Запускается ВРУЧНУЮ с машины владельца. Прод-скрейпер, его расписания и
|
||||||
|
прокси-пул не задействованы вообще: браузер — уже открытый Chrome владельца
|
||||||
|
(подключение по CDP), парсер — импорт из scraper-kit, заливка — поток в psql
|
||||||
|
через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает.
|
||||||
|
|
||||||
|
Дефолтный режим — --measure 100 (замер): полный проход только по явному --full.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, Iterable
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
# --- импорт парсера из scraper-kit без установки backend -------------------
|
||||||
|
_KIT_SRC = Path(__file__).resolve().parents[2] / "packages" / "scraper-kit" / "src"
|
||||||
|
if str(_KIT_SRC) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_KIT_SRC))
|
||||||
|
|
||||||
|
from scraper_kit.providers.avito.serp import ( # noqa: E402
|
||||||
|
AvitoScraper,
|
||||||
|
_is_firewall_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Вкладка, открытая у владельца: вторичка, Москва + МО.
|
||||||
|
DEFAULT_BASE_URL = (
|
||||||
|
"https://www.avito.ru/moskva_i_mo/kvartiry/prodam/vtorichka-ASgBAgICAkSSA8YQ5geMUg"
|
||||||
|
"?f=ASgBAgICA0SSA8YQ5geMUuDC3c0CgOkP"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Замерено живым проходом (не из документации): выдача Москва+МО отдаёт 50 карточек
|
||||||
|
# на страницу. Пока здесь стояло 60, planned_pages считал count/60 и не запрашивал
|
||||||
|
# последние ~17% каждого коридора — 34 753 по счётчику против 28 352 собранных.
|
||||||
|
# Молчаливое усечение читается как «покрыто всё», поэтому число проверяется живьём.
|
||||||
|
PAGE_SIZE = 50 # карточек на странице выдачи
|
||||||
|
MAX_PAGES = 30 # потолок пагинации Авито → 30*50 = 1500 на один запрос
|
||||||
|
HARD_CAP = PAGE_SIZE * MAX_PAGES
|
||||||
|
|
||||||
|
PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽
|
||||||
|
PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы
|
||||||
|
PRICE_CEIL = 2_000_000_000
|
||||||
|
MIN_WIDTH_RATIO = 1.05 # уже этого коридор не делим (геометрическая ширина)
|
||||||
|
MAX_DEPTH = 12
|
||||||
|
|
||||||
|
_BATCH_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||||
|
_POW_MARKERS = ("startpow", "доступ ограничен: проверка безопасности")
|
||||||
|
|
||||||
|
|
||||||
|
# Заполняется при входе в Loader.__aenter__ (playwright импортируется лениво,
|
||||||
|
# чтобы --help работал без установленного пакета).
|
||||||
|
PlaywrightTimeoutError: type[BaseException] = TimeoutError
|
||||||
|
|
||||||
|
|
||||||
|
# Через столько загрузок вкладка сборщика пересоздаётся (см. _recycle_if_needed).
|
||||||
|
PAGE_RECYCLE_EVERY = 75
|
||||||
|
|
||||||
|
|
||||||
|
class Blocked(Exception):
|
||||||
|
"""Первый признак блока. Ретраев нет — только немедленный стоп."""
|
||||||
|
|
||||||
|
def __init__(self, reason: str, detail: str = "") -> None:
|
||||||
|
super().__init__(f"{reason}: {detail}" if detail else reason)
|
||||||
|
self.reason = reason
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetExhausted(Exception):
|
||||||
|
"""Потолок --measure выбран: штатный выход, не ошибка."""
|
||||||
|
|
||||||
|
|
||||||
|
# --- план коридоров --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Corridor:
|
||||||
|
lo: int | None
|
||||||
|
hi: int | None
|
||||||
|
count: int | None = None
|
||||||
|
truncated: bool = False
|
||||||
|
pages_done: int = 0
|
||||||
|
status: str = "pending" # pending | done
|
||||||
|
missed: int = 0 # заведомо недобрано (count - HARD_CAP), если truncated
|
||||||
|
|
||||||
|
def label(self) -> str:
|
||||||
|
lo = "-" if self.lo is None else f"{self.lo:_}"
|
||||||
|
hi = "-" if self.hi is None else f"{self.hi:_}"
|
||||||
|
return f"[{lo} .. {hi}]"
|
||||||
|
|
||||||
|
def to_json(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"lo": self.lo, "hi": self.hi, "count": self.count,
|
||||||
|
"truncated": self.truncated, "pages_done": self.pages_done,
|
||||||
|
"status": self.status, "missed": self.missed,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(d: dict[str, Any]) -> "Corridor":
|
||||||
|
return Corridor(
|
||||||
|
lo=d.get("lo"), hi=d.get("hi"), count=d.get("count"),
|
||||||
|
truncated=bool(d.get("truncated")),
|
||||||
|
pages_done=int(d.get("pages_done") or 0),
|
||||||
|
status=d.get("status") or "pending", missed=int(d.get("missed") or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def planned_pages(self) -> int:
|
||||||
|
if not self.count:
|
||||||
|
return 1
|
||||||
|
return max(1, min(MAX_PAGES, math.ceil(self.count / PAGE_SIZE)))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Plan:
|
||||||
|
base_url: str
|
||||||
|
target: int
|
||||||
|
batch_id: str
|
||||||
|
corridors: list[Corridor] = field(default_factory=list)
|
||||||
|
created_at: str = ""
|
||||||
|
|
||||||
|
def save(self, path: Path) -> None:
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"version": 1, "base_url": self.base_url, "target": self.target,
|
||||||
|
"batch_id": self.batch_id, "created_at": self.created_at,
|
||||||
|
"corridors": [c.to_json() for c in self.corridors],
|
||||||
|
},
|
||||||
|
ensure_ascii=False, indent=1,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load(path: Path) -> "Plan":
|
||||||
|
d = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return Plan(
|
||||||
|
base_url=d["base_url"], target=int(d["target"]), batch_id=d["batch_id"],
|
||||||
|
created_at=d.get("created_at", ""),
|
||||||
|
corridors=[Corridor.from_json(c) for c in d.get("corridors", [])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_url(base_url: str, page: int, lo: int | None, hi: int | None) -> str:
|
||||||
|
"""URL коридора: pmin/pmax + пагинация. Гео-параметры не добавляем — #3043."""
|
||||||
|
parts = urlsplit(base_url)
|
||||||
|
q = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
|
||||||
|
if k not in {"p", "pmin", "pmax"}]
|
||||||
|
if lo is not None:
|
||||||
|
q.append(("pmin", str(int(lo))))
|
||||||
|
if hi is not None:
|
||||||
|
q.append(("pmax", str(int(hi))))
|
||||||
|
if page > 1:
|
||||||
|
q.append(("p", str(page)))
|
||||||
|
return urlunsplit(
|
||||||
|
(parts.scheme, parts.netloc, parts.path, urlencode(q), parts.fragment)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def geometric_mid(lo: int | None, hi: int) -> int:
|
||||||
|
"""Геометрическая середина коридора.
|
||||||
|
|
||||||
|
Цены логнормальны: арифметическая середина 1 млн..100 млн (≈50 млн)
|
||||||
|
отрезает вырожденно-пустую верхнюю половину. sqrt(lo*hi) делит выборку
|
||||||
|
заметно ровнее.
|
||||||
|
"""
|
||||||
|
low = max(int(lo or PRICE_FLOOR), 1)
|
||||||
|
mid = int(math.sqrt(low * float(hi)))
|
||||||
|
return max(low + 1, min(hi - 1, mid))
|
||||||
|
|
||||||
|
|
||||||
|
def width_ratio(lo: int | None, hi: int | None) -> float:
|
||||||
|
if hi is None:
|
||||||
|
return float("inf")
|
||||||
|
return float(hi) / max(float(lo or PRICE_FLOOR), 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- загрузка страницы -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _guard(html: str, status: int | None) -> None:
|
||||||
|
"""Порядок проверок фиксирован заданием; первое срабатывание = стоп."""
|
||||||
|
if status in (403, 439):
|
||||||
|
raise Blocked("platform", f"HTTP {status}")
|
||||||
|
if status == 429:
|
||||||
|
raise Blocked("ratelimit", "HTTP 429")
|
||||||
|
if _is_firewall_page(html):
|
||||||
|
raise Blocked("firewall", "firewall-страница на HTTP 200")
|
||||||
|
head = html[:4096].lower()
|
||||||
|
if any(m in head for m in _POW_MARKERS):
|
||||||
|
raise Blocked("challenge", "PoW / проверка безопасности")
|
||||||
|
|
||||||
|
|
||||||
|
class Loader:
|
||||||
|
"""Одна СВОЯ вкладка в уже открытом Chrome владельца (CDP).
|
||||||
|
|
||||||
|
Ни браузер, ни контекст, ни чужие вкладки не закрываются и не трогаются:
|
||||||
|
это рабочий Chrome с залогиненным техаккаунтом.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, delay: float, page_budget: int | None) -> None:
|
||||||
|
self._delay = delay
|
||||||
|
self._budget = page_budget
|
||||||
|
self.loads = 0
|
||||||
|
self._page: Any = None
|
||||||
|
self._pw: Any = None
|
||||||
|
self._browser: Any = None
|
||||||
|
self._ctx: Any = None
|
||||||
|
self._last_load = 0.0
|
||||||
|
self._loads_on_page = 0
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "Loader":
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
from playwright.async_api import TimeoutError as _PwTimeout
|
||||||
|
|
||||||
|
global PlaywrightTimeoutError
|
||||||
|
PlaywrightTimeoutError = _PwTimeout
|
||||||
|
|
||||||
|
endpoint = os.environ.get("AVITO_CDP", "http://localhost:9222")
|
||||||
|
self._pw = await async_playwright().start()
|
||||||
|
try:
|
||||||
|
self._browser = await self._pw.chromium.connect_over_cdp(endpoint)
|
||||||
|
except Exception as exc: # noqa: BLE001 — подсказка важнее типа
|
||||||
|
await self._pw.stop()
|
||||||
|
raise SystemExit(
|
||||||
|
f"Не удалось подключиться по CDP к {endpoint}: {exc}\n"
|
||||||
|
"Запусти Chrome с залогиненным техаккаунтом Авито и ключом "
|
||||||
|
"--remote-debugging-port=9222, либо укажи адрес в AVITO_CDP."
|
||||||
|
) from exc
|
||||||
|
if not self._browser.contexts:
|
||||||
|
await self._pw.stop()
|
||||||
|
raise SystemExit(
|
||||||
|
"В подключённом Chrome нет ни одного контекста. Открой обычное окно "
|
||||||
|
"Chrome, запущенное с --remote-debugging-port=9222."
|
||||||
|
)
|
||||||
|
self._ctx = self._browser.contexts[0]
|
||||||
|
self._page = await self._ctx.new_page()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc: object) -> None:
|
||||||
|
if self._page is not None:
|
||||||
|
try:
|
||||||
|
await self._page.close() # ТОЛЬКО своя вкладка
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
if self._pw is not None:
|
||||||
|
try:
|
||||||
|
await self._pw.stop()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
def budget_left(self) -> bool:
|
||||||
|
return self._budget is None or self.loads < self._budget
|
||||||
|
|
||||||
|
async def _pause(self) -> None:
|
||||||
|
if self._last_load == 0.0:
|
||||||
|
return
|
||||||
|
jitter = self._delay * random.uniform(-0.2, 0.2)
|
||||||
|
wait = max(0.0, self._delay + jitter - (time.monotonic() - self._last_load))
|
||||||
|
if wait > 0:
|
||||||
|
print(f" пауза {wait:.1f} с", flush=True)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
|
||||||
|
async def fetch(self, url: str) -> tuple[str, int | None]:
|
||||||
|
if not self.budget_left():
|
||||||
|
raise BudgetExhausted()
|
||||||
|
await self._recycle_if_needed()
|
||||||
|
await self._pause()
|
||||||
|
resp = await self._goto(url)
|
||||||
|
self._loads_on_page += 1
|
||||||
|
self.loads += 1
|
||||||
|
self._last_load = time.monotonic()
|
||||||
|
status = resp.status if resp is not None else None
|
||||||
|
try:
|
||||||
|
await self._page.wait_for_selector('[data-marker="item"]', timeout=7_000)
|
||||||
|
except Exception: # noqa: BLE001 — пустая/блочная страница разбирается ниже
|
||||||
|
pass
|
||||||
|
html = await self._read_content()
|
||||||
|
_guard(html, status)
|
||||||
|
return html, status
|
||||||
|
|
||||||
|
async def _recycle_if_needed(self) -> None:
|
||||||
|
"""Каждые PAGE_RECYCLE_EVERY загрузок пересоздаём свою вкладку.
|
||||||
|
|
||||||
|
Рендерер Chrome накапливает память по всем навигациям вкладки, а проход
|
||||||
|
по Москве — под тысячу страниц в одной. Наблюдалось живьём: вкладка
|
||||||
|
падала с «Опаньки… Код ошибки: Out of Memory» при 34 ГБ свободных в
|
||||||
|
системе, то есть упирался именно рендерер, а не машина. Свежая вкладка
|
||||||
|
стоит одну навигацию и обнуляет счёт.
|
||||||
|
|
||||||
|
Закрывается ТОЛЬКО своя вкладка; контекст и чужие вкладки владельца не
|
||||||
|
трогаются — это его рабочий Chrome.
|
||||||
|
"""
|
||||||
|
if self._loads_on_page < PAGE_RECYCLE_EVERY:
|
||||||
|
return
|
||||||
|
print(f" вкладка пересоздаётся после {self._loads_on_page} загрузок "
|
||||||
|
"(память рендерера)", flush=True)
|
||||||
|
old = self._page
|
||||||
|
self._page = await self._ctx.new_page()
|
||||||
|
self._loads_on_page = 0
|
||||||
|
try:
|
||||||
|
await old.close()
|
||||||
|
except Exception: # noqa: BLE001 — старая вкладка могла уже умереть
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _goto(self, url: str, attempts: int = 3):
|
||||||
|
"""goto с ограниченным ретраем на таймаут навигации.
|
||||||
|
|
||||||
|
Авито изредка держит соединение до упора и goto падает по timeout. Это
|
||||||
|
НЕ признак отказа: в наблюдавшемся случае вкладка показывала нормальную
|
||||||
|
выдачу, а маркеров фаервола/PoW не было. Но и молча ретраить бесконечно
|
||||||
|
нельзя — тихий отказ выглядит ровно так же. Поэтому: перед каждым
|
||||||
|
повтором пробуем прочитать то, что в документе, и прогнать через _guard,
|
||||||
|
чтобы настоящий блок остановил прогон с правильной причиной; исчерпали
|
||||||
|
попытки — жёсткий стоп с причиной nav_timeout.
|
||||||
|
"""
|
||||||
|
for i in range(attempts):
|
||||||
|
try:
|
||||||
|
return await self._page.goto(url, wait_until="domcontentloaded",
|
||||||
|
timeout=90_000)
|
||||||
|
except PlaywrightTimeoutError:
|
||||||
|
try:
|
||||||
|
partial = await self._page.content()
|
||||||
|
except Exception: # noqa: BLE001 — документа может не быть вовсе
|
||||||
|
partial = ""
|
||||||
|
if partial:
|
||||||
|
_guard(partial, None) # настоящий блок остановит прогон здесь
|
||||||
|
if i == attempts - 1:
|
||||||
|
raise Blocked("nav_timeout") from None
|
||||||
|
print(f" таймаут навигации, попытка {i + 2}/{attempts}", flush=True)
|
||||||
|
await asyncio.sleep(10.0)
|
||||||
|
|
||||||
|
async def _read_content(self, attempts: int = 4) -> str:
|
||||||
|
"""page.content() с узким ретраем на гонку клиентской перенавигации.
|
||||||
|
|
||||||
|
Авито дорисовывает выдачу после domcontentloaded, и content() иногда
|
||||||
|
попадает ровно в момент смены документа: "Unable to retrieve content
|
||||||
|
because the page is navigating and changing the content". Это НЕ отказ
|
||||||
|
площадки — гвардов не касается, поэтому ретраим только эту ошибку и
|
||||||
|
только её, а любую другую поднимаем как есть.
|
||||||
|
"""
|
||||||
|
last: Exception | None = None
|
||||||
|
for i in range(attempts):
|
||||||
|
try:
|
||||||
|
return await self._page.content()
|
||||||
|
except Exception as exc: # noqa: BLE001 — сузили проверкой текста ниже
|
||||||
|
if "page is navigating" not in str(exc):
|
||||||
|
raise
|
||||||
|
last = exc
|
||||||
|
print(f" content() поймал перенавигацию, попытка {i + 2}/{attempts}",
|
||||||
|
flush=True)
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
raise RuntimeError(f"page.content() не отдал документ за {attempts} попыток") from last
|
||||||
|
|
||||||
|
|
||||||
|
# --- заливка в msk_raw -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _sql_str(value: str) -> str:
|
||||||
|
return "'" + value.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_rows(rows: Iterable[dict[str, Any]]) -> str:
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf, lineterminator="\n")
|
||||||
|
for r in rows:
|
||||||
|
writer.writerow([
|
||||||
|
r["source_id"], r["observed_at"], r["batch_id"], r["kind"],
|
||||||
|
r["url"], r["price"], r["payload"],
|
||||||
|
])
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def build_sql(batch_id: str, query: str, rows: list[dict[str, Any]],
|
||||||
|
started_at: str, kind: str = "serp") -> str:
|
||||||
|
"""Один поток на `psql -f -`: batch (FK!) → TEMP staging → \\copy → INSERT.
|
||||||
|
|
||||||
|
Одиночный `psql -c` через ssh ломается на квотинге скобок и кавычек, поэтому
|
||||||
|
только поток. rows_new = разница count(*) по batch_id до и после вставки.
|
||||||
|
"""
|
||||||
|
bid = _sql_str(batch_id)
|
||||||
|
return (
|
||||||
|
"BEGIN;\n"
|
||||||
|
"INSERT INTO msk_raw.batches (batch_id, kind, query, started_at)\n"
|
||||||
|
f"VALUES ({bid}, {_sql_str(kind)}, {_sql_str(query)}, "
|
||||||
|
f"CAST({_sql_str(started_at)} AS timestamptz))\n"
|
||||||
|
"ON CONFLICT (batch_id) DO NOTHING;\n"
|
||||||
|
"CREATE TEMP TABLE _stg (LIKE msk_raw.avito_cards INCLUDING DEFAULTS) "
|
||||||
|
"ON COMMIT DROP;\n"
|
||||||
|
"CREATE TEMP TABLE _before ON COMMIT DROP AS\n"
|
||||||
|
f" SELECT count(*) AS n FROM msk_raw.avito_cards WHERE batch_id = {bid};\n"
|
||||||
|
"\\copy _stg (source_id,observed_at,batch_id,kind,url,price,payload) "
|
||||||
|
"FROM STDIN WITH (FORMAT csv)\n"
|
||||||
|
+ _csv_rows(rows)
|
||||||
|
+ "\\.\n"
|
||||||
|
"INSERT INTO msk_raw.avito_cards "
|
||||||
|
"(source_id,observed_at,batch_id,kind,url,price,payload)\n"
|
||||||
|
"SELECT source_id,observed_at,batch_id,kind,url,price,payload FROM _stg\n"
|
||||||
|
"ON CONFLICT (source_id,batch_id,kind) DO NOTHING;\n"
|
||||||
|
"UPDATE msk_raw.batches b SET\n"
|
||||||
|
" rows_sent = coalesce(b.rows_sent,0) + (SELECT count(*) FROM _stg),\n"
|
||||||
|
" rows_new = coalesce(b.rows_new,0) +\n"
|
||||||
|
f" ((SELECT count(*) FROM msk_raw.avito_cards WHERE batch_id = {bid})\n"
|
||||||
|
" - (SELECT n FROM _before))\n"
|
||||||
|
f"WHERE b.batch_id = {bid};\n"
|
||||||
|
"COMMIT;\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_finalize_sql(batch_id: str, query: str, notes: str) -> str:
|
||||||
|
bid = _sql_str(batch_id)
|
||||||
|
return (
|
||||||
|
"INSERT INTO msk_raw.batches (batch_id, kind, query, started_at)\n"
|
||||||
|
f"VALUES ({bid}, 'serp', {_sql_str(query)}, now())\n"
|
||||||
|
"ON CONFLICT (batch_id) DO NOTHING;\n"
|
||||||
|
f"UPDATE msk_raw.batches SET finished_at = now(), notes = {_sql_str(notes)}\n"
|
||||||
|
f"WHERE batch_id = {bid};\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_psql(sql: str, ssh_host: str, container: str, db_user: str, db_name: str,
|
||||||
|
attempts: int = 4) -> None:
|
||||||
|
"""Заливка батча через ssh с ретраем на обрыв транспорта.
|
||||||
|
|
||||||
|
Прогон длится часами, и ssh рвётся: живьём поймано «Connection reset by peer»
|
||||||
|
(ssh возвращает 255) прямо посреди заливки — весь прогон умирал, а несброшенный
|
||||||
|
батч терялся. Ретраить безопасно: SQL идемпотентен (batch через ON CONFLICT DO
|
||||||
|
NOTHING, карточки через ON CONFLICT (source_id,batch_id,kind) DO NOTHING).
|
||||||
|
|
||||||
|
Ретраится ТОЛЬКО транспорт (ssh 255). Ошибка самого psql (ON_ERROR_STOP, любой
|
||||||
|
другой код) — это дефект данных или SQL, её повтор не лечит: поднимаем сразу.
|
||||||
|
"""
|
||||||
|
cmd = [
|
||||||
|
"ssh", ssh_host,
|
||||||
|
f"docker exec -i {container} psql -U {db_user} -d {db_name} "
|
||||||
|
"-v ON_ERROR_STOP=1 -f -",
|
||||||
|
]
|
||||||
|
for i in range(attempts):
|
||||||
|
proc = subprocess.run(cmd, input=sql.encode("utf-8"), capture_output=True)
|
||||||
|
out = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip()
|
||||||
|
if proc.returncode == 0:
|
||||||
|
if out:
|
||||||
|
print(f" psql: {out}", flush=True)
|
||||||
|
return
|
||||||
|
if proc.returncode != 255 or i == attempts - 1:
|
||||||
|
raise RuntimeError(f"psql через ssh вернул {proc.returncode}:\n{out}")
|
||||||
|
tail = out.splitlines()[-1] if out else "без вывода"
|
||||||
|
print(f" ssh оборвался ({tail}), повтор заливки {i + 2}/{attempts}", flush=True)
|
||||||
|
time.sleep(15.0 * (i + 1))
|
||||||
|
|
||||||
|
|
||||||
|
# --- накопитель карточек ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Sink:
|
||||||
|
"""Батчами на прод (ssh+psql) или в локальный CSV при --dry-run."""
|
||||||
|
|
||||||
|
batch_id: str
|
||||||
|
started_at: str
|
||||||
|
query: str
|
||||||
|
batch_size: int
|
||||||
|
dry_run: bool
|
||||||
|
csv_path: Path
|
||||||
|
ssh_host: str
|
||||||
|
container: str
|
||||||
|
db_user: str
|
||||||
|
db_name: str
|
||||||
|
buffer: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
sent: int = 0
|
||||||
|
skipped_non_numeric: int = 0
|
||||||
|
|
||||||
|
def add(self, lot: Any) -> None:
|
||||||
|
raw_id = str(getattr(lot, "source_id", "") or "")
|
||||||
|
try:
|
||||||
|
source_id = int(raw_id) # в БД bigint, у ScrapedLot — строка
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.skipped_non_numeric += 1
|
||||||
|
return
|
||||||
|
payload = lot.model_dump(mode="json")
|
||||||
|
self.buffer.append({
|
||||||
|
"source_id": source_id,
|
||||||
|
"observed_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"batch_id": self.batch_id,
|
||||||
|
"kind": "serp",
|
||||||
|
"url": payload.get("source_url"),
|
||||||
|
"price": payload.get("price_rub"),
|
||||||
|
"payload": json.dumps(payload, ensure_ascii=False),
|
||||||
|
})
|
||||||
|
|
||||||
|
def maybe_flush(self) -> None:
|
||||||
|
if len(self.buffer) >= self.batch_size:
|
||||||
|
self.flush()
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
if not self.buffer:
|
||||||
|
return
|
||||||
|
rows, self.buffer = self.buffer, []
|
||||||
|
if self.dry_run:
|
||||||
|
fresh = not self.csv_path.exists()
|
||||||
|
with self.csv_path.open("a", encoding="utf-8", newline="") as fh:
|
||||||
|
if fresh:
|
||||||
|
fh.write("source_id,observed_at,batch_id,kind,url,price,payload\n")
|
||||||
|
fh.write(_csv_rows(rows))
|
||||||
|
print(f" [dry-run] {len(rows)} строк → {self.csv_path}", flush=True)
|
||||||
|
else:
|
||||||
|
run_psql(
|
||||||
|
build_sql(self.batch_id, self.query, rows, self.started_at),
|
||||||
|
self.ssh_host, self.container, self.db_user, self.db_name,
|
||||||
|
)
|
||||||
|
print(f" залито {len(rows)} строк в msk_raw.avito_cards", flush=True)
|
||||||
|
self.sent += len(rows)
|
||||||
|
|
||||||
|
def finalize(self, notes: str) -> None:
|
||||||
|
self.flush()
|
||||||
|
if self.dry_run:
|
||||||
|
print(f" [dry-run] finalize: {notes}", flush=True)
|
||||||
|
return
|
||||||
|
run_psql(
|
||||||
|
build_finalize_sql(self.batch_id, self.query, notes),
|
||||||
|
self.ssh_host, self.container, self.db_user, self.db_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- сбор ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def parse_page(scraper: AvitoScraper, html: str, url: str) -> tuple[int | None, list[Any]]:
|
||||||
|
count = scraper._extract_total_count(html)
|
||||||
|
lots = scraper._parse_html(html, "https://www.avito.ru")
|
||||||
|
if not lots and count:
|
||||||
|
raise Blocked("empty_page", f"0 карточек при счётчике {count}: {url}")
|
||||||
|
return count, lots
|
||||||
|
|
||||||
|
|
||||||
|
async def probe(loader: Loader, scraper: AvitoScraper, base_url: str,
|
||||||
|
lo: int | None, hi: int | None) -> tuple[int | None, list[Any]]:
|
||||||
|
url = build_url(base_url, 1, lo, hi)
|
||||||
|
html, _ = await loader.fetch(url)
|
||||||
|
return parse_page(scraper, html, url)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_plan(loader: Loader, scraper: AvitoScraper, base_url: str, target: int,
|
||||||
|
cache: dict[tuple[int | None, int | None], list[Any]]
|
||||||
|
) -> list[Corridor]:
|
||||||
|
"""Адаптивная бисекция по цене; страница 1 каждого коридора кэшируется."""
|
||||||
|
corridors: list[Corridor] = []
|
||||||
|
|
||||||
|
def emit(lo: int | None, hi: int | None, count: int | None,
|
||||||
|
truncated: bool, lots: list[Any]) -> None:
|
||||||
|
missed = max(0, (count or 0) - HARD_CAP) if truncated else 0
|
||||||
|
c = Corridor(lo=lo, hi=hi, count=count, truncated=truncated, missed=missed)
|
||||||
|
corridors.append(c)
|
||||||
|
cache[(lo, hi)] = lots
|
||||||
|
flag = " TRUNCATED" if truncated else ""
|
||||||
|
print(f" коридор {c.label()} count={count} "
|
||||||
|
f"страниц={c.planned_pages()}{flag}", flush=True)
|
||||||
|
if truncated:
|
||||||
|
print(f" ВНИМАНИЕ: коридор {c.label()} не влезает в потолок "
|
||||||
|
f"{HARD_CAP}; заведомо не добрано ~{missed} объявлений", flush=True)
|
||||||
|
|
||||||
|
async def find_upper(lo: int | None) -> int:
|
||||||
|
"""Верхнюю границу открытого коридора ищем удвоением от разумного старта."""
|
||||||
|
cand = max(int(lo or PRICE_FLOOR) * 2, PRICE_PROBE_START)
|
||||||
|
while cand < PRICE_CEIL:
|
||||||
|
cnt, _ = await probe(loader, scraper, base_url, cand, None)
|
||||||
|
print(f" проба хвоста pmin={cand:_} count={cnt}", flush=True)
|
||||||
|
if cnt is not None and cnt <= target:
|
||||||
|
return cand
|
||||||
|
cand *= 2
|
||||||
|
return cand
|
||||||
|
|
||||||
|
async def split(lo: int | None, hi: int | None, depth: int,
|
||||||
|
count: int | None, lots: list[Any]) -> None:
|
||||||
|
if count is None:
|
||||||
|
url = build_url(base_url, 1, lo, hi)
|
||||||
|
raise Blocked("empty_page", f"счётчик не прочитался: {url}")
|
||||||
|
if count <= target:
|
||||||
|
emit(lo, hi, count, False, lots)
|
||||||
|
return
|
||||||
|
if depth >= MAX_DEPTH or width_ratio(lo, hi) <= MIN_WIDTH_RATIO:
|
||||||
|
# Предохранитель: не молчим — помечаем truncated и считаем недобор.
|
||||||
|
emit(lo, hi, count, count > HARD_CAP, lots)
|
||||||
|
return
|
||||||
|
upper = hi if hi is not None else await find_upper(lo)
|
||||||
|
if hi is None:
|
||||||
|
tail_cnt, tail_lots = await probe(loader, scraper, base_url, upper, None)
|
||||||
|
emit(upper, None, tail_cnt,
|
||||||
|
bool(tail_cnt and tail_cnt > HARD_CAP), tail_lots)
|
||||||
|
mid = geometric_mid(lo, upper)
|
||||||
|
for sub_lo, sub_hi in ((lo, mid), (mid, upper)):
|
||||||
|
sub_cnt, sub_lots = await probe(loader, scraper, base_url, sub_lo, sub_hi)
|
||||||
|
print(f" проба {sub_lo or '-'}..{sub_hi} count={sub_cnt}", flush=True)
|
||||||
|
await split(sub_lo, sub_hi, depth + 1, sub_cnt, sub_lots)
|
||||||
|
|
||||||
|
root_cnt, root_lots = await probe(loader, scraper, base_url, None, None)
|
||||||
|
print(f"Всего по базовому запросу: {root_cnt}", flush=True)
|
||||||
|
await split(None, None, 0, root_cnt, root_lots)
|
||||||
|
return corridors
|
||||||
|
|
||||||
|
|
||||||
|
async def collect(args: argparse.Namespace) -> int:
|
||||||
|
# avito_serp_ekb_only=False обязателен: с True парсер выбрасывает всё, где в
|
||||||
|
# URL нет /ekaterinburg/ — то есть все подмосковные слаги (serp.py:2154).
|
||||||
|
scraper = AvitoScraper(
|
||||||
|
SimpleNamespace(avito_serp_ekb_only=False), # type: ignore[arg-type]
|
||||||
|
target_city_slug="moskva",
|
||||||
|
)
|
||||||
|
out_dir = Path(args.out_dir).resolve()
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
plan_path = out_dir / f"plan-{args.batch_id}.json"
|
||||||
|
csv_path = out_dir / f"cards-{args.batch_id}.csv"
|
||||||
|
started_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
plan: Plan | None = None
|
||||||
|
if args.resume:
|
||||||
|
if not plan_path.exists():
|
||||||
|
print(f"--resume: плана нет — {plan_path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
plan = Plan.load(plan_path)
|
||||||
|
done = sum(1 for c in plan.corridors if c.status == "done")
|
||||||
|
print(f"Resume по {plan_path}: коридоров {len(plan.corridors)}, "
|
||||||
|
f"готово {done}", flush=True)
|
||||||
|
|
||||||
|
# Resume: URL берём из сохранённого плана, а не из CLI — коридоры посчитаны
|
||||||
|
# именно под него. Расхождение = молчаливая заливка чужой выдачи под тем же
|
||||||
|
# batch_id, поэтому это ошибка, а не тихий приоритет одного из двух.
|
||||||
|
if plan is not None and plan.base_url != args.base_url:
|
||||||
|
raise SystemExit(
|
||||||
|
"--resume: план построен для другого URL."
|
||||||
|
f" В плане {plan.base_url}, в аргументах {args.base_url}."
|
||||||
|
" Убери --base-url (возьмётся из плана) либо начни новый batch_id."
|
||||||
|
)
|
||||||
|
base_url = plan.base_url if plan is not None else args.base_url
|
||||||
|
|
||||||
|
page_budget = None if args.full else args.measure
|
||||||
|
mode = "FULL" if args.full else f"MEASURE<={page_budget}"
|
||||||
|
print(f"Режим: {mode}; batch_id={args.batch_id}; delay={args.delay}s; "
|
||||||
|
f"target={args.target_count}; dry_run={args.dry_run}", flush=True)
|
||||||
|
|
||||||
|
sink = Sink(
|
||||||
|
batch_id=args.batch_id, started_at=started_at, query=base_url,
|
||||||
|
batch_size=args.batch_size, dry_run=args.dry_run, csv_path=csv_path,
|
||||||
|
ssh_host=args.ssh_host, container=args.container,
|
||||||
|
db_user=args.db_user, db_name=args.db_name,
|
||||||
|
)
|
||||||
|
cache: dict[tuple[int | None, int | None], list[Any]] = {}
|
||||||
|
total = 0
|
||||||
|
stop_reason = ""
|
||||||
|
rc = 0
|
||||||
|
loads = 0
|
||||||
|
|
||||||
|
async with Loader(args.delay, page_budget) as loader:
|
||||||
|
try:
|
||||||
|
if plan is None:
|
||||||
|
print("Строю план коридоров...", flush=True)
|
||||||
|
corridors = await build_plan(loader, scraper, base_url,
|
||||||
|
args.target_count, cache)
|
||||||
|
plan = Plan(base_url=base_url, target=args.target_count,
|
||||||
|
batch_id=args.batch_id, corridors=corridors,
|
||||||
|
created_at=started_at)
|
||||||
|
plan.save(plan_path)
|
||||||
|
print(f"План сохранён: {plan_path} ({len(corridors)} коридоров)",
|
||||||
|
flush=True)
|
||||||
|
|
||||||
|
for corridor in plan.corridors:
|
||||||
|
if corridor.status == "done":
|
||||||
|
continue
|
||||||
|
pages = corridor.planned_pages()
|
||||||
|
print(f"Коридор {corridor.label()} count={corridor.count} "
|
||||||
|
f"страниц={pages} (с {corridor.pages_done + 1})", flush=True)
|
||||||
|
for page in range(corridor.pages_done + 1, pages + 1):
|
||||||
|
key = (corridor.lo, corridor.hi)
|
||||||
|
if page == 1 and key in cache:
|
||||||
|
lots = cache.pop(key) # страница 1 уже скачана при планировании
|
||||||
|
else:
|
||||||
|
url = build_url(base_url, page, corridor.lo, corridor.hi)
|
||||||
|
html, _ = await loader.fetch(url)
|
||||||
|
_, lots = parse_page(scraper, html, url)
|
||||||
|
for lot in lots:
|
||||||
|
sink.add(lot)
|
||||||
|
total += len(lots)
|
||||||
|
corridor.pages_done = page
|
||||||
|
print(f" стр.{page}/{pages}: карточек {len(lots)}, "
|
||||||
|
f"итого {total}", flush=True)
|
||||||
|
sink.maybe_flush()
|
||||||
|
plan.save(plan_path)
|
||||||
|
if not lots:
|
||||||
|
print(" пустая страница — конец коридора", flush=True)
|
||||||
|
break
|
||||||
|
corridor.status = "done"
|
||||||
|
plan.save(plan_path)
|
||||||
|
except BudgetExhausted:
|
||||||
|
stop_reason = "потолок --measure исчерпан"
|
||||||
|
print(f"Стоп: {stop_reason}", flush=True)
|
||||||
|
except Blocked as exc:
|
||||||
|
stop_reason = f"BLOCKED/{exc.reason}: {exc.detail}"
|
||||||
|
print(f"СТОП: {stop_reason}", file=sys.stderr, flush=True)
|
||||||
|
rc = 2
|
||||||
|
finally:
|
||||||
|
loads = loader.loads
|
||||||
|
if plan is not None:
|
||||||
|
plan.save(plan_path)
|
||||||
|
|
||||||
|
truncated = [c for c in (plan.corridors if plan else []) if c.truncated]
|
||||||
|
missed = sum(c.missed for c in truncated)
|
||||||
|
notes = "; ".join(x for x in [
|
||||||
|
f"mode={mode}", f"loads={loads}", f"cards={total}",
|
||||||
|
f"skipped_non_numeric={sink.skipped_non_numeric}",
|
||||||
|
(f"truncated_corridors={len(truncated)} missed~{missed}" if truncated else ""),
|
||||||
|
stop_reason,
|
||||||
|
] if x)
|
||||||
|
try:
|
||||||
|
sink.finalize(notes)
|
||||||
|
except Exception as exc: # noqa: BLE001 — не прятать исходную причину стопа
|
||||||
|
print(f"finalize провалился: {exc}", file=sys.stderr)
|
||||||
|
rc = rc or 1
|
||||||
|
print(f"Готово. Загрузок: {loads}; карточек: {total}; отправлено: {sink.sent}; "
|
||||||
|
f"пропущено нечисловых source_id: {sink.skipped_non_numeric}; "
|
||||||
|
f"notes: {notes}", flush=True)
|
||||||
|
return rc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
prog="collect.py",
|
||||||
|
description="Ручной сбор SERP Авито (вторичка, Москва+МО) в прод-схему msk_raw.",
|
||||||
|
)
|
||||||
|
p.add_argument("--base-url", default=DEFAULT_BASE_URL,
|
||||||
|
help="базовый URL выдачи (дефолт — вкладка владельца)")
|
||||||
|
p.add_argument("--measure", type=int, default=100, metavar="N",
|
||||||
|
help="режим замера: не больше N загрузок страниц (дефолт 100)")
|
||||||
|
p.add_argument("--full", action="store_true",
|
||||||
|
help="полный проход без потолка страниц (включается только явно)")
|
||||||
|
p.add_argument("--dry-run", action="store_true",
|
||||||
|
help="ничего не слать на прод, писать CSV локально")
|
||||||
|
p.add_argument("--resume", action="store_true",
|
||||||
|
help="продолжить по сохранённому плану коридоров")
|
||||||
|
p.add_argument("--delay", type=float, default=8.0,
|
||||||
|
help="пауза между загрузками, с (±20%% джиттер, дефолт 8.0)")
|
||||||
|
p.add_argument("--batch-size", type=int, default=1000,
|
||||||
|
help="карточек в одной заливке (дефолт 1000)")
|
||||||
|
p.add_argument("--target-count", type=int, default=1500,
|
||||||
|
help="целевой размер коридора; больше — делим (дефолт 1500)")
|
||||||
|
p.add_argument("--batch-id", default=None,
|
||||||
|
help="batch_id в msk_raw.batches (дефолт msk-serp-<UTC>)")
|
||||||
|
p.add_argument("--out-dir", default=str(Path(__file__).resolve().parent / "runs"),
|
||||||
|
help="каталог плана/CSV")
|
||||||
|
p.add_argument("--ssh-host", default="selectel", help="ssh-хост прода")
|
||||||
|
p.add_argument("--container", default="tradein-postgres",
|
||||||
|
help="имя контейнера Postgres на проде")
|
||||||
|
p.add_argument("--db-user", default="tradein")
|
||||||
|
p.add_argument("--db-name", default="tradein")
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
if args.batch_id is None:
|
||||||
|
args.batch_id = "msk-serp-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
|
if not _BATCH_ID_RE.match(args.batch_id):
|
||||||
|
p.error("--batch-id: допустимы только символы [A-Za-z0-9._-]")
|
||||||
|
if args.measure < 1:
|
||||||
|
p.error("--measure должен быть >= 1")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
return asyncio.run(collect(parse_args(argv)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -133,6 +133,11 @@ users:
|
||||||
# продукта; ранее expired с 2026-06-27). Безлимитная квота оценок
|
# продукта; ранее expired с 2026-06-27). Безлимитная квота оценок
|
||||||
# выдана через account_quota_overrides.unlimited (migration 191),
|
# выдана через account_quota_overrides.unlimited (migration 191),
|
||||||
# не через код — см. app.services.account_quota.is_unlimited.
|
# не через код — см. app.services.account_quota.is_unlimited.
|
||||||
|
buyer1: pilot # Тестовый доступ потенциального покупателя — заведён 2026-09-02 по
|
||||||
|
# просьбе владельца. Квота 50 оценок/мес через
|
||||||
|
# account_quota_overrides.monthly_limit (не unlimited). DB-роль
|
||||||
|
# manager (как praktika/kopylov — самостоятельный внешний аккаунт,
|
||||||
|
# не employee под чьим-то manager_id).
|
||||||
admintest: admin # temp QA 2026-05-26
|
admintest: admin # temp QA 2026-05-26
|
||||||
pilottest: pilot # temp QA 2026-05-26
|
pilottest: pilot # temp QA 2026-05-26
|
||||||
analysttest: analyst # temp QA 2026-06-07 (#962)
|
analysttest: analyst # temp QA 2026-06-07 (#962)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ per-row SAVEPOINT при UPSERT (битая фича не валит weekly-sync
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -101,20 +102,47 @@ def parse_voltage_class(name: str | None) -> str | None:
|
||||||
return m.group(1).replace(".", "/")
|
return m.group(1).replace(".", "/")
|
||||||
|
|
||||||
|
|
||||||
def _stable_external_id(feature: dict, props: dict) -> str:
|
def _coord_e5(value: float | None) -> str:
|
||||||
"""Стабильный external_id фичи: feature['id'] или хэш ключевых полей.
|
"""Координата → целое в единицах 1e-5 градуса (~1 м), полукруглением от нуля.
|
||||||
|
|
||||||
WFS обычно отдаёт стабильный ``feature['id']``; если его нет — детерминированный
|
Целое, а не форматированный float: ключ обязан совпадать байт-в-байт с SQL-
|
||||||
sha1 по (sc_name, координаты) чтобы UPSERT оставался идемпотентным.
|
бэкфиллом (99c), а текстовое представление double в питоне и в PG разное.
|
||||||
|
Округление ДВОИЧНОЕ (по значению double, не по десятичному представлению):
|
||||||
|
64.423605*1e5 == 6442360.499999999 → 6442360, хотя «по десятичному» было бы
|
||||||
|
6442361. В 99c та же семантика: floor/abs/sign над float8, БЕЗ каста в numeric
|
||||||
|
(каст округляет по кратчайшему десятичному repr и расходится в 0.19% координат).
|
||||||
"""
|
"""
|
||||||
fid = feature.get("id")
|
if value is None:
|
||||||
if fid:
|
return ""
|
||||||
return str(fid)
|
n = math.floor(abs(value) * 100000 + 0.5)
|
||||||
geom = feature.get("geometry") or {}
|
return str(-n if value < 0 else n)
|
||||||
coords = geom.get("coordinates")
|
|
||||||
seed = f"{props.get('sc_name', '')}|{coords}"
|
|
||||||
# sha1 здесь — стабильный дедуп-id фичи, не криптография.
|
def _stable_external_id(feature: dict, props: dict) -> str:
|
||||||
return "h:" + hashlib.sha1(seed.encode("utf-8")).hexdigest()[:16]
|
"""Стабильный external_id фичи — хэш атрибутов. ``feature['id']`` ИГНОРИРУЕТСЯ.
|
||||||
|
|
||||||
|
GeoServer отдаёт СЕССИОННЫЙ fid (``sc_points_fullview.fid--<random>``), новый на
|
||||||
|
каждый GetFeature → ON CONFLICT (source, external_id) не срабатывал ни разу и
|
||||||
|
таблица росла ×10 (4880 строк на 481 ЦП, #3322). Ключ считаем только по стабильным
|
||||||
|
атрибутам: нормализованное имя | класс напряжения | координаты в 1e-5 градуса.
|
||||||
|
|
||||||
|
ФОРМУЛА ПРОДУБЛИРОВАНА в ``data/sql/99c_power_supply_centers_dedup.sql`` (бэкфилл
|
||||||
|
существующих строк) — менять только синхронно. sha256, а не sha1: sha256 встроен
|
||||||
|
в PG16, sha1 требует pgcrypto. Префикс ``h:`` отличает новый ключ от старого fid.
|
||||||
|
"""
|
||||||
|
geom_pair = _point_geom_sql(feature)
|
||||||
|
coords = geom_pair[1] if geom_pair else {}
|
||||||
|
sc_name = props.get("sc_name")
|
||||||
|
seed = "|".join(
|
||||||
|
(
|
||||||
|
normalize_sc_name(sc_name),
|
||||||
|
parse_voltage_class(sc_name) or "",
|
||||||
|
_coord_e5(coords.get("lon")),
|
||||||
|
_coord_e5(coords.get("lat")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# sha256 здесь — стабильный дедуп-id фичи, не криптография.
|
||||||
|
return "h:" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
def _map_load_index(props: dict) -> str | None:
|
def _map_load_index(props: dict) -> str | None:
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ Pure / mock-based — без реальной сети и БД. Покрывае
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -96,6 +97,64 @@ def test_map_load_index_unknown_and_missing() -> None:
|
||||||
assert rw._map_load_index({"sc_indexload_id": "мусор"}) is None
|
assert rw._map_load_index({"sc_indexload_id": "мусор"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── _stable_external_id (#3322: сессионный fid раздувал таблицу ×10) ───────────
|
||||||
|
|
||||||
|
|
||||||
|
def _wfs_feature(fid: str, name: str, lon: float, lat: float) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": fid,
|
||||||
|
"geometry": {"type": "Point", "coordinates": [lon, lat]},
|
||||||
|
"properties": {"sc_name": name},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_stable_external_id_ignores_session_fid() -> None:
|
||||||
|
"""Разные сессионные fid + одинаковые атрибуты → ОДИН ключ (регрессия #3322)."""
|
||||||
|
a = _wfs_feature("sc_points_fullview.fid--1a2b3c", "ПС 110/10 Уктус", 60.47123, 56.77456)
|
||||||
|
b = _wfs_feature("sc_points_fullview.fid--9f8e7d", "ПС 110/10 Уктус", 60.47123, 56.77456)
|
||||||
|
|
||||||
|
key_a = rw._stable_external_id(a, a["properties"])
|
||||||
|
assert key_a == rw._stable_external_id(b, b["properties"])
|
||||||
|
# Проверка ПО ЗНАЧЕНИЮ: ключ = sha256 по «имя|напряжение|lon_e5|lat_e5»,
|
||||||
|
# не fid. Тот же seed повторён в data/sql/99c_power_supply_centers_dedup.sql.
|
||||||
|
expected = "h:" + hashlib.sha256("уктус|110/10|6047123|5677456".encode()).hexdigest()[:16]
|
||||||
|
assert key_a == expected == "h:844e54f0152d2799"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stable_external_id_differs_on_attributes() -> None:
|
||||||
|
"""Одинаковый fid, разные атрибуты (координата / имя) → РАЗНЫЕ ключи."""
|
||||||
|
base = _wfs_feature("sc_points_fullview.fid--same", "ПС 110/10 Уктус", 60.47123, 56.77456)
|
||||||
|
moved = _wfs_feature("sc_points_fullview.fid--same", "ПС 110/10 Уктус", 60.47124, 56.77456)
|
||||||
|
renamed = _wfs_feature("sc_points_fullview.fid--same", "ПС 110/10 Северная", 60.47123, 56.77456)
|
||||||
|
|
||||||
|
keys = {rw._stable_external_id(f, f["properties"]) for f in (base, moved, renamed)}
|
||||||
|
assert len(keys) == 3
|
||||||
|
assert rw._stable_external_id(moved, moved["properties"]) == "h:8c5b5fa5f35c651b"
|
||||||
|
assert rw._stable_external_id(renamed, renamed["properties"]) == "h:297e00f6e0d4f3b8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stable_external_id_no_geometry() -> None:
|
||||||
|
"""Фича без геометрии: координатные компоненты пустые, ключ всё равно стабилен."""
|
||||||
|
f: dict[str, Any] = {"id": "fid--x", "properties": {"sc_name": "ПС 110/10 Уктус"}}
|
||||||
|
assert rw._stable_external_id(f, f["properties"]) == "h:eb91917f35aff23f"
|
||||||
|
|
||||||
|
|
||||||
|
def test_coord_e5_rounds_on_binary_double_not_decimal() -> None:
|
||||||
|
"""Округление по ДВОИЧНОМУ double, не по десятичному представлению.
|
||||||
|
|
||||||
|
64.423605*1e5 == 6442360.499999999 → 6442360; «по десятичному» вышло бы 6442361
|
||||||
|
(так считал бы round(ST_X(geom)::numeric*100000) — расхождение на 0.19% реальных
|
||||||
|
координат). 99c обязана давать те же цифры, поэтому семантика закреплена тестом.
|
||||||
|
"""
|
||||||
|
assert rw._coord_e5(64.423605) == "6442360"
|
||||||
|
assert rw._coord_e5(-64.423605) == "-6442360"
|
||||||
|
# 60.123455*1e5 == ровно 6012345.5 → полукругление ОТ нуля, симметрично знаку.
|
||||||
|
assert rw._coord_e5(60.123455) == "6012346"
|
||||||
|
assert rw._coord_e5(-60.123455) == "-6012346"
|
||||||
|
assert rw._coord_e5(60.6) == "6060000"
|
||||||
|
assert rw._coord_e5(None) == ""
|
||||||
|
|
||||||
|
|
||||||
# ── sanitize_tp_capacity_mva (кВА-санитайз) ───────────────────────────────────
|
# ── sanitize_tp_capacity_mva (кВА-санитайз) ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
54
caddy/deploy-window.caddy.snippet
Normal file
54
caddy/deploy-window.caddy.snippet
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# caddy/deploy-window.caddy.snippet — ответ на окно деплоя (#3274)
|
||||||
|
#
|
||||||
|
# Импортируется ВНУТРЬ `handle_errors 502 503 504 { ... }` (см. apps.caddy):
|
||||||
|
# сам по себе снипет ничего не перехватывает, он только решает, ЧТО отдать,
|
||||||
|
# когда апстрим не отвечает.
|
||||||
|
#
|
||||||
|
# ЧТО ЭТО ЛЕЧИТ, А ЧТО НЕТ. Каждый деплой tradein-frontend/tradein-backend
|
||||||
|
# оставляет окно 30–90 с, в котором контейнера просто нет: Caddy набирает
|
||||||
|
# новый апстрим сразу, тот ещё не слушает (замер по access-логам, #3274 —
|
||||||
|
# все 502 кластеризуются на окнах мержа, duration < 2 мс = мгновенный отказ
|
||||||
|
# соединения). Снипет НЕ УБИРАЕТ окно — он меняет то, что видит человек и
|
||||||
|
# клиент внутри окна. Настоящее лечение (готовность нового контейнера до
|
||||||
|
# переключения) — п.1 issue, решение владельца, здесь его нет.
|
||||||
|
#
|
||||||
|
# ПОЧЕМУ 503, А НЕ 502. 502 значит «апстрим ответил мусором» — постоянная
|
||||||
|
# поломка; поисковик по нему выкидывает страницу из индекса, клиентские
|
||||||
|
# библиотеки не ретраят. 503 + `Retry-After: 30` — стандартный код «временно
|
||||||
|
# недоступен, приходи через 30 секунд»: Googlebot держит страницу в индексе,
|
||||||
|
# HTTP-клиенты понимают, что повтор осмыслен.
|
||||||
|
#
|
||||||
|
# ПОЧЕМУ ДВА ТЕЛА. `/trade-in/api/*` вызывают из JS и внешних клиентов — они
|
||||||
|
# парсят JSON, и HTML-страница у них превращается в ошибку разбора вместо
|
||||||
|
# читаемого статуса. Всё остальное открывает человек браузером.
|
||||||
|
#
|
||||||
|
# ВНЕШНИХ РЕСУРСОВ В СТРАНИЦЕ НЕТ ВООБЩЕ — ни шрифта, ни CSS-файла, ни
|
||||||
|
# картинки. В окне деплоя они пришли бы с того же мёртвого апстрима, и
|
||||||
|
# страница-заглушка отрисовалась бы голым текстом. Отсюда же инлайновые
|
||||||
|
# `style=` вместо блока `<style>`: фигурные скобки в теле `respond` Caddy
|
||||||
|
# пытается разобрать как плейсхолдеры.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@deployWindowApi path /trade-in/api/*
|
||||||
|
handle @deployWindowApi {
|
||||||
|
header Content-Type "application/json; charset=utf-8"
|
||||||
|
header Retry-After "30"
|
||||||
|
respond `{"detail":"Сервис обновляется, повторите запрос через минуту","error":"service_unavailable","retry_after":30}` 503
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
header Content-Type "text/html; charset=utf-8"
|
||||||
|
header Retry-After "30"
|
||||||
|
respond `<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Сервис обновляется</title>
|
||||||
|
<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:#fff;color:#111">
|
||||||
|
<main style="max-width:30rem;padding:2rem;text-align:center">
|
||||||
|
<h1 style="font-size:1.25rem;font-weight:600;margin:0 0 .75rem">Сервис обновляется</h1>
|
||||||
|
<p style="margin:0;line-height:1.6;color:#444">Это занимает около минуты. Обновите страницу чуть позже — введённые данные не потеряются.</p>
|
||||||
|
</main>
|
||||||
|
` 503
|
||||||
|
}
|
||||||
|
|
@ -175,10 +175,28 @@ gendsgn.ru {
|
||||||
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
|
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
|
||||||
# delete) — корректное поведение не должно зависеть от того, как именно
|
# delete) — корректное поведение не должно зависеть от того, как именно
|
||||||
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
|
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
|
||||||
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
|
# X-Internal-Auth-Secret ЗДЕСЬ БОЛЬШЕ НЕ ПОДСТАВЛЯЕТСЯ (#3324). До этой
|
||||||
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
|
# правки Caddy инжектил его в КАЖДЫЙ запрос этого хопа — включая
|
||||||
# basic_auth), это единственное, что теперь отсекает подделку заголовков
|
# анонимный, до всякого логина trade-in.
|
||||||
# изнутри gendesign_shared network для legacy dual-mode пути.
|
#
|
||||||
|
# ПОЧЕМУ ОН БЫЛ МЁРТВЫМ. Единственное место, где backend читает этот
|
||||||
|
# ЗАГОЛОВОК, — `app/core/rbac.py` (legacy trusted-header ветка): он
|
||||||
|
# сверяется ТОЛЬКО после того, как в запросе нашёлся непустой
|
||||||
|
# X-Authenticated-User (иначе ветка отдаёт 401 раньше, на «no
|
||||||
|
# authenticated user»). А X-Authenticated-User на этом же хопе строкой
|
||||||
|
# ниже удаляется — то есть пара «имя + секрет» через Caddy прийти не
|
||||||
|
# может по построению, сравнение недостижимо. Второй потребитель
|
||||||
|
# секрета, приёмник вебхуков GlitchTip (`app/api/v1/glitchtip.py`),
|
||||||
|
# берёт его из query-параметра `?secret=`, а не из заголовка, и на эту
|
||||||
|
# правку не реагирует.
|
||||||
|
#
|
||||||
|
# ЧТО ОТСЕКАЕТ ПОДДЕЛКУ ЗАГОЛОВКОВ ИЗНУТРИ gendesign_shared — проверка
|
||||||
|
# в rbac.py, а не факт подстановки в Caddy; она остаётся нетронутой.
|
||||||
|
# Инжект работал в обратную сторону: раздавал внутренний секрет по
|
||||||
|
# анонимному пути (он же виден в env контейнера Caddy). Легитимный
|
||||||
|
# dual-mode трафик — внутрисетевой (`docker exec tradein-backend curl
|
||||||
|
# -H 'X-Authenticated-User: …' -H 'X-Internal-Auth-Secret: …'`,
|
||||||
|
# см. auth/roles.yaml) — ходит мимо Caddy и сам несёт оба заголовка.
|
||||||
handle /trade-in/api/* {
|
handle /trade-in/api/* {
|
||||||
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
|
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
|
||||||
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
|
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
|
||||||
|
|
@ -186,7 +204,6 @@ gendsgn.ru {
|
||||||
uri strip_prefix /trade-in
|
uri strip_prefix /trade-in
|
||||||
reverse_proxy tradein-backend:8000 {
|
reverse_proxy tradein-backend:8000 {
|
||||||
header_up -X-Authenticated-User
|
header_up -X-Authenticated-User
|
||||||
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,9 +241,13 @@ gendsgn.ru {
|
||||||
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
# См. комментарий над /trade-in/api/* выше — та же логика (явное
|
# См. комментарий над /trade-in/api/* выше — та же логика (явное
|
||||||
# удаление вместо Set с пустым {http.auth.user.id}).
|
# удаление вместо Set с пустым {http.auth.user.id}), и по той же
|
||||||
|
# причине здесь больше нет инжекта X-Internal-Auth-Secret
|
||||||
|
# (#3324). На этом хопе он был мёртв ещё очевиднее: адресат —
|
||||||
|
# Next-сервер tradein-frontend, в его коде заголовок не читается
|
||||||
|
# и дальше в backend не пробрасывается (SSR не форвардит
|
||||||
|
# входящие заголовки — `headers()` во фронте не используется).
|
||||||
header_up -X-Authenticated-User
|
header_up -X-Authenticated-User
|
||||||
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,6 +267,32 @@ gendsgn.ru {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Окно деплоя (#3274) — ТОЛЬКО для /trade-in. Каждый мерж в tradein
|
||||||
|
# оставляет 30–90 с, в которые контейнера нет и посетитель видит голый 502
|
||||||
|
# (замер по access-логам: 23 × 502 на этом домене за 4 суток, все —
|
||||||
|
# на окнах деплоя). Снипет подменяет это на 503 + Retry-After и читаемое
|
||||||
|
# тело; разбор «что лечится, а что нет» — в самом снипете.
|
||||||
|
#
|
||||||
|
# ГЕЙТ ПО ПУТИ ОБЯЗАТЕЛЕН: `handle_errors` объявляется на весь site-блок,
|
||||||
|
# а этот блок обслуживает ещё и Site Finder («Птица») — его апстримы
|
||||||
|
# (backend, frontend) деплоятся отдельным пайплайном и в задачу не входят.
|
||||||
|
# Пути вне матчера не попадают ни в один вложенный handle, ошибка остаётся
|
||||||
|
# необработанной, и Caddy отдаёт ровно то, что отдавал раньше. Проверено на
|
||||||
|
# живом Caddy 2.11.3 (dead-upstream 127.0.0.1:9): `/` и `/api/v1/*` —
|
||||||
|
# прежний пустой 502, `/trade-in/*` — новый 503.
|
||||||
|
#
|
||||||
|
# Матчер здесь сверяется с ИСХОДНЫМ путём запроса, а не с переписанным:
|
||||||
|
# для error-маршрута Caddy восстанавливает запрос, каким он пришёл. Поэтому
|
||||||
|
# `/trade-in/api/*` внутри снипета матчится, хотя на основном маршруте до
|
||||||
|
# падения апстрима уже отработал `uri strip_prefix /trade-in`. Тоже
|
||||||
|
# проверено на стенде, а не выведено из документации.
|
||||||
|
handle_errors 502 503 504 {
|
||||||
|
@tradeinScope path /trade-in /trade-in/*
|
||||||
|
handle @tradeinScope {
|
||||||
|
import ../deploy-window.caddy.snippet
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
www.gendsgn.ru {
|
www.gendsgn.ru {
|
||||||
|
|
@ -463,7 +510,40 @@ meraocenka.ru {
|
||||||
# `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок,
|
# `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок,
|
||||||
# CPU-нагрузка по запросу), который на лэндинге не используется вообще:
|
# CPU-нагрузка по запросу), который на лэндинге не используется вообще:
|
||||||
# next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется.
|
# next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется.
|
||||||
|
#
|
||||||
|
# #3324: внутри разрешённой статики закрыто ПОДДЕРЕВО ЧУЖИХ МАРШРУТОВ.
|
||||||
|
# App Router раскладывает код постранично: `chunks/app/<маршрут>/page-<hash>.js`
|
||||||
|
# (замер на проде 02.09.2026 — лэндингу нужны РОВНО `chunks/app/layout-*`,
|
||||||
|
# `chunks/app/error-*`, `chunks/app/global-error-*`, `chunks/app/mera-public/*`
|
||||||
|
# и общие хэш-чанки прямо в `chunks/`). Всё остальное под `chunks/app/` —
|
||||||
|
# это /admin, /scrapers, /team, /history, /cache, /versions, /v2,
|
||||||
|
# /sale-share, /login, /ui-preview: страницы закрытого контура, которые
|
||||||
|
# аноним с публичного домена качал целиком и читал в них имена внутренних
|
||||||
|
# ручек. Матчер отсекает ровно «есть подкаталог, и он не mera-public» —
|
||||||
|
# `not` вместо негативного lookahead, RE2 его не поддерживает.
|
||||||
|
#
|
||||||
|
# `_not-found` в исключениях — это не маршрут продукта, а внутренняя
|
||||||
|
# страница Next (404 при клиентской навигации по битому href). Закрытого
|
||||||
|
# кода не несёт, а без исключения клиентский переход падал бы в
|
||||||
|
# error-boundary вместо честного «не найдено».
|
||||||
|
#
|
||||||
|
# Ограничение честно: это убирает КОД, но не имена файлов — карта чанков
|
||||||
|
# лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень
|
||||||
|
# маршрутов из неё по-прежнему вычитывается. По той же причине оставлены
|
||||||
|
# без фильтра и `static/css/app/<маршрут>/*`: это тот же класс «имена, а не
|
||||||
|
# код», и городить второй матчер ради него смысла нет. Полное разделение —
|
||||||
|
# split build (отдельный public-бандл), отдельная задача.
|
||||||
|
#
|
||||||
|
# `respond` внутри `handle` выполняется раньше `reverse_proxy` (порядок
|
||||||
|
# директив Caddy), поэтому вложенный матчер не зависит от сортировки
|
||||||
|
# handle-блоков между собой.
|
||||||
handle /trade-in/_next/static/* {
|
handle /trade-in/_next/static/* {
|
||||||
|
@foreignRouteChunk {
|
||||||
|
path_regexp ^/trade-in/_next/static/chunks/app/[^/]+/
|
||||||
|
not path /trade-in/_next/static/chunks/app/mera-public/* /trade-in/_next/static/chunks/app/_not-found/*
|
||||||
|
}
|
||||||
|
respond @foreignRouteChunk 404
|
||||||
|
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
header_up -X-Authenticated-User
|
header_up -X-Authenticated-User
|
||||||
}
|
}
|
||||||
|
|
@ -499,9 +579,12 @@ meraocenka.ru {
|
||||||
# strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не
|
# strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не
|
||||||
# часть маршрута FastAPI.
|
# часть маршрута FastAPI.
|
||||||
#
|
#
|
||||||
# X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130):
|
# X-Internal-Auth-Secret здесь НЕ подставляется: публичные ручки его не
|
||||||
# публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с
|
# проверяют, а инжектить внутренний секрет в хоп с анонимного домена —
|
||||||
# анонимного домена — расширять доверие без нужды.
|
# расширять доверие без нужды. С #3324 это уже не отличие от B2B-блока
|
||||||
|
# gendsgn.ru, а общее правило: инжекта не осталось нигде в Caddyfile —
|
||||||
|
# заголовок нужен только внутрисетевому dual-mode трафику, который ходит
|
||||||
|
# мимо прокси и несёт его сам.
|
||||||
handle /trade-in/api/public/* {
|
handle /trade-in/api/public/* {
|
||||||
uri strip_prefix /trade-in
|
uri strip_prefix /trade-in
|
||||||
reverse_proxy tradein-backend:8000 {
|
reverse_proxy tradein-backend:8000 {
|
||||||
|
|
@ -514,6 +597,19 @@ meraocenka.ru {
|
||||||
handle {
|
handle {
|
||||||
respond 404
|
respond 404
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Окно деплоя (#3274). В отличие от gendsgn.ru гейт по пути не нужен: все
|
||||||
|
# апстримы этого блока — контейнеры МЕРЫ (tradein-frontend/tradein-backend),
|
||||||
|
# чей деплой и создаёт окно. 15 × 502 за 4 суток, все на окнах мержа.
|
||||||
|
#
|
||||||
|
# Белый список выше это НЕ ослабляет. Во-первых, `handle_errors`
|
||||||
|
# срабатывает только на перечисленные статусы, а отказ белого списка —
|
||||||
|
# 404. Во-вторых, `respond 404` пишет ответ напрямую и ошибкой маршрута
|
||||||
|
# вообще не является. Проверено на стенде: при мёртвом апстриме `/admin`
|
||||||
|
# по-прежнему отдаёт 404, а не страницу обновления.
|
||||||
|
handle_errors 502 503 504 {
|
||||||
|
import ../deploy-window.caddy.snippet
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.
|
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.
|
||||||
|
|
|
||||||
137
data/sql/99c_power_supply_centers_dedup.sql
Normal file
137
data/sql/99c_power_supply_centers_dedup.sql
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
-- 99c_power_supply_centers_dedup.sql
|
||||||
|
-- Issue #3322 — power_supply_centers раздут ×10: 4880 строк на 481 уникальный ЦП.
|
||||||
|
--
|
||||||
|
-- Причина. rosseti_wfs_loader брал external_id из feature['id'] WFS-ответа, а
|
||||||
|
-- GeoServer отдаёт СЕССИОННЫЙ fid (новый на каждый GetFeature) → ON CONFLICT
|
||||||
|
-- (source, external_id) не срабатывал ни разу, каждый weekly-прогон добавлял
|
||||||
|
-- полный набор ~488 фич заново. Починка разбора сама старые строки не убирает
|
||||||
|
-- (ON CONFLICT ничего не перезапишет, ключи не совпадут) → нужен этот бэкфилл.
|
||||||
|
--
|
||||||
|
-- Что делает файл:
|
||||||
|
-- (а) пересчитывает external_id по НОВОЙ формуле (см. ниже) для всех строк
|
||||||
|
-- source='rosseti_wfs';
|
||||||
|
-- (б) схлопывает копии: победитель группы — свежайший снапшот
|
||||||
|
-- (fetched_at DESC NULLS LAST, id DESC — DESC в PG это NULLS FIRST,
|
||||||
|
-- поэтому NULLS LAST задан ЯВНО);
|
||||||
|
-- (в) печатает числа: строк до / после, удалено, переключено на новый ключ.
|
||||||
|
-- Ожидание после прогона — ~481-488 строк (столько ЦП отдаёт источник).
|
||||||
|
-- (г) идемпотентен: на повторном прогоне ключи уже совпадают → 0 удалений,
|
||||||
|
-- 0 обновлений, «до» = «после».
|
||||||
|
--
|
||||||
|
-- ФОРМУЛА КЛЮЧА (дублирует rosseti_wfs_loader._stable_external_id — менять только
|
||||||
|
-- синхронно, иначе следующий weekly-прогон вставит второй комплект строк):
|
||||||
|
-- seed = sc_name_norm || '|' || voltage_class || '|' || lon_e5 || '|' || lat_e5
|
||||||
|
-- external_id = 'h:' || left(hex(sha256(utf8(seed))), 16)
|
||||||
|
-- где lon_e5/lat_e5 — координата в единицах 1e-5 градуса (~1 м), округление
|
||||||
|
-- floor(|v|*1e5 + 0.5) со знаком — ДВОИЧНОЕ, ровно как в питоне; пустая строка,
|
||||||
|
-- если geom отсутствует. Целые, а не форматированный float — текстовое
|
||||||
|
-- представление double в питоне и в PG различается.
|
||||||
|
--
|
||||||
|
-- ПОЧЕМУ НЕ round(...::numeric): каст float8→numeric берёт кратчайшее десятичное
|
||||||
|
-- представление, и округление идёт по нему, а не по двоичному double. На реальных
|
||||||
|
-- координатах расходится в 0.19% случаев (замер: 761 из 400000), напр. 64.423605
|
||||||
|
-- → питон 6442360 (двоичное 6442360.499999999), numeric-путь 6442361. Каждое
|
||||||
|
-- расхождение = вечный дубль ЦП, который сам не зарастёт: миграция применяется
|
||||||
|
-- один раз (_schema_migrations). Поэтому в SQL считаем ТЕМ ЖЕ double: floor/abs/
|
||||||
|
-- sign над float8 — это IEEE754, бит в бит как math.floor в питоне.
|
||||||
|
-- sha256, а не sha1: sha256 встроен в PG16, sha1 потребовал бы pgcrypto.
|
||||||
|
--
|
||||||
|
-- Байт-в-байт совпадение с питоном держится на том, что SQL НИЧЕГО не нормализует
|
||||||
|
-- сам: sc_name_norm и voltage_class — уже готовые колонки, их записал тот же
|
||||||
|
-- normalize_sc_name / parse_voltage_class. Если normalize_sc_name когда-нибудь
|
||||||
|
-- изменится, старые sc_name_norm разъедутся с новыми ключами — тогда нужен
|
||||||
|
-- повторный прогон логики этого файла (он идемпотентен, ре-apply безопасен).
|
||||||
|
--
|
||||||
|
-- Порядок: миграция ПЕРЕД деплоем кода (schema-first) — новый код после неё
|
||||||
|
-- попадает ON CONFLICT-ом в уже схлопнутые строки.
|
||||||
|
--
|
||||||
|
-- Naming: deploy.yml применяет файлы по `ls -1 data/sql/*.sql | sort`;
|
||||||
|
-- '99c_' идёт после '99b_grant_quarter_price_index_fdw.sql' ('b' < 'c').
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
rows_before bigint;
|
||||||
|
names_before bigint;
|
||||||
|
rows_after bigint;
|
||||||
|
names_after bigint;
|
||||||
|
deleted bigint;
|
||||||
|
rekeyed bigint;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*), count(DISTINCT sc_name_norm)
|
||||||
|
INTO rows_before, names_before
|
||||||
|
FROM power_supply_centers
|
||||||
|
WHERE source = 'rosseti_wfs';
|
||||||
|
|
||||||
|
CREATE TEMP TABLE psc_new_key ON COMMIT DROP AS
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
fetched_at,
|
||||||
|
'h:' || substring(
|
||||||
|
encode(
|
||||||
|
sha256(convert_to(
|
||||||
|
sc_name_norm
|
||||||
|
|| '|' || coalesce(voltage_class, '')
|
||||||
|
|| '|' || CASE WHEN geom IS NULL THEN ''
|
||||||
|
ELSE (sign(ST_X(geom))
|
||||||
|
* floor(abs(ST_X(geom)) * 100000 + 0.5))::bigint::text END
|
||||||
|
|| '|' || CASE WHEN geom IS NULL THEN ''
|
||||||
|
ELSE (sign(ST_Y(geom))
|
||||||
|
* floor(abs(ST_Y(geom)) * 100000 + 0.5))::bigint::text END,
|
||||||
|
'UTF8'
|
||||||
|
)),
|
||||||
|
'hex'
|
||||||
|
) FROM 1 FOR 16
|
||||||
|
) AS new_key
|
||||||
|
FROM power_supply_centers
|
||||||
|
WHERE source = 'rosseti_wfs';
|
||||||
|
|
||||||
|
-- (б) схлопывание: оставляем свежайший снапшот каждой группы.
|
||||||
|
-- Резервы (reserve_mva и пр.) не теряются: rosseti/eesk-лоадеры пишут их
|
||||||
|
-- UPDATE-ом по sc_name_norm, т.е. во ВСЕ копии сразу, победитель их несёт.
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY new_key
|
||||||
|
ORDER BY fetched_at DESC NULLS LAST, id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM psc_new_key
|
||||||
|
)
|
||||||
|
DELETE FROM power_supply_centers p
|
||||||
|
USING ranked r
|
||||||
|
WHERE p.id = r.id
|
||||||
|
AND r.rn > 1;
|
||||||
|
GET DIAGNOSTICS deleted = ROW_COUNT;
|
||||||
|
|
||||||
|
-- (а) пересчёт ключа у выживших. После DELETE каждый new_key принадлежит
|
||||||
|
-- ровно одной строке → UNIQUE (source, external_id) не нарушается.
|
||||||
|
-- IS DISTINCT FROM даёт идемпотентность: второй прогон обновит 0 строк.
|
||||||
|
UPDATE power_supply_centers p
|
||||||
|
SET external_id = k.new_key
|
||||||
|
FROM psc_new_key k
|
||||||
|
WHERE p.id = k.id
|
||||||
|
AND p.external_id IS DISTINCT FROM k.new_key;
|
||||||
|
GET DIAGNOSTICS rekeyed = ROW_COUNT;
|
||||||
|
|
||||||
|
SELECT count(*), count(DISTINCT sc_name_norm)
|
||||||
|
INTO rows_after, names_after
|
||||||
|
FROM power_supply_centers
|
||||||
|
WHERE source = 'rosseti_wfs';
|
||||||
|
|
||||||
|
RAISE NOTICE '#3322 power_supply_centers: было % строк / % имён -> стало % строк / % имён (удалено %, переключено на стабильный ключ %)',
|
||||||
|
rows_before, names_before, rows_after, names_after, deleted, rekeyed;
|
||||||
|
|
||||||
|
-- 700 — потолок здравого смысла: источник отдаёт ~488 ЦП по области.
|
||||||
|
-- Превышение = формула ключа не схлопнула дубли. EXCEPTION, а не WARNING:
|
||||||
|
-- иначе файл пометится applied навсегда, а дубли останутся. Откат всей
|
||||||
|
-- транзакции ничего не теряет и оставляет миграцию непринятой до разбора.
|
||||||
|
IF rows_after > 700 THEN
|
||||||
|
RAISE EXCEPTION '#3322: после дедупа осталось % строк (ожидалось ~481-488) — формула ключа не схлопнула дубли, транзакция откачена', rows_after;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -313,6 +313,30 @@ services:
|
||||||
# /data/anton-sqlite/analysis.db).
|
# /data/anton-sqlite/analysis.db).
|
||||||
- /opt/gendesign/site-finder:/data/anton-sqlite:ro
|
- /opt/gendesign/site-finder:/data/anton-sqlite:ro
|
||||||
command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=8", "--queues=celery,scrape_kn,geo"]
|
command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=8", "--queues=celery,scrape_kn,geo"]
|
||||||
|
# #3324: до этого у worker'а healthcheck'а не было ВООБЩЕ — контейнер в
|
||||||
|
# crash-loop'е (ImportError в новом коде, протухший uv.lock) уезжал зелёным
|
||||||
|
# деплоем: деплой смотрел только `curl backend /health`.
|
||||||
|
# Проба — `inspect ping` ИМЕННО В ЭТОТ узел (`-d celery@$(hostname)`, у нас
|
||||||
|
# nodename дефолтный: в command нет `-n`). Без `-d` ping вернул бы OK на
|
||||||
|
# ответ ЛЮБОГО воркера на брокере — мёртвый контейнер выглядел бы живым.
|
||||||
|
# Ответ на ping = жив parent-процесс и держится соединение с Redis, т.е.
|
||||||
|
# ровно та связность, без которой очереди не разбираются. `$$` — экранировка
|
||||||
|
# для compose (в контейнер уезжает литеральное `$(hostname)`).
|
||||||
|
# Интервал 60s (не 30s как у backend): каждая проба — отдельный запуск
|
||||||
|
# celery-CLI с импортом приложения, дешёвым его не назовёшь.
|
||||||
|
# start_period 90s: холодный старт worker'а с Chromium-образа заметно
|
||||||
|
# медленнее backend'а.
|
||||||
|
# stderr НЕ глушим: docker хранит вывод пробы в .State.Health.Log, и деплой
|
||||||
|
# печатает его в диагнозе — с `2>&1` там была бы пустота вместо причины.
|
||||||
|
# retries 5 (не 3): при interval 60s тройка промахов = 3 минуты, столько
|
||||||
|
# длится обычный флап Redis, а из unhealthy контейнер сам не выходит по
|
||||||
|
# restart-политике — следующий деплой краснел бы за исправный воркер.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "celery -A app.workers.celery_app inspect ping -d celery@$$(hostname) -t 10 >/dev/null"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 5
|
||||||
|
start_period: 90s
|
||||||
# #976 cross-DB ETL tradein→gendesign: worker запускает etl_newbuilding_crossload task,
|
# #976 cross-DB ETL tradein→gendesign: worker запускает etl_newbuilding_crossload task,
|
||||||
# которому нужен прямой TCP-доступ к tradein-postgres через gendesign_shared.
|
# которому нужен прямой TCP-доступ к tradein-postgres через gendesign_shared.
|
||||||
# default — обязательно явно, иначе сервис выпадет из дефолтной сети.
|
# default — обязательно явно, иначе сервис выпадет из дефолтной сети.
|
||||||
|
|
@ -336,6 +360,25 @@ services:
|
||||||
# хранит только last_run_at для periodic tasks — потеря на restart OK,
|
# хранит только last_run_at для periodic tasks — потеря на restart OK,
|
||||||
# beat перестроит из `celery_app.conf.beat_schedule` на старте.
|
# beat перестроит из `celery_app.conf.beat_schedule` на старте.
|
||||||
command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info", "--schedule=/tmp/celerybeat-schedule"]
|
command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info", "--schedule=/tmp/celerybeat-schedule"]
|
||||||
|
# #3324: beat тоже жил без healthcheck'а. На `inspect ping` beat НЕ отвечает
|
||||||
|
# (remote control — свойство воркера, не планировщика), поэтому проба другая:
|
||||||
|
# СВЕЖЕСТЬ shelve-файла расписания. Celery beat синкует его на диск не реже
|
||||||
|
# чем раз в `Scheduler.sync_every` = 180 с — но только когда в этом окне была
|
||||||
|
# отправлена задача. У нас в расписании есть поминутная (nspd-geo-zombie-
|
||||||
|
# cleanup, `* * * * *`) и двухминутная задачи, так что живой beat обновляет
|
||||||
|
# mtime примерно каждые 3 минуты, а вставший — не обновляет вовсе. Порог 10
|
||||||
|
# минут = 3× запас к этой каденции.
|
||||||
|
# Почему glob `celerybeat-schedule*`: имя на диске зависит от того, какой
|
||||||
|
# backend выберет shelve/dbm в образе (gnu → тот же файл, dumb → .dat/.dir).
|
||||||
|
# Почему не `pgrep`/`true`: PID-1 процесс жив ровно пока жив контейнер —
|
||||||
|
# такая проба повторяет `State.Running` и не ловит подвисший планировщик.
|
||||||
|
# `grep -q .`: сам find возвращает 0 и когда не нашёл ничего.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "find /tmp -maxdepth 1 -name 'celerybeat-schedule*' -mmin -10 | grep -q ."]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 120s
|
||||||
|
|
||||||
# ── infra-postgres: лёгкий кластер ОСТАЮЩЕЙСЯ инфраструктуры (#3061) ────────
|
# ── infra-postgres: лёгкий кластер ОСТАЮЩЕЙСЯ инфраструктуры (#3061) ────────
|
||||||
# Переезд продукта Beget (46.173.16.127) → Selectel Poincare (188.124.37.140),
|
# Переезд продукта Beget (46.173.16.127) → Selectel Poincare (188.124.37.140),
|
||||||
|
|
@ -735,6 +778,9 @@ services:
|
||||||
# роняя ВСЕ сайты хоста, а не только metrics.gendsgn.ru.
|
# роняя ВСЕ сайты хоста, а не только metrics.gendsgn.ru.
|
||||||
- ./caddy/metrics-ingest.caddy.snippet:/etc/caddy/caddy/metrics-ingest.caddy.snippet:ro
|
- ./caddy/metrics-ingest.caddy.snippet:/etc/caddy/caddy/metrics-ingest.caddy.snippet:ro
|
||||||
- ./caddy/metrics-ui.caddy.snippet:/etc/caddy/caddy/metrics-ui.caddy.snippet:ro
|
- ./caddy/metrics-ui.caddy.snippet:/etc/caddy/caddy/metrics-ui.caddy.snippet:ro
|
||||||
|
# То же самое для страницы окна деплоя (#3274): caddy/sites/apps.caddy
|
||||||
|
# импортирует её как `import ../deploy-window.caddy.snippet`.
|
||||||
|
- ./caddy/deploy-window.caddy.snippet:/etc/caddy/caddy/deploy-window.caddy.snippet:ro
|
||||||
# Untracked локальные site-блоки (см. import в конце Caddyfile). Каталог
|
# Untracked локальные site-блоки (см. import в конце Caddyfile). Каталог
|
||||||
# держится в git через caddy/local/.gitignore — иначе docker создал бы
|
# держится в git через caddy/local/.gitignore — иначе docker создал бы
|
||||||
# отсутствующий bind-source сам, root-owned пустышкой.
|
# отсутствующий bind-source сам, root-owned пустышкой.
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,26 @@ loki.process "scrub_credentials" {
|
||||||
expression = "://[^:@/ ]+:([^@ ]+)@"
|
expression = "://[^:@/ ]+:([^@ ]+)@"
|
||||||
replace = "***"
|
replace = "***"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Секреты в query-строке (#3354) ─────────────────────────────────────────
|
||||||
|
// Прод-факт (#3154): uvicorn пишет в access-log ПОЛНЫЙ путь с query, и туда
|
||||||
|
// уезжал `?secret=<64 hex>` вебхука GlitchTip. Приложение чистит это у себя
|
||||||
|
// (tradein-mvp/backend/app/core/log_scrub.py), здесь — второй слой на случай
|
||||||
|
// строки, пришедшей мимо фильтра: другой процесс, sidecar, будущий логгер.
|
||||||
|
//
|
||||||
|
// Множество имён держим ОДИНАКОВЫМ с log_scrub.py. Префикс `[\w.-]*` перед
|
||||||
|
// альтернацией — ради суффиксных имён (`client_secret`, `refresh_token`,
|
||||||
|
// `webhook_secret`); значение обрывается на `&`, пробеле или кавычке, потому
|
||||||
|
// что access-строка uvicorn обрамляет запрос кавычками.
|
||||||
|
//
|
||||||
|
// Группа захвата стоит на ЗНАЧЕНИИ, а не на имени параметра: Alloy заменяет
|
||||||
|
// содержимое групп, а не весь совпавший фрагмент (ровно как у DSN выше — там
|
||||||
|
// группа на пароле, поэтому пользователь и хост уцелевают). Обернуть группой
|
||||||
|
// `?secret=` значило бы затереть имя и оставить сам секрет.
|
||||||
|
stage.replace {
|
||||||
|
expression = "(?i)[?&][\\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=([^&\\s\"'<>]+)"
|
||||||
|
replace = "***"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loki.relabel "journal" {
|
loki.relabel "journal" {
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,18 @@ loki.process "scrub_credentials" {
|
||||||
expression = "://[^:@/ ]+:([^@ ]+)@"
|
expression = "://[^:@/ ]+:([^@ ]+)@"
|
||||||
replace = "***"
|
replace = "***"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Секреты в query-строке (#3354) ─────────────────────────────────────────
|
||||||
|
// То же выражение, что в alloy-apps.alloy, и по той же причине — подробности
|
||||||
|
// там. Здесь оно нужно не меньше: на инфра-хосте живут Forgejo и GlitchTip,
|
||||||
|
// у обоих есть ручки с `?token=` в адресе, и их логи идут в тот же Loki.
|
||||||
|
//
|
||||||
|
// Группа захвата — на ЗНАЧЕНИИ: Alloy заменяет содержимое групп, а не весь
|
||||||
|
// совпавший фрагмент (как у DSN выше, где группа на пароле).
|
||||||
|
stage.replace {
|
||||||
|
expression = "(?i)[?&][\\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=([^&\\s\"'<>]+)"
|
||||||
|
replace = "***"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loki.relabel "journal" {
|
loki.relabel "journal" {
|
||||||
|
|
|
||||||
|
|
@ -52,15 +52,22 @@ check() {
|
||||||
}
|
}
|
||||||
|
|
||||||
check_post() {
|
check_post() {
|
||||||
local desc="$1" url="$2" body="$3" expected="$4"
|
local desc="$1" url="$2" body="$3" expected="$4" reject="${5:-}"
|
||||||
local code
|
local out code head_and_body
|
||||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
|
# -i: заголовки попадают в вывод вместе с телом — по ним отличаем ответ
|
||||||
|
# приложения от заглушки Caddy (см. $reject у вызова payments/notify).
|
||||||
|
out=$(curl -s -i -w '\n%{http_code}' --max-time 15 \
|
||||||
-X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null)
|
-X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null)
|
||||||
if [ "$code" = "$expected" ]; then
|
code=${out##*$'\n'}
|
||||||
echo "PASS: $desc ($url -> $code)"
|
head_and_body=${out%$'\n'*}
|
||||||
else
|
if [ "$code" != "$expected" ]; then
|
||||||
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
|
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
|
||||||
fail=1
|
fail=1
|
||||||
|
elif [ -n "$reject" ] && printf '%s' "$head_and_body" | grep -qEi "$reject"; then
|
||||||
|
echo "FAIL: $desc ($url -> $code, но ответ от заглушки окна деплоя, а не от приложения)"
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
echo "PASS: $desc ($url -> $code)"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,6 +126,51 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)
|
||||||
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
|
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
|
||||||
check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404
|
check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404
|
||||||
|
|
||||||
|
# 2c-bis. Внутри разрешённой статики закрыто поддерево ЧУЖИХ маршрутов (#3324):
|
||||||
|
# App Router кладёт код постранично в chunks/app/<маршрут>/, и до этой
|
||||||
|
# правки аноним скачивал с публичного домена бандлы /admin, /team,
|
||||||
|
# /scrapers — с именами внутренних ручек внутри.
|
||||||
|
#
|
||||||
|
# КОД 404 ЗДЕСЬ НЕДОСТАТОЧЕН: несуществующий чанк Next тоже отдаёт 404,
|
||||||
|
# поэтому проверка не отличила бы «Caddy отсёк» от «Caddy проксировал, а
|
||||||
|
# файла нет» — и осталась бы зелёной после отката матчера. Отличаем по
|
||||||
|
# ТЕЛУ: `respond 404` Caddy пустой (0 байт), 404 от Next — непустой
|
||||||
|
# (замер на проде 02.09.2026: 9 байт).
|
||||||
|
check_caddy_404() {
|
||||||
|
local desc="$1" url="$2"
|
||||||
|
local out code size
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url" 2>/dev/null)
|
||||||
|
code=${out%% *}
|
||||||
|
size=${out##* }
|
||||||
|
if [ "$code" = "404" ] && [ "$size" = "0" ]; then
|
||||||
|
echo "PASS: $desc ($url -> 404, пустое тело = отсёк Caddy)"
|
||||||
|
else
|
||||||
|
echo "FAIL: $desc ($url -> got '${out:-<no response>}', expected '404 0')"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_caddy_404 "meraocenka.ru — чанки /admin не раздаются" \
|
||||||
|
"$BASE_MERA/trade-in/_next/static/chunks/app/admin/page-smoke.js"
|
||||||
|
check_caddy_404 "meraocenka.ru — чанки /admin/analytics не раздаются" \
|
||||||
|
"$BASE_MERA/trade-in/_next/static/chunks/app/admin/analytics/page-smoke.js"
|
||||||
|
check_caddy_404 "meraocenka.ru — чанки /team не раздаются" \
|
||||||
|
"$BASE_MERA/trade-in/_next/static/chunks/app/team/page-smoke.js"
|
||||||
|
|
||||||
|
# Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться
|
||||||
|
# живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы
|
||||||
|
# успешной, а публичный сайт молча остался бы без JS.
|
||||||
|
layout_chunk=$(curl -s --max-time 15 "$BASE_MERA/" 2>/dev/null \
|
||||||
|
| grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1)
|
||||||
|
if [ -z "$layout_chunk" ]; then
|
||||||
|
# Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о
|
||||||
|
# чём — поэтому это FAIL, а не «пропустим».
|
||||||
|
echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)"
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200
|
||||||
|
fi
|
||||||
|
|
||||||
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
|
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
|
||||||
# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене
|
# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене
|
||||||
# по-прежнему 404.
|
# по-прежнему 404.
|
||||||
|
|
@ -156,8 +208,23 @@ check_post "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/ap
|
||||||
# Гейт данных переехал на API — там и проверяем, иначе тест зелёный при
|
# Гейт данных переехал на API — там и проверяем, иначе тест зелёный при
|
||||||
# открытом наружу бэкенде.
|
# открытом наружу бэкенде.
|
||||||
check "trade-in /api/v1/me — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/me" 401
|
check "trade-in /api/v1/me — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/me" 401
|
||||||
check "trade-in /api/v1/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/history" 401
|
# Путь именно `/api/v1/trade-in/history`: роутер trade_in подключён в main.py с
|
||||||
check "trade-in /api/v1/admin/* — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/admin/users" 401
|
# префиксом `/api/v1/trade-in`, а сама ручка объявлена как `@router.get("/history")`.
|
||||||
|
# До 05.09.2026 здесь стоял несуществующий `/api/v1/history` — проверка была
|
||||||
|
# зелёной только потому, что guard отвечал 401 на ЛЮБОЙ путь; после #3352
|
||||||
|
# несуществующий путь даёт 404, и проверка честно покраснела.
|
||||||
|
check "trade-in /api/v1/trade-in/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/trade-in/history" 401
|
||||||
|
# #3360: admin-префикс анониму отвечает 404, а НЕ 401. Периметр здесь не срезан
|
||||||
|
# (Caddy-блок /trade-in/api/* стоит выше basic_auth-снипета, и срезать нельзя —
|
||||||
|
# admin-UI зовёт эти же пути из браузера), поэтому существование ручки прячет сам
|
||||||
|
# guard. Пара путей взята намеренно разная: `/proxies` — РЕАЛЬНЫЙ роут
|
||||||
|
# (app/api/v1/admin.py), `/users` — несуществующий. Одинаковый код на обоих и
|
||||||
|
# означает, что перебором имён admin-API снаружи ничего не узнать; 401 на первом
|
||||||
|
# = регресс маскировки (app/core/rbac.py::_unauthenticated).
|
||||||
|
check "trade-in /api/v1/admin/proxies — 404 anonymous (существующая ручка скрыта)" \
|
||||||
|
"$BASE_MAIN/trade-in/api/v1/admin/proxies" 404
|
||||||
|
check "trade-in /api/v1/admin/users — 404 anonymous (несуществующая — тот же ответ)" \
|
||||||
|
"$BASE_MAIN/trade-in/api/v1/admin/users" 404
|
||||||
|
|
||||||
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга
|
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга
|
||||||
# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается
|
# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается
|
||||||
|
|
@ -206,8 +273,16 @@ check "meraocenka.ru payments/checkout — must 404 (Caddy не проксиру
|
||||||
# маршрут существует (не 404 — старый образ) И что приём платежей выключен
|
# маршрут существует (не 404 — старый образ) И что приём платежей выключен
|
||||||
# (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув
|
# (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув
|
||||||
# этот смоук. GET → 405 закрепляет, что путь принимает только POST.
|
# этот смоук. GET → 405 закрепляет, что путь принимает только POST.
|
||||||
|
#
|
||||||
|
# С #3274 голый код 503 больше не доказывает ничего: Caddy сам отдаёт 503 на
|
||||||
|
# окне деплоя (`handle_errors` -> caddy/deploy-window.caddy.snippet), и тогда
|
||||||
|
# запрос до приложения не дошёл вовсе. Отличаем по признакам заглушки —
|
||||||
|
# заголовок `Retry-After: 30` и `"error":"service_unavailable"` в теле; у
|
||||||
|
# приложения тело `{"detail":"payments are disabled"}` и никакого Retry-After.
|
||||||
|
# Совпал код, но пришла заглушка → FAIL, а не молчаливый PASS.
|
||||||
check_post "trade-in payments/notify — 503 anonymous (маршрут есть, приём выключен)" \
|
check_post "trade-in payments/notify — 503 anonymous (маршрут есть, приём выключен)" \
|
||||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503
|
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503 \
|
||||||
|
'service_unavailable|^retry-after: *30'
|
||||||
check "trade-in payments/notify — 405 на GET (только POST)" \
|
check "trade-in payments/notify — 405 на GET (только POST)" \
|
||||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405
|
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405
|
||||||
check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \
|
check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ from pydantic import BaseModel, Field, field_validator
|
||||||
# убрал legacy app.services.scheduler.scheduler_loop fallback) — тот же orchestrator,
|
# убрал legacy app.services.scheduler.scheduler_loop fallback) — тот же orchestrator,
|
||||||
# что и debug-роуты этого файла.
|
# что и debug-роуты этого файла.
|
||||||
from scraper_kit.base import save_listings
|
from scraper_kit.base import save_listings
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
|
||||||
from scraper_kit.orchestration.pipeline import (
|
from scraper_kit.orchestration.pipeline import (
|
||||||
DEFAULT_REGION_CODE,
|
DEFAULT_REGION_CODE,
|
||||||
run_avito_city_sweep,
|
run_avito_city_sweep,
|
||||||
|
|
@ -49,6 +48,7 @@ from scraper_kit.orchestration.pipeline import (
|
||||||
run_yandex_city_sweep,
|
run_yandex_city_sweep,
|
||||||
run_yandex_full_load,
|
run_yandex_full_load,
|
||||||
)
|
)
|
||||||
|
from scraper_kit.providers._base import build_browser_fetcher
|
||||||
from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment
|
from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment
|
||||||
from scraper_kit.providers.avito.houses import fetch_house_catalog, save_house_catalog_enrichment
|
from scraper_kit.providers.avito.houses import fetch_house_catalog, save_house_catalog_enrichment
|
||||||
from scraper_kit.providers.avito.imv import (
|
from scraper_kit.providers.avito.imv import (
|
||||||
|
|
@ -268,6 +268,7 @@ async def geocode_missing(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
target: Literal["listings", "deals"] = "listings",
|
target: Literal["listings", "deals"] = "listings",
|
||||||
|
region_code: int = 66,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
|
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
|
||||||
|
|
||||||
|
|
@ -279,6 +280,10 @@ async def geocode_missing(
|
||||||
geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed-
|
geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed-
|
||||||
адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается.
|
адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается.
|
||||||
geom обновляется автоматически триггером.
|
geom обновляется автоматически триггером.
|
||||||
|
|
||||||
|
region_code (дефолт 66, #3051) — фильтрует обе таблицы (`listings`/`deals`
|
||||||
|
несут колонку) и прокидывается в `known_city_hint`/`geocode`. Дефолт 66 —
|
||||||
|
прежнее поведение без изменений (cron не меняется, follow-up ниже).
|
||||||
"""
|
"""
|
||||||
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
|
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
|
||||||
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
|
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
|
||||||
|
|
@ -291,13 +296,14 @@ async def geocode_missing(
|
||||||
WHERE lat IS NULL
|
WHERE lat IS NULL
|
||||||
AND COALESCE(address, '') != ''
|
AND COALESCE(address, '') != ''
|
||||||
{extra_filter}
|
{extra_filter}
|
||||||
|
AND region_code = CAST(:region_code AS int)
|
||||||
AND (geocode_tried_at IS NULL
|
AND (geocode_tried_at IS NULL
|
||||||
OR geocode_tried_at < NOW() - interval '7 days')
|
OR geocode_tried_at < NOW() - interval '7 days')
|
||||||
ORDER BY geocode_tried_at NULLS FIRST
|
ORDER BY geocode_tried_at NULLS FIRST
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"limit": limit},
|
{"limit": limit, "region_code": region_code},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
|
|
@ -332,8 +338,8 @@ async def geocode_missing(
|
||||||
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
|
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
|
||||||
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
|
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
|
||||||
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
|
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
|
||||||
city = known_city_hint(row.get("city"))
|
city = known_city_hint(row.get("city"), region_code)
|
||||||
result = await geocode(clean, db, city_hint=city)
|
result = await geocode(clean, db, city_hint=city, region_code=region_code)
|
||||||
if result is None:
|
if result is None:
|
||||||
# Помечаем что пробовали — иначе ретрай на каждом cron.
|
# Помечаем что пробовали — иначе ретрай на каждом cron.
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -362,10 +368,12 @@ async def geocode_missing(
|
||||||
WHERE lat IS NULL
|
WHERE lat IS NULL
|
||||||
AND COALESCE(address, '') != ''
|
AND COALESCE(address, '') != ''
|
||||||
{extra_filter}
|
{extra_filter}
|
||||||
|
AND region_code = CAST(:region_code AS int)
|
||||||
AND (geocode_tried_at IS NULL
|
AND (geocode_tried_at IS NULL
|
||||||
OR geocode_tried_at < NOW() - interval '7 days')
|
OR geocode_tried_at < NOW() - interval '7 days')
|
||||||
"""
|
"""
|
||||||
)
|
),
|
||||||
|
{"region_code": region_code},
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -508,9 +516,16 @@ async def cian_auto_login(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with BrowserFetcher(
|
# #3197 (хвост): через фабрику (endpoint/environment из одного места), но
|
||||||
source="cian", endpoint=settings.browser_http_endpoint
|
# НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает
|
||||||
) as fetcher:
|
# (browser/server.py:2814-2817 — `_no_live_proxy(provider, None)`; и сам
|
||||||
|
# `_post_login` не кладёт payload["proxy"], это делают только fetch/fetch_json) —
|
||||||
|
# логин идёт с env-узла сайдкара. Аренда здесь была бы холостой и при пустом пуле
|
||||||
|
# блокировала бы ручку восстановления (`_acquire_lease` → NoProxyAvailableError →
|
||||||
|
# 502 ровно во время инцидента с пулом). Пул для логина — отдельная задача сайдкара.
|
||||||
|
# `use_pool` без провайдера фетчер игнорирует (`_acquire_lease`: use_pool AND
|
||||||
|
# provider is not None), поэтому передавать его тут безвредно, но и бесполезно.
|
||||||
|
async with build_browser_fetcher(RealScraperConfig(), "cian") as fetcher:
|
||||||
raw_cookies = await fetcher.login(
|
raw_cookies = await fetcher.login(
|
||||||
url=settings.cian_login_url,
|
url=settings.cian_login_url,
|
||||||
email=email,
|
email=email,
|
||||||
|
|
@ -699,7 +714,12 @@ async def debug_domclick_detail_fetch(
|
||||||
# построен и verified live именно 2026-07-04 (815КБ __SSR_STATE__ через свежий IP).
|
# построен и verified live именно 2026-07-04 (815КБ __SSR_STATE__ через свежий IP).
|
||||||
# Заменяет прежний source="cian" (docstring domclick/detail.py — устаревшая
|
# Заменяет прежний source="cian" (docstring domclick/detail.py — устаревшая
|
||||||
# рекомендация от 2026-06-27, до появления выделенного пула).
|
# рекомендация от 2026-06-27, до появления выделенного пула).
|
||||||
async with BrowserFetcher(source="domclick", endpoint=settings.browser_http_endpoint) as bf:
|
# #3197 (хвост): фабрика вместо прямой конструкции — она и есть то место, где
|
||||||
|
# proxy_provider/use_pool/environment попадают в тело POST /fetch. Без них узел
|
||||||
|
# выбирал сайдкар из своего env, а не пул с provider_affinity='domclick'.
|
||||||
|
async with build_browser_fetcher(
|
||||||
|
RealScraperConfig(), "domclick", proxy_provider=_kit_proxy_provider()
|
||||||
|
) as bf:
|
||||||
try:
|
try:
|
||||||
enrichment = await domclick_fetch_detail(
|
enrichment = await domclick_fetch_detail(
|
||||||
body.card_url, browser_fetcher=bf, cookies=cookies
|
body.card_url, browser_fetcher=bf, cookies=cookies
|
||||||
|
|
@ -1106,6 +1126,10 @@ async def scrape_avito_imv(
|
||||||
has_balcony=has_balcony,
|
has_balcony=has_balcony,
|
||||||
has_loggia=has_loggia,
|
has_loggia=has_loggia,
|
||||||
config=RealScraperConfig(),
|
config=RealScraperConfig(),
|
||||||
|
# #3386: без provider'а curl_proxy_url считает use_pool=False (флаг AND
|
||||||
|
# provider is not None) → env-прокси SCRAPER_PROXY_URL, мёртвый узел (#2613).
|
||||||
|
# _kit_proxy_provider() отдаёт None при выключенных флагах — ship-dark цел.
|
||||||
|
proxy_provider=_kit_proxy_provider(),
|
||||||
)
|
)
|
||||||
except IMVAddressNotFoundError as e:
|
except IMVAddressNotFoundError as e:
|
||||||
# Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning
|
# Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning
|
||||||
|
|
@ -1944,7 +1968,10 @@ async def scrape_yandex_newbuilding(
|
||||||
"""
|
"""
|
||||||
# fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен.
|
# fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен.
|
||||||
# config= обязателен (#2322 fix) — иначе BrowserFetcher строится без endpoint=.
|
# config= обязателен (#2322 fix) — иначе BrowserFetcher строится без endpoint=.
|
||||||
scraper = YandexNewbuildingScraper(config=RealScraperConfig())
|
# proxy_provider (#3197): без него сайдкар уходит на свой env-узел мимо пула.
|
||||||
|
scraper = YandexNewbuildingScraper(
|
||||||
|
config=RealScraperConfig(), proxy_provider=_kit_proxy_provider()
|
||||||
|
)
|
||||||
result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city)
|
result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city)
|
||||||
if result is None:
|
if result is None:
|
||||||
raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")
|
raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@ _send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ
|
||||||
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ —
|
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ —
|
||||||
``{"text": str, "attachments": [{"title","title_link","text","color",
|
``{"text": str, "attachments": [{"title","title_link","text","color",
|
||||||
"fields",...}]}`` — просто у uptime пустые/отсутствующие ``fields``/``color``;
|
"fields",...}]}`` — просто у uptime пустые/отсутствующие ``fields``/``color``;
|
||||||
2) единственный канал для аутентификации — сам URL (как и Slack-вебхуки).
|
2) единственный канал для аутентификации от САМОГО GlitchTip — сам URL (как и
|
||||||
Секрет ОБЯЗАН ехать query-параметром, HTTP-заголовок здесь поставить
|
у Slack-вебхуков): заголовок GlitchTip-сторона не добавляет. Поэтому
|
||||||
нечем (GlitchTip-сторона его не добавляет).
|
хендлер принимает секрет и из заголовка ``X-GlitchTip-Secret``
|
||||||
|
(предпочтительно — не течёт в access-log, #3154), и из query-параметра
|
||||||
|
``?secret=`` как fallback для текущего отправителя.
|
||||||
|
|
||||||
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
|
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
|
||||||
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета — тот же
|
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета — тот же
|
||||||
|
|
@ -47,7 +49,7 @@ import secrets
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
@ -175,8 +177,9 @@ def _alerts_configured() -> bool:
|
||||||
|
|
||||||
def _verify_secret(provided: str) -> None:
|
def _verify_secret(provided: str) -> None:
|
||||||
expected = settings.tradein_internal_auth_secret
|
expected = settings.tradein_internal_auth_secret
|
||||||
|
# constant-time: длина/префикс секрета не утекают через время ответа.
|
||||||
if not secrets.compare_digest(provided or "", expected):
|
if not secrets.compare_digest(provided or "", expected):
|
||||||
logger.warning("glitchtip webhook: invalid or missing secret query param")
|
logger.warning("glitchtip webhook: invalid or missing secret")
|
||||||
raise HTTPException(status_code=401, detail="invalid or missing secret")
|
raise HTTPException(status_code=401, detail="invalid or missing secret")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -184,18 +187,27 @@ def _verify_secret(provided: str) -> None:
|
||||||
async def glitchtip_webhook(
|
async def glitchtip_webhook(
|
||||||
request: Request,
|
request: Request,
|
||||||
secret: Annotated[str, Query()] = "",
|
secret: Annotated[str, Query()] = "",
|
||||||
|
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
|
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
|
||||||
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
|
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
|
||||||
— ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
|
— ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
|
||||||
|
|
||||||
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот
|
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот
|
||||||
хендлер сам делает единственную проверку (``secret`` query-параметр).
|
хендлер сам делает единственную проверку секрета.
|
||||||
|
|
||||||
|
Секрет принимается ИЗ ЗАГОЛОВКА ``X-GlitchTip-Secret``, а query-параметр
|
||||||
|
``?secret=`` остаётся fallback'ом (#3154). Заголовок предпочтителен потому,
|
||||||
|
что query едет в access-log и оттуда в Loki открытым текстом; query оставлен,
|
||||||
|
т.к. САМ GlitchTip 6.1.6 заголовков не шлёт вовсе (``send_webhook()`` —
|
||||||
|
``session.post(url, json=...)`` без headers, см. docstring модуля), и убрать
|
||||||
|
query можно только когда заголовок начнёт подставлять кто-то перед нами
|
||||||
|
(Caddy ``header_up`` на маршруте вебхука) либо после смены отправителя.
|
||||||
"""
|
"""
|
||||||
if not _alerts_configured():
|
if not _alerts_configured():
|
||||||
raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured")
|
raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured")
|
||||||
|
|
||||||
_verify_secret(secret)
|
_verify_secret(header_secret or secret)
|
||||||
|
|
||||||
raw_body = await request.body()
|
raw_body = await request.body()
|
||||||
received_at = datetime.now(UTC)
|
received_at = datetime.now(UTC)
|
||||||
|
|
|
||||||
|
|
@ -187,12 +187,52 @@ _ABANDONABLE_STATUSES = frozenset({"NEW", "FORM_SHOWED"})
|
||||||
# списания требуют, чтобы человек намеренно оплатил обе формы.
|
# списания требуют, чтобы человек намеренно оплатил обе формы.
|
||||||
_ABANDONED_AFTER_MINUTES = 30
|
_ABANDONED_AFTER_MINUTES = 30
|
||||||
|
|
||||||
|
# Статус для строки, чей Init отвалился: своего кода вроде INIT_FAILED в
|
||||||
|
# CHECK миграции 233 нет, а заводить его ради этого случая значило бы менять
|
||||||
|
# схему ради ярлыка — «почему» и так лежит в error_code/error_message. Берём
|
||||||
|
# терминальный DEADLINE_EXPIRED, которым checkout уже помечает попытки, из
|
||||||
|
# которых платёж не выйдет сам. Обязательное свойство ровно одно: статус ВНЕ
|
||||||
|
# предиката 279 (= вне _REUSABLE_STATUSES), иначе мёртвая строка продолжит
|
||||||
|
# держать пару (estimate_id, product_code). Это стережёт
|
||||||
|
# tests/test_payments_router.py::test_init_failed_status_is_terminal.
|
||||||
|
_INIT_FAILED_STATUS = "DEADLINE_EXPIRED"
|
||||||
|
|
||||||
_ORDER_ID_PREFIX = "mera-"
|
_ORDER_ID_PREFIX = "mera-"
|
||||||
# 32 байта энтропии (43 символа base64url) — перебор capability-ссылки
|
# 32 байта энтропии (43 символа base64url) — перебор capability-ссылки
|
||||||
# неосуществим, а сама ссылка остаётся кликабельной в мессенджере.
|
# неосуществим, а сама ссылка остаётся кликабельной в мессенджере.
|
||||||
_REPORT_TOKEN_BYTES = 32
|
_REPORT_TOKEN_BYTES = 32
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_init_failed(db: Session, order_id: str, *, code: str, message: str) -> None:
|
||||||
|
"""Переводит строку провалившегося Init в терминальный статус + «почему».
|
||||||
|
|
||||||
|
Общая для ВСЕХ исходов, после которых ссылки у строки не будет: отказ банка
|
||||||
|
и Success:true без PaymentURL. Статус NEW тут оставлять нельзя — см.
|
||||||
|
комментарий к `_INIT_FAILED_STATUS`: строка без `payment_url` невидима для
|
||||||
|
`_find_live_payment`, но видима предикату UNIQUE миграции 279, и следующий
|
||||||
|
checkout получил бы ложный 409 на все `_ABANDONED_AFTER_MINUTES`.
|
||||||
|
"""
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE payments
|
||||||
|
SET status = :status,
|
||||||
|
error_code = :code,
|
||||||
|
error_message = :message,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE order_id = :order_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"status": _INIT_FAILED_STATUS,
|
||||||
|
"code": code[:64],
|
||||||
|
"message": message[:500],
|
||||||
|
"order_id": order_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def _require_enabled() -> None:
|
def _require_enabled() -> None:
|
||||||
"""Kill-switch контура. 503, а не 404: путь существует, приём оплаты выключен."""
|
"""Kill-switch контура. 503, а не 404: путь существует, приём оплаты выключен."""
|
||||||
if not settings.payments_enabled:
|
if not settings.payments_enabled:
|
||||||
|
|
@ -389,21 +429,17 @@ def checkout(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except TBankApiError as exc:
|
except TBankApiError as exc:
|
||||||
# Запись остаётся в БД со статусом NEW и текстом ошибки — иначе факт
|
# Запись остаётся в БД с текстом ошибки — иначе факт попытки (и заказа,
|
||||||
# попытки (и возможного холда, если обрыв случился после приёма запроса
|
# который банк мог принять до обрыва) не остался бы нигде. Холда здесь
|
||||||
# банком) не остался бы нигде. Слепой повтор Init по тому же order_id
|
# быть не может: Init только заводит заказ и отдаёт ссылку на форму, а
|
||||||
|
# авторизация суммы происходит, когда покупатель платит по форме — её
|
||||||
|
# ему не выдавали. Слепой повтор Init по тому же order_id всё равно
|
||||||
# запрещён (см. докстринг init_payment) — это работа реконсиляции.
|
# запрещён (см. докстринг init_payment) — это работа реконсиляции.
|
||||||
db.execute(
|
#
|
||||||
text(
|
# Статус — терминальный (см. `_mark_init_failed`); если банк всё же
|
||||||
"""
|
# пришлёт по этой строке нотификацию, статус-машина notify доведёт её до
|
||||||
UPDATE payments
|
# конца (терминальный статус не блокирует CONFIRMED).
|
||||||
SET error_code = :code, error_message = :message, updated_at = NOW()
|
_mark_init_failed(db, order_id, code=exc.error_code, message=str(exc))
|
||||||
WHERE order_id = :order_id
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{"code": exc.error_code[:64], "message": str(exc)[:500], "order_id": order_id},
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
logger.warning("checkout: Init отклонён банком, order_id=%s: %s", order_id, exc)
|
logger.warning("checkout: Init отклонён банком, order_id=%s: %s", order_id, exc)
|
||||||
raise HTTPException(status_code=502, detail="payment provider error") from exc
|
raise HTTPException(status_code=502, detail="payment provider error") from exc
|
||||||
|
|
||||||
|
|
@ -411,7 +447,15 @@ def checkout(
|
||||||
tbank_payment_id = init.get("PaymentId")
|
tbank_payment_id = init.get("PaymentId")
|
||||||
if not isinstance(payment_url, str) or not payment_url:
|
if not isinstance(payment_url, str) or not payment_url:
|
||||||
# Success:true без PaymentURL — контракт банка нарушен; выдумывать
|
# Success:true без PaymentURL — контракт банка нарушен; выдумывать
|
||||||
# ссылку нечем.
|
# ссылку нечем. Замок тот же, что и у отказа выше, и даже вернее: банк
|
||||||
|
# заказ ПРИНЯЛ, а ссылки у строки уже не будет — оставить её в NEW
|
||||||
|
# значит отдать следующему checkout ложный 409 на 30 минут.
|
||||||
|
_mark_init_failed(
|
||||||
|
db,
|
||||||
|
order_id,
|
||||||
|
code="no_payment_url",
|
||||||
|
message=f"Init: Success без PaymentURL (Status={init.get('Status')!r})",
|
||||||
|
)
|
||||||
logger.error("checkout: Init без PaymentURL, order_id=%s", order_id)
|
logger.error("checkout: Init без PaymentURL, order_id=%s", order_id)
|
||||||
raise HTTPException(status_code=502, detail="payment provider returned no payment url")
|
raise HTTPException(status_code=502, detail="payment provider returned no payment url")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.sql.elements import TextClause
|
from sqlalchemy.sql.elements import TextClause
|
||||||
|
|
||||||
from app.core.auth import get_role
|
from app.core.auth import get_role, yaml_role
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.password import hash_password
|
from app.core.password import hash_password
|
||||||
|
|
@ -288,7 +288,9 @@ def _upsert_quota_override(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]:
|
def _batch_quota_status(
|
||||||
|
db: Session, usernames: list[str], known_roles: dict[str, str] | None = None
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
|
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
|
||||||
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
|
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
|
||||||
|
|
||||||
|
|
@ -351,13 +353,21 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str
|
||||||
result: dict[str, dict[str, Any]] = {}
|
result: dict[str, dict[str, Any]] = {}
|
||||||
for username in usernames:
|
for username in usernames:
|
||||||
override = override_by_username.get(username)
|
override = override_by_username.get(username)
|
||||||
try:
|
# #3316: get_role ходит в реестр, а вызывающий уже прочитал роли этих
|
||||||
role = get_role(username)
|
# же строк — иначе батч снова стал бы N+1 (ловит
|
||||||
except KeyError:
|
# test_list_employees_query_count_is_not_n_plus_1). Роль реестра —
|
||||||
role = None
|
# ровно то, что вернул бы get_role: он спрашивает реестр первым.
|
||||||
|
role: str | None
|
||||||
|
if known_roles is not None and username in known_roles:
|
||||||
|
role = known_roles[username]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
role = get_role(username)
|
||||||
|
except KeyError:
|
||||||
|
role = None
|
||||||
if role == "admin":
|
if role == "admin":
|
||||||
unlimited = True
|
unlimited = True
|
||||||
elif role is not None:
|
elif yaml_role(username) is not None:
|
||||||
unlimited = bool(override is not None and override["unlimited"])
|
unlimited = bool(override is not None and override["unlimited"])
|
||||||
else:
|
else:
|
||||||
# username не в roles.yaml — is_unlimited() короткое замыкание на
|
# username не в roles.yaml — is_unlimited() короткое замыкание на
|
||||||
|
|
@ -432,6 +442,20 @@ async def create_employee(
|
||||||
`identity_db` — реестр (строка сотрудника), `db` — продуктовая квота;
|
`identity_db` — реестр (строка сотрудника), `db` — продуктовая квота;
|
||||||
в дефолтном режиме это одна и та же сессия и одна транзакция.
|
в дефолтном режиме это одна и та же сессия и одна транзакция.
|
||||||
"""
|
"""
|
||||||
|
# #3316 defense-in-depth: имя, за которым в roles.yaml уже числятся права
|
||||||
|
# (admin/pilot/analyst), занять нельзя. Роль резолвится из реестра первой
|
||||||
|
# (app.core.auth.get_role), так что эскалации не было бы и без этой
|
||||||
|
# проверки — но совпадение имён само по себе означает двух разных людей с
|
||||||
|
# одним логином, и дешевле отказать на входе, чем разбирать это в логах.
|
||||||
|
legacy = yaml_role(body.username)
|
||||||
|
if legacy is not None and legacy != "employee":
|
||||||
|
logger.warning(
|
||||||
|
"create_employee: %r refused — username занят в roles.yaml (role=%s)",
|
||||||
|
body.username,
|
||||||
|
legacy,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=409, detail="username reserved in roles config")
|
||||||
|
|
||||||
schema = identity_schema()
|
schema = identity_schema()
|
||||||
existing = identity_db.execute(
|
existing = identity_db.execute(
|
||||||
text(f"SELECT id FROM {schema.users_table} WHERE username = :u"),
|
text(f"SELECT id FROM {schema.users_table} WHERE username = :u"),
|
||||||
|
|
@ -762,7 +786,11 @@ async def list_employees(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
|
quota_by_username = _batch_quota_status(
|
||||||
|
db,
|
||||||
|
[row["username"] for row in rows],
|
||||||
|
known_roles={row["username"]: row["role"] for row in rows},
|
||||||
|
)
|
||||||
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]
|
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ from app.schemas.trade_in import (
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
)
|
)
|
||||||
from app.services import account_quota
|
from app.services import account_quota
|
||||||
|
from app.services import regions as regions_mod
|
||||||
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
||||||
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
|
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
|
||||||
from app.services.user_events import schedule_event
|
from app.services.user_events import schedule_event
|
||||||
|
|
@ -763,8 +764,22 @@ def load_estimate(
|
||||||
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
|
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
|
||||||
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
|
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
|
||||||
target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
|
target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
|
||||||
|
# #3051 PR-A: тот же region_code-скоуп, что и POST /estimate — гарантирует
|
||||||
|
# «регион не резолвится → DEFAULT_REGION_CODE (66)», не NULL (NULL в SQL
|
||||||
|
# обнулил бы фильтр). row.lat/row.lon персистятся с estimate-time.
|
||||||
|
target_region = (
|
||||||
|
regions_mod.region_for_point(row.lat, row.lon)
|
||||||
|
if row.lat is not None and row.lon is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
target_region_code = target_region.code if target_region else regions_mod.DEFAULT_REGION_CODE
|
||||||
dkp_raw = _fetch_dkp_corridor(
|
dkp_raw = _fetch_dkp_corridor(
|
||||||
db, address=row.address, rooms=row.rooms, area=area_f, city=target_city
|
db,
|
||||||
|
address=row.address,
|
||||||
|
rooms=row.rooms,
|
||||||
|
area=area_f,
|
||||||
|
city=target_city,
|
||||||
|
region_code=target_region_code,
|
||||||
)
|
)
|
||||||
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
|
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
|
||||||
|
|
||||||
|
|
@ -776,7 +791,16 @@ def load_estimate(
|
||||||
recommended_price=int(imv_raw["recommended_price"]),
|
recommended_price=int(imv_raw["recommended_price"]),
|
||||||
lower_price=int(imv_raw["lower_price"]) if imv_raw.get("lower_price") else None,
|
lower_price=int(imv_raw["lower_price"]) if imv_raw.get("lower_price") else None,
|
||||||
higher_price=int(imv_raw["higher_price"]) if imv_raw.get("higher_price") else None,
|
higher_price=int(imv_raw["higher_price"]) if imv_raw.get("higher_price") else None,
|
||||||
market_count=int(imv_raw["market_count"]) if imv_raw.get("market_count") else None,
|
# #3323: `is not None` (0 — самый тонкий рынок, не «неизвестно») + thin_market
|
||||||
|
# считаем тем же порогом, что POST-путь в estimator, иначе одна и та же
|
||||||
|
# оценка при переоткрытии по ссылке / в PDF теряла флаг тонкого рынка.
|
||||||
|
market_count=(
|
||||||
|
int(imv_raw["market_count"]) if imv_raw.get("market_count") is not None else None
|
||||||
|
),
|
||||||
|
thin_market=(
|
||||||
|
imv_raw.get("market_count") is not None
|
||||||
|
and int(imv_raw["market_count"]) < settings.avito_imv_thin_market_threshold
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if imv_raw is not None and imv_raw.get("recommended_price")
|
if imv_raw is not None and imv_raw.get("recommended_price")
|
||||||
else None
|
else None
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ from repo root. We deliberately do NOT share code between repos via
|
||||||
|
|
||||||
When updating one copy, update the other.
|
When updating one copy, update the other.
|
||||||
|
|
||||||
|
⚠️ РАСХОЖДЕНИЕ С ЗЕРКАЛОМ (#3316, намеренное — не «синхронизировать» обратно):
|
||||||
|
здесь `get_role` резолвит роль СНАЧАЛА из реестра людей (`tradein_users.role` /
|
||||||
|
`auth.users.role`), и только потом из YAML. У основного бэкенда реестра нет,
|
||||||
|
там копия остаётся YAML-only.
|
||||||
|
|
||||||
Caddy gates the whole site with basic_auth (см. `caddy/users.caddy.snippet`)
|
Caddy gates the whole site with basic_auth (см. `caddy/users.caddy.snippet`)
|
||||||
и пропускает в backend заголовок `X-Authenticated-User: <username>` через
|
и пропускает в backend заголовок `X-Authenticated-User: <username>` через
|
||||||
`header_up X-Authenticated-User {http.auth.user.id}` в каждом reverse_proxy.
|
`header_up X-Authenticated-User {http.auth.user.id}` в каждом reverse_proxy.
|
||||||
|
|
@ -25,13 +30,15 @@ import logging
|
||||||
import re
|
import re
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal, TypedDict
|
from typing import Literal, TypedDict, cast
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
Role = Literal["admin", "pilot", "analyst", "expired"]
|
# legacy roles.yaml-роли + роли реестра ('admin'|'manager'|'employee', CHECK
|
||||||
|
# tradein м.192 / auth м.004). Оба набора приходят из одного `get_role` (#3316).
|
||||||
|
Role = Literal["admin", "pilot", "analyst", "expired", "manager", "employee"]
|
||||||
|
|
||||||
|
|
||||||
class UserScope(TypedDict):
|
class UserScope(TypedDict):
|
||||||
|
|
@ -155,8 +162,74 @@ def _load_roles_config() -> dict:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def yaml_role(username: str) -> Role | None:
|
||||||
|
"""Роль из roles.yaml (без похода в реестр) или None, если юзера там нет.
|
||||||
|
|
||||||
|
Нужна там, где спрашивают именно про legacy-файл, а не про эффективную роль:
|
||||||
|
`team.create_employee` (#3316) не даёт занять имя, за которым в YAML уже
|
||||||
|
числятся права.
|
||||||
|
"""
|
||||||
|
users: dict[str, Role] = _load_roles_config()["users"]
|
||||||
|
return users.get(username)
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_role(username: str) -> str | None:
|
||||||
|
"""Роль из реестра людей (`tradein_users.role` / `auth.users.role`) или None.
|
||||||
|
|
||||||
|
None означает «реестр про этого юзера ничего не сказал»: строки нет, роль
|
||||||
|
пустая, либо реестр вообще недоступен. Во всех трёх случаях решение
|
||||||
|
остаётся за roles.yaml — падение БД не имеет права выключить legacy-вход.
|
||||||
|
|
||||||
|
⚠️ Осознанный компромисс (#3316 review): последняя ветка — недоступный
|
||||||
|
реестр — на время сбоя ВОЗВРАЩАЕТ авторитетность roles.yaml, то есть ровно
|
||||||
|
то состояние, которое этот фикс и лечит. Сегодня это безопасно: коллизий
|
||||||
|
имён между реестром и YAML на проде нет, а новые закрыты 409-гвардом в
|
||||||
|
`team.create_employee`. Если коллизия всё же появится (ручной INSERT в
|
||||||
|
реестр, расширение roles.yaml) — сбой БД станет окном эскалации, и тогда
|
||||||
|
эту ветку надо менять на fail-closed (отказ вместо YAML-роли), а не
|
||||||
|
дописывать проверки у вызывающих.
|
||||||
|
|
||||||
|
Имя таблицы берётся из фиксированного словаря `identity_schema()`, значение
|
||||||
|
едет bind-параметром: снаружи в SQL не попадает ничего.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.services.identity_store import identity_schema, identity_session
|
||||||
|
|
||||||
|
schema = identity_schema()
|
||||||
|
with identity_session() as db:
|
||||||
|
row = db.execute(
|
||||||
|
text(f"SELECT role FROM {schema.users_table} WHERE username = :username"),
|
||||||
|
{"username": username},
|
||||||
|
).fetchone()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"registry role lookup failed for %r — fallback to roles.yaml",
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if row is None or not row.role:
|
||||||
|
return None
|
||||||
|
return str(row.role)
|
||||||
|
|
||||||
|
|
||||||
def get_role(username: str) -> Role:
|
def get_role(username: str) -> Role:
|
||||||
"""Return the role for *username* or raise KeyError if unknown."""
|
"""Эффективная роль *username*: реестр (БД) первый, roles.yaml — fallback.
|
||||||
|
|
||||||
|
Raises KeyError, если юзера нет ни там, ни там.
|
||||||
|
|
||||||
|
#3316: раньше роль резолвилась ТОЛЬКО из roles.yaml, при том что люди
|
||||||
|
заводятся в БД (`tradein_users`) — два дефекта разом. Вверх: сотрудник,
|
||||||
|
чьё имя совпало с YAML-админом, получал admin (IDOR по чужим оценкам +
|
||||||
|
безлимит квоты). Вниз: сотрудник, которого в YAML нет, получал KeyError →
|
||||||
|
403 на СОБСТВЕННУЮ оценку. Единственный источник истины теперь один, и он
|
||||||
|
здесь — вызывающие (rbac, trade_in, team, account_quota) не меняются.
|
||||||
|
"""
|
||||||
|
db_role = _registry_role(username)
|
||||||
|
if db_role is not None:
|
||||||
|
return cast(Role, db_role)
|
||||||
|
|
||||||
config = _load_roles_config()
|
config = _load_roles_config()
|
||||||
users: dict[str, Role] = config["users"]
|
users: dict[str, Role] = config["users"]
|
||||||
if username not in users:
|
if username not in users:
|
||||||
|
|
@ -217,13 +290,22 @@ def get_user_scope(username: str) -> UserScope:
|
||||||
"""
|
"""
|
||||||
config = _load_roles_config()
|
config = _load_roles_config()
|
||||||
role = get_role(username)
|
role = get_role(username)
|
||||||
role_def = config["roles"][role]
|
role_def = config["roles"].get(role)
|
||||||
|
if role_def is None:
|
||||||
|
# Роль реестра (employee/manager) — её scope живёт в DB_ROLE_PATHS, а не
|
||||||
|
# в roles.yaml (#3316: get_role теперь может вернуть и такую роль).
|
||||||
|
from app.services.auth_session import get_db_role_scope
|
||||||
|
|
||||||
|
allowed_paths, deny_paths = get_db_role_scope(role)
|
||||||
|
else:
|
||||||
|
allowed_paths = list(role_def.get("paths", []) or [])
|
||||||
|
deny_paths = list(role_def.get("deny", []) or [])
|
||||||
display_name, org, email = get_profile_for_user(username)
|
display_name, org, email = get_profile_for_user(username)
|
||||||
return UserScope(
|
return UserScope(
|
||||||
username=username,
|
username=username,
|
||||||
role=role,
|
role=role,
|
||||||
allowed_paths=list(role_def.get("paths", []) or []),
|
allowed_paths=allowed_paths,
|
||||||
deny_paths=list(role_def.get("deny", []) or []),
|
deny_paths=deny_paths,
|
||||||
brand=get_brand_for_user(username),
|
brand=get_brand_for_user(username),
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
org=org,
|
org=org,
|
||||||
|
|
|
||||||
|
|
@ -1007,6 +1007,14 @@ class Settings(BaseSettings):
|
||||||
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
|
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
|
||||||
yandex_proxy_max_rotations: int = 4
|
yandex_proxy_max_rotations: int = 4
|
||||||
|
|
||||||
|
# ── Порог полноты detail-страницы Яндекса (#3191) ────────────────────────
|
||||||
|
# Полная карточка оффера — 3-5 МБ; недорендеренная приходит с HTTP 200, валидным
|
||||||
|
# HTML и БЕЗ блока контактов (наблюдалось 1,8 МБ). Размер — второй признак к
|
||||||
|
# структурному (scraper_kit...yandex.detail.DETAIL_CONTACTS_MARKERS); любой из двух
|
||||||
|
# даёт отказ, объявление остаётся в очереди (detail_enriched_at не проставляется).
|
||||||
|
# ENV: YANDEX_DETAIL_MIN_HTML_BYTES.
|
||||||
|
yandex_detail_min_html_bytes: int = 1_000_000
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def yandex_proxy_url(self) -> str | None:
|
def yandex_proxy_url(self) -> str | None:
|
||||||
"""Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
|
"""Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
|
||||||
|
|
@ -1179,7 +1187,8 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
# #3283g: ротация exit-IP НА САМ БАН площадки, а не только по счётчику попыток.
|
# #3283g: ротация exit-IP НА САМ БАН площадки, а не только по счётчику попыток.
|
||||||
# Бан привязан к IP (замерено вживую: rotate_proxy() лечит забаненный узел за
|
# Бан привязан к IP (замерено вживую: rotate_proxy() лечит забаненный узел за
|
||||||
# секунды, clear_source_bans снимает запись из scrape_proxy_source_bans), но
|
# секунды, clear_source_bans гасит бан в scrape_proxy_source_bans — строка живёт
|
||||||
|
# до purge, #3404), но
|
||||||
# #3251/#3212 запрещают сбрасывать browser-context на КАЖДЫЙ блок -- сброс без
|
# #3251/#3212 запрещают сбрасывать browser-context на КАЖДЫЙ блок -- сброс без
|
||||||
# смены IP выбрасывает пройденный QRATOR-PoW и запускает самоподдерживающийся
|
# смены IP выбрасывает пройденный QRATOR-PoW и запускает самоподдерживающийся
|
||||||
# каскад блоков на том же адресе. rotate_on_ban МЕНЯЕТ IP вместе со сбросом,
|
# каскад блоков на том же адресе. rotate_on_ban МЕНЯЕТ IP вместе со сбросом,
|
||||||
|
|
|
||||||
72
tradein-mvp/backend/app/core/log_scrub.py
Normal file
72
tradein-mvp/backend/app/core/log_scrub.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""Секреты из query-строки не попадают в лог процесса (#3154).
|
||||||
|
|
||||||
|
Прод-факт: uvicorn пишет в access-log ПОЛНЫЙ путь вместе с query, а лог уезжает
|
||||||
|
в Loki (ретенция 30 суток, доступ по входу в Grafana):
|
||||||
|
|
||||||
|
INFO: 172.18.0.3:60322 - "POST /api/v1/trade-in/ops/glitchtip-webhook
|
||||||
|
?secret=<64 hex> HTTP/1.1" 200 OK
|
||||||
|
|
||||||
|
Скруббер в Alloy (#3115) это не ловит: там одно выражение под форму
|
||||||
|
``scheme://user:pass@host`` (DSN postgres_exporter, #3114). Чиним в СВОЁМ
|
||||||
|
процессе — тогда секрета нет и в `docker logs`, до отправки куда-либо.
|
||||||
|
|
||||||
|
Фильтр вешается на логгер (`logging.Filter`), а не на форматтер: uvicorn.access
|
||||||
|
кладёт путь в ``record.args``, до форматирования он уже там. Поэтому берём
|
||||||
|
``record.getMessage()`` и, если что-то замаскировали, подменяем msg/args.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Имена параметров, значение которых маскируем: имя ОКАНЧИВАЕТСЯ на чувствительное
|
||||||
|
# слово, поэтому перед альтернацией допускаем префикс (`client_secret`,
|
||||||
|
# `refresh_token`, `webhook_secret`). Значение — до следующего `&`, пробела или
|
||||||
|
# кавычки (access-строка uvicorn обрамляет запрос кавычками).
|
||||||
|
_SENSITIVE_QUERY = re.compile(
|
||||||
|
r"([?&][\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=)"
|
||||||
|
r"[^&\s\"'<>]+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def scrub_query_secrets(text: str) -> str:
|
||||||
|
"""Заменяет значения чувствительных query-параметров на ``***``.
|
||||||
|
|
||||||
|
Имя параметра и остальная строка сохраняются — иначе access-лог перестал бы
|
||||||
|
годиться для диагностики.
|
||||||
|
"""
|
||||||
|
return _SENSITIVE_QUERY.sub(r"\1***", text)
|
||||||
|
|
||||||
|
|
||||||
|
class QuerySecretFilter(logging.Filter):
|
||||||
|
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан."""
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
message = record.getMessage()
|
||||||
|
scrubbed = scrub_query_secrets(message)
|
||||||
|
if scrubbed != message:
|
||||||
|
record.msg = scrubbed
|
||||||
|
record.args = ()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def install_query_secret_filter(*logger_names: str) -> None:
|
||||||
|
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.
|
||||||
|
|
||||||
|
Двумя местами, потому что uvicorn в своём log-config ставит `uvicorn.access`
|
||||||
|
собственный handler с ``propagate = False`` — до корневого его записи не
|
||||||
|
доходят. А фильтр на handler'ах корня закрывает всё остальное приложение
|
||||||
|
(записи дочерних логгеров фильтры родителя не проходят, фильтры handler'а —
|
||||||
|
проходят).
|
||||||
|
|
||||||
|
Идемпотентно: повторный вызов не наплодит дублей.
|
||||||
|
"""
|
||||||
|
targets: list[logging.Logger | logging.Handler] = [
|
||||||
|
logging.getLogger(name) for name in logger_names or ("uvicorn.access",)
|
||||||
|
]
|
||||||
|
targets.extend(logging.getLogger().handlers)
|
||||||
|
for target in targets:
|
||||||
|
if not any(isinstance(f, QuerySecretFilter) for f in target.filters):
|
||||||
|
target.addFilter(QuerySecretFilter())
|
||||||
|
|
@ -35,10 +35,11 @@ from typing import Any
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
from starlette.routing import Match
|
||||||
|
|
||||||
from app.core.auth import get_role, is_path_allowed
|
from app.core.auth import get_role, is_path_allowed
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.auth_session import get_db_role_scope, get_session_user
|
from app.services.auth_session import DB_ROLE_PATHS, get_db_role_scope, get_session_user
|
||||||
from app.services.identity_store import identity_session
|
from app.services.identity_store import identity_session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -181,6 +182,90 @@ def _db_role_path_allowed(role: str, path: str) -> bool:
|
||||||
return any(_db_glob_match(p, path) for p in paths)
|
return any(_db_glob_match(p, path) for p in paths)
|
||||||
|
|
||||||
|
|
||||||
|
def _path_is_routed(request: Request) -> bool:
|
||||||
|
"""Есть ли у пути хоть один маршрут в роутере приложения.
|
||||||
|
|
||||||
|
#3324: guard — HTTP-middleware, он отрабатывает ДО роутинга, поэтому раньше
|
||||||
|
отвечал 401 и на пути, которых в приложении нет вовсе. Анониму этого хватало,
|
||||||
|
чтобы бесплатно разведать периметр: мусор под «интересным» префиксом
|
||||||
|
(``/api/public/whatever``) давал 401 с rbac-текстом, а мусор под публичным
|
||||||
|
префиксом — 404 роутера. Выключенная/закрытая ручка отличалась от
|
||||||
|
несуществующей. Несуществующий путь обязан отвечать одинаково независимо от
|
||||||
|
префикса, поэтому такие запросы пропускаются дальше — 404 отдаёт роутер, тот
|
||||||
|
же самый, что и на любой другой мусор.
|
||||||
|
|
||||||
|
ЧТО ИМЕННО РАЗМЕНЯНО (это НЕ «ослабления нет»). Раньше аноним получал 401 на
|
||||||
|
ЛЮБОЙ non-public путь — то есть оракул был ПРЕФИКСНЫЙ: он говорил «префикс
|
||||||
|
закрыт», но перечислить по нему таблицу маршрутов было нельзя. Теперь 401 =
|
||||||
|
«такой маршрут есть», 404 = «нет», и это уже оракул СУЩЕСТВОВАНИЯ маршрута:
|
||||||
|
перебором аноним восстанавливает список всех ручек приложения, включая имена
|
||||||
|
под ``/api/v1/admin/*``. Доступа это не даёт (закрытая ручка по-прежнему
|
||||||
|
отвечает 401/403), но карту периметра — даёт.
|
||||||
|
|
||||||
|
Почему размен принят. Точечно: 404 на несуществующее — норма HTTP, а
|
||||||
|
подобранное ИМЯ ручки без креденшелов бесполезно; исчезает же реальный
|
||||||
|
признак «этот префикс что-то охраняет». Это защита в глубину, и её глубина
|
||||||
|
здесь честно меньше, чем была.
|
||||||
|
|
||||||
|
⚠️ Периметр admin-путей снаружи НЕ срезан — проверено по конфигу, а не по
|
||||||
|
предположению: ``caddy/sites/apps.caddy`` → блок ``handle /trade-in/api/*``
|
||||||
|
делает ``uri strip_prefix /trade-in`` + ``reverse_proxy tradein-backend:8000``
|
||||||
|
и стоит ЦЕЛИКОМ ВЫШЕ ``import caddy/users.caddy.snippet`` (basic_auth), т.е.
|
||||||
|
у trade-in своя авторизация и внешнего barrier'а нет. Значит внешний
|
||||||
|
``https://gendsgn.ru/trade-in/api/v1/admin/...`` доходит до этого guard'а
|
||||||
|
анонимно, и перебор имён admin-ручек выполним снаружи, не только изнутри
|
||||||
|
docker-сети. Хочется убрать — резать надо в Caddy (отдельный issue), guard
|
||||||
|
этого не сделает: он про роли, а не про сетевой периметр.
|
||||||
|
|
||||||
|
``Match.NONE`` по ВСЕМ маршрутам значит, что выполнять нечего — хендлера, до
|
||||||
|
которого можно было бы дотянуться, не существует. ``Match.PARTIAL`` (путь
|
||||||
|
есть, метод другой) считается маршрутом и идёт в guard как раньше — там путь
|
||||||
|
реально существует, скрывать нечего.
|
||||||
|
|
||||||
|
Трейлинг-слэш был вторым каналом того же оракула в обход guard'а:
|
||||||
|
``/api/v1/me/`` не матчит ни один маршрут (``Match.NONE``) → guard пропускает
|
||||||
|
→ Starlette-роутер отвечал 307 на ``/api/v1/me``, то есть «маршрут есть»
|
||||||
|
сообщал редирект, а не 401. Закрыто в ``app/main.py``:
|
||||||
|
``FastAPI(redirect_slashes=False)`` — теперь такой путь даёт тот же 404, что
|
||||||
|
и любой другой мусор.
|
||||||
|
|
||||||
|
Неизвестное приложение (``scope["app"]`` не выставлен) — ведём себя как
|
||||||
|
раньше, то есть отдаём запрос в guard.
|
||||||
|
"""
|
||||||
|
router = getattr(request.scope.get("app"), "router", None)
|
||||||
|
if router is None:
|
||||||
|
return True
|
||||||
|
return any(route.matches(request.scope)[0] != Match.NONE for route in router.routes)
|
||||||
|
|
||||||
|
|
||||||
|
def _unauthenticated(path: str, detail: str) -> JSONResponse:
|
||||||
|
"""Отказ анониму: 401 везде, но на admin-префиксе — 404 роутера.
|
||||||
|
|
||||||
|
#3360: периметр admin-путей снаружи НЕ срезан (``caddy/sites/apps.caddy``,
|
||||||
|
блок ``handle /trade-in/api/*`` стоит выше ``import
|
||||||
|
caddy/users.caddy.snippet``), и срезать его нельзя — admin-UI кабинета зовёт
|
||||||
|
``/api/v1/admin/*`` ИЗ БРАУЗЕРА (tradein-mvp/frontend/src/app/scrapers/**,
|
||||||
|
components/scrapers/**, lib/admin-audit-api.ts). Значит внешний аноним
|
||||||
|
доходит сюда, а после #3324 (несуществующий путь → 404 роутера) 401 на
|
||||||
|
существующей ручке работал оракулом: перебором имён восстанавливался список
|
||||||
|
admin-API. Отвечаем ТЕМ ЖЕ, что роутер отдаёт на несуществующий путь, —
|
||||||
|
существующая и несуществующая admin-ручки анониму неразличимы.
|
||||||
|
|
||||||
|
Скрываем ровно от НЕаутентифицированного. Аутентифицированный не-admin
|
||||||
|
по-прежнему получает 403 «admin only»: он уже прошёл идентификацию, прятать
|
||||||
|
от него наличие ручки незачем, а 404 вместо 403 маскировал бы отладку.
|
||||||
|
|
||||||
|
Тело — литерал ``{"detail": "Not Found"}``: это ответ дефолтного
|
||||||
|
http_exception_handler FastAPI, тот же, что придёт из роутера. Тест
|
||||||
|
сравнивает два ЖИВЫХ ответа между собой, а не с этой константой, — если
|
||||||
|
фреймворк сменит формулировку, покраснеет он, а не прод.
|
||||||
|
"""
|
||||||
|
if _ADMIN_API_RE.match(path):
|
||||||
|
logger.info("RBAC: anonymous probe of admin path %s — cloaked as 404", path)
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "Not Found"})
|
||||||
|
return JSONResponse(status_code=401, content={"detail": detail})
|
||||||
|
|
||||||
|
|
||||||
def _propagate_authenticated_user(request: Request, username: str) -> None:
|
def _propagate_authenticated_user(request: Request, username: str) -> None:
|
||||||
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
|
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
|
||||||
только добавляя при отсутствии, — чтобы ``RateLimitMiddleware``/
|
только добавляя при отсутствии, — чтобы ``RateLimitMiddleware``/
|
||||||
|
|
@ -239,6 +324,11 @@ async def rbac_guard(
|
||||||
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PATH_PREFIXES):
|
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PATH_PREFIXES):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
# #3324: путь, которого нет в роутере, отвечает как любой несуществующий
|
||||||
|
# путь (404 роутера) — иначе 401 работает оракулом существования ручки.
|
||||||
|
if not _path_is_routed(request):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
role: str | None = None
|
role: str | None = None
|
||||||
from_session = False
|
from_session = False
|
||||||
|
|
@ -278,18 +368,12 @@ async def rbac_guard(
|
||||||
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
|
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
|
||||||
# отключён, даже если валидный X-Authenticated-User присутствует.
|
# отключён, даже если валидный X-Authenticated-User присутствует.
|
||||||
if settings.auth_mode != "dual":
|
if settings.auth_mode != "dual":
|
||||||
return JSONResponse(
|
return _unauthenticated(path, "valid session required")
|
||||||
status_code=401,
|
|
||||||
content={"detail": "valid session required"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
|
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
|
||||||
username = request.headers.get("X-Authenticated-User")
|
username = request.headers.get("X-Authenticated-User")
|
||||||
if not username:
|
if not username:
|
||||||
return JSONResponse(
|
return _unauthenticated(path, "no authenticated user (valid session required)")
|
||||||
status_code=401,
|
|
||||||
content={"detail": "no authenticated user (valid session required)"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
|
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
|
||||||
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
|
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
|
||||||
|
|
@ -305,10 +389,7 @@ async def rbac_guard(
|
||||||
username,
|
username,
|
||||||
path,
|
path,
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return _unauthenticated(path, "invalid or missing internal auth secret")
|
||||||
status_code=401,
|
|
||||||
content={"detail": "invalid or missing internal auth secret"},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
role = get_role(username)
|
role = get_role(username)
|
||||||
|
|
@ -334,7 +415,11 @@ async def rbac_guard(
|
||||||
# scope-narrowed юзер не смог бы получить свою роль вовсе.
|
# scope-narrowed юзер не смог бы получить свою роль вовсе.
|
||||||
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
|
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
|
||||||
external_path = _EXTERNAL_PREFIX + path
|
external_path = _EXTERNAL_PREFIX + path
|
||||||
if from_session:
|
# Матчер выбирается по РОДУ роли, а не только по источнику (#3316):
|
||||||
|
# с DB-first резолвом legacy trusted-header путь тоже может отдать роль
|
||||||
|
# реестра (employee/manager), а её паттернов в roles.yaml нет — сверка
|
||||||
|
# с `is_path_allowed` дала бы 403 на всё.
|
||||||
|
if from_session or role in DB_ROLE_PATHS:
|
||||||
allowed = _db_role_path_allowed(role, external_path)
|
allowed = _db_role_path_allowed(role, external_path)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ from app.core.config import settings
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.core.fdw import ensure_fdw_user_mapping
|
from app.core.fdw import ensure_fdw_user_mapping
|
||||||
from app.core.http_errors import install_validation_error_handler
|
from app.core.http_errors import install_validation_error_handler
|
||||||
|
from app.core.log_scrub import install_query_secret_filter
|
||||||
from app.core.ratelimit import RateLimitMiddleware
|
from app.core.ratelimit import RateLimitMiddleware
|
||||||
from app.core.rbac import rbac_guard
|
from app.core.rbac import rbac_guard
|
||||||
from app.core.request_audit import RequestAuditMiddleware
|
from app.core.request_audit import RequestAuditMiddleware
|
||||||
|
|
@ -64,6 +65,11 @@ logging.basicConfig(
|
||||||
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
|
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
|
||||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
# #3154: uvicorn access-log печатает полный путь С QUERY, а лог уезжает в Loki —
|
||||||
|
# так секрет вебхука GlitchTip (`?secret=…`) оказался в хранилище открытым.
|
||||||
|
# Маскируем значения чувствительных query-параметров ДО записи строки.
|
||||||
|
install_query_secret_filter()
|
||||||
|
|
||||||
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
|
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
|
||||||
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
|
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
|
||||||
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
|
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
|
||||||
|
|
@ -218,6 +224,16 @@ app = FastAPI(
|
||||||
description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", # noqa: E501
|
description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", # noqa: E501
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
# #3324: трейлинг-слэш обходил rbac_guard как канал разведки периметра.
|
||||||
|
# `/api/v1/me/` не матчит ни один маршрут → guard пропускает (см.
|
||||||
|
# rbac._path_is_routed) → роутер отвечал 307 на `/api/v1/me`, т.е. «маршрут
|
||||||
|
# существует» сообщал редирект вместо 401. Выключение проверено на предмет
|
||||||
|
# поломок: ни один route не объявлен с трейлинг-слэшем (нет `@router.get("/")`
|
||||||
|
# и пустых путей), ни один из 173 вызовов `api/v1` во фронте
|
||||||
|
# (tradein-mvp/frontend/src) не заканчивается слэшем, mount/StaticFiles нет.
|
||||||
|
# Deny-правила на слэш тоже не зависят от редиректа — они переведены на
|
||||||
|
# глоб-форму специально ради этого (см. auth_session.DB_ROLE_PATHS).
|
||||||
|
redirect_slashes=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,15 +101,23 @@ def _should_run() -> bool:
|
||||||
return settings.scheduler_enable
|
return settings.scheduler_enable
|
||||||
|
|
||||||
|
|
||||||
async def _await_scheduler(task: asyncio.Task[None]) -> None:
|
async def _await_scheduler(task: asyncio.Task[None]) -> bool:
|
||||||
"""Дождаться завершения scheduler-задачи с кооперативным SIGTERM-drain'ом.
|
"""Дождаться завершения scheduler-задачи с кооперативным SIGTERM-drain'ом.
|
||||||
|
|
||||||
|
Возвращает True, если задачу пришлось хард-кансельнуть (grace истёк), False — если
|
||||||
|
она вышла сама. Вызывающий обязан различать эти исходы в логах (#3391): строка
|
||||||
|
«drained cleanly» после hard-cancel'а — ложь, а именно она печаталась на проде
|
||||||
|
07.09 сразу за WARNING'ом о превышении grace.
|
||||||
|
|
||||||
- Нет shutdown: scheduler_loop бесконечен → задача никогда не завершается, ждём её
|
- Нет shutdown: scheduler_loop бесконечен → задача никогда не завершается, ждём её
|
||||||
как есть (процесс просто работает).
|
как есть (процесс просто работает).
|
||||||
- shutdown запрошен, задача ещё бежит: bounded `wait_for(_DRAIN_TIMEOUT_S)` —
|
- shutdown запрошен, задача ещё бежит: bounded `wait_for(_DRAIN_TIMEOUT_S)` —
|
||||||
кооперативная задача докоммитит текущий unit на ближайшем checkpoint'е и выйдет
|
кооперативная задача докоммитит текущий unit на ближайшем checkpoint'е и выйдет
|
||||||
сама. Если превысила grace → hard-cancel + suppress CancelledError, чтобы
|
сама. Если превысила grace → hard-cancel + suppress CancelledError, чтобы
|
||||||
некооперативная задача не подвесила процесс за пределами docker stop_grace_period.
|
некооперативная задача не подвесила процесс за пределами docker stop_grace_period.
|
||||||
|
Пометку `interrupted` своим in-flight прогонам kit-scheduler успевает поставить в
|
||||||
|
обработчике CancelledError (SchedulerContext.mark_inflight_interrupted): между
|
||||||
|
hard-cancel'ом и SIGKILL'ом остаётся 20 с docker-grace (120s − 100s).
|
||||||
"""
|
"""
|
||||||
# Гонка «задача завершилась сама» против «пришёл SIGTERM»: отсчёт safety-net'а
|
# Гонка «задача завершилась сама» против «пришёл SIGTERM»: отсчёт safety-net'а
|
||||||
# должен стартовать от МОМЕНТА запроса drain'а, а не от старта процесса.
|
# должен стартовать от МОМЕНТА запроса drain'а, а не от старта процесса.
|
||||||
|
|
@ -127,7 +135,7 @@ async def _await_scheduler(task: asyncio.Task[None]) -> None:
|
||||||
# упал (сохраняем прежнюю loud-crash семантику `await task`), а не глушит его.
|
# упал (сохраняем прежнюю loud-crash семантику `await task`), а не глушит его.
|
||||||
task.result()
|
task.result()
|
||||||
logger.info("scheduler_main: scheduler task exited cleanly")
|
logger.info("scheduler_main: scheduler task exited cleanly")
|
||||||
return
|
return False
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"scheduler_main: SIGTERM-drain — waiting up to %.0fs for in-flight unit to commit",
|
"scheduler_main: SIGTERM-drain — waiting up to %.0fs for in-flight unit to commit",
|
||||||
|
|
@ -136,6 +144,7 @@ async def _await_scheduler(task: asyncio.Task[None]) -> None:
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(task, timeout=_DRAIN_TIMEOUT_S)
|
await asyncio.wait_for(task, timeout=_DRAIN_TIMEOUT_S)
|
||||||
logger.info("scheduler_main: scheduler drained and exited cleanly")
|
logger.info("scheduler_main: scheduler drained and exited cleanly")
|
||||||
|
return False
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"scheduler_main: drain exceeded %.0fs grace — hard-cancelling scheduler task",
|
"scheduler_main: drain exceeded %.0fs grace — hard-cancelling scheduler task",
|
||||||
|
|
@ -144,6 +153,7 @@ async def _await_scheduler(task: asyncio.Task[None]) -> None:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await task
|
await task
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _run_kit_scheduler() -> None:
|
async def _run_kit_scheduler() -> None:
|
||||||
|
|
@ -224,8 +234,12 @@ async def _run() -> None:
|
||||||
# Windows dev: signal handlers через loop не поддерживаются
|
# Windows dev: signal handlers через loop не поддерживаются
|
||||||
logger.warning("scheduler_main: loop.add_signal_handler not supported (Windows dev)")
|
logger.warning("scheduler_main: loop.add_signal_handler not supported (Windows dev)")
|
||||||
|
|
||||||
await _await_scheduler(task)
|
hard_cancelled = await _await_scheduler(task)
|
||||||
|
|
||||||
|
if hard_cancelled:
|
||||||
|
# Дрейн НЕ был чистым: задача не вышла сама, её сняли. WARNING об этом уже
|
||||||
|
# напечатан в _await_scheduler — второй строкой её не «переобъявляем».
|
||||||
|
return
|
||||||
if shutdown_requested():
|
if shutdown_requested():
|
||||||
logger.info("scheduler_main: scheduler drained cleanly (SIGTERM)")
|
logger.info("scheduler_main: scheduler drained cleanly (SIGTERM)")
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ from fastapi import HTTPException
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.auth import get_role
|
from app.core.auth import get_role, yaml_role
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -61,8 +61,7 @@ def limit_exhausted_message(limit: int) -> str:
|
||||||
отличаться от глобального MONTHLY_LIMIT для персонального override ИЛИ
|
отличаться от глобального MONTHLY_LIMIT для персонального override ИЛИ
|
||||||
anon default_limit, см. #b2c-antiabuse-2)."""
|
anon default_limit, см. #b2c-antiabuse-2)."""
|
||||||
return (
|
return (
|
||||||
f"Лимит из {limit} оценок в этом месяце исчерпан. "
|
f"Лимит из {limit} оценок в этом месяце исчерпан. За полной версией обращайтесь к Копылову."
|
||||||
"За полной версией обращайтесь к Копылову."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,6 +96,14 @@ def is_unlimited(db: Session, username: str) -> bool:
|
||||||
return False
|
return False
|
||||||
if role == "admin":
|
if role == "admin":
|
||||||
return True
|
return True
|
||||||
|
# #3316: get_role резолвит роль из реестра (БД) первой, поэтому сотрудник
|
||||||
|
# team-API больше не даёт KeyError. Право на ПЕРСОНАЛЬНЫЙ безлимит при этом
|
||||||
|
# осталось там же, где было — за roles.yaml: фикс убирает эскалацию, а не
|
||||||
|
# раздаёт новую. Иначе руками проставленный `unlimited` начал бы работать
|
||||||
|
# для аккаунтов, которым он раньше молча игнорировался (и разъехался бы с
|
||||||
|
# `_batch_quota_status` в списке «Команды»).
|
||||||
|
if yaml_role(username) is None:
|
||||||
|
return False
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ from __future__ import annotations
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from scraper_kit.orchestration.runs import BAN_KIND_PLATFORM
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BlockRatioBreaker:
|
class BlockRatioBreaker:
|
||||||
|
|
@ -88,7 +90,26 @@ class BlockRatioBreaker:
|
||||||
"""Знаменатель ratio-критерия: сколько попыток уже влезло в окно."""
|
"""Знаменатель ratio-критерия: сколько попыток уже влезло в окно."""
|
||||||
return len(self._window)
|
return len(self._window)
|
||||||
|
|
||||||
def record_block(self) -> None:
|
def record_block(self, kind: str = BAN_KIND_PLATFORM) -> None:
|
||||||
|
"""Блок в числитель ratio-критерия — ТОЛЬКО отказ площадки (#3288).
|
||||||
|
|
||||||
|
`kind` — диагноз того же исключения, что уходит в counters.ban_kinds
|
||||||
|
(ban_kind_of_exception). Всё, что не 'platform' (в первую очередь
|
||||||
|
BAN_KIND_INFRA: отказ нашего сайдкара, пустой пул), — отказ НАШЕЙ стороны:
|
||||||
|
площадка его не показывала, и рвать по нему прогон значит объявить бан там,
|
||||||
|
где площадка молчала. Прогон 5425 оборвался по доле на 41 infra из 48
|
||||||
|
«блоков» при 41 успешно обогащённой карточке.
|
||||||
|
|
||||||
|
Такой отказ идёт ровно туда же, куда record_failure(): в ЗНАМЕНАТЕЛЬ окна
|
||||||
|
(доля платформенных блоков от него честно падает), мимо серии и safety-net.
|
||||||
|
Отдельно важно, что серию он не двигает: иначе safety-net на снапшоте
|
||||||
|
короче окна рвал бы прогон по тем же infra-отказам, только другим
|
||||||
|
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
|
||||||
|
не считают.
|
||||||
|
"""
|
||||||
|
if kind != BAN_KIND_PLATFORM:
|
||||||
|
self.record_failure()
|
||||||
|
return
|
||||||
self._consecutive_blocks += 1
|
self._consecutive_blocks += 1
|
||||||
self._window.append(True)
|
self._window.append(True)
|
||||||
|
|
||||||
|
|
|
||||||
464
tradein-mvp/backend/app/services/cbr_macro.py
Normal file
464
tradein-mvp/backend/app/services/cbr_macro.py
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
"""Макро-ряды ЦБ РФ: ипотека по субъектам (XLSX) + ключевая ставка (SOAP).
|
||||||
|
|
||||||
|
CONTEXT: продукту нужен региональный макро-контекст ипотечного рынка (динамика ставок,
|
||||||
|
объёмов выдач и задолженности по субъектам РФ) и дневная ключевая ставка. Этот модуль
|
||||||
|
только собирает данные в cbr_mortgage_series / cbr_key_rate (292_cbr_macro_series.sql) —
|
||||||
|
подключение к estimator вынесено в отдельное продуктовое решение (out of scope).
|
||||||
|
|
||||||
|
ИСТОЧНИК (ипотека): три XLSX-дашборда cbr.ru, wide-формат (территория × месяц),
|
||||||
|
строка 1 пустая, строка 2 — заголовок-описание, строка 3 — шапка периодов
|
||||||
|
(«Январь 2019», «Февраль 2019», …), строки 4+ — территории в колонке A
|
||||||
|
(РФ целиком → федеральные округа → субъекты). Unpivot в длинный ряд
|
||||||
|
(region, period_month, series, value).
|
||||||
|
|
||||||
|
ИСТОЧНИК (ключевая ставка): POST https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx,
|
||||||
|
SOAP 1.1 (Content-Type: text/xml; charset=utf-8, SOAPAction: http://web.cbr.ru/KeyRate),
|
||||||
|
метод KeyRate(fromDate, ToDate). Ответ — DataSet-XML со строками
|
||||||
|
<KR><DT>дата</DT><Rate>значение</Rate></KR> (namespace-агностичный парсинг: берём по
|
||||||
|
локальному имени тега, т.к. .NET DataSet оборачивает их в diffgram/reset-default-ns).
|
||||||
|
|
||||||
|
TLS: cbr.ru отдаёт RU-сертификат → verify=False (open data, без auth/PII).
|
||||||
|
psycopg v3: CAST(:x AS type), НИКОГДА :x::type.
|
||||||
|
|
||||||
|
Дизайн-инвариант (как domrf_kapremont_loader): сервис-функции НЕ коммитят — коммитит
|
||||||
|
caller (app/tasks/cbr_macro_pull.py). Изоляция сбоев — try/except на серию,
|
||||||
|
db.rollback() в except (иначе аборченная транзакция каскадит на следующие серии).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import openpyxl
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DOWNLOAD_TIMEOUT_SEC = 60.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ипотека по субъектам — конфиг серий
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MORTGAGE_BASE_URL = "https://www.cbr.ru/vfs/statistics/BankSector/Mortgage"
|
||||||
|
NEW_LOANS_URL = f"{_MORTGAGE_BASE_URL}/02_11_New_loans_mortgage.xlsx"
|
||||||
|
RATES_URL = f"{_MORTGAGE_BASE_URL}/02_13_Rates_mortgage.xlsx"
|
||||||
|
DEBT_URL = f"{_MORTGAGE_BASE_URL}/02_14_Debt_mortgage.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CbrMortgageSeries:
|
||||||
|
slug: str
|
||||||
|
url: str
|
||||||
|
sheet: str
|
||||||
|
|
||||||
|
|
||||||
|
# По одной серии на файл (лист «в рублях» — основной ряд каждого дашборда).
|
||||||
|
CBR_MORTGAGE_SERIES: list[CbrMortgageSeries] = [
|
||||||
|
CbrMortgageSeries("new_loans_rub", NEW_LOANS_URL, "в рублях"),
|
||||||
|
CbrMortgageSeries("rate_rub", RATES_URL, "ставка в рублях"),
|
||||||
|
CbrMortgageSeries("debt_rub", DEBT_URL, "в рублях"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class CbrMortgageRow:
|
||||||
|
region: str
|
||||||
|
period_month: date
|
||||||
|
series: str
|
||||||
|
value: float
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Период: русские имена месяцев → date(y, m, 1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
RU_MONTHS: dict[str, int] = {
|
||||||
|
"Январь": 1,
|
||||||
|
"Февраль": 2,
|
||||||
|
"Март": 3,
|
||||||
|
"Апрель": 4,
|
||||||
|
"Май": 5,
|
||||||
|
"Июнь": 6,
|
||||||
|
"Июль": 7,
|
||||||
|
"Август": 8,
|
||||||
|
"Сентябрь": 9,
|
||||||
|
"Октябрь": 10,
|
||||||
|
"Ноябрь": 11,
|
||||||
|
"Декабрь": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ru_period(period_str: str) -> date:
|
||||||
|
"""Разобрать шапку периода «Январь 2019» → date(2019, 1, 1)."""
|
||||||
|
parts = str(period_str).strip().split()
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise ValueError(f"cbr_macro: не удалось разобрать период {period_str!r}")
|
||||||
|
month_name, year_str = parts
|
||||||
|
month = RU_MONTHS.get(month_name)
|
||||||
|
if month is None:
|
||||||
|
raise ValueError(f"cbr_macro: неизвестное имя месяца {month_name!r} в {period_str!r}")
|
||||||
|
try:
|
||||||
|
year = int(year_str)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"cbr_macro: неверный год в периоде {period_str!r}") from exc
|
||||||
|
return date(year, month, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_period_cell(cell: object) -> date:
|
||||||
|
"""Разобрать ячейку шапки периода в первое число месяца.
|
||||||
|
|
||||||
|
ЦБ отдаёт шапку в двух формах, зависящих от файла:
|
||||||
|
* текст «Январь 2019» — 02_11_New_loans_mortgage, 02_13_Rates_mortgage;
|
||||||
|
* настоящий datetime/date — 02_14_Debt_mortgage.
|
||||||
|
Обе приводятся к date(y, m, 1).
|
||||||
|
"""
|
||||||
|
if isinstance(cell, datetime):
|
||||||
|
return date(cell.year, cell.month, 1)
|
||||||
|
if isinstance(cell, date):
|
||||||
|
return date(cell.year, cell.month, 1)
|
||||||
|
return parse_ru_period(str(cell))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unpivot листа (чистая функция — тестируется без сети/файла)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
HEADER_SCAN_ROWS = 6
|
||||||
|
"""Сколько первых строк просматривать в поисках шапки периодов."""
|
||||||
|
|
||||||
|
MIN_HEADER_PERIODS = 6
|
||||||
|
"""Минимум распознанных периодов, чтобы считать строку шапкой (а не данными)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _locate_header_row(rows: list[tuple[object, ...]]) -> tuple[int, dict[int, date]]:
|
||||||
|
"""Найти строку шапки периодов и колонки-периоды в ней.
|
||||||
|
|
||||||
|
Раскладка у файлов ЦБ РАЗНАЯ и жёстко фиксировать индекс строки нельзя:
|
||||||
|
* 02_11 / 02_13 — строка 1 пустая, строка 2 — описание, шапка в строке 3 (idx 2);
|
||||||
|
* 02_14 (задолженность) — строка 1 описание, шапка уже в строке 2 (idx 1),
|
||||||
|
и периоды там datetime, а не «Январь 2019».
|
||||||
|
Ранее индекс был захардкожен на idx 2, из-за чего серия задолженности молча
|
||||||
|
давала ноль строк. Поэтому шапку ищем: берём строку с наибольшим числом
|
||||||
|
распознанных периодов среди первых HEADER_SCAN_ROWS.
|
||||||
|
"""
|
||||||
|
best_idx = -1
|
||||||
|
best_periods: dict[int, date] = {}
|
||||||
|
for row_idx, row in enumerate(rows[:HEADER_SCAN_ROWS]):
|
||||||
|
periods: dict[int, date] = {}
|
||||||
|
for col_idx, cell in enumerate(row):
|
||||||
|
if col_idx == 0 or cell is None or str(cell).strip() == "":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
periods[col_idx] = parse_period_cell(cell)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if len(periods) > len(best_periods):
|
||||||
|
best_idx, best_periods = row_idx, periods
|
||||||
|
if len(best_periods) < MIN_HEADER_PERIODS:
|
||||||
|
return -1, {}
|
||||||
|
return best_idx, best_periods
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mortgage_sheet(
|
||||||
|
rows: list[tuple[object, ...]], *, series_slug: str
|
||||||
|
) -> list[CbrMortgageRow]:
|
||||||
|
"""Unpivot wide-листа ЦБ (территория × месяц) в длинный ряд.
|
||||||
|
|
||||||
|
rows — результат ws.iter_rows(values_only=True). Строка шапки периодов ищется
|
||||||
|
динамически (`_locate_header_row`), территории идут сразу после неё.
|
||||||
|
"""
|
||||||
|
if len(rows) < 3:
|
||||||
|
return []
|
||||||
|
|
||||||
|
header_idx, periods = _locate_header_row(rows)
|
||||||
|
if header_idx < 0:
|
||||||
|
logger.warning(
|
||||||
|
"cbr_macro: шапка периодов не найдена для series=%s — лист пропущен", series_slug
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
out: list[CbrMortgageRow] = []
|
||||||
|
for data_row in rows[header_idx + 1 :]:
|
||||||
|
if not data_row or data_row[0] is None:
|
||||||
|
continue
|
||||||
|
region = str(data_row[0]).strip()
|
||||||
|
if not region:
|
||||||
|
continue
|
||||||
|
for col_idx, period_month in periods.items():
|
||||||
|
if col_idx >= len(data_row):
|
||||||
|
continue
|
||||||
|
raw_value = data_row[col_idx]
|
||||||
|
if raw_value is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = float(raw_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning(
|
||||||
|
"cbr_macro: пропуск ячейки region=%r period=%s series=%s — не число: %r",
|
||||||
|
region,
|
||||||
|
period_month,
|
||||||
|
series_slug,
|
||||||
|
raw_value,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
CbrMortgageRow(
|
||||||
|
region=region, period_month=period_month, series=series_slug, value=value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Загрузка XLSX + upsert
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_UPSERT_MORTGAGE_SQL = text("""
|
||||||
|
INSERT INTO cbr_mortgage_series (region, period_month, series, value, source, fetched_at)
|
||||||
|
VALUES (
|
||||||
|
CAST(:region AS text),
|
||||||
|
CAST(:period_month AS date),
|
||||||
|
CAST(:series AS text),
|
||||||
|
CAST(:value AS double precision),
|
||||||
|
'cbr',
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (region, period_month, series)
|
||||||
|
DO UPDATE SET
|
||||||
|
value = EXCLUDED.value
|
||||||
|
-- fetched_at НЕ трогаем (#2846 у sber_price_index): означает
|
||||||
|
-- «когда мы ВПЕРВЫЕ увидели этот период», а не время последней загрузки.
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_mortgage_rows(db: Session, rows: list[CbrMortgageRow]) -> int:
|
||||||
|
for row in rows:
|
||||||
|
db.execute(
|
||||||
|
_UPSERT_MORTGAGE_SQL,
|
||||||
|
{
|
||||||
|
"region": row.region,
|
||||||
|
"period_month": row.period_month.isoformat(),
|
||||||
|
"series": row.series,
|
||||||
|
"value": row.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _download(url: str, *, client: httpx.Client) -> bytes:
|
||||||
|
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
def load_cbr_mortgage_series(
|
||||||
|
db: Session,
|
||||||
|
series: CbrMortgageSeries,
|
||||||
|
*,
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Скачать один XLSX-дашборд, разобрать один лист, upsert-нуть ряд.
|
||||||
|
|
||||||
|
Не коммитит — коммитит caller.
|
||||||
|
"""
|
||||||
|
own_client = client is None
|
||||||
|
if own_client:
|
||||||
|
# cbr.ru отдаёт RU-сертификат НУЦ Минцифры → verify=False. Открытые данные,
|
||||||
|
# без auth/PII (прецедент: sber_index.py, domrf_kapremont_loader.py).
|
||||||
|
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
|
||||||
|
try:
|
||||||
|
data = _download(series.url, client=client)
|
||||||
|
finally:
|
||||||
|
if own_client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||||||
|
try:
|
||||||
|
if series.sheet not in wb.sheetnames:
|
||||||
|
raise ValueError(
|
||||||
|
f"cbr_macro: лист {series.sheet!r} отсутствует в {series.url} "
|
||||||
|
f"(есть: {wb.sheetnames!r})"
|
||||||
|
)
|
||||||
|
ws = wb[series.sheet]
|
||||||
|
rows_raw = list(ws.iter_rows(values_only=True))
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
parsed = parse_mortgage_sheet(rows_raw, series_slug=series.slug)
|
||||||
|
upserted = 0 if dry_run else _upsert_mortgage_rows(db, parsed)
|
||||||
|
return {"rows": len(parsed), "upserted": upserted}
|
||||||
|
|
||||||
|
|
||||||
|
def pull_cbr_mortgage(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
series_list: list[CbrMortgageSeries] | None = None,
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Забрать все (или выбранные) серии ипотечной статистики.
|
||||||
|
|
||||||
|
Per-series try/except: одна сбойнувшая серия логируется и не роняет остальные
|
||||||
|
(урок #1345 у sber_index: без rollback аборченная транзакция каскадит дальше).
|
||||||
|
"""
|
||||||
|
if series_list is None:
|
||||||
|
series_list = CBR_MORTGAGE_SERIES
|
||||||
|
|
||||||
|
counters = {"upserted": 0, "skipped": 0, "errors": 0}
|
||||||
|
own_client = client is None
|
||||||
|
if own_client:
|
||||||
|
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
|
||||||
|
try:
|
||||||
|
for series in series_list:
|
||||||
|
try:
|
||||||
|
result = load_cbr_mortgage_series(db, series, client=client, dry_run=dry_run)
|
||||||
|
if result["rows"] == 0:
|
||||||
|
logger.info("cbr_macro: пустой результат для series=%s", series.slug)
|
||||||
|
counters["skipped"] += 1
|
||||||
|
else:
|
||||||
|
counters["upserted"] += result["upserted"]
|
||||||
|
logger.info(
|
||||||
|
"cbr_macro: upserted %d rows for series=%s", result["upserted"], series.slug
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("cbr_macro: series=%s сбойнула — пропуск", series.slug)
|
||||||
|
counters["errors"] += 1
|
||||||
|
finally:
|
||||||
|
if own_client:
|
||||||
|
client.close()
|
||||||
|
return counters
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ключевая ставка — SOAP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CBR_SOAP_URL = "https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx"
|
||||||
|
KEY_RATE_START = date(2013, 9, 13) # инструмент введён Советом директоров ЦБ РФ
|
||||||
|
|
||||||
|
_KEY_RATE_ENVELOPE = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" \
|
||||||
|
xmlns:xsd="http://www.w3.org/2001/XMLSchema" \
|
||||||
|
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soap:Body>
|
||||||
|
<KeyRate xmlns="http://web.cbr.ru/">
|
||||||
|
<fromDate>{from_date}</fromDate>
|
||||||
|
<ToDate>{to_date}</ToDate>
|
||||||
|
</KeyRate>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_key_rate_envelope(from_date: date, to_date: date) -> str:
|
||||||
|
return _KEY_RATE_ENVELOPE.format(from_date=from_date.isoformat(), to_date=to_date.isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
def _local_tag(tag: str) -> str:
|
||||||
|
"""Локальное имя тега без namespace-префикса ('{ns}KR' → 'KR')."""
|
||||||
|
return tag.rsplit("}", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_key_rate_response(xml_text: str) -> list[tuple[date, float]]:
|
||||||
|
"""Разобрать SOAP-ответ KeyRate: строки <KR><DT>…</DT><Rate>…</Rate></KR>.
|
||||||
|
|
||||||
|
Namespace-агностично (ищем по локальному имени тега) — .NET DataSet
|
||||||
|
оборачивает строки в diffgram/reset-default-ns, точный путь не гарантирован.
|
||||||
|
"""
|
||||||
|
root = ET.fromstring(xml_text)
|
||||||
|
out: list[tuple[date, float]] = []
|
||||||
|
for el in root.iter():
|
||||||
|
if _local_tag(el.tag) != "KR":
|
||||||
|
continue
|
||||||
|
dt_text: str | None = None
|
||||||
|
rate_text: str | None = None
|
||||||
|
for child in el:
|
||||||
|
name = _local_tag(child.tag)
|
||||||
|
if name == "DT":
|
||||||
|
dt_text = child.text
|
||||||
|
elif name == "Rate":
|
||||||
|
rate_text = child.text
|
||||||
|
if not dt_text or rate_text is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rate_date = datetime.fromisoformat(dt_text).date()
|
||||||
|
rate = float(rate_text)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning(
|
||||||
|
"cbr_macro: пропуск строки KeyRate — не распознана: DT=%r Rate=%r",
|
||||||
|
dt_text,
|
||||||
|
rate_text,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
out.append((rate_date, rate))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_key_rate(
|
||||||
|
client: httpx.Client, *, from_date: date, to_date: date
|
||||||
|
) -> list[tuple[date, float]]:
|
||||||
|
body = build_key_rate_envelope(from_date, to_date)
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "text/xml; charset=utf-8",
|
||||||
|
"SOAPAction": "http://web.cbr.ru/KeyRate",
|
||||||
|
}
|
||||||
|
resp = client.post(CBR_SOAP_URL, content=body.encode("utf-8"), headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return parse_key_rate_response(resp.text)
|
||||||
|
|
||||||
|
|
||||||
|
_UPSERT_KEY_RATE_SQL = text("""
|
||||||
|
INSERT INTO cbr_key_rate (rate_date, rate, fetched_at)
|
||||||
|
VALUES (CAST(:rate_date AS date), CAST(:rate AS double precision), now())
|
||||||
|
ON CONFLICT (rate_date)
|
||||||
|
DO UPDATE SET
|
||||||
|
rate = EXCLUDED.rate
|
||||||
|
-- fetched_at НЕ трогаем — та же логика, что и у cbr_mortgage_series.
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_key_rate_rows(db: Session, rows: list[tuple[date, float]]) -> int:
|
||||||
|
for rate_date, rate in rows:
|
||||||
|
db.execute(_UPSERT_KEY_RATE_SQL, {"rate_date": rate_date.isoformat(), "rate": rate})
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def load_key_rate(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
from_date: date | None = None,
|
||||||
|
to_date: date | None = None,
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Скачать ключевую ставку за диапазон дат, upsert-нуть в cbr_key_rate.
|
||||||
|
|
||||||
|
Не коммитит — коммитит caller. from_date/to_date по умолчанию — вся история
|
||||||
|
инструмента (KEY_RATE_START) до сегодня.
|
||||||
|
"""
|
||||||
|
if from_date is None:
|
||||||
|
from_date = KEY_RATE_START
|
||||||
|
if to_date is None:
|
||||||
|
to_date = date.today()
|
||||||
|
|
||||||
|
own_client = client is None
|
||||||
|
if own_client:
|
||||||
|
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
|
||||||
|
try:
|
||||||
|
rows = fetch_key_rate(client, from_date=from_date, to_date=to_date)
|
||||||
|
finally:
|
||||||
|
if own_client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
upserted = 0 if dry_run else _upsert_key_rate_rows(db, rows)
|
||||||
|
result = {"rows": len(rows), "upserted": upserted}
|
||||||
|
logger.info("cbr_macro: key_rate load DONE (dry_run=%s): %s", dry_run, result)
|
||||||
|
return result
|
||||||
|
|
@ -34,25 +34,6 @@ from app.services.scraper_settings import get_scraper_delay
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class _PoolCurlConfig(RealScraperConfig):
|
|
||||||
"""RealScraperConfig с принудительно включённым pool-режимом curl (#2830).
|
|
||||||
|
|
||||||
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml
|
|
||||||
services.scraper.environment), а этот бэкфилл запускается ручкой
|
|
||||||
`POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет
|
|
||||||
→ `settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url`
|
|
||||||
ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`:
|
|
||||||
один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод).
|
|
||||||
|
|
||||||
Флаг — рубильник раскатки pool-режима для планировщика, а не решение «этому пути
|
|
||||||
пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def use_proxy_pool_curl(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CianPriceHistoryResult:
|
class CianPriceHistoryResult:
|
||||||
checked: int = 0
|
checked: int = 0
|
||||||
|
|
@ -84,7 +65,13 @@ async def backfill_cian_price_history(
|
||||||
# Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает
|
# Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает
|
||||||
# `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт
|
# `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт
|
||||||
# (mark_banned на CianBlockedError / mark_health / release).
|
# (mark_banned на CianBlockedError / mark_health / release).
|
||||||
scraper_config = _PoolCurlConfig()
|
#
|
||||||
|
# Флаг читается из окружения как у всех (#3386 хвост): до #3387 у контейнера
|
||||||
|
# `backend` не было `USE_PROXY_POOL_CURL`, и здесь стоял подкласс с зашитым
|
||||||
|
# `use_proxy_pool_curl = True` — иначе `curl_proxy_url` игнорировал бы
|
||||||
|
# `proxy_provider`. Теперь переменная задана и сервису `backend` (compose), а
|
||||||
|
# зашитая константа делала рубильник неотключаемым ровно на этом пути.
|
||||||
|
scraper_config = RealScraperConfig()
|
||||||
proxy_provider = RealProxyProvider()
|
proxy_provider = RealProxyProvider()
|
||||||
|
|
||||||
if listing_id is not None:
|
if listing_id is not None:
|
||||||
|
|
|
||||||
157
tradein-mvp/backend/app/services/dtp_index.py
Normal file
157
tradein-mvp/backend/app/services/dtp_index.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""Радиусные агрегаты ДТП вокруг точки — `dtp_incidents` (#3410).
|
||||||
|
|
||||||
|
Отдельный модуль, не расширение `location_index.py`: та модель считает ЦЕНОВОЙ индекс
|
||||||
|
(медиана ₽/м² локально vs по городу) с фиксированным bbox продукт-ядра города — другая
|
||||||
|
предметная область и другой контракт результата (status/coverage у location_index
|
||||||
|
завязан на `regions_mod`/`bbox_product_core`, ДТП покрывает всю область без city-bbox
|
||||||
|
ограничения источника). Общее — только сам паттерн запроса (bbox-префильтр +
|
||||||
|
`ST_DWithin(geom::geography, …)`) и dtp_incidents/osm_poi_ekb_local roднит один и тот же
|
||||||
|
"пустая таблица -> unavailable" graceful fallback (см. `_fetch_nearby_poi` в
|
||||||
|
location_index.py) — переиспользован здесь буквально.
|
||||||
|
|
||||||
|
Bbox-префильтр здесь, в отличие от location_index (там фиксированный bbox city-ядра),
|
||||||
|
строится ВОКРУГ ТОЧКИ по радиусу (`_bbox_from_point`) — dtp_incidents не ограничена
|
||||||
|
одним городом, фиксированного bbox покрытия нет. Дешёвый lat/lon BETWEEN перед
|
||||||
|
ST_DWithin — тот же приём, что в шаблоне recon_geo.md п.3.
|
||||||
|
|
||||||
|
psycopg v3: `CAST(:x AS type)`, никогда `:x::type`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Радиус по умолчанию для "фактора безопасности" района — сопоставим по порядку
|
||||||
|
# величины с DEFAULT_POI_RADIUS_M=1200 в location_index.py (тот же масштаб "пешком от
|
||||||
|
# дома"), но не импортируется оттуда: разные предметные области, совпадение числа
|
||||||
|
# не должно создавать ложную связь между модулями.
|
||||||
|
DEFAULT_DTP_RADIUS_M = 1000
|
||||||
|
|
||||||
|
# Сколько лет ДТП назад учитывать по умолчанию — источник копит данные с 2015 (см.
|
||||||
|
# докстринг dtp_stat_loader.py), но "фактор безопасности" района должен отражать
|
||||||
|
# ТЕКУЩУЮ ситуацию, а не десятилетнюю историю.
|
||||||
|
DEFAULT_YEARS_LOOKBACK = 5
|
||||||
|
|
||||||
|
_METERS_PER_DEGREE_LAT = 111_320.0
|
||||||
|
|
||||||
|
|
||||||
|
def _bbox_from_point(lat: float, lon: float, radius_m: int) -> tuple[float, float, float, float]:
|
||||||
|
"""(south, north, west, east) — bbox вокруг точки радиусом radius_m (приближённо)."""
|
||||||
|
lat_delta = radius_m / _METERS_PER_DEGREE_LAT
|
||||||
|
lon_scale = math.cos(math.radians(lat))
|
||||||
|
# Защита от вырождения делителя у полюсов — для Свердловской обл. (~56-61° с.ш.)
|
||||||
|
# cos никогда не приближается к нулю, но защита дешёвая и делает функцию safe везде.
|
||||||
|
lon_delta = radius_m / (_METERS_PER_DEGREE_LAT * max(lon_scale, 0.01))
|
||||||
|
return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta)
|
||||||
|
|
||||||
|
|
||||||
|
class DtpAreaStats(BaseModel):
|
||||||
|
"""Результат compute_dtp_stats."""
|
||||||
|
|
||||||
|
status: str # "ok" | "unavailable" (dtp_incidents пуста — рефреш ещё не запускался)
|
||||||
|
radius_m: int
|
||||||
|
years: int
|
||||||
|
incidents_count: int
|
||||||
|
severe_count: int
|
||||||
|
dead: int
|
||||||
|
injured: int
|
||||||
|
|
||||||
|
|
||||||
|
_DTP_STATS_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
count(*) AS incidents_count,
|
||||||
|
count(*) FILTER (WHERE severity = 'Тяжёлый') AS severe_count,
|
||||||
|
COALESCE(sum(dead), 0) AS dead_total,
|
||||||
|
COALESCE(sum(injured), 0) AS injured_total
|
||||||
|
FROM dtp_incidents
|
||||||
|
WHERE dtp_at >= NOW() - make_interval(years => CAST(:years AS integer))
|
||||||
|
AND 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)
|
||||||
|
AND ST_DWithin(
|
||||||
|
geom::geography,
|
||||||
|
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
|
||||||
|
CAST(:radius_m AS double precision)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_dtp_stats(
|
||||||
|
db: Any,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
*,
|
||||||
|
radius_m: int = DEFAULT_DTP_RADIUS_M,
|
||||||
|
years: int = DEFAULT_YEARS_LOOKBACK,
|
||||||
|
) -> DtpAreaStats:
|
||||||
|
"""Посчитать агрегаты ДТП в радиусе `radius_m` вокруг точки за последние `years` лет.
|
||||||
|
|
||||||
|
Graceful fallback: `dtp_incidents` пуста (рефреш ещё не запускался на этом
|
||||||
|
окружении, source dormant по умолчанию — миграция 295) -> status="unavailable",
|
||||||
|
нули НЕ фабрикуются. Ноль инцидентов при непустой таблице — легитимный "ok"
|
||||||
|
результат (в радиусе правда ничего не было), отличается статусом от "нет данных".
|
||||||
|
"""
|
||||||
|
total = db.execute(text("SELECT count(*) FROM dtp_incidents")).scalar() or 0
|
||||||
|
if total == 0:
|
||||||
|
logger.warning(
|
||||||
|
"dtp_index: dtp_incidents пуста (рефреш ещё не запускался на этом "
|
||||||
|
"окружении) — unavailable, без сфабрикованных нулей"
|
||||||
|
)
|
||||||
|
return DtpAreaStats(
|
||||||
|
status="unavailable",
|
||||||
|
radius_m=radius_m,
|
||||||
|
years=years,
|
||||||
|
incidents_count=0,
|
||||||
|
severe_count=0,
|
||||||
|
dead=0,
|
||||||
|
injured=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
bbox_south, bbox_north, bbox_west, bbox_east = _bbox_from_point(lat, lon, radius_m)
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
_DTP_STATS_SQL,
|
||||||
|
{
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"radius_m": radius_m,
|
||||||
|
"years": years,
|
||||||
|
"bbox_south": bbox_south,
|
||||||
|
"bbox_north": bbox_north,
|
||||||
|
"bbox_west": bbox_west,
|
||||||
|
"bbox_east": bbox_east,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return DtpAreaStats(
|
||||||
|
status="ok",
|
||||||
|
radius_m=radius_m,
|
||||||
|
years=years,
|
||||||
|
incidents_count=0,
|
||||||
|
severe_count=0,
|
||||||
|
dead=0,
|
||||||
|
injured=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return DtpAreaStats(
|
||||||
|
status="ok",
|
||||||
|
radius_m=radius_m,
|
||||||
|
years=years,
|
||||||
|
incidents_count=int(row["incidents_count"] or 0),
|
||||||
|
severe_count=int(row["severe_count"] or 0),
|
||||||
|
dead=int(row["dead_total"] or 0),
|
||||||
|
injured=int(row["injured_total"] or 0),
|
||||||
|
)
|
||||||
281
tradein-mvp/backend/app/services/dtp_stat_loader.py
Normal file
281
tradein-mvp/backend/app/services/dtp_stat_loader.py
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
"""dtp-stat.ru loader: слой ДТП по Свердловской обл. в `dtp_incidents` (#3410).
|
||||||
|
|
||||||
|
CONTEXT: карточка района МЕРЫ хочет "фактор безопасности" — сколько ДТП/тяжёлых/
|
||||||
|
погибших/раненых было рядом за последние годы. dtp-stat.ru агрегирует открытые данные
|
||||||
|
ГИБДД в GeoJSON по регионам, без auth.
|
||||||
|
|
||||||
|
ИСТОЧНИК: https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip — ZIP
|
||||||
|
(~5 МБ) с одним файлом `sverdlovskaia-oblast.geojson` (~71 МБ распакованного,
|
||||||
|
FeatureCollection, ~31k Point-фич). Проверено живьём 08.09.2026: HTTP 200,
|
||||||
|
5 047 685 байт. Домен обычный (не RU-gov минцифровский сертификат) — verify=False
|
||||||
|
НЕ ставим, лишний.
|
||||||
|
|
||||||
|
МОНИТОРИНГ ПРОТУХАНИЯ (не реализован в этом PR — зафиксировано, чтобы следующий не
|
||||||
|
считал пустую дельту багом): на момент проверки 08.09.2026 дамп источника заморожен,
|
||||||
|
Last-Modified 26.02.2026. TRUNCATE+INSERT переливает те же ~31k строк каждый рефреш —
|
||||||
|
это ожидаемо, а не признак сломанного парсинга. Если нужен freshness-монитор — см.
|
||||||
|
паттерн `recon_macro.md` (СберИндекс), здесь не сделан осознанно (вне скоупа #3410).
|
||||||
|
|
||||||
|
СТРИМИНГ (обязательно, файл большой): `json.load()` целиком держал бы ~71 МБ + объектный
|
||||||
|
граф в памяти разом. Используем `ijson.items(stream, "features.item")` поверх
|
||||||
|
файлового объекта из `zipfile.ZipFile.open(name)` — постоянная память вне зависимости
|
||||||
|
от размера файла, каждая feature обрабатывается и отбрасывается по одной.
|
||||||
|
|
||||||
|
ПДн — НЕ СОХРАНЯЕМ (осознанное решение, зафиксировано и в 294_dtp_incidents.sql):
|
||||||
|
`properties.vehicles[].participants[]` источника несёт пол/роль/нарушения физлиц —
|
||||||
|
это персональные данные конкретных людей, продукту не нужны и не разрешены. Парсер
|
||||||
|
(`_parse_feature`) НИКОГДА не читает ключи `vehicles`/`participants` — ни в raw-jsonb,
|
||||||
|
ни отдельной колонкой. Из `properties` берём только неперсональные агрегаты и атрибуты
|
||||||
|
происшествия (see DtpIncidentRow).
|
||||||
|
|
||||||
|
Идемпотентность: TRUNCATE + bulk INSERT в одной транзакции (тот же паттерн, что
|
||||||
|
`app/tasks/osm_poi_ekb_refresh.py` — полная замена, не upsert; повторный прогон с тем
|
||||||
|
же дампом даёт тот же результат).
|
||||||
|
|
||||||
|
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
|
||||||
|
Массивы (`weather`, `nearby`, тип `text[]`) — psycopg v3 адаптирует Python `list[str]`
|
||||||
|
в `text[]` напрямую через `CAST(:x AS text[])` (тот же идиом, что
|
||||||
|
`app/tasks/deactivate_stale_avito.py`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import zipfile
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import IO, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import ijson
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SRC_URL = "https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip"
|
||||||
|
DOWNLOAD_TIMEOUT_SEC = 180
|
||||||
|
INSERT_CHUNK_SIZE = 2000
|
||||||
|
|
||||||
|
# properties.datetime — "2015-01-01 03:00:00", без явной таймзоны в источнике.
|
||||||
|
# Храним как naive → колонка timestamptz получит его в timezone сессии БД (обычно UTC).
|
||||||
|
# Источник не публикует TZ, поэтому точная привязка к МСК не гарантирована — для
|
||||||
|
# радиусных агрегатов по годам (dtp_index.py) это несущественно.
|
||||||
|
_DT_FMT = "%Y-%m-%d %H:%M:%S"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class DtpIncidentRow:
|
||||||
|
"""Одна строка ДТП — ТОЛЬКО неперсональные атрибуты (см. докстринг модуля)."""
|
||||||
|
|
||||||
|
source_id: str
|
||||||
|
dtp_at: datetime | None
|
||||||
|
category: str | None
|
||||||
|
severity: str | None
|
||||||
|
dead: int | None
|
||||||
|
injured: int | None
|
||||||
|
address: str | None
|
||||||
|
light: str | None
|
||||||
|
weather: list[str] | None
|
||||||
|
nearby: list[str] | None
|
||||||
|
lat: float
|
||||||
|
lon: float
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_datetime(raw: Any) -> datetime | None:
|
||||||
|
if not isinstance(raw, str) or not raw.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(raw.strip(), _DT_FMT)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("dtp_stat_loader: не распарсен datetime %r", raw)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_str_list(raw: Any) -> list[str] | None:
|
||||||
|
if not isinstance(raw, list) or not raw:
|
||||||
|
return None
|
||||||
|
return [str(item) for item in raw if item is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int(raw: Any) -> int | None:
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(raw, int):
|
||||||
|
return raw
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Санити-рамка координат. Выгрузка dtp-stat по Свердловской области содержит битые
|
||||||
|
# точки: на реальном дампе 2026-02-26 из 31 253 записей 74 лежат вне широт региона,
|
||||||
|
# в том числе 10 ровно в (0.0, 0.0) и такие значения, как (1.0, 1.0) и (41.0, 47.0).
|
||||||
|
# Пускать их в geo-таблицу нельзя: слой кормит радиусные агрегаты локации.
|
||||||
|
# Рамка намеренно шире официальных границ области (~56.0-62.3 N, ~57.2-66.2 E),
|
||||||
|
# чтобы не срезать легитимные приграничные точки.
|
||||||
|
REGION_LAT_RANGE = (55.0, 63.5)
|
||||||
|
REGION_LON_RANGE = (56.0, 67.5)
|
||||||
|
|
||||||
|
|
||||||
|
def _coords_plausible(lat: float, lon: float) -> bool:
|
||||||
|
"""Точка похожа на реальное ДТП в регионе выгрузки, а не на мусор источника."""
|
||||||
|
if lat == 0.0 and lon == 0.0:
|
||||||
|
return False
|
||||||
|
if not (REGION_LAT_RANGE[0] <= lat <= REGION_LAT_RANGE[1]):
|
||||||
|
return False
|
||||||
|
return REGION_LON_RANGE[0] <= lon <= REGION_LON_RANGE[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_feature(feature: dict[str, Any]) -> DtpIncidentRow | None:
|
||||||
|
"""Чистая функция: одна GeoJSON Feature -> DtpIncidentRow, либо None (пропуск).
|
||||||
|
|
||||||
|
НЕ читает `properties.vehicles` / `properties.participants` — намеренно (ПДн,
|
||||||
|
см. докстринг модуля). Пропускает фичи без валидной Point-геометрии/id, а также
|
||||||
|
с координатами вне санити-рамки региона (см. `_coords_plausible`).
|
||||||
|
"""
|
||||||
|
geometry = feature.get("geometry") or {}
|
||||||
|
if geometry.get("type") != "Point":
|
||||||
|
return None
|
||||||
|
coords = geometry.get("coordinates")
|
||||||
|
if not isinstance(coords, list) or len(coords) < 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
lon, lat = float(coords[0]), float(coords[1])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not _coords_plausible(lat, lon):
|
||||||
|
return None
|
||||||
|
|
||||||
|
props = feature.get("properties") or {}
|
||||||
|
source_id = props.get("id")
|
||||||
|
if source_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return DtpIncidentRow(
|
||||||
|
source_id=str(source_id),
|
||||||
|
dtp_at=_parse_datetime(props.get("datetime")),
|
||||||
|
category=props.get("category") if isinstance(props.get("category"), str) else None,
|
||||||
|
severity=props.get("severity") if isinstance(props.get("severity"), str) else None,
|
||||||
|
dead=_parse_int(props.get("dead_count")),
|
||||||
|
injured=_parse_int(props.get("injured_count")),
|
||||||
|
address=props.get("address") if isinstance(props.get("address"), str) else None,
|
||||||
|
light=props.get("light") if isinstance(props.get("light"), str) else None,
|
||||||
|
weather=_parse_str_list(props.get("weather")),
|
||||||
|
nearby=_parse_str_list(props.get("nearby")),
|
||||||
|
lat=lat,
|
||||||
|
lon=lon,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_incidents(stream: IO[bytes]) -> Iterator[DtpIncidentRow]:
|
||||||
|
"""Потоковый парс GeoJSON FeatureCollection -> DtpIncidentRow, по одной фиче за раз.
|
||||||
|
|
||||||
|
`stream` — бинарный файловый объект (из `zipfile.ZipFile.open()` или обычного
|
||||||
|
`open(path, "rb")`). Использует `ijson.items(..., "features.item")` — постоянная
|
||||||
|
память, НЕ читает файл целиком.
|
||||||
|
"""
|
||||||
|
for feature in ijson.items(stream, "features.item"):
|
||||||
|
row = _parse_feature(feature)
|
||||||
|
if row is not None:
|
||||||
|
yield row
|
||||||
|
|
||||||
|
|
||||||
|
def _open_geojson_in_zip(zf: zipfile.ZipFile) -> IO[bytes]:
|
||||||
|
names = [n for n in zf.namelist() if n.lower().endswith(".geojson")]
|
||||||
|
if not names:
|
||||||
|
raise ValueError(f"zip не содержит .geojson записей: {zf.namelist()!r}")
|
||||||
|
return zf.open(names[0])
|
||||||
|
|
||||||
|
|
||||||
|
def download_dtp_zip(*, client: httpx.Client) -> bytes:
|
||||||
|
"""Скачать ZIP dtp-stat.ru. Открытые данные, без auth/PII — стандартный verify."""
|
||||||
|
resp = client.get(SRC_URL, timeout=DOWNLOAD_TIMEOUT_SEC)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
_TRUNCATE_SQL = text("TRUNCATE dtp_incidents")
|
||||||
|
|
||||||
|
_INSERT_SQL = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO dtp_incidents
|
||||||
|
(source_id, dtp_at, category, severity, dead, injured, address, light,
|
||||||
|
weather, nearby, lat, lon, geom)
|
||||||
|
VALUES
|
||||||
|
(CAST(:source_id AS text), CAST(:dtp_at AS timestamptz),
|
||||||
|
CAST(:category AS text), CAST(:severity AS text),
|
||||||
|
CAST(:dead AS integer), CAST(:injured AS integer),
|
||||||
|
CAST(:address AS text), CAST(:light AS text),
|
||||||
|
CAST(:weather AS text[]), CAST(:nearby AS text[]),
|
||||||
|
CAST(:lat AS double precision), CAST(:lon AS double precision),
|
||||||
|
ST_SetSRID(ST_MakePoint(CAST(:lon AS double precision), CAST(:lat AS double precision)),
|
||||||
|
4326))
|
||||||
|
ON CONFLICT (source_id) DO NOTHING
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_params(row: DtpIncidentRow) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"source_id": row.source_id,
|
||||||
|
"dtp_at": row.dtp_at,
|
||||||
|
"category": row.category,
|
||||||
|
"severity": row.severity,
|
||||||
|
"dead": row.dead,
|
||||||
|
"injured": row.injured,
|
||||||
|
"address": row.address,
|
||||||
|
"light": row.light,
|
||||||
|
"weather": row.weather,
|
||||||
|
"nearby": row.nearby,
|
||||||
|
"lat": row.lat,
|
||||||
|
"lon": row.lon,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_dtp_incidents(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
src_path: str | Path | None = None,
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
chunk_size: int = INSERT_CHUNK_SIZE,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""TRUNCATE dtp_incidents; потоковый парс + bulk INSERT из ZIP dtp-stat.ru.
|
||||||
|
|
||||||
|
`src_path` — локальный ZIP (тесты/ручной прогон); если None — качаем `SRC_URL`
|
||||||
|
через `client` (обязателен параметром, чтобы был чем подменить в тестах;
|
||||||
|
создаётся временный `httpx.Client()` если не передан).
|
||||||
|
|
||||||
|
`dry_run=True` — парсит и считает строки, НЕ трогает БД (ни TRUNCATE, ни INSERT).
|
||||||
|
|
||||||
|
Не коммитит — коммитит caller (app/tasks/dtp_stat_refresh.py).
|
||||||
|
"""
|
||||||
|
owns_client = client is None
|
||||||
|
client = client or httpx.Client()
|
||||||
|
try:
|
||||||
|
if src_path is not None:
|
||||||
|
with zipfile.ZipFile(src_path) as zf, _open_geojson_in_zip(zf) as stream:
|
||||||
|
rows = list(iter_incidents(stream))
|
||||||
|
else:
|
||||||
|
data = download_dtp_zip(client=client)
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf, _open_geojson_in_zip(zf) as stream:
|
||||||
|
rows = list(iter_incidents(stream))
|
||||||
|
finally:
|
||||||
|
if owns_client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
parsed = len(rows)
|
||||||
|
inserted = 0
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
db.execute(_TRUNCATE_SQL)
|
||||||
|
for i in range(0, len(rows), chunk_size):
|
||||||
|
chunk = rows[i : i + chunk_size]
|
||||||
|
result = db.execute(_INSERT_SQL, [_row_params(r) for r in chunk])
|
||||||
|
inserted += result.rowcount or 0
|
||||||
|
|
||||||
|
result_counts = {"parsed": parsed, "inserted": inserted}
|
||||||
|
logger.info("dtp_stat_loader load DONE (dry_run=%s): %s", dry_run, result_counts)
|
||||||
|
return result_counts
|
||||||
|
|
@ -51,6 +51,7 @@ from scraper_kit.providers.yandex.valuation import (
|
||||||
YandexValuationResult,
|
YandexValuationResult,
|
||||||
YandexValuationScraper,
|
YandexValuationScraper,
|
||||||
)
|
)
|
||||||
|
from scraper_kit.proxy_errors import caused_by_no_proxy
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -65,6 +66,7 @@ from app.schemas.trade_in import (
|
||||||
PriceTrendPoint,
|
PriceTrendPoint,
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
)
|
)
|
||||||
|
from app.services import regions as regions_mod
|
||||||
from app.services.dadata import DadataAddressResult
|
from app.services.dadata import DadataAddressResult
|
||||||
|
|
||||||
# enrich_address = /clean с graceful fallback на /suggest (#dadata-403: на проде услуга
|
# enrich_address = /clean с graceful fallback на /suggest (#dadata-403: на проде услуга
|
||||||
|
|
@ -79,7 +81,7 @@ from app.services.geocoder import (
|
||||||
)
|
)
|
||||||
from app.services.house_metadata import get_house_metadata
|
from app.services.house_metadata import get_house_metadata
|
||||||
from app.services.matching.houses import match_house_readonly, match_or_create_house
|
from app.services.matching.houses import match_house_readonly, match_or_create_house
|
||||||
from app.services.scraper_adapters import RealScraperConfig
|
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
|
||||||
from app.services.scraper_settings import get_scraper_delay
|
from app.services.scraper_settings import get_scraper_delay
|
||||||
from app.tasks.asking_to_sold_ratio import area_bucket
|
from app.tasks.asking_to_sold_ratio import area_bucket
|
||||||
|
|
||||||
|
|
@ -343,22 +345,24 @@ DEAL_MAX_FLOOR = 60 # выше реального максимума ЕКБ →
|
||||||
# глобальный пол 50k ₽/м² для дешёвых городов области НЕ anti-outlier guard, а
|
# глобальный пол 50k ₽/м² для дешёвых городов области НЕ anti-outlier guard, а
|
||||||
# cut-off легитимного рынка (Североуральск median ≈21.7k, Новоуральск ≈36k,
|
# cut-off легитимного рынка (Североуральск median ≈21.7k, Новоуральск ≈36k,
|
||||||
# Асбест ≈41k — все ниже 50k → 46.6% не-ЕКБ сделок молча дропались).
|
# Асбест ≈41k — все ниже 50k → 46.6% не-ЕКБ сделок молча дропались).
|
||||||
# deal_city_price_bands (миграция 178) — per-city [ppm2_min, ppm2_max] band из
|
# deal_city_price_bands (миграция 178, ключ (region_code, city) — миграция 298
|
||||||
# перцентилей p1/p99 реальных rosreestr-сделок города (hard floor/ceiling
|
# #3051) — per-(region, city) [ppm2_min, ppm2_max] band из перцентилей p1/p99
|
||||||
# 8000/800000 всё равно режут доли/опечатки). Екатеринбург НАМЕРЕННО исключён
|
# реальных rosreestr-сделок города (hard floor/ceiling 8000/800000 всё равно
|
||||||
# из таблицы — .get(city, ...) fallback на глобальные DEAL_MIN_PPM2/MAX_PPM2
|
# режут доли/опечатки). Екатеринбург (region_code=66) НАМЕРЕННО исключён из
|
||||||
# ниже гарантирует byte-identical ЕКБ-поведение.
|
# таблицы — .get((region_code, city), ...) fallback на глобальные
|
||||||
|
# DEAL_MIN_PPM2/MAX_PPM2 ниже гарантирует byte-identical ЕКБ-поведение.
|
||||||
_CITY_PRICE_BANDS_CACHE_TTL_S = 300.0
|
_CITY_PRICE_BANDS_CACHE_TTL_S = 300.0
|
||||||
_city_price_bands_cache: tuple[dict[str, tuple[int, int]], float] | None = None
|
_city_price_bands_cache: tuple[dict[tuple[int, str], tuple[int, int]], float] | None = None
|
||||||
|
|
||||||
|
|
||||||
def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]:
|
def _load_city_price_bands(db: Session) -> dict[tuple[int, str], tuple[int, int]]:
|
||||||
"""#2478: {city: (ppm2_min, ppm2_max)} из deal_city_price_bands (миграция 178).
|
"""#2478 + #3051 (298): {(region_code, city): (ppm2_min, ppm2_max)} из deal_city_price_bands.
|
||||||
|
|
||||||
Кэш в процессе (TTL _CITY_PRICE_BANDS_CACHE_TTL_S) — таблица рефрешится
|
Кэш в процессе (TTL _CITY_PRICE_BANDS_CACHE_TTL_S) — таблица рефрешится
|
||||||
периодическим ре-запуском миграции, не на каждый estimate. Таблицы нет /
|
периодическим ре-запуском derivation (298 / deal_city_price_bands_refresh),
|
||||||
любая ошибка → {} (graceful) — вызывающий код fallback'ит на глобальные
|
не на каждый estimate. Таблицы нет / любая ошибка → {} (graceful) —
|
||||||
DEAL_MIN_PPM2/DEAL_MAX_PPM2, т.е. поведение как до #2478.
|
вызывающий код fallback'ит на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2, т.е.
|
||||||
|
поведение как до #2478.
|
||||||
"""
|
"""
|
||||||
global _city_price_bands_cache
|
global _city_price_bands_cache
|
||||||
if _city_price_bands_cache is not None:
|
if _city_price_bands_cache is not None:
|
||||||
|
|
@ -367,14 +371,18 @@ def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]:
|
||||||
return bands
|
return bands
|
||||||
try:
|
try:
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(text("SELECT city, ppm2_min, ppm2_max FROM deal_city_price_bands"))
|
db.execute(
|
||||||
|
text("SELECT region_code, city, ppm2_min, ppm2_max FROM deal_city_price_bands")
|
||||||
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("deal_city_price_bands lookup failed (graceful): %s", exc)
|
logger.warning("deal_city_price_bands lookup failed (graceful): %s", exc)
|
||||||
return {}
|
return {}
|
||||||
bands = {r["city"]: (int(r["ppm2_min"]), int(r["ppm2_max"])) for r in rows}
|
bands = {
|
||||||
|
(int(r["region_code"]), r["city"]): (int(r["ppm2_min"]), int(r["ppm2_max"])) for r in rows
|
||||||
|
}
|
||||||
_city_price_bands_cache = (bands, time.monotonic())
|
_city_price_bands_cache = (bands, time.monotonic())
|
||||||
return bands
|
return bands
|
||||||
|
|
||||||
|
|
@ -794,6 +802,11 @@ async def _get_or_fetch_imv_cached(
|
||||||
has_balcony=has_balcony,
|
has_balcony=has_balcony,
|
||||||
has_loggia=has_loggia,
|
has_loggia=has_loggia,
|
||||||
config=RealScraperConfig(),
|
config=RealScraperConfig(),
|
||||||
|
# #3386: без provider'а `curl_proxy_url` считает use_pool=False (флаг AND
|
||||||
|
# provider is not None) и уходит на env-прокси SCRAPER_PROXY_URL — мёртвый
|
||||||
|
# узел (#2613). Один lease на вызов: acquire/release внутри curl_proxy_url,
|
||||||
|
# release в finally на всех выходах (исключение/таймаут — тоже).
|
||||||
|
proxy_provider=RealProxyProvider(),
|
||||||
)
|
)
|
||||||
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
|
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -827,6 +840,7 @@ async def _get_or_fetch_imv_cached(
|
||||||
has_balcony=has_balcony,
|
has_balcony=has_balcony,
|
||||||
has_loggia=has_loggia,
|
has_loggia=has_loggia,
|
||||||
config=RealScraperConfig(),
|
config=RealScraperConfig(),
|
||||||
|
proxy_provider=RealProxyProvider(), # #3386, см. первый вызов выше
|
||||||
)
|
)
|
||||||
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
|
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -852,7 +866,14 @@ async def _get_or_fetch_imv_cached(
|
||||||
logger.warning("imv: transient error, skipping retry in estimator context: %s", e)
|
logger.warning("imv: transient error, skipping retry in estimator context: %s", e)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("imv: fetch failed — estimator продолжает без IMV: %s", e)
|
if caused_by_no_proxy(e):
|
||||||
|
# #3386: «пул прокси пуст» — НАША инфраструктура, не сбой фетча и не
|
||||||
|
# transient-ошибка площадки; HTTP-запрос вообще не уходил. Отдельный текст,
|
||||||
|
# чтобы в логах /estimate это не читалось как «Авито отвалился».
|
||||||
|
# Возврат None — тот же graceful путь: ответ отдаётся без IMV-якоря, не 5xx.
|
||||||
|
logger.warning("imv: пул прокси пуст — estimator продолжает без IMV: %s", e)
|
||||||
|
else:
|
||||||
|
logger.warning("imv: fetch failed — estimator продолжает без IMV: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -892,8 +913,18 @@ def _defer_external_refresh(label: str, work: Callable[[Session], Awaitable[obje
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
await work(db)
|
await work(db)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("deferred %s: догрузка не удалась (кэш не прогрет)", label)
|
if caused_by_no_proxy(exc):
|
||||||
|
# #3398: пустой пул — НАША инфраструктура, HTTP-запрос не уходил вовсе
|
||||||
|
# (NoProxyAvailableError поднимается в curl_proxy_url ДО запроса). WARNING,
|
||||||
|
# не ERROR: GlitchTip слушает event_level=ERROR, а это штатная деградация
|
||||||
|
# прогрева, не сбой площадки — событие тут было бы шумом.
|
||||||
|
# На проде ESTIMATE_EXTERNAL_SOURCES_BACKGROUND=true, поэтому настоящий
|
||||||
|
# фетч уходит именно сюда: synchronous-ветка с fetch_on_miss=False отдаёт
|
||||||
|
# None ДО прокси-слоя и до своего WARNING в estimate_quality не доходит.
|
||||||
|
logger.warning("deferred %s: пул прокси пуст — кэш не прогрет: %s", label, exc)
|
||||||
|
else:
|
||||||
|
logger.exception("deferred %s: догрузка не удалась (кэш не прогрет)", label)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
@ -992,14 +1023,27 @@ async def _get_or_fetch_yandex_valuation_cached(
|
||||||
# Fresh fetch
|
# Fresh fetch
|
||||||
try:
|
try:
|
||||||
async with YandexValuationScraper(
|
async with YandexValuationScraper(
|
||||||
RealScraperConfig(), delay_provider=get_scraper_delay
|
RealScraperConfig(),
|
||||||
|
delay_provider=get_scraper_delay,
|
||||||
|
# #3398: без provider'а `curl_proxy_url` считает use_pool=False (флаг AND
|
||||||
|
# provider is not None) — пул выключен по построению, curl-сессия уходит на
|
||||||
|
# env-прокси SCRAPER_PROXY_URL (мёртвый узел #2613 → 407 на каждой пробе).
|
||||||
|
# Lease один на сессию: acquire в __aenter__, release в __aexit__ — в т.ч.
|
||||||
|
# на исключении и на отмене по таймауту _with_budget.
|
||||||
|
proxy_provider=RealProxyProvider(),
|
||||||
) as scraper:
|
) as scraper:
|
||||||
result = await scraper.fetch_house_history(
|
result = await scraper.fetch_house_history(
|
||||||
address=address,
|
address=address,
|
||||||
offer_category=offer_category,
|
offer_category=offer_category,
|
||||||
offer_type=offer_type,
|
offer_type=offer_type,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
if caused_by_no_proxy(e):
|
||||||
|
# #3398: «пул прокси пуст» — НАША инфраструктура, HTTP-запрос не уходил вовсе.
|
||||||
|
# Отдельный текст (и WARNING, не ERROR): это не сбой Яндекса, GlitchTip-событие
|
||||||
|
# здесь было бы шумом. Возврат None — тот же graceful путь, /estimate не 5xx.
|
||||||
|
logger.warning("yandex_valuation: пул прокси пуст — продолжаем без Yandex: %s", e)
|
||||||
|
return None
|
||||||
# logger.exception (не .warning) — намеренно: GlitchTip LoggingIntegration
|
# logger.exception (не .warning) — намеренно: GlitchTip LoggingIntegration
|
||||||
# (main.py/scheduler_main.py) слушает event_level=logging.ERROR. WARNING,
|
# (main.py/scheduler_main.py) слушает event_level=logging.ERROR. WARNING,
|
||||||
# даже с exc_info=True, остаётся ниже порога и НЕ создаёт событие в GlitchTip
|
# даже с exc_info=True, остаётся ниже порога и НЕ создаёт событие в GlitchTip
|
||||||
|
|
@ -1009,7 +1053,15 @@ async def _get_or_fetch_yandex_valuation_cached(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
logger.info("yandex_valuation: empty result for address=%s", address[:60])
|
# #3398: None здесь НЕразличимо «дом не найден» и «фетч не дошёл» — kit ловит
|
||||||
|
# ProxyError/HTTP-код внутри fetch_house_history и логирует своей строкой
|
||||||
|
# («yandex valuation fetch failed» / «returned NNN»). Прежний текст «empty result»
|
||||||
|
# читался как «у Яндекса нет данных», хотя при мёртвом прокси это был 407.
|
||||||
|
logger.info(
|
||||||
|
"yandex_valuation: пусто для address=%s — данных нет ЛИБО фетч не дошёл "
|
||||||
|
"(причина строкой выше от scraper_kit: прокси/HTTP)",
|
||||||
|
address[:60],
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Save to cache (UPSERT on (source, cache_key))
|
# Save to cache (UPSERT on (source, cache_key))
|
||||||
|
|
@ -1320,6 +1372,8 @@ def _apply_imv_blend(
|
||||||
anchor_higher: int | None,
|
anchor_higher: int | None,
|
||||||
weight: float,
|
weight: float,
|
||||||
threshold: float,
|
threshold: float,
|
||||||
|
market_count: int | None = None,
|
||||||
|
thin_market_threshold: int = 0,
|
||||||
) -> tuple[int, int, float, bool, int | None]:
|
) -> tuple[int, int, float, bool, int | None]:
|
||||||
"""Чистая (testable без БД) blend-трансформация для #651.
|
"""Чистая (testable без БД) blend-трансформация для #651.
|
||||||
|
|
||||||
|
|
@ -1330,12 +1384,22 @@ def _apply_imv_blend(
|
||||||
Если A ниже медианы — медиану НЕ трогаем, но диапазон можем расширить, чтобы
|
Если A ниже медианы — медиану НЕ трогаем, но диапазон можем расширить, чтобы
|
||||||
включить A (информативность). Null-guard: при anchor_total=None — no-op.
|
включить A (информативность). Null-guard: при anchor_total=None — no-op.
|
||||||
|
|
||||||
|
#3323: тонкий рынок (`market_count` < `thin_market_threshold`) → якорь
|
||||||
|
статистически ненадёжен и ОТБРАСЫВАЕТСЯ из денежного пути целиком: ни blend
|
||||||
|
медианы, ни расширение range_high. Гейт стоит здесь — в единственной точке,
|
||||||
|
через которую IMV влияет на деньги, а не в ветках построения якоря.
|
||||||
|
`thin_market_threshold=0` (default) = гейт выключен: market_count неизвестен →
|
||||||
|
поведение прежнее.
|
||||||
|
|
||||||
Returns (new_median_price, new_range_high, new_median_ppm2, blended,
|
Returns (new_median_price, new_range_high, new_median_ppm2, blended,
|
||||||
anchor_used_total).
|
anchor_used_total).
|
||||||
"""
|
"""
|
||||||
if anchor_total is None or anchor_total <= 0 or median_price <= 0 or area <= 0:
|
if anchor_total is None or anchor_total <= 0 or median_price <= 0 or area <= 0:
|
||||||
return median_price, range_high, median_ppm2, False, None
|
return median_price, range_high, median_ppm2, False, None
|
||||||
|
|
||||||
|
if market_count is not None and market_count < thin_market_threshold:
|
||||||
|
return median_price, range_high, median_ppm2, False, None
|
||||||
|
|
||||||
blended = False
|
blended = False
|
||||||
new_median = median_price
|
new_median = median_price
|
||||||
new_ppm2 = median_ppm2
|
new_ppm2 = median_ppm2
|
||||||
|
|
@ -1775,6 +1839,7 @@ def _fetch_dkp_corridor(
|
||||||
rooms: int | None,
|
rooms: int | None,
|
||||||
area: float | None,
|
area: float | None,
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
|
region_code: int = regions_mod.DEFAULT_REGION_CODE,
|
||||||
period_months: int = DEALS_PERIOD_MONTHS,
|
period_months: int = DEALS_PERIOD_MONTHS,
|
||||||
area_tolerance: float = AREA_TOLERANCE,
|
area_tolerance: float = AREA_TOLERANCE,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
|
|
@ -1794,6 +1859,16 @@ def _fetch_dkp_corridor(
|
||||||
Тагиле/Лесном/Краснотурьинске/Серове/ЕКБ/Первоуральске) — коридор превращался
|
Тагиле/Лесном/Краснотурьинске/Серове/ЕКБ/Первоуральске) — коридор превращался
|
||||||
в кросс-областной мусор для не-ЕКБ таргетов. None (город не определился) →
|
в кросс-областной мусор для не-ЕКБ таргетов. None (город не определился) →
|
||||||
фильтр не применяется, прежнее (pre-oblast) поведение сохраняется как есть.
|
фильтр не применяется, прежнее (pre-oblast) поведение сохраняется как есть.
|
||||||
|
|
||||||
|
region_code: (#3051 «Москва», PR-A) — регион целевого запроса, ОБЯЗАТЕЛЬНЫЙ
|
||||||
|
фильтр (не advisory, в отличие от city) в обоих ДКП-запросах. `city`
|
||||||
|
распознаётся только для Свердловской области (_resolve_target_city матчит
|
||||||
|
исключительно SVERDLOVSK_OBLAST_CITIES) — для Москвы/любого нового региона
|
||||||
|
city=None и street-ILIKE фильтр остаётся ЕДИНСТВЕННЫМ скоупом сделки; без
|
||||||
|
region_code одноимённая улица чужого региона (или сделка без узнанного
|
||||||
|
города вовсе) утекает в коридор через тот же street ILIKE. Дефолт —
|
||||||
|
regions_mod.DEFAULT_REGION_CODE (66) — байт-в-байт прежнее поведение для
|
||||||
|
всех сегодняшних вызовов (все сделки в БД сейчас region_code=66).
|
||||||
"""
|
"""
|
||||||
if not address or rooms is None or not area:
|
if not address or rooms is None or not area:
|
||||||
return None
|
return None
|
||||||
|
|
@ -1816,8 +1891,10 @@ def _fetch_dkp_corridor(
|
||||||
f"""
|
f"""
|
||||||
SELECT d.price_per_m2, d.deal_date
|
SELECT d.price_per_m2, d.deal_date
|
||||||
FROM deals d
|
FROM deals d
|
||||||
LEFT JOIN deal_city_price_bands b ON b.city = d.city
|
LEFT JOIN deal_city_price_bands b
|
||||||
|
ON b.region_code = d.region_code AND b.city = d.city
|
||||||
WHERE d.source = 'rosreestr'
|
WHERE d.source = 'rosreestr'
|
||||||
|
AND d.region_code = CAST(:region_code AS int)
|
||||||
AND d.address ILIKE :street_pattern
|
AND d.address ILIKE :street_pattern
|
||||||
AND d.address ~* :street_regex
|
AND d.address ~* :street_regex
|
||||||
AND d.rooms = CAST(:rooms AS integer)
|
AND d.rooms = CAST(:rooms AS integer)
|
||||||
|
|
@ -1826,11 +1903,12 @@ def _fetch_dkp_corridor(
|
||||||
- (CAST(:period_months AS integer) || ' months')::interval
|
- (CAST(:period_months AS integer) || ' months')::interval
|
||||||
AND d.price_per_m2 > 0
|
AND d.price_per_m2 > 0
|
||||||
{city_filter_sql}
|
{city_filter_sql}
|
||||||
-- #699 + #2478: режем нерыночные ppm²-выбросы из коридора
|
-- #699 + #2478 + #3051: режем нерыночные ppm²-выбросы из коридора
|
||||||
-- expected_sold. Per-city band (deal_city_price_bands, миграция
|
-- expected_sold. Per-(region, city) band (deal_city_price_bands,
|
||||||
-- 178) когда для города сделки есть строка (не-ЕКБ область);
|
-- миграция 178, ключ (region_code, city) — миграция 298) когда для
|
||||||
-- иначе (в т.ч. Екатеринбург, НАМЕРЕННО не в таблице) fallback
|
-- (региона, города) сделки есть строка; иначе (в т.ч. Екатеринбург
|
||||||
-- на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 — byte-identical.
|
-- region_code=66, НАМЕРЕННО не в таблице) fallback на глобальные
|
||||||
|
-- DEAL_MIN_PPM2/DEAL_MAX_PPM2 — byte-identical.
|
||||||
AND d.price_per_m2 BETWEEN COALESCE(b.ppm2_min, CAST(:ppm_min AS int))
|
AND d.price_per_m2 BETWEEN COALESCE(b.ppm2_min, CAST(:ppm_min AS int))
|
||||||
AND COALESCE(b.ppm2_max, CAST(:ppm_max AS int))
|
AND COALESCE(b.ppm2_max, CAST(:ppm_max AS int))
|
||||||
"""
|
"""
|
||||||
|
|
@ -1845,6 +1923,7 @@ def _fetch_dkp_corridor(
|
||||||
"ppm_min": DEAL_MIN_PPM2,
|
"ppm_min": DEAL_MIN_PPM2,
|
||||||
"ppm_max": DEAL_MAX_PPM2,
|
"ppm_max": DEAL_MAX_PPM2,
|
||||||
"target_city": city.lower() if city else None,
|
"target_city": city.lower() if city else None,
|
||||||
|
"region_code": region_code,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
@ -1897,8 +1976,10 @@ def _fetch_dkp_corridor(
|
||||||
"""
|
"""
|
||||||
SELECT d.price_per_m2, d.deal_date
|
SELECT d.price_per_m2, d.deal_date
|
||||||
FROM deals d
|
FROM deals d
|
||||||
LEFT JOIN deal_city_price_bands b ON b.city = d.city
|
LEFT JOIN deal_city_price_bands b
|
||||||
|
ON b.region_code = d.region_code AND b.city = d.city
|
||||||
WHERE d.source = 'rosreestr'
|
WHERE d.source = 'rosreestr'
|
||||||
|
AND d.region_code = CAST(:region_code AS int)
|
||||||
AND d.city IS NOT NULL
|
AND d.city IS NOT NULL
|
||||||
AND LOWER(d.city) = CAST(:target_city AS text)
|
AND LOWER(d.city) = CAST(:target_city AS text)
|
||||||
AND d.rooms = CAST(:rooms AS integer)
|
AND d.rooms = CAST(:rooms AS integer)
|
||||||
|
|
@ -1918,6 +1999,7 @@ def _fetch_dkp_corridor(
|
||||||
"period_months": period_months,
|
"period_months": period_months,
|
||||||
"ppm_min": DEAL_MIN_PPM2,
|
"ppm_min": DEAL_MIN_PPM2,
|
||||||
"ppm_max": DEAL_MAX_PPM2,
|
"ppm_max": DEAL_MAX_PPM2,
|
||||||
|
"region_code": region_code,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
@ -3347,8 +3429,16 @@ def _price_from_inputs(
|
||||||
anchor_higher = (
|
anchor_higher = (
|
||||||
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
|
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
|
||||||
)
|
)
|
||||||
anchor_label = "оценке Avito IMV"
|
# Публичная копия: клиент видит эту строку в confidence_explanation
|
||||||
_imv_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
|
# (HeroSummary), название площадки туда не должно утекать —
|
||||||
|
# та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
|
||||||
|
anchor_label = "оценочной модели площадки"
|
||||||
|
# `is not None`: market_count=0 — самый тонкий рынок, а не «неизвестно».
|
||||||
|
_imv_mc = (
|
||||||
|
int(imv_anchor["market_count"])
|
||||||
|
if imv_anchor.get("market_count") is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
avito_imv_summary = AvitoImvSummary(
|
avito_imv_summary = AvitoImvSummary(
|
||||||
recommended_price=anchor_total,
|
recommended_price=anchor_total,
|
||||||
lower_price=(
|
lower_price=(
|
||||||
|
|
@ -3379,7 +3469,10 @@ def _price_from_inputs(
|
||||||
elif imv_eval is not None and imv_eval.recommended_price:
|
elif imv_eval is not None and imv_eval.recommended_price:
|
||||||
anchor_total = int(imv_eval.recommended_price)
|
anchor_total = int(imv_eval.recommended_price)
|
||||||
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
|
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
|
||||||
anchor_label = "оценке Avito IMV"
|
# Публичная копия: клиент видит эту строку в confidence_explanation
|
||||||
|
# (HeroSummary), название площадки туда не должно утекать —
|
||||||
|
# та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
|
||||||
|
anchor_label = "оценочной модели площадки"
|
||||||
avito_imv_summary = AvitoImvSummary(
|
avito_imv_summary = AvitoImvSummary(
|
||||||
recommended_price=anchor_total,
|
recommended_price=anchor_total,
|
||||||
lower_price=(int(imv_eval.lower_price) if imv_eval.lower_price else None),
|
lower_price=(int(imv_eval.lower_price) if imv_eval.lower_price else None),
|
||||||
|
|
@ -3391,17 +3484,27 @@ def _price_from_inputs(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# #audit-5b: thin-market warning.
|
# #audit-5b / #3323: thin-market warning. Раньше порог рождал ТОЛЬКО warning,
|
||||||
|
# а IMV всё равно двигал headline и растягивал range_high — теперь гейт в
|
||||||
|
# `_apply_imv_blend` отбрасывает якорь, и текст говорит именно это.
|
||||||
if avito_imv_summary is not None and avito_imv_summary.thin_market:
|
if avito_imv_summary is not None and avito_imv_summary.thin_market:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"avito_imv thin_market #audit-5b: market_count=%s"
|
"avito_imv thin_market #3323: market_count=%s"
|
||||||
" (< avito_imv_thin_market_threshold=%d) — IMV reliability low",
|
" (< avito_imv_thin_market_threshold=%d) — IMV ОТБРОШЕН из денежного"
|
||||||
|
" пути (ни blend медианы, ни расширение range_high); остаётся"
|
||||||
|
" display-only в карточке avito_imv",
|
||||||
avito_imv_summary.market_count,
|
avito_imv_summary.market_count,
|
||||||
settings.avito_imv_thin_market_threshold,
|
settings.avito_imv_thin_market_threshold,
|
||||||
)
|
)
|
||||||
|
|
||||||
if anchor_total is not None:
|
if anchor_total is not None:
|
||||||
imv_anchor_present = True
|
# #3323: на тонком рынке якорь отброшен гейтом ниже, значит и Guard-1b
|
||||||
|
# (#764, quarter-index) не должен глушить поправку «потому что якорь есть» —
|
||||||
|
# иначе headline не получит НИ ОДНОЙ поправки, и отброшенный IMV подвинет
|
||||||
|
# деньги вычитанием. avito_imv_summary здесь уже собран обеими ветками.
|
||||||
|
imv_anchor_present = (
|
||||||
|
not avito_imv_summary.thin_market if avito_imv_summary is not None else True
|
||||||
|
)
|
||||||
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
|
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
|
||||||
median_price=median_price,
|
median_price=median_price,
|
||||||
range_high=range_high,
|
range_high=range_high,
|
||||||
|
|
@ -3411,6 +3514,10 @@ def _price_from_inputs(
|
||||||
anchor_higher=anchor_higher,
|
anchor_higher=anchor_higher,
|
||||||
weight=settings.estimate_imv_blend_weight,
|
weight=settings.estimate_imv_blend_weight,
|
||||||
threshold=settings.estimate_imv_blend_threshold,
|
threshold=settings.estimate_imv_blend_threshold,
|
||||||
|
market_count=(
|
||||||
|
avito_imv_summary.market_count if avito_imv_summary is not None else None
|
||||||
|
),
|
||||||
|
thin_market_threshold=settings.avito_imv_thin_market_threshold,
|
||||||
)
|
)
|
||||||
if blended:
|
if blended:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -3442,7 +3549,11 @@ def _price_from_inputs(
|
||||||
# median/expected_sold/ranges блок не трогает.
|
# median/expected_sold/ranges блок не трогает.
|
||||||
if avito_imv_summary is None:
|
if avito_imv_summary is None:
|
||||||
if imv_anchor is not None and imv_anchor.get("recommended_price"):
|
if imv_anchor is not None and imv_anchor.get("recommended_price"):
|
||||||
_disp_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
|
_disp_mc = (
|
||||||
|
int(imv_anchor["market_count"])
|
||||||
|
if imv_anchor.get("market_count") is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
avito_imv_summary = AvitoImvSummary(
|
avito_imv_summary = AvitoImvSummary(
|
||||||
recommended_price=int(imv_anchor["recommended_price"]),
|
recommended_price=int(imv_anchor["recommended_price"]),
|
||||||
lower_price=(
|
lower_price=(
|
||||||
|
|
@ -4469,6 +4580,15 @@ async def estimate_quality(
|
||||||
target_city = _resolve_target_city(payload.address) or _resolve_target_city(
|
target_city = _resolve_target_city(payload.address) or _resolve_target_city(
|
||||||
(dadata.canonical_address if dadata else None) or geo.full_address
|
(dadata.canonical_address if dadata else None) or geo.full_address
|
||||||
)
|
)
|
||||||
|
# #3051 PR-A: региональный скоуп ДКП-коридора — target_city выше распознаёт
|
||||||
|
# ТОЛЬКО города Свердловской области (_resolve_target_city матчит
|
||||||
|
# SVERDLOVSK_OBLAST_CITIES), т.е. для Москвы/любого нового региона city=None
|
||||||
|
# и без region_code street ILIKE ловил бы одноимённую улицу ЧУЖОГО региона.
|
||||||
|
# region_for_point(None) (точка вне охвата ни одного региона) → дефолт 66,
|
||||||
|
# НЕ None — NULL в SQL-параметре обнулил бы фильтр (`= NULL` не матчит
|
||||||
|
# ничего), т.е. держим гарантию «не резолвится → 66», не пусто.
|
||||||
|
target_region = regions_mod.region_for_point(geo.lat, geo.lon)
|
||||||
|
target_region_code = target_region.code if target_region else regions_mod.DEFAULT_REGION_CODE
|
||||||
dkp_raw = await asyncio.to_thread(
|
dkp_raw = await asyncio.to_thread(
|
||||||
_fetch_dkp_corridor,
|
_fetch_dkp_corridor,
|
||||||
db,
|
db,
|
||||||
|
|
@ -4476,6 +4596,7 @@ async def estimate_quality(
|
||||||
rooms=payload.rooms,
|
rooms=payload.rooms,
|
||||||
area=payload.area_m2,
|
area=payload.area_m2,
|
||||||
city=target_city,
|
city=target_city,
|
||||||
|
region_code=target_region_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Stage 3: Avito IMV evaluation as 5-th source (on-demand cached) ──
|
# ── Stage 3: Avito IMV evaluation as 5-th source (on-demand cached) ──
|
||||||
|
|
@ -4569,6 +4690,21 @@ async def estimate_quality(
|
||||||
"deal_type": "sale",
|
"deal_type": "sale",
|
||||||
"use_cache": True,
|
"use_cache": True,
|
||||||
"house_id": target_house_id,
|
"house_id": target_house_id,
|
||||||
|
# #3398: без provider'а curl_proxy_url считает use_pool=False (флаг AND
|
||||||
|
# provider is not None) → env-прокси CIAN_PROXY_URL/SCRAPER_PROXY_URL, мёртвый
|
||||||
|
# узел (#2613) → «Cian valuation fetch failed: … 407». Lease живёт внутри
|
||||||
|
# curl_proxy_url: acquire до запроса, release в finally.
|
||||||
|
#
|
||||||
|
# Весь этот dict переиспользуется отложенной фоновой догрузкой ниже
|
||||||
|
# (`_defer_external_refresh` захватывает `_c_kwargs` замыканием), то есть
|
||||||
|
# `config` и `proxy_provider` — ОДИН инстанс на два вызова, которые могут идти
|
||||||
|
# одновременно (фон стартует после ответа, но живёт своей задачей). Корректно
|
||||||
|
# это ровно пока оба stateless: `RealScraperConfig` — read-only снимок настроек,
|
||||||
|
# `RealProxyProvider` не хранит полей вообще и открывает короткую сессию БД на
|
||||||
|
# каждую операцию (acquire/release/mark_health), поэтому lease'ы двух вызовов не
|
||||||
|
# пересекаются. Появится у любого из них per-вызов состояние (кэш lease'а,
|
||||||
|
# счётчик, открытая сессия) — фоновой задаче нужен СВОЙ инстанс, а не общий.
|
||||||
|
"proxy_provider": RealProxyProvider(),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
cian_val = await _with_budget(
|
cian_val = await _with_budget(
|
||||||
|
|
@ -4588,13 +4724,19 @@ async def estimate_quality(
|
||||||
cian_val.sale_accuracy,
|
cian_val.sale_accuracy,
|
||||||
cian_val.external_house_id,
|
cian_val.external_house_id,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
# logger.exception (не .warning) — намеренно: GlitchTip LoggingIntegration
|
if caused_by_no_proxy(e):
|
||||||
# (main.py/scheduler_main.py) слушает event_level=logging.ERROR. WARNING,
|
# #3398: пустой пул поднимает NoProxyAvailableError ДО HTTP (curl_proxy_url),
|
||||||
# даже с exc_info=True, остаётся ниже порога и НЕ создаёт событие в GlitchTip
|
# то есть это не сбой Циана. WARNING, не ERROR: в GlitchTip такое событие —
|
||||||
# (только breadcrumb) — config-wiring регрессия здесь была бы не видна
|
# шум. Оценка отдаётся без cian-источника, /estimate не 5xx.
|
||||||
# мониторингу. .exception() логирует на ERROR + traceback (#2337).
|
logger.warning("cian_valuation: пул прокси пуст — продолжаем без Cian: %s", e)
|
||||||
logger.exception("cian_valuation: lookup failed (graceful)")
|
else:
|
||||||
|
# logger.exception (не .warning) — намеренно: GlitchTip LoggingIntegration
|
||||||
|
# (main.py/scheduler_main.py) слушает event_level=logging.ERROR. WARNING,
|
||||||
|
# даже с exc_info=True, остаётся ниже порога и НЕ создаёт событие в GlitchTip
|
||||||
|
# (только breadcrumb) — config-wiring регрессия здесь была бы не видна
|
||||||
|
# мониторингу. .exception() логирует на ERROR + traceback (#2337).
|
||||||
|
logger.exception("cian_valuation: lookup failed (graceful)")
|
||||||
|
|
||||||
# ── Pre-fetch: same-building anchor comps ─────────────────────────────────
|
# ── Pre-fetch: same-building anchor comps ─────────────────────────────────
|
||||||
# Guard mirrors the original in-block guard exactly; when false → ([], None).
|
# Guard mirrors the original in-block guard exactly; when false → ([], None).
|
||||||
|
|
@ -6419,25 +6561,29 @@ def _is_plausible_deal(
|
||||||
area_m2: float | None = None,
|
area_m2: float | None = None,
|
||||||
price_rub: float | None = None,
|
price_rub: float | None = None,
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
bands: dict[str, tuple[int, int]] | None = None,
|
bands: dict[tuple[int, str], tuple[int, int]] | None = None,
|
||||||
|
region_code: int = regions_mod.DEFAULT_REGION_CODE,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""#699 + Mera-audit fix-2 + #2478: True если ДКП-сделка правдоподобна (не выброс).
|
"""#699 + Mera-audit fix-2 + #2478 + #3051 (298): True если ДКП-сделка правдоподобна.
|
||||||
|
|
||||||
Абсолютные guard-bands (см. DEAL_* константы). None-поля не судим (keep —
|
Абсолютные guard-bands (см. DEAL_* константы). None-поля не судим (keep —
|
||||||
нечем сравнивать). Проверки:
|
нечем сравнивать). Проверки:
|
||||||
- price_per_m2 вне [ppm_min, ppm_max] → drop, где (ppm_min, ppm_max) =
|
- price_per_m2 вне [ppm_min, ppm_max] → drop, где (ppm_min, ppm_max) =
|
||||||
bands.get(city, (DEAL_MIN_PPM2, DEAL_MAX_PPM2)) — #2478: per-city band
|
bands.get((region_code, city), (DEAL_MIN_PPM2, DEAL_MAX_PPM2)) — #2478:
|
||||||
(deal_city_price_bands, миграция 178) для не-ЕКБ городов области, где
|
per-(region, city) band (deal_city_price_bands, миграция 178, ключ
|
||||||
глобальный пол 50k ₽/м² режет легитимно дешёвый рынок. city не в bands
|
(region_code, city) — миграция 298) для не-ЕКБ городов области, где
|
||||||
(в т.ч. Екатеринбург — НАМЕРЕННО не в таблице) или bands=None →
|
глобальный пол 50k ₽/м² режет легитимно дешёвый рынок. region_code —
|
||||||
fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 (byte-identical
|
kw-only с дефолтом regions_mod.DEFAULT_REGION_CODE (66), позиционные
|
||||||
сегодняшнему поведению).
|
вызовы не ломаются. (region_code, city) не в bands (в т.ч. Екатеринбург
|
||||||
|
region_code=66 — НАМЕРЕННО не в таблице) или bands=None → fallback на
|
||||||
|
глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 (byte-identical сегодняшнему
|
||||||
|
поведению).
|
||||||
- floor < 1 или floor > DEAL_MAX_FLOOR → drop (битый парсер: floor=-5/999)
|
- floor < 1 или floor > DEAL_MAX_FLOOR → drop (битый парсер: floor=-5/999)
|
||||||
- floor > total_floors физически невозможен → drop
|
- floor > total_floors физически невозможен → drop
|
||||||
- area_m2 задана и <= 0 → drop (битый парсер)
|
- area_m2 задана и <= 0 → drop (битый парсер)
|
||||||
- price_rub задана и <= 0 → drop (нерыночная/технческая сделка)
|
- price_rub задана и <= 0 → drop (нерыночная/технческая сделка)
|
||||||
"""
|
"""
|
||||||
ppm_min, ppm_max = (bands or {}).get(city, (DEAL_MIN_PPM2, DEAL_MAX_PPM2))
|
ppm_min, ppm_max = (bands or {}).get((region_code, city), (DEAL_MIN_PPM2, DEAL_MAX_PPM2))
|
||||||
if price_per_m2 is not None and not (ppm_min <= price_per_m2 <= ppm_max):
|
if price_per_m2 is not None and not (ppm_min <= price_per_m2 <= ppm_max):
|
||||||
return False
|
return False
|
||||||
if floor is not None:
|
if floor is not None:
|
||||||
|
|
@ -6462,7 +6608,7 @@ def _fetch_deals(
|
||||||
SELECT
|
SELECT
|
||||||
source, address, lat, lon,
|
source, address, lat, lon,
|
||||||
rooms, area_m2, floor, total_floors,
|
rooms, area_m2, floor, total_floors,
|
||||||
price_rub, price_per_m2, city,
|
price_rub, price_per_m2, city, region_code,
|
||||||
deal_date, days_on_market,
|
deal_date, days_on_market,
|
||||||
cadastral_number,
|
cadastral_number,
|
||||||
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m
|
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m
|
||||||
|
|
@ -6492,8 +6638,11 @@ def _fetch_deals(
|
||||||
|
|
||||||
# #699 + Mera-audit fix-2: отсекаем ДКП-выбросы (битый этаж / нерыночный ppm²
|
# #699 + Mera-audit fix-2: отсекаем ДКП-выбросы (битый этаж / нерыночный ppm²
|
||||||
# / нулевая площадь / нулевая цена) до выдачи в actual_deals и expected_sold.
|
# / нулевая площадь / нулевая цена) до выдачи в actual_deals и expected_sold.
|
||||||
# #2478: per-city ppm² band (deal_city_price_bands) вместо глобального
|
# #2478 + #3051 (298): per-(region, city) ppm² band (deal_city_price_bands)
|
||||||
# DEAL_MIN_PPM2/MAX_PPM2 — грузим один раз на вызов, graceful {} при ошибке.
|
# вместо глобального DEAL_MIN_PPM2/MAX_PPM2 — грузим один раз на вызов,
|
||||||
|
# graceful {} при ошибке. region_code=NULL (не должно случаться для
|
||||||
|
# rosreestr-строк, см. 177/288 backfill) → DEFAULT_REGION_CODE, чтобы band
|
||||||
|
# lookup не падал на None-ключе.
|
||||||
bands = _load_city_price_bands(db)
|
bands = _load_city_price_bands(db)
|
||||||
deals = [dict(r) for r in rows]
|
deals = [dict(r) for r in rows]
|
||||||
clean = [
|
clean = [
|
||||||
|
|
@ -6507,6 +6656,7 @@ def _fetch_deals(
|
||||||
d.get("price_rub"),
|
d.get("price_rub"),
|
||||||
city=d.get("city"),
|
city=d.get("city"),
|
||||||
bands=bands,
|
bands=bands,
|
||||||
|
region_code=d.get("region_code") or regions_mod.DEFAULT_REGION_CODE,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
if len(clean) < len(deals):
|
if len(clean) < len(deals):
|
||||||
|
|
|
||||||
|
|
@ -290,8 +290,9 @@ matplotlib.rcParams["font.family"] = "Manrope"
|
||||||
|
|
||||||
# ── Source pseudo-logos (текстовые pill-badges с брендовыми цветами источников) ─
|
# ── Source pseudo-logos (текстовые pill-badges с брендовыми цветами источников) ─
|
||||||
# Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится
|
# Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится
|
||||||
# через безопасный fallback в _source_pill: серый фон + source.title() — код на
|
# через безопасный fallback в _source_pill: серый фон + «Другой источник» (#3341 —
|
||||||
# исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
|
# сырой id тоже мог бы утечь именем площадки, source.title() больше не используется)
|
||||||
|
# — код на исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
|
||||||
# сторонних площадок (Avito/Циан/...), НЕ часть внутренней design-системы —
|
# сторонних площадок (Avito/Циан/...), НЕ часть внутренней design-системы —
|
||||||
# намеренно не конвертируются в наши OKLCH-токены.
|
# намеренно не конвертируются в наши OKLCH-токены.
|
||||||
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
|
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
|
||||||
|
|
@ -303,15 +304,59 @@ _SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
|
||||||
"etazhi": ("#e30613", "#fff"), # Этажи красный
|
"etazhi": ("#e30613", "#fff"), # Этажи красный
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Публичные лейблы — канон `frontend/src/lib/source-registry.ts::SOURCES` (#3341,
|
||||||
|
# класс #3264): клиентский PDF не должен называть площадки-источники так же, как
|
||||||
|
# веб-отчёт и лендинг перестали делать это с 31.08. Ключи — ТОЛЬКО базовые id
|
||||||
|
# (алиасы валюации/написания резолвятся через _SOURCE_CANONICAL/_canonical_source
|
||||||
|
# ДО обращения сюда — иначе два источника правды для одной площадки).
|
||||||
_SOURCE_DISPLAY_NAMES: dict[str, str] = {
|
_SOURCE_DISPLAY_NAMES: dict[str, str] = {
|
||||||
"avito": "Avito",
|
"avito": "Источник 1",
|
||||||
"cian": "Циан",
|
"cian": "Источник 2",
|
||||||
"domklik": "Домклик · Сбер",
|
"yandex": "Источник 3",
|
||||||
"yandex": "Я.Недвижимость",
|
"domklik": "Источник 4",
|
||||||
|
"etazhi": "Источник 5",
|
||||||
"rosreestr": "Росреестр",
|
"rosreestr": "Росреестр",
|
||||||
"etazhi": "Этажи",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Алиас → базовый id. Зеркалит группировку publicLabel/dot в
|
||||||
|
# frontend/src/lib/source-registry.ts (одна площадка = один номер/цвет там же);
|
||||||
|
# правишь один реестр — проверь другой (#3341 review: без канонизации
|
||||||
|
# estimate.sources_used = [avito, avito_imv, cian, cian_valuation, ...]
|
||||||
|
# (estimator.py `_canonical_sources`, listing+valuation union) рендерил
|
||||||
|
# ДВЕ одинаковые пилюли «Источник 1, Источник 1» и серую точку у алиаса —
|
||||||
|
# ни avito_imv/cian_valuation/yandex_valuation/domclick/etagi не было ключом
|
||||||
|
# ни в _SOURCE_LOGO_COLORS, ни (после дедупа лейблов) в _SOURCE_DISPLAY_NAMES).
|
||||||
|
_SOURCE_CANONICAL: dict[str, str] = {
|
||||||
|
"avito_imv": "avito",
|
||||||
|
"cian_valuation": "cian",
|
||||||
|
"yandex_valuation": "yandex",
|
||||||
|
"domclick": "domklik",
|
||||||
|
"etagi": "etazhi",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_source(source: str) -> str:
|
||||||
|
"""Алиас (valuation-вариант / альтернативное написание) → базовый id площадки."""
|
||||||
|
return _SOURCE_CANONICAL.get(source, source)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_sources(ids: list[str]) -> list[str]:
|
||||||
|
"""Канонизирует id и дедуплицирует с сохранением порядка первого появления.
|
||||||
|
|
||||||
|
`estimate.sources_used` — отсортированное объединение listing-id и их
|
||||||
|
valuation-алиасов (estimator.py `_canonical_sources`), напр.
|
||||||
|
`[avito, avito_imv, cian, cian_valuation, domklik, yandex, yandex_valuation]`.
|
||||||
|
Без канонизации+дедупа ДО среза `[:5]` рендерились дубли лейблов и «Источник 3»
|
||||||
|
(yandex) мог быть вытеснен алиасом другой площадки (#3341 review)."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for raw in ids:
|
||||||
|
canon = _canonical_source(raw)
|
||||||
|
if canon not in seen:
|
||||||
|
seen.add(canon)
|
||||||
|
result.append(canon)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _source_logo_pill(source: str) -> str:
|
def _source_logo_pill(source: str) -> str:
|
||||||
"""Source pill — мягкий HUD-чип (.source-pill, ObjectSummary.tsx dot-идиома):
|
"""Source pill — мягкий HUD-чип (.source-pill, ObjectSummary.tsx dot-идиома):
|
||||||
|
|
@ -319,10 +364,12 @@ def _source_logo_pill(source: str) -> str:
|
||||||
непрозрачный цветной чип; теперь единая с остальным документом мягкая палитра
|
непрозрачный цветной чип; теперь единая с остальным документом мягкая палитра
|
||||||
(Mera v2), бренд-цвет остаётся только акцентной точкой. Название — из
|
(Mera v2), бренд-цвет остаётся только акцентной точкой. Название — из
|
||||||
_SOURCE_DISPLAY_NAMES (короткий bounded набор, max ~18 символов) —
|
_SOURCE_DISPLAY_NAMES (короткий bounded набор, max ~18 символов) —
|
||||||
overflow-wrap на всякий случай, если source незнаком и попадёт .title()
|
overflow-wrap на всякий случай, если source незнаком (см. fallback ниже).
|
||||||
произвольной длины (см. fallback ниже)."""
|
Канонизирует алиасы (avito_imv → avito и т.п.) ДО lookup, чтобы цвет/лейбл
|
||||||
dot = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))[0]
|
совпадали с базовой площадкой (#3341 review)."""
|
||||||
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
|
canon = _canonical_source(source)
|
||||||
|
dot = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff"))[0]
|
||||||
|
name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник")
|
||||||
return (
|
return (
|
||||||
"<span class='source-pill' style='display:inline-flex;align-items:center;gap:4pt;"
|
"<span class='source-pill' style='display:inline-flex;align-items:center;gap:4pt;"
|
||||||
f"margin:0 4pt 4pt 0;background:{_CARD_BG};color:{_BODY};vertical-align:middle;"
|
f"margin:0 4pt 4pt 0;background:{_CARD_BG};color:{_BODY};vertical-align:middle;"
|
||||||
|
|
@ -333,11 +380,13 @@ def _source_logo_pill(source: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _source_badge_inline(source: str | None) -> str:
|
def _source_badge_inline(source: str | None) -> str:
|
||||||
"""Маленький source badge для table cells (без фона)."""
|
"""Маленький source badge для table cells (без фона). Канонизирует алиасы
|
||||||
|
ДО lookup — см. _source_logo_pill."""
|
||||||
if not source:
|
if not source:
|
||||||
return f"<span style='color:{_MUTED};'>—</span>"
|
return f"<span style='color:{_MUTED};'>—</span>"
|
||||||
bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))
|
canon = _canonical_source(source)
|
||||||
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
|
bg, fg = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff"))
|
||||||
|
name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник")
|
||||||
return (
|
return (
|
||||||
f"<span style='display:inline-block;padding:1pt 4pt;background:{bg};color:{fg};"
|
f"<span style='display:inline-block;padding:1pt 4pt;background:{bg};color:{fg};"
|
||||||
f"font-size:{_FS_XS};font-weight:700;border-radius:2pt;'>{_html.escape(name)}</span>"
|
f"font-size:{_FS_XS};font-weight:700;border-radius:2pt;'>{_html.escape(name)}</span>"
|
||||||
|
|
@ -1167,12 +1216,12 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
||||||
advice_discount_text = (
|
advice_discount_text = (
|
||||||
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
|
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
|
||||||
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
|
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
|
||||||
f"ДомКлик и продажи агентств недвижимости"
|
f"сделки площадок и продажи агентств недвижимости"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
advice_discount_text = (
|
advice_discount_text = (
|
||||||
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
|
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
|
||||||
"Росреестр, ДомКлик и продажи агентств недвижимости"
|
"Росреестр, сделки площадок и продажи агентств недвижимости"
|
||||||
)
|
)
|
||||||
|
|
||||||
disclaimer_html = ""
|
disclaimer_html = ""
|
||||||
|
|
@ -1357,7 +1406,11 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
||||||
# count «с учётом ремонта» не существует, второе число было идентично n_total.
|
# count «с учётом ремонта» не существует, второе число было идентично n_total.
|
||||||
|
|
||||||
# Source logos (pseudo) — берём из estimate.sources_used (не захардкоженный список).
|
# Source logos (pseudo) — берём из estimate.sources_used (не захардкоженный список).
|
||||||
sources_to_show = estimate.sources_used or []
|
# #3341 review: sources_used — union listing+valuation алиасов (estimator.py
|
||||||
|
# `_canonical_sources`), напр. [avito, avito_imv, cian, cian_valuation, ...] —
|
||||||
|
# _public_sources канонизирует+дедуплицирует ДО среза [:5], иначе дубли пилюль
|
||||||
|
# и «Источник 3» мог быть вытеснен алиасом другой площадки.
|
||||||
|
sources_to_show = _public_sources(estimate.sources_used or [])
|
||||||
sources_html = "".join(_source_logo_pill(s) for s in sources_to_show[:5])
|
sources_html = "".join(_source_logo_pill(s) for s in sources_to_show[:5])
|
||||||
|
|
||||||
# Params правой колонки — параметры поиска (НЕ конкретной квартиры)
|
# Params правой колонки — параметры поиска (НЕ конкретной квартиры)
|
||||||
|
|
@ -1598,10 +1651,13 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
||||||
|
|
||||||
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
|
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
|
||||||
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
|
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
|
||||||
|
# #3341 review: канонизация+дедуп (_public_sources) ДО фильтра/среза [:5] — те же
|
||||||
|
# причины, что и на странице листингов (см. коммент там).
|
||||||
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
|
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
|
||||||
deal_sources = [s for s in (estimate.sources_used or []) if s in _deal_source_keys]
|
canonical_sources = _public_sources(estimate.sources_used or [])
|
||||||
|
deal_sources = [s for s in canonical_sources if s in _deal_source_keys]
|
||||||
if not deal_sources:
|
if not deal_sources:
|
||||||
deal_sources = [s for s in (estimate.sources_used or [])]
|
deal_sources = canonical_sources
|
||||||
sources_html = "".join(_source_logo_pill(s) for s in deal_sources[:5])
|
sources_html = "".join(_source_logo_pill(s) for s in deal_sources[:5])
|
||||||
|
|
||||||
area = float(input_snapshot.get("area_m2", 0) or 0)
|
area = float(input_snapshot.get("area_m2", 0) or 0)
|
||||||
|
|
@ -1847,7 +1903,7 @@ def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
||||||
<td style="padding:6pt 4pt;">
|
<td style="padding:6pt 4pt;">
|
||||||
<div class="bold">Расходы на рекламу</div>
|
<div class="bold">Расходы на рекламу</div>
|
||||||
<div style="font-size:{_FS_XS};color:{_MUTED};">Ежемесячное базовое продвижение объекта
|
<div style="font-size:{_FS_XS};color:{_MUTED};">Ежемесячное базовое продвижение объекта
|
||||||
на Циан, Авито, Я.Недвижимости</div>
|
на основных площадках объявлений</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
||||||
бесплатно</td>
|
бесплатно</td>
|
||||||
|
|
|
||||||
58
tradein-mvp/backend/app/services/fns_lookup.py
Normal file
58
tradein-mvp/backend/app/services/fns_lookup.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""ФНС opendata lookup по ИНН — тонкое чтение `fns_legal_entity_facts`.
|
||||||
|
|
||||||
|
CONTEXT: см. `fns_opendata_loader.py`. Этот модуль — единственная точка чтения
|
||||||
|
загруженных фактов. ПОТРЕБИТЕЛЯ У НЕГО ПОКА НЕТ: ничто в продукте (скоринг
|
||||||
|
застройщика/УК, estimator и т.п.) сюда не ходит — подключение решается отдельной
|
||||||
|
задачей вне этого PR.
|
||||||
|
|
||||||
|
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
_LOOKUP_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT dataset, series, period, value, org_name
|
||||||
|
FROM fns_legal_entity_facts
|
||||||
|
WHERE inn = CAST(:inn AS text)
|
||||||
|
ORDER BY dataset, series, period
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FnsFact(TypedDict):
|
||||||
|
series: str
|
||||||
|
period: date
|
||||||
|
value: float
|
||||||
|
org_name: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def get_facts_by_inn(db: Session, inn: str) -> dict[str, list[FnsFact]]:
|
||||||
|
"""Факты по ИНН, сгруппированные по dataset (`revexp`/`sshr2019`/`debtam`/`snr`).
|
||||||
|
|
||||||
|
Пустой/пробельный ИНН и отсутствие данных → `{}` (не исключение — вызывающий код
|
||||||
|
не обязан оборачивать lookup в try/except ради нормального «нет данных»).
|
||||||
|
"""
|
||||||
|
normalized = (inn or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
rows = db.execute(_LOOKUP_SQL, {"inn": normalized}).mappings().all()
|
||||||
|
out: dict[str, list[FnsFact]] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
out[row["dataset"]].append(
|
||||||
|
{
|
||||||
|
"series": row["series"],
|
||||||
|
"period": row["period"],
|
||||||
|
"value": row["value"],
|
||||||
|
"org_name": row["org_name"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return dict(out)
|
||||||
497
tradein-mvp/backend/app/services/fns_opendata_loader.py
Normal file
497
tradein-mvp/backend/app/services/fns_opendata_loader.py
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
"""ФНС opendata loader: доходы/расходы, ССЧ, недоимка, спецрежимы юрлиц → lookup по ИНН.
|
||||||
|
|
||||||
|
CONTEXT: открытых данных ЕГРН по правообладателям-физлицам не существует (218-ФЗ
|
||||||
|
ст. 62) — это жёсткий блокер. По ЮРЛИЦАМ данные открыты, лицензия ФНС
|
||||||
|
(nalog.gov.ru/opendata) разрешает переработку и перераспространение — легальный обход
|
||||||
|
для того среза, где он в принципе доступен. Наборы (маска slug'а: 7707329152-<slug>):
|
||||||
|
revexp — доходы и расходы;
|
||||||
|
sshr2019 — среднесписочная численность;
|
||||||
|
debtam — недоимка и задолженность;
|
||||||
|
snr — спецрежимы.
|
||||||
|
ЕГРЮЛ-данных здесь НЕТ: ни адреса, ни ОКВЭД, ни учредителей — только ИНН + наименование
|
||||||
|
+ показатели набора.
|
||||||
|
|
||||||
|
ПОТРЕБИТЕЛЯ У ЭТОГО СЕРВИСА ПОКА НЕТ. Этот модуль (+ fns_lookup.py) — только
|
||||||
|
загрузка и lookup по ИНН. Подключение к продукту (например, скоринг застройщика/УК
|
||||||
|
в оценке) сюда сознательно не входит и не реализовано — это решается отдельной
|
||||||
|
задачей вне текущего PR. `estimator.py` не тронут.
|
||||||
|
|
||||||
|
ИСТОЧНИК: https://www.nalog.gov.ru/opendata/7707329152-<slug>/ — HTML-каталог набора.
|
||||||
|
Прямая ссылка на .zip вида
|
||||||
|
https://file.nalog.ru/opendata/7707329152-revexp/data-20260825-structure-20180110.zip
|
||||||
|
резолвится ДИНАМИЧЕСКИ парсом href со страницы каталога (`resolve_dataset_file_url`):
|
||||||
|
дата в имени файла меняется с каждой публикацией набора — хардкодить URL нельзя,
|
||||||
|
протухнет на следующей публикации. Внутри .zip — множество XML-файлов.
|
||||||
|
|
||||||
|
ПАРСИНГ XML — generic-харвестер, а не хардкод конкретных тегов набора. Точная схема
|
||||||
|
атрибутов XSD structure-20180110 варьируется по набору и НЕ была вживую сверена в
|
||||||
|
этом окружении (нет сетевого доступа к file.nalog.ru отсюда). Вместо этого: любой
|
||||||
|
XML-элемент, несущий ИНН-подобный атрибут (ИННЮЛ/ИНН — данные ФНС opendata, как и
|
||||||
|
ГАР/ФИАС, лежат в атрибутах элементов, не в тексте, см. gar_flats_loader.py), даёт
|
||||||
|
identity строки; ЛЮБОЙ его собственный числовой атрибут (кроме ИНН/КПП/ОКПО/ОКТМО/
|
||||||
|
ОКВЭД/ОГРН и атрибутов-имени) становится отдельным фактом (series=имя атрибута,
|
||||||
|
value=число). Это устойчиво к точным названиям показателей набора ценой чуть более
|
||||||
|
широкого набора series, чем «официальный» словарь показателей — ПЕРЕД первым боевым
|
||||||
|
прогоном на реальном .zip стоит свериться с фактическим XML и, если харвестер тянет
|
||||||
|
лишнее (например служебные коды), сузить `_ID_ATTRS_EXCLUDE`.
|
||||||
|
|
||||||
|
ПАМЯТЬ: .zip целиком лежит в памяти как байты (httpx возвращает `.content` — иначе
|
||||||
|
не проверить целостность архива до распаковки), но НИ ОДИН XML-член НЕ
|
||||||
|
распаковывается в память/на диск целиком: каждый открывается потоково через
|
||||||
|
`zf.open(name)` и парсится `lxml.etree.iterparse` с очисткой обработанных элементов
|
||||||
|
(тот же приём, что в gar_flats_loader.py, — там на многогигабайтных standalone XML,
|
||||||
|
здесь на множестве XML внутри одного архива, открываемых по одному).
|
||||||
|
|
||||||
|
TLS: nalog.gov.ru/file.nalog.ru отдают сертификат НУЦ Минцифры → httpx с default
|
||||||
|
trust store не верифицирует → verify=False. Открытые данные, без auth/PII.
|
||||||
|
Прецеденты: sber_index.py, domrf_kapremont_loader.py.
|
||||||
|
|
||||||
|
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from lxml import etree
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CATALOG_URL_TEMPLATE = "https://www.nalog.gov.ru/opendata/7707329152-{slug}/"
|
||||||
|
DATASET_SLUGS: tuple[str, ...] = ("revexp", "sshr2019", "debtam", "snr")
|
||||||
|
|
||||||
|
DOWNLOAD_TIMEOUT_SEC = 300
|
||||||
|
UPSERT_CHUNK_SIZE = 500
|
||||||
|
|
||||||
|
# Атрибуты-идентификаторы/классификаторы — не показатели, исключаем из харвеста.
|
||||||
|
_INN_ATTRS = ("ИННЮЛ", "ИНН")
|
||||||
|
_NAME_ATTRS = ("НаимОрг", "НаимОрганизации", "НаимОрганизацииПолн", "НаимЮЛПолн")
|
||||||
|
_PERIOD_ATTRS = ("ДатаСост",)
|
||||||
|
# Служебные атрибуты, которые парсятся как числа, но фактом не являются:
|
||||||
|
# идентификаторы, коды и даты документа.
|
||||||
|
_ID_ATTRS_EXCLUDE = frozenset(
|
||||||
|
{
|
||||||
|
"ИННЮЛ",
|
||||||
|
"ИНН",
|
||||||
|
"КПП",
|
||||||
|
"ОКПО",
|
||||||
|
"ОКТМО",
|
||||||
|
"ОКВЭД",
|
||||||
|
"ОГРН",
|
||||||
|
"ИдДок",
|
||||||
|
"ИдФайл",
|
||||||
|
"ДатаДок",
|
||||||
|
"ДатаСост",
|
||||||
|
"ВерсФорм",
|
||||||
|
"ВерсПрог",
|
||||||
|
"КолДок",
|
||||||
|
"ТипИнф",
|
||||||
|
*_NAME_ATTRS,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_RECORD_TAGS = frozenset({"Документ", "Док", "СвЮЛ", "Сведения"})
|
||||||
|
"""Теги, на которых собирается запись. Реальные выгрузки ФНС используют `Документ`;
|
||||||
|
остальные — запас под соседние наборы. Ограничение обязательно: иначе `Файл`
|
||||||
|
переоткрывал бы уже собранные документы и удваивал записи."""
|
||||||
|
|
||||||
|
_ZIP_HREF_RE = re.compile(r'href="([^"]+\.zip)"', re.IGNORECASE)
|
||||||
|
_PUBLISH_DATE_RE = re.compile(r"data-(\d{8})-")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Резолв ссылки со страницы каталога (URL меняется на каждой публикации)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def resolve_dataset_file_url(html: str, *, base_url: str) -> tuple[str, date | None]:
|
||||||
|
"""Ссылка на .zip актуальной версии набора, снятая со страницы каталога.
|
||||||
|
|
||||||
|
Ищет href, оканчивающийся на .zip и несущий токен даты `data-YYYYMMDD-` — эта
|
||||||
|
дата меняется с каждой публикацией (см. docstring модуля), поэтому URL нельзя
|
||||||
|
хардкодить. Если на странице несколько подходящих ссылок (несколько версий
|
||||||
|
структуры/публикаций), берёт с МАКСИМАЛЬНОЙ датой — детерминированно, без сети.
|
||||||
|
|
||||||
|
Возвращает (абсолютный_url, published_on). published_on — None, если ссылка
|
||||||
|
найдена, но токен даты не распарсился (защитный случай, не должен происходить
|
||||||
|
для ссылок, прошедших регэксп даты).
|
||||||
|
|
||||||
|
Raises ValueError, если на странице нет ни одной ссылки вида *data-YYYYMMDD-*.zip.
|
||||||
|
"""
|
||||||
|
candidates: list[tuple[str, date]] = []
|
||||||
|
for m in _ZIP_HREF_RE.finditer(html):
|
||||||
|
href = m.group(1)
|
||||||
|
date_m = _PUBLISH_DATE_RE.search(href)
|
||||||
|
if not date_m:
|
||||||
|
continue
|
||||||
|
token = date_m.group(1)
|
||||||
|
try:
|
||||||
|
published = date(int(token[:4]), int(token[4:6]), int(token[6:8]))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
candidates.append((href, published))
|
||||||
|
if not candidates:
|
||||||
|
raise ValueError(f"каталог {base_url}: не найдено ссылок вида href=*data-YYYYMMDD-*.zip")
|
||||||
|
href, published = max(candidates, key=lambda c: c[1])
|
||||||
|
return urljoin(base_url, href), published
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# In-memory модель факта
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FnsRecord:
|
||||||
|
inn: str
|
||||||
|
dataset: str
|
||||||
|
series: str
|
||||||
|
period: date
|
||||||
|
value: float
|
||||||
|
org_name: str | None
|
||||||
|
|
||||||
|
def to_params(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"inn": self.inn,
|
||||||
|
"dataset": self.dataset,
|
||||||
|
"series": self.series,
|
||||||
|
"period": self.period,
|
||||||
|
"value": self.value,
|
||||||
|
"org_name": self.org_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Стриминговый парсер XML внутри ZIP (lxml iterparse + очистка, без extractall)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def _find_attr(elem: etree._Element, candidates: tuple[str, ...]) -> str | None:
|
||||||
|
for attr in candidates:
|
||||||
|
raw = elem.get(attr)
|
||||||
|
if raw:
|
||||||
|
stripped = raw.strip()
|
||||||
|
if stripped:
|
||||||
|
return stripped
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_numeric(raw: str) -> float | None:
|
||||||
|
stripped = raw.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(stripped.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_attr_deep(elem: etree._Element, candidates: tuple[str, ...]) -> str | None:
|
||||||
|
"""Найти атрибут на самом элементе ИЛИ на любом его потомке.
|
||||||
|
|
||||||
|
В реальных выгрузках ФНС ИНН и показатели лежат на РАЗНЫХ соседних элементах
|
||||||
|
внутри `<Документ>` — см. докстринг `harvest_element`.
|
||||||
|
"""
|
||||||
|
found = _find_attr(elem, candidates)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
for child in elem.iterdescendants():
|
||||||
|
found = _find_attr(child, candidates)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _period_from_element(elem: etree._Element, fallback: date) -> date:
|
||||||
|
"""Отчётная дата документа из `ДатаСост` (ДД.ММ.ГГГГ), иначе — переданная."""
|
||||||
|
raw = _find_attr_deep(elem, _PERIOD_ATTRS)
|
||||||
|
if raw is None:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
day, month, year = (int(part) for part in raw.split("."))
|
||||||
|
return date(year, month, day)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _numeric_attrs(node: etree._Element, seen: set[str]) -> list[tuple[str, float]]:
|
||||||
|
"""Числовые не-служебные атрибуты узла, без повторов по имени серии."""
|
||||||
|
out: list[tuple[str, float]] = []
|
||||||
|
for key, raw in node.attrib.items():
|
||||||
|
if key in _ID_ATTRS_EXCLUDE or key in seen:
|
||||||
|
continue
|
||||||
|
value = _parse_numeric(raw)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append((key, value))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def harvest_element(elem: etree._Element, *, dataset: str, period: date) -> list[FnsRecord]:
|
||||||
|
"""Один XML-элемент → 0..N FnsRecord (один на каждый числовой не-id атрибут).
|
||||||
|
|
||||||
|
Реальная форма выгрузки (проверено на data-20260825 набора revexp, 2118 XML
|
||||||
|
в архиве, 40 002 факта на первых 20 001 организаций)::
|
||||||
|
|
||||||
|
<Документ ИдДок="..." ДатаДок="25.08.2026" ДатаСост="31.12.2025">
|
||||||
|
<СведНП НаимОрг="ООО ..." ИННЮЛ="4205406898"/>
|
||||||
|
<СведДохРасх СумДоход="341864000.00" СумРасход="282224000.00"/>
|
||||||
|
</Документ>
|
||||||
|
|
||||||
|
То есть ИНН живёт на `СведНП`, а показатели — на СОСЕДНЕМ элементе. Поэтому
|
||||||
|
носители ИНН ищутся по всему поддереву, а значения без собственного ИНН
|
||||||
|
(`СведДохРасх` и подобные) привязываются к организации ТОЛЬКО когда носитель в
|
||||||
|
поддереве один — иначе непонятно, чьи это цифры, и мы их не выдумываем.
|
||||||
|
|
||||||
|
Отчётный период берётся из `ДатаСост` документа, если он есть; переданный
|
||||||
|
`period` — запасное значение.
|
||||||
|
|
||||||
|
Чистая функция (без БД/сети). Поддерево без ИНН даёт пустой список.
|
||||||
|
"""
|
||||||
|
nodes = [elem, *elem.iterdescendants()]
|
||||||
|
holders = [n for n in nodes if _find_attr(n, _INN_ATTRS) is not None]
|
||||||
|
if not holders:
|
||||||
|
return []
|
||||||
|
doc_period = _period_from_element(elem, period)
|
||||||
|
shared = [n for n in nodes if n not in holders] if len(holders) == 1 else []
|
||||||
|
|
||||||
|
out: list[FnsRecord] = []
|
||||||
|
for holder in holders:
|
||||||
|
inn = _find_attr(holder, _INN_ATTRS)
|
||||||
|
if inn is None: # pragma: no cover - отфильтровано выше
|
||||||
|
continue
|
||||||
|
org_name = _find_attr(holder, _NAME_ATTRS) or (
|
||||||
|
_find_attr_deep(elem, _NAME_ATTRS) if len(holders) == 1 else None
|
||||||
|
)
|
||||||
|
seen: set[str] = set()
|
||||||
|
for node in (holder, *shared):
|
||||||
|
for series, value in _numeric_attrs(node, seen):
|
||||||
|
out.append(
|
||||||
|
FnsRecord(
|
||||||
|
inn=inn,
|
||||||
|
dataset=dataset,
|
||||||
|
series=series,
|
||||||
|
period=doc_period,
|
||||||
|
value=value,
|
||||||
|
org_name=org_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_xml_records(fh: object, *, dataset: str, period: date) -> Iterator[FnsRecord]:
|
||||||
|
"""Стримит FnsRecord из одного XML-потока `fh` (открытый член ZIP или файл).
|
||||||
|
|
||||||
|
`events=("end",)` + `elem.clear()` + срез предыдущих сиблингов — bounded memory
|
||||||
|
на произвольно большом XML (приём из gar_flats_loader._stream_rows).
|
||||||
|
"""
|
||||||
|
context = etree.iterparse(
|
||||||
|
fh, events=("end",), recover=True, huge_tree=True, resolve_entities=False
|
||||||
|
)
|
||||||
|
for _event, elem in context:
|
||||||
|
if not isinstance(elem.tag, str) or elem.tag not in _RECORD_TAGS:
|
||||||
|
continue
|
||||||
|
yield from harvest_element(elem, dataset=dataset, period=period)
|
||||||
|
# Чистим ТОЛЬКО на границе записи. `end`-события детей приходят раньше
|
||||||
|
# родительского, поэтому безусловный `elem.clear()` на каждом элементе
|
||||||
|
# вычищал `<СведНП>`/`<СведДохРасх>` ДО закрытия `<Документ>` — и парсер
|
||||||
|
# молча извлекал ноль записей из реального дампа.
|
||||||
|
elem.clear()
|
||||||
|
parent = elem.getparent()
|
||||||
|
if parent is not None:
|
||||||
|
while elem.getprevious() is not None:
|
||||||
|
del parent[0]
|
||||||
|
del context
|
||||||
|
|
||||||
|
|
||||||
|
def iter_dataset_records(zip_bytes: bytes, *, dataset: str, period: date) -> Iterator[FnsRecord]:
|
||||||
|
"""Обходит все *.xml внутри `zip_bytes`, элемент за элементом, без extractall.
|
||||||
|
|
||||||
|
Каждый XML-член открывается через `zf.open(name)` как поток (НЕ `zf.read()`
|
||||||
|
целиком, НЕ `zf.extractall()`) — см. docstring модуля § ПАМЯТЬ.
|
||||||
|
Raises ValueError, если в архиве нет ни одного .xml.
|
||||||
|
"""
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||||
|
names = [n for n in zf.namelist() if n.lower().endswith(".xml")]
|
||||||
|
if not names:
|
||||||
|
raise ValueError(f"zip набора {dataset} не содержит .xml записей: {zf.namelist()!r}")
|
||||||
|
for name in names:
|
||||||
|
with zf.open(name) as fh:
|
||||||
|
yield from _iter_xml_records(fh, dataset=dataset, period=period)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# UPSERT фактов + версия набора
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
_UPSERT_FACTS_SQL = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO fns_legal_entity_facts (inn, dataset, series, period, value, org_name, loaded_at)
|
||||||
|
VALUES (
|
||||||
|
CAST(:inn AS text), CAST(:dataset AS text), CAST(:series AS text),
|
||||||
|
CAST(:period AS date), CAST(:value AS numeric), CAST(:org_name AS text), now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (inn, dataset, series, period) DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
org_name = EXCLUDED.org_name,
|
||||||
|
loaded_at = now()
|
||||||
|
WHERE fns_legal_entity_facts.value IS DISTINCT FROM EXCLUDED.value
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_SELECT_VERSION_SQL = text(
|
||||||
|
"SELECT file_url, published_on FROM fns_dataset_versions WHERE dataset = CAST(:dataset AS text)"
|
||||||
|
)
|
||||||
|
|
||||||
|
_UPSERT_VERSION_SQL = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO fns_dataset_versions (dataset, file_url, published_on, loaded_at)
|
||||||
|
VALUES (CAST(:dataset AS text), CAST(:file_url AS text), CAST(:published_on AS date), now())
|
||||||
|
ON CONFLICT (dataset) DO UPDATE SET
|
||||||
|
file_url = EXCLUDED.file_url,
|
||||||
|
published_on = EXCLUDED.published_on,
|
||||||
|
loaded_at = now()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _chunks(items: list[FnsRecord], size: int) -> Iterator[list[FnsRecord]]:
|
||||||
|
for i in range(0, len(items), size):
|
||||||
|
yield items[i : i + size]
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_records(
|
||||||
|
db: Session, records: list[FnsRecord], *, chunk_size: int = UPSERT_CHUNK_SIZE
|
||||||
|
) -> int:
|
||||||
|
"""Батчевый UPSERT в fns_legal_entity_facts. НЕ коммитит (коммитит caller).
|
||||||
|
|
||||||
|
SAVEPOINT на каждый батч — сбойный батч откатывается изолированно, остальные
|
||||||
|
доезжают (тот же приём, что и gar_flats_loader.upsert_gar_houses).
|
||||||
|
"""
|
||||||
|
upserted = 0
|
||||||
|
failed_batches = 0
|
||||||
|
for batch in _chunks(records, chunk_size):
|
||||||
|
params = [r.to_params() for r in batch]
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
db.execute(_UPSERT_FACTS_SQL, params)
|
||||||
|
upserted += len(batch)
|
||||||
|
except Exception:
|
||||||
|
failed_batches += 1
|
||||||
|
logger.warning(
|
||||||
|
"fns_opendata upsert: батч из %d строк сбойнул (пропущен)",
|
||||||
|
len(batch),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
if failed_batches:
|
||||||
|
logger.warning("fns_opendata upsert: сбойных батчей=%d", failed_batches)
|
||||||
|
return upserted
|
||||||
|
|
||||||
|
|
||||||
|
def get_loaded_version(db: Session, dataset: str) -> tuple[str, date | None] | None:
|
||||||
|
"""Уже загруженная версия набора (file_url, published_on) или None."""
|
||||||
|
row = db.execute(_SELECT_VERSION_SQL, {"dataset": dataset}).first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return row[0], row[1]
|
||||||
|
|
||||||
|
|
||||||
|
def record_dataset_version(
|
||||||
|
db: Session, dataset: str, file_url: str, published_on: date | None
|
||||||
|
) -> None:
|
||||||
|
"""UPSERT версии набора. НЕ коммитит (коммитит caller)."""
|
||||||
|
db.execute(
|
||||||
|
_UPSERT_VERSION_SQL,
|
||||||
|
{"dataset": dataset, "file_url": file_url, "published_on": published_on},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Orchestration
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def _download(url: str, *, client: httpx.Client) -> bytes:
|
||||||
|
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
def load_dataset(
|
||||||
|
db: Session,
|
||||||
|
slug: str,
|
||||||
|
*,
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
force: bool = False,
|
||||||
|
chunk_size: int = UPSERT_CHUNK_SIZE,
|
||||||
|
) -> dict[str, int | str]:
|
||||||
|
"""Резолвит актуальную ссылку набора `slug` → скачивает .zip → парсит → UPSERT.
|
||||||
|
|
||||||
|
Пропускает скачивание, если `fns_dataset_versions` уже содержит ТУ ЖЕ ссылку
|
||||||
|
(force=True форсирует перекачку). dry_run — резолв ссылки происходит (проверяем
|
||||||
|
каталог доступен), скачивание/парс/запись — нет. `client` — для тестов
|
||||||
|
(httpx.MockTransport); если не передан, открывается и закрывается свой.
|
||||||
|
НЕ коммитит — коммитит caller.
|
||||||
|
"""
|
||||||
|
if slug not in DATASET_SLUGS:
|
||||||
|
raise ValueError(f"неизвестный slug набора: {slug!r}, ожидались {DATASET_SLUGS}")
|
||||||
|
|
||||||
|
owns_client = client is None
|
||||||
|
if client is None:
|
||||||
|
# TLS: см. docstring модуля § TLS — verify=False, открытые данные без auth/PII.
|
||||||
|
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
catalog_url = CATALOG_URL_TEMPLATE.format(slug=slug)
|
||||||
|
resp = client.get(catalog_url, timeout=DOWNLOAD_TIMEOUT_SEC)
|
||||||
|
resp.raise_for_status()
|
||||||
|
file_url, published_on = resolve_dataset_file_url(resp.text, base_url=catalog_url)
|
||||||
|
|
||||||
|
existing = get_loaded_version(db, slug)
|
||||||
|
if not force and existing is not None and existing[0] == file_url:
|
||||||
|
logger.info("fns_opendata %s: версия %s уже загружена, skip", slug, file_url)
|
||||||
|
return {"dataset": slug, "skipped": 1, "records": 0, "upserted": 0}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info(
|
||||||
|
"fns_opendata %s: dry_run — резолвлен %s (published_on=%s), скачивание пропущено",
|
||||||
|
slug,
|
||||||
|
file_url,
|
||||||
|
published_on,
|
||||||
|
)
|
||||||
|
return {"dataset": slug, "skipped": 0, "records": 0, "upserted": 0}
|
||||||
|
|
||||||
|
zip_bytes = _download(file_url, client=client)
|
||||||
|
period = published_on or date.today()
|
||||||
|
records = list(iter_dataset_records(zip_bytes, dataset=slug, period=period))
|
||||||
|
upserted = upsert_records(db, records, chunk_size=chunk_size)
|
||||||
|
record_dataset_version(db, slug, file_url, published_on)
|
||||||
|
|
||||||
|
result: dict[str, int | str] = {
|
||||||
|
"dataset": slug,
|
||||||
|
"skipped": 0,
|
||||||
|
"records": len(records),
|
||||||
|
"upserted": upserted,
|
||||||
|
}
|
||||||
|
logger.info("fns_opendata load %s DONE (dry_run=%s): %s", slug, dry_run, result)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
if owns_client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def load_datasets(
|
||||||
|
db: Session,
|
||||||
|
slugs: tuple[str, ...] = DATASET_SLUGS,
|
||||||
|
*,
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict[str, dict[str, int | str]]:
|
||||||
|
"""load_dataset для каждого slug из `slugs`. НЕ коммитит между наборами (caller)."""
|
||||||
|
return {
|
||||||
|
slug: load_dataset(db, slug, client=client, dry_run=dry_run, force=force) for slug in slugs
|
||||||
|
}
|
||||||
688
tradein-mvp/backend/app/services/frt_mkd_loader.py
Normal file
688
tradein-mvp/backend/app/services/frt_mkd_loader.py
Normal file
|
|
@ -0,0 +1,688 @@
|
||||||
|
"""АИС ППК «ФРТ» (бывш. Реформа ЖКХ) loader: houses.area_land/foundation_type/
|
||||||
|
elevators_total + добор year_built/material_walls/total_floors/entrances/is_emergency/
|
||||||
|
flat_count/heat_supply_type/gas_supply_type/hot_water (issue #frt-mkd).
|
||||||
|
|
||||||
|
CONTEXT: houses.area_land и houses.foundation_type отсутствовали вовсе; elevators_total —
|
||||||
|
новая колонка (в источнике — ОБЩЕЕ число лифтов, houses раздельно хранит только
|
||||||
|
passenger_elevators/cargo_elevators, поэтому мапить в них нельзя — потеряли бы тип).
|
||||||
|
Остальные поля (year_built, material_walls, ...) уже существуют и заполнены частично
|
||||||
|
другими источниками (ДОМ.РФ капремонт, ГИС-ЖКХ) — этот loader их ДОБИРАЕТ через
|
||||||
|
COALESCE (только NULL), никогда не перезаписывает.
|
||||||
|
|
||||||
|
ИСТОЧНИК: АИС ППК ФРТ open data, https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/{node_id}
|
||||||
|
node 110 = реестр МКД региона 66 (Свердловская обл.) — проверено живьём 08.09.2026:
|
||||||
|
HTTP 200, application/octet-stream, zip → один CSV `export-reestrmkd-66-*.csv`,
|
||||||
|
UTF-8 BOM, разделитель ';', 60 колонок, ~41.8k строк по СО.
|
||||||
|
|
||||||
|
robots.txt источника запрещает /opendata/export/ и требует Crawl-delay 10. Один прогон
|
||||||
|
этого loader'а делает РОВНО ОДИН GET (один node_id) — задержка не нужна. Если когда-нибудь
|
||||||
|
понадобится тянуть несколько node_id за один запуск (напр. node 427 — аварийный фонд РФ,
|
||||||
|
node 1 — реестр УО РФ, оба вне скоупа этого PR) — между запросами обязательна пауза
|
||||||
|
CRAWL_DELAY_SEC (константа ниже), иначе нарушаем Crawl-delay из robots.txt.
|
||||||
|
|
||||||
|
ЧТО НЕ ДЕЛАЕМ (осознанно):
|
||||||
|
* project_type НЕ мапим в houses.series_name — свободный текст, фактический дубль
|
||||||
|
материала стен (замер: пусто 10635, «кирпичный» 1754, «нет данных» 1496,
|
||||||
|
«панельный» 1012, «Блочный»/«блочный» двумя разными строками). Как серию
|
||||||
|
использовать нельзя.
|
||||||
|
* energy_efficiency НЕ добавляем в houses — миграция 284 уже приняла это решение;
|
||||||
|
реальный класс присвоен лишь ~10% домов (у большинства «Не присвоен»).
|
||||||
|
* elevators_count НЕ мапим в houses.passenger_elevators — источник отдаёт общее
|
||||||
|
число лифтов без разбивки, а houses хранит passenger/cargo раздельно. Пишем в
|
||||||
|
отдельную houses.elevators_total.
|
||||||
|
* playground/sportsground — id справочника (498/499/500), не булев — в staging как
|
||||||
|
есть, houses.has_playground не трогаем.
|
||||||
|
* estimator.py не трогаем — встраивание признаков дома в подбор аналогов вне скоупа.
|
||||||
|
|
||||||
|
МУСОРНЫЕ ЗНАЧЕНИЯ источника (встречаются как обычные строки текстовых полей):
|
||||||
|
'', 'нет данных', 'Не заполнено', 'отсутствует', 'данные отсутствуют', 'нет'.
|
||||||
|
Единый хелпер `clean_text()` отфильтровывает их при парсе КАЖДОГО текстового поля.
|
||||||
|
|
||||||
|
TLS: домен xn--80adsazqn.xn--p1aee.xn--p1ai отдаёт RU-сертификат НУЦ Минцифры, не
|
||||||
|
верифицируемый дефолтным trust store'ом httpx — та же ситуация, что sber_index.py и
|
||||||
|
domrf_kapremont_loader.py. Открытые данные без auth/PII — verify=False приемлем.
|
||||||
|
|
||||||
|
psycopg v3: SQL через `text(...)` использует CAST(:x AS type), НИКОГДА :x::type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass, fields
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Константы
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
BASE_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai"
|
||||||
|
DEFAULT_NODE_ID = 110 # реестр МКД региона 66 — проверено живьём 08.09.2026
|
||||||
|
DEFAULT_REGION_CODE = 66
|
||||||
|
|
||||||
|
DOWNLOAD_TIMEOUT_SEC = 180
|
||||||
|
UPSERT_CHUNK_SIZE = 500
|
||||||
|
|
||||||
|
# robots.txt источника: Crawl-delay 10. Используется только если когда-нибудь понадобится
|
||||||
|
# тянуть несколько node_id за один запуск (сейчас — ровно один GET на прогон).
|
||||||
|
CRAWL_DELAY_SEC = 10
|
||||||
|
|
||||||
|
# Мусорные значения текстовых полей источника — не данные, а «пусто» в других обёртках.
|
||||||
|
_NOISE_VALUES = frozenset(
|
||||||
|
{"", "нет данных", "не заполнено", "отсутствует", "данные отсутствуют", "нет"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Правдоподобные границы, используются и на парсе (staging), и повторно в backfill SQL.
|
||||||
|
YEAR_BUILT_MIN = 1850
|
||||||
|
YEAR_BUILT_FUTURE_SLACK = 2
|
||||||
|
FLOOR_COUNT_MAX_BOUND = 100
|
||||||
|
ENTRANCE_COUNT_MAX_BOUND = 50
|
||||||
|
ELEVATORS_COUNT_MAX_BOUND = 50
|
||||||
|
FLAT_COUNT_MAX_BOUND = 3000
|
||||||
|
AREA_LAND_MAX_BOUND = 500_000 # м2, с большим запасом для дворовых территорий МКД
|
||||||
|
|
||||||
|
|
||||||
|
def plausible_year_max() -> int:
|
||||||
|
"""Верхняя граница правдоподобного года постройки (текущий год + slack)."""
|
||||||
|
return date.today().year + YEAR_BUILT_FUTURE_SLACK
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def clean_text(raw: str | None) -> str | None:
|
||||||
|
"""Строка источника → строка|None, мусорные значения-заглушки схлопываются в None."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
s = raw.strip()
|
||||||
|
if not s or s.lower() in _NOISE_VALUES:
|
||||||
|
return None
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def parse_decimal_comma(raw: str | None) -> float | None:
|
||||||
|
"""«930,60» / «930.60» / «» / мусор → float|None. Запятая — decimal separator АИС ФРТ."""
|
||||||
|
s = clean_text(raw)
|
||||||
|
if s is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(s.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_int_field(
|
||||||
|
raw: str | None, *, min_value: int | None = None, max_value: int | None = None
|
||||||
|
) -> int | None:
|
||||||
|
"""Устойчивый str → int|None с опциональным sanity-гейтом [min_value, max_value]."""
|
||||||
|
s = clean_text(raw)
|
||||||
|
if s is None:
|
||||||
|
return None
|
||||||
|
value: int | None = None
|
||||||
|
try:
|
||||||
|
value = int(s)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
value = int(float(s.replace(",", ".")))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if min_value is not None and value < min_value:
|
||||||
|
return None
|
||||||
|
if max_value is not None and value > max_value:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bool_da_net(raw: str | None) -> bool | None:
|
||||||
|
"""«Да»/«Нет» (регистронезависимо) → bool|None. Пусто/иное → None.
|
||||||
|
|
||||||
|
НЕ идёт через clean_text() — «нет» само по себе входит в _NOISE_VALUES (заглушка
|
||||||
|
текстовых полей типа «отсутствует»/«нет»), и там это совпадение уместно. Но для
|
||||||
|
is_alarm «Нет» — валидный содержательный ответ («не аварийный»), а не отсутствие
|
||||||
|
данных, поэтому здесь разбираем сырую строку напрямую.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
s = raw.strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
low = s.lower()
|
||||||
|
if low == "да":
|
||||||
|
return True
|
||||||
|
if low == "нет":
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FrtMkdRow:
|
||||||
|
"""Одна строка реестра МКД АИС ФРТ (house-per-row), готова к UPSERT в staging."""
|
||||||
|
|
||||||
|
houseguid: str
|
||||||
|
region_code: int
|
||||||
|
address: str | None
|
||||||
|
built_year: int | None
|
||||||
|
exploitation_start_year: int | None
|
||||||
|
project_type: str | None
|
||||||
|
house_type: str | None
|
||||||
|
is_alarm: bool | None
|
||||||
|
floor_count_max: int | None
|
||||||
|
floor_count_min: int | None
|
||||||
|
entrance_count: int | None
|
||||||
|
elevators_count: int | None
|
||||||
|
energy_efficiency: str | None
|
||||||
|
quarters_count: int | None
|
||||||
|
living_quarters_count: int | None
|
||||||
|
unliving_quarters_count: int | None
|
||||||
|
area_total: float | None
|
||||||
|
area_residential: float | None
|
||||||
|
area_non_residential: float | None
|
||||||
|
area_common_property: float | None
|
||||||
|
area_land: float | None
|
||||||
|
parking_square: float | None
|
||||||
|
playground: int | None
|
||||||
|
sportsground: int | None
|
||||||
|
other_beautification: str | None
|
||||||
|
foundation_type: str | None
|
||||||
|
floor_type: str | None
|
||||||
|
wall_material: str | None
|
||||||
|
basement_area: float | None
|
||||||
|
chute_type: str | None
|
||||||
|
chute_count: int | None
|
||||||
|
heating_type: str | None
|
||||||
|
hot_water_type: str | None
|
||||||
|
cold_water_type: str | None
|
||||||
|
sewerage_type: str | None
|
||||||
|
gas_type: str | None
|
||||||
|
ventilation_type: str | None
|
||||||
|
firefighting_type: str | None
|
||||||
|
drainage_type: str | None
|
||||||
|
management_organization_id: int | None
|
||||||
|
method_of_forming_overhaul_fund: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frt_mkd_csv(path: str | Path, *, region_code: int) -> dict[str, FrtMkdRow]:
|
||||||
|
"""CSV реестра МКД АИС ФРТ → dict по houseguid. Строки без houseguid пропускаются.
|
||||||
|
|
||||||
|
Дубликаты houseguid в источнике РЕАЛЬНЫ и взаимодополняющи: в выгрузке региона 66
|
||||||
|
за 2026-09-01 из 41 790 строк 912 guid'ов повторяются (968 лишних строк), причём у
|
||||||
|
одной строки пары заполнен, например, `area_total`, а у другой — нет. Поэтому
|
||||||
|
«последняя побеждает» терять нельзя: дубликаты СЛИВАЮТСЯ по полям, побеждает первое
|
||||||
|
непустое значение (`_merge_rows`). Иначе бэкфилл недосчитывался бы заполненных полей
|
||||||
|
примерно у 2 % домов области.
|
||||||
|
"""
|
||||||
|
rows: dict[str, FrtMkdRow] = {}
|
||||||
|
with open(path, encoding="utf-8-sig", newline="") as f:
|
||||||
|
reader = csv.DictReader(f, delimiter=";")
|
||||||
|
for raw in reader:
|
||||||
|
houseguid = clean_text(raw.get("houseguid"))
|
||||||
|
if not houseguid:
|
||||||
|
continue
|
||||||
|
parsed = FrtMkdRow(
|
||||||
|
houseguid=houseguid,
|
||||||
|
region_code=region_code,
|
||||||
|
address=clean_text(raw.get("address")),
|
||||||
|
built_year=parse_int_field(
|
||||||
|
raw.get("built_year"), min_value=YEAR_BUILT_MIN, max_value=plausible_year_max()
|
||||||
|
),
|
||||||
|
exploitation_start_year=parse_int_field(
|
||||||
|
raw.get("exploitation_start_year"),
|
||||||
|
min_value=YEAR_BUILT_MIN,
|
||||||
|
max_value=plausible_year_max(),
|
||||||
|
),
|
||||||
|
project_type=clean_text(raw.get("project_type")),
|
||||||
|
house_type=clean_text(raw.get("house_type")),
|
||||||
|
is_alarm=parse_bool_da_net(raw.get("is_alarm")),
|
||||||
|
floor_count_max=parse_int_field(
|
||||||
|
raw.get("floor_count_max"), min_value=1, max_value=FLOOR_COUNT_MAX_BOUND
|
||||||
|
),
|
||||||
|
floor_count_min=parse_int_field(
|
||||||
|
raw.get("floor_count_min"), min_value=1, max_value=FLOOR_COUNT_MAX_BOUND
|
||||||
|
),
|
||||||
|
entrance_count=parse_int_field(
|
||||||
|
raw.get("entrance_count"), min_value=1, max_value=ENTRANCE_COUNT_MAX_BOUND
|
||||||
|
),
|
||||||
|
elevators_count=parse_int_field(
|
||||||
|
raw.get("elevators_count"), min_value=0, max_value=ELEVATORS_COUNT_MAX_BOUND
|
||||||
|
),
|
||||||
|
energy_efficiency=clean_text(raw.get("energy_efficiency")),
|
||||||
|
quarters_count=parse_int_field(raw.get("quarters_count"), min_value=0),
|
||||||
|
living_quarters_count=parse_int_field(
|
||||||
|
raw.get("living_quarters_count"), min_value=0, max_value=FLAT_COUNT_MAX_BOUND
|
||||||
|
),
|
||||||
|
unliving_quarters_count=parse_int_field(
|
||||||
|
raw.get("unliving_quarters_count"), min_value=0
|
||||||
|
),
|
||||||
|
area_total=parse_decimal_comma(raw.get("area_total")),
|
||||||
|
area_residential=parse_decimal_comma(raw.get("area_residential")),
|
||||||
|
area_non_residential=parse_decimal_comma(raw.get("area_non_residential")),
|
||||||
|
area_common_property=parse_decimal_comma(raw.get("area_common_property")),
|
||||||
|
area_land=parse_decimal_comma(raw.get("area_land")),
|
||||||
|
parking_square=parse_decimal_comma(raw.get("parking_square")),
|
||||||
|
# playground/sportsground — id справочника благоустройства, не булев флаг.
|
||||||
|
playground=parse_int_field(raw.get("playground"), min_value=0),
|
||||||
|
sportsground=parse_int_field(raw.get("sportsground"), min_value=0),
|
||||||
|
other_beautification=clean_text(raw.get("other_beautification")),
|
||||||
|
foundation_type=clean_text(raw.get("foundation_type")),
|
||||||
|
floor_type=clean_text(raw.get("floor_type")),
|
||||||
|
wall_material=clean_text(raw.get("wall_material")),
|
||||||
|
basement_area=parse_decimal_comma(raw.get("basement_area")),
|
||||||
|
chute_type=clean_text(raw.get("chute_type")),
|
||||||
|
chute_count=parse_int_field(raw.get("chute_count"), min_value=0),
|
||||||
|
heating_type=clean_text(raw.get("heating_type")),
|
||||||
|
hot_water_type=clean_text(raw.get("hot_water_type")),
|
||||||
|
cold_water_type=clean_text(raw.get("cold_water_type")),
|
||||||
|
sewerage_type=clean_text(raw.get("sewerage_type")),
|
||||||
|
gas_type=clean_text(raw.get("gas_type")),
|
||||||
|
ventilation_type=clean_text(raw.get("ventilation_type")),
|
||||||
|
firefighting_type=clean_text(raw.get("firefighting_type")),
|
||||||
|
drainage_type=clean_text(raw.get("drainage_type")),
|
||||||
|
management_organization_id=parse_int_field(
|
||||||
|
raw.get("management_organization_id"), min_value=0
|
||||||
|
),
|
||||||
|
method_of_forming_overhaul_fund=clean_text(
|
||||||
|
raw.get("method_of_forming_overhaul_fund")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
prev = rows.get(houseguid)
|
||||||
|
rows[houseguid] = _merge_rows(prev, parsed) if prev is not None else parsed
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_rows(prev: FrtMkdRow, new: FrtMkdRow) -> FrtMkdRow:
|
||||||
|
"""Слить дубликат houseguid: для каждого поля берём первое непустое значение.
|
||||||
|
|
||||||
|
Источник отдаёт повторы одного дома разными строками с частично заполненными
|
||||||
|
полями (см. `parse_frt_mkd_csv`). Побеждает уже накопленное значение; новое
|
||||||
|
подставляется только туда, где накопленного нет.
|
||||||
|
"""
|
||||||
|
merged: dict[str, object] = {}
|
||||||
|
for field in fields(FrtMkdRow):
|
||||||
|
current = getattr(prev, field.name)
|
||||||
|
merged[field.name] = current if current is not None else getattr(new, field.name)
|
||||||
|
return FrtMkdRow(**merged) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# HTTP: скачивание zip + извлечение CSV
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
def _download_zip(url: str, *, client: httpx.Client) -> bytes:
|
||||||
|
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_csv_from_zip(data: bytes, dest_dir: Path) -> Path:
|
||||||
|
"""Распаковывает первый *.csv из zip-байтов в dest_dir, возвращает путь."""
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
csv_names = [n for n in zf.namelist() if n.lower().endswith(".csv")]
|
||||||
|
if not csv_names:
|
||||||
|
raise ValueError(f"zip не содержит .csv записей: {zf.namelist()!r}")
|
||||||
|
extracted = zf.extract(csv_names[0], dest_dir)
|
||||||
|
return Path(extracted)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_frt_mkd_csv(dest_dir: Path, *, node_id: int, client: httpx.Client) -> Path:
|
||||||
|
"""Скачивает zip export/{node_id}, распаковывает CSV в dest_dir, возвращает путь."""
|
||||||
|
url = f"{BASE_URL}/opendata/export/{node_id}"
|
||||||
|
return _extract_csv_from_zip(_download_zip(url, client=client), dest_dir)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# UPSERT staging (frt_mkd, мигр. 290)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
_UPSERT_SQL = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO frt_mkd (
|
||||||
|
houseguid, region_code, address, built_year, exploitation_start_year,
|
||||||
|
project_type, house_type, is_alarm, floor_count_max, floor_count_min,
|
||||||
|
entrance_count, elevators_count, energy_efficiency, quarters_count,
|
||||||
|
living_quarters_count, unliving_quarters_count, area_total, area_residential,
|
||||||
|
area_non_residential, area_common_property, area_land, parking_square,
|
||||||
|
playground, sportsground, other_beautification, foundation_type, floor_type,
|
||||||
|
wall_material, basement_area, chute_type, chute_count, heating_type,
|
||||||
|
hot_water_type, cold_water_type, sewerage_type, gas_type, ventilation_type,
|
||||||
|
firefighting_type, drainage_type, management_organization_id,
|
||||||
|
method_of_forming_overhaul_fund, loaded_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
CAST(:houseguid AS text), CAST(:region_code AS smallint), CAST(:address AS text),
|
||||||
|
CAST(:built_year AS smallint), CAST(:exploitation_start_year AS smallint),
|
||||||
|
CAST(:project_type AS text), CAST(:house_type AS text), CAST(:is_alarm AS boolean),
|
||||||
|
CAST(:floor_count_max AS smallint), CAST(:floor_count_min AS smallint),
|
||||||
|
CAST(:entrance_count AS smallint), CAST(:elevators_count AS smallint),
|
||||||
|
CAST(:energy_efficiency AS text), CAST(:quarters_count AS int),
|
||||||
|
CAST(:living_quarters_count AS int), CAST(:unliving_quarters_count AS int),
|
||||||
|
CAST(:area_total AS numeric), CAST(:area_residential AS numeric),
|
||||||
|
CAST(:area_non_residential AS numeric), CAST(:area_common_property AS numeric),
|
||||||
|
CAST(:area_land AS numeric), CAST(:parking_square AS numeric),
|
||||||
|
CAST(:playground AS int), CAST(:sportsground AS int),
|
||||||
|
CAST(:other_beautification AS text), CAST(:foundation_type AS text),
|
||||||
|
CAST(:floor_type AS text), CAST(:wall_material AS text),
|
||||||
|
CAST(:basement_area AS numeric), CAST(:chute_type AS text),
|
||||||
|
CAST(:chute_count AS smallint), CAST(:heating_type AS text),
|
||||||
|
CAST(:hot_water_type AS text), CAST(:cold_water_type AS text),
|
||||||
|
CAST(:sewerage_type AS text), CAST(:gas_type AS text),
|
||||||
|
CAST(:ventilation_type AS text), CAST(:firefighting_type AS text),
|
||||||
|
CAST(:drainage_type AS text), CAST(:management_organization_id AS bigint),
|
||||||
|
CAST(:method_of_forming_overhaul_fund AS text), now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (houseguid) DO UPDATE SET
|
||||||
|
region_code = EXCLUDED.region_code,
|
||||||
|
address = EXCLUDED.address,
|
||||||
|
built_year = EXCLUDED.built_year,
|
||||||
|
exploitation_start_year = EXCLUDED.exploitation_start_year,
|
||||||
|
project_type = EXCLUDED.project_type,
|
||||||
|
house_type = EXCLUDED.house_type,
|
||||||
|
is_alarm = EXCLUDED.is_alarm,
|
||||||
|
floor_count_max = EXCLUDED.floor_count_max,
|
||||||
|
floor_count_min = EXCLUDED.floor_count_min,
|
||||||
|
entrance_count = EXCLUDED.entrance_count,
|
||||||
|
elevators_count = EXCLUDED.elevators_count,
|
||||||
|
energy_efficiency = EXCLUDED.energy_efficiency,
|
||||||
|
quarters_count = EXCLUDED.quarters_count,
|
||||||
|
living_quarters_count = EXCLUDED.living_quarters_count,
|
||||||
|
unliving_quarters_count = EXCLUDED.unliving_quarters_count,
|
||||||
|
area_total = EXCLUDED.area_total,
|
||||||
|
area_residential = EXCLUDED.area_residential,
|
||||||
|
area_non_residential = EXCLUDED.area_non_residential,
|
||||||
|
area_common_property = EXCLUDED.area_common_property,
|
||||||
|
area_land = EXCLUDED.area_land,
|
||||||
|
parking_square = EXCLUDED.parking_square,
|
||||||
|
playground = EXCLUDED.playground,
|
||||||
|
sportsground = EXCLUDED.sportsground,
|
||||||
|
other_beautification = EXCLUDED.other_beautification,
|
||||||
|
foundation_type = EXCLUDED.foundation_type,
|
||||||
|
floor_type = EXCLUDED.floor_type,
|
||||||
|
wall_material = EXCLUDED.wall_material,
|
||||||
|
basement_area = EXCLUDED.basement_area,
|
||||||
|
chute_type = EXCLUDED.chute_type,
|
||||||
|
chute_count = EXCLUDED.chute_count,
|
||||||
|
heating_type = EXCLUDED.heating_type,
|
||||||
|
hot_water_type = EXCLUDED.hot_water_type,
|
||||||
|
cold_water_type = EXCLUDED.cold_water_type,
|
||||||
|
sewerage_type = EXCLUDED.sewerage_type,
|
||||||
|
gas_type = EXCLUDED.gas_type,
|
||||||
|
ventilation_type = EXCLUDED.ventilation_type,
|
||||||
|
firefighting_type = EXCLUDED.firefighting_type,
|
||||||
|
drainage_type = EXCLUDED.drainage_type,
|
||||||
|
management_organization_id = EXCLUDED.management_organization_id,
|
||||||
|
method_of_forming_overhaul_fund = EXCLUDED.method_of_forming_overhaul_fund,
|
||||||
|
loaded_at = now()
|
||||||
|
WHERE frt_mkd.address IS DISTINCT FROM EXCLUDED.address
|
||||||
|
OR frt_mkd.built_year IS DISTINCT FROM EXCLUDED.built_year
|
||||||
|
OR frt_mkd.is_alarm IS DISTINCT FROM EXCLUDED.is_alarm
|
||||||
|
OR frt_mkd.wall_material IS DISTINCT FROM EXCLUDED.wall_material
|
||||||
|
OR frt_mkd.foundation_type IS DISTINCT FROM EXCLUDED.foundation_type
|
||||||
|
OR frt_mkd.area_land IS DISTINCT FROM EXCLUDED.area_land
|
||||||
|
OR frt_mkd.elevators_count IS DISTINCT FROM EXCLUDED.elevators_count
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_rows(items: list[FrtMkdRow], size: int) -> Iterator[list[FrtMkdRow]]:
|
||||||
|
for i in range(0, len(items), size):
|
||||||
|
yield items[i : i + size]
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_frt_mkd(
|
||||||
|
db: Session, rows: list[FrtMkdRow], *, chunk_size: int = UPSERT_CHUNK_SIZE
|
||||||
|
) -> int:
|
||||||
|
"""UPSERT списка FrtMkdRow в frt_mkd, чанками по SAVEPOINT. Не коммитит (caller).
|
||||||
|
|
||||||
|
Идемпотентно (IS DISTINCT FROM gate на ключевых полях в _UPSERT_SQL). Сбойный чанк
|
||||||
|
откатывается изолированно (SAVEPOINT-паттерн domrf_kapremont_loader/zhkh_flats_loader).
|
||||||
|
"""
|
||||||
|
upserted = 0
|
||||||
|
for chunk in _chunk_rows(rows, chunk_size):
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
for r in chunk:
|
||||||
|
res = db.execute(
|
||||||
|
_UPSERT_SQL,
|
||||||
|
{
|
||||||
|
"houseguid": r.houseguid,
|
||||||
|
"region_code": r.region_code,
|
||||||
|
"address": r.address,
|
||||||
|
"built_year": r.built_year,
|
||||||
|
"exploitation_start_year": r.exploitation_start_year,
|
||||||
|
"project_type": r.project_type,
|
||||||
|
"house_type": r.house_type,
|
||||||
|
"is_alarm": r.is_alarm,
|
||||||
|
"floor_count_max": r.floor_count_max,
|
||||||
|
"floor_count_min": r.floor_count_min,
|
||||||
|
"entrance_count": r.entrance_count,
|
||||||
|
"elevators_count": r.elevators_count,
|
||||||
|
"energy_efficiency": r.energy_efficiency,
|
||||||
|
"quarters_count": r.quarters_count,
|
||||||
|
"living_quarters_count": r.living_quarters_count,
|
||||||
|
"unliving_quarters_count": r.unliving_quarters_count,
|
||||||
|
"area_total": r.area_total,
|
||||||
|
"area_residential": r.area_residential,
|
||||||
|
"area_non_residential": r.area_non_residential,
|
||||||
|
"area_common_property": r.area_common_property,
|
||||||
|
"area_land": r.area_land,
|
||||||
|
"parking_square": r.parking_square,
|
||||||
|
"playground": r.playground,
|
||||||
|
"sportsground": r.sportsground,
|
||||||
|
"other_beautification": r.other_beautification,
|
||||||
|
"foundation_type": r.foundation_type,
|
||||||
|
"floor_type": r.floor_type,
|
||||||
|
"wall_material": r.wall_material,
|
||||||
|
"basement_area": r.basement_area,
|
||||||
|
"chute_type": r.chute_type,
|
||||||
|
"chute_count": r.chute_count,
|
||||||
|
"heating_type": r.heating_type,
|
||||||
|
"hot_water_type": r.hot_water_type,
|
||||||
|
"cold_water_type": r.cold_water_type,
|
||||||
|
"sewerage_type": r.sewerage_type,
|
||||||
|
"gas_type": r.gas_type,
|
||||||
|
"ventilation_type": r.ventilation_type,
|
||||||
|
"firefighting_type": r.firefighting_type,
|
||||||
|
"drainage_type": r.drainage_type,
|
||||||
|
"management_organization_id": r.management_organization_id,
|
||||||
|
"method_of_forming_overhaul_fund": r.method_of_forming_overhaul_fund,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
upserted += res.rowcount
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"frt_mkd upsert: чанк из %d строк сбойнул (откат savepoint)",
|
||||||
|
len(chunk),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return upserted
|
||||||
|
|
||||||
|
|
||||||
|
def load_frt_mkd(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
src_path: str | Path | None = None,
|
||||||
|
work_dir: str | Path | None = None,
|
||||||
|
node_id: int = DEFAULT_NODE_ID,
|
||||||
|
region_code: int = DEFAULT_REGION_CODE,
|
||||||
|
chunk_size: int = UPSERT_CHUNK_SIZE,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Скачивает (если src_path не задан) реестр МКД node_id, парсит, UPSERT в frt_mkd.
|
||||||
|
|
||||||
|
src_path — локальный CSV (пропустить скачивание; тесты/ops-дебаг). work_dir — куда
|
||||||
|
распаковывать скачанный zip (по умолчанию — временный каталог, удаляется после).
|
||||||
|
dry_run — парс происходит, но НИ ОДНОЙ записи в БД не делается. Не коммитит (caller).
|
||||||
|
"""
|
||||||
|
tmp_ctx: tempfile.TemporaryDirectory[str] | None = None
|
||||||
|
if src_path is None:
|
||||||
|
if work_dir is not None:
|
||||||
|
dest = Path(work_dir)
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
tmp_ctx = tempfile.TemporaryDirectory()
|
||||||
|
dest = Path(tmp_ctx.name)
|
||||||
|
# verify=False: источник отдаёт RU-сертификат НУЦ Минцифры, не верифицируемый
|
||||||
|
# дефолтным trust store'ом (та же ситуация, что sber_index.py и
|
||||||
|
# domrf_kapremont_loader.py). Открытые данные без auth/PII — приемлемо.
|
||||||
|
with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False) as client:
|
||||||
|
src_path = fetch_frt_mkd_csv(dest, node_id=node_id, client=client)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows_by_guid = parse_frt_mkd_csv(src_path, region_code=region_code)
|
||||||
|
rows = list(rows_by_guid.values())
|
||||||
|
upserted = 0 if dry_run else upsert_frt_mkd(db, rows, chunk_size=chunk_size)
|
||||||
|
finally:
|
||||||
|
if tmp_ctx is not None:
|
||||||
|
tmp_ctx.cleanup()
|
||||||
|
|
||||||
|
result = {"rows": len(rows), "upserted": upserted}
|
||||||
|
logger.info("frt_mkd load DONE (dry_run=%s): %s", dry_run, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Backfill houses (COALESCE-семантика — только NULL-поля)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Матч: s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid) —
|
||||||
|
# тот же приоритетный COALESCE-джойн, что domrf_kapremont_loader/zhkh_flats_loader.
|
||||||
|
# Каждое поле СВОИМ гейтом "h.col IS NULL AND s.col-в-границах" — ни одно поле, уже
|
||||||
|
# заполненное другим источником, не перезаписывается. frt_matched_at проставляется один
|
||||||
|
# раз (COALESCE(h.frt_matched_at, now())) при ЛЮБОМ найденном матче — метка «дом уже
|
||||||
|
# сверялся с этим источником», даже если дозаполнять было уже нечего.
|
||||||
|
_BACKFILL_HOUSES_SQL = text(
|
||||||
|
"""
|
||||||
|
UPDATE houses h
|
||||||
|
SET year_built = CASE
|
||||||
|
WHEN h.year_built IS NULL
|
||||||
|
AND s.built_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||||
|
THEN s.built_year
|
||||||
|
ELSE h.year_built
|
||||||
|
END,
|
||||||
|
material_walls = COALESCE(h.material_walls, s.wall_material),
|
||||||
|
material_floors = COALESCE(h.material_floors, s.floor_type),
|
||||||
|
total_floors = CASE
|
||||||
|
WHEN h.total_floors IS NULL
|
||||||
|
AND s.floor_count_max BETWEEN 1 AND CAST(:floor_max AS int)
|
||||||
|
THEN s.floor_count_max
|
||||||
|
ELSE h.total_floors
|
||||||
|
END,
|
||||||
|
entrances = CASE
|
||||||
|
WHEN h.entrances IS NULL
|
||||||
|
AND s.entrance_count BETWEEN 1 AND CAST(:entrance_max AS int)
|
||||||
|
THEN s.entrance_count
|
||||||
|
ELSE h.entrances
|
||||||
|
END,
|
||||||
|
elevators_total = CASE
|
||||||
|
WHEN h.elevators_total IS NULL
|
||||||
|
AND s.elevators_count BETWEEN 0 AND CAST(:elevator_max AS int)
|
||||||
|
THEN s.elevators_count
|
||||||
|
ELSE h.elevators_total
|
||||||
|
END,
|
||||||
|
is_emergency = COALESCE(h.is_emergency, s.is_alarm),
|
||||||
|
flat_count = CASE
|
||||||
|
WHEN h.flat_count IS NULL
|
||||||
|
AND s.living_quarters_count BETWEEN 0 AND CAST(:flat_max AS int)
|
||||||
|
THEN s.living_quarters_count
|
||||||
|
ELSE h.flat_count
|
||||||
|
END,
|
||||||
|
heat_supply_type = COALESCE(h.heat_supply_type, s.heating_type),
|
||||||
|
gas_supply_type = COALESCE(h.gas_supply_type, s.gas_type),
|
||||||
|
hot_water = COALESCE(h.hot_water, s.hot_water_type),
|
||||||
|
area_land = CASE
|
||||||
|
WHEN h.area_land IS NULL
|
||||||
|
AND s.area_land BETWEEN 0 AND CAST(:area_land_max AS numeric)
|
||||||
|
THEN s.area_land
|
||||||
|
ELSE h.area_land
|
||||||
|
END,
|
||||||
|
foundation_type = COALESCE(h.foundation_type, s.foundation_type),
|
||||||
|
frt_matched_at = COALESCE(h.frt_matched_at, now())
|
||||||
|
FROM frt_mkd s
|
||||||
|
WHERE s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
|
||||||
|
AND (
|
||||||
|
(h.year_built IS NULL
|
||||||
|
AND s.built_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
|
||||||
|
OR (h.material_walls IS NULL AND s.wall_material IS NOT NULL)
|
||||||
|
OR (h.material_floors IS NULL AND s.floor_type IS NOT NULL)
|
||||||
|
OR (h.total_floors IS NULL
|
||||||
|
AND s.floor_count_max BETWEEN 1 AND CAST(:floor_max AS int))
|
||||||
|
OR (h.entrances IS NULL
|
||||||
|
AND s.entrance_count BETWEEN 1 AND CAST(:entrance_max AS int))
|
||||||
|
OR (h.elevators_total IS NULL
|
||||||
|
AND s.elevators_count BETWEEN 0 AND CAST(:elevator_max AS int))
|
||||||
|
OR (h.is_emergency IS NULL AND s.is_alarm IS NOT NULL)
|
||||||
|
OR (h.flat_count IS NULL
|
||||||
|
AND s.living_quarters_count BETWEEN 0 AND CAST(:flat_max AS int))
|
||||||
|
OR (h.heat_supply_type IS NULL AND s.heating_type IS NOT NULL)
|
||||||
|
OR (h.gas_supply_type IS NULL AND s.gas_type IS NOT NULL)
|
||||||
|
OR (h.hot_water IS NULL AND s.hot_water_type IS NOT NULL)
|
||||||
|
OR (h.area_land IS NULL
|
||||||
|
AND s.area_land BETWEEN 0 AND CAST(:area_land_max AS numeric))
|
||||||
|
OR (h.foundation_type IS NULL AND s.foundation_type IS NOT NULL)
|
||||||
|
OR h.frt_matched_at IS NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_BACKFILL_HOUSES_COUNT_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM houses h
|
||||||
|
JOIN frt_mkd s
|
||||||
|
ON s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
|
||||||
|
WHERE (
|
||||||
|
(h.year_built IS NULL
|
||||||
|
AND s.built_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
|
||||||
|
OR (h.material_walls IS NULL AND s.wall_material IS NOT NULL)
|
||||||
|
OR (h.material_floors IS NULL AND s.floor_type IS NOT NULL)
|
||||||
|
OR (h.total_floors IS NULL
|
||||||
|
AND s.floor_count_max BETWEEN 1 AND CAST(:floor_max AS int))
|
||||||
|
OR (h.entrances IS NULL
|
||||||
|
AND s.entrance_count BETWEEN 1 AND CAST(:entrance_max AS int))
|
||||||
|
OR (h.elevators_total IS NULL
|
||||||
|
AND s.elevators_count BETWEEN 0 AND CAST(:elevator_max AS int))
|
||||||
|
OR (h.is_emergency IS NULL AND s.is_alarm IS NOT NULL)
|
||||||
|
OR (h.flat_count IS NULL
|
||||||
|
AND s.living_quarters_count BETWEEN 0 AND CAST(:flat_max AS int))
|
||||||
|
OR (h.heat_supply_type IS NULL AND s.heating_type IS NOT NULL)
|
||||||
|
OR (h.gas_supply_type IS NULL AND s.gas_type IS NOT NULL)
|
||||||
|
OR (h.hot_water IS NULL AND s.hot_water_type IS NOT NULL)
|
||||||
|
OR (h.area_land IS NULL
|
||||||
|
AND s.area_land BETWEEN 0 AND CAST(:area_land_max AS numeric))
|
||||||
|
OR (h.foundation_type IS NULL AND s.foundation_type IS NOT NULL)
|
||||||
|
OR h.frt_matched_at IS NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]:
|
||||||
|
"""COALESCE-добор houses.* из frt_mkd (только NULL-поля). Не коммитит (caller).
|
||||||
|
|
||||||
|
dry_run — ноль записей, только SELECT count(*) по тому же предикату.
|
||||||
|
"""
|
||||||
|
bounds = {
|
||||||
|
"ymin": YEAR_BUILT_MIN,
|
||||||
|
"ymax": plausible_year_max(),
|
||||||
|
"floor_max": FLOOR_COUNT_MAX_BOUND,
|
||||||
|
"entrance_max": ENTRANCE_COUNT_MAX_BOUND,
|
||||||
|
"elevator_max": ELEVATORS_COUNT_MAX_BOUND,
|
||||||
|
"flat_max": FLAT_COUNT_MAX_BOUND,
|
||||||
|
"area_land_max": AREA_LAND_MAX_BOUND,
|
||||||
|
}
|
||||||
|
if dry_run:
|
||||||
|
would_update = db.execute(_BACKFILL_HOUSES_COUNT_SQL, bounds).scalar_one()
|
||||||
|
result = {"would_update": would_update, "houses_updated": 0}
|
||||||
|
logger.info("backfill_houses (frt_mkd) DRY-RUN: %s", result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
updated = db.execute(_BACKFILL_HOUSES_SQL, bounds).rowcount
|
||||||
|
result = {"houses_updated": updated}
|
||||||
|
logger.info("backfill_houses (frt_mkd) DONE: %s", result)
|
||||||
|
return result
|
||||||
|
|
@ -28,9 +28,16 @@ from tenacity import retry, stop_after_attempt, wait_exponential
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services import dadata
|
from app.services import dadata
|
||||||
from app.services.regions import REGIONS as _ALL_REGIONS
|
from app.services.regions import REGIONS as _ALL_REGIONS
|
||||||
|
from app.services.regions import Region, is_within_bbox
|
||||||
|
|
||||||
_REGION_66 = _ALL_REGIONS[66]
|
_REGION_66 = _ALL_REGIONS[66]
|
||||||
|
|
||||||
|
# #3051: маркер `address.state` Nominatim по региону, для region cross-check
|
||||||
|
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
|
||||||
|
# без записи здесь получают `marker=None` → cross-check пропускается
|
||||||
|
# (fallback на bbox-only, прежнее поведение).
|
||||||
|
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"}
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Общий ограничитель темпа обращений к Nominatim (#2953) ──────────────────
|
# ── Общий ограничитель темпа обращений к Nominatim (#2953) ──────────────────
|
||||||
|
|
@ -170,8 +177,15 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
|
||||||
SVERDLOVSK_OBLAST_CITIES = _REGION_66.cities # #3051: список — в реестре регионов
|
SVERDLOVSK_OBLAST_CITIES = _REGION_66.cities # #3051: список — в реестре регионов
|
||||||
|
|
||||||
|
|
||||||
def known_city_hint(value: str | None) -> str | None:
|
def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
|
||||||
"""`value` как city_hint, если это узнаваемое имя города региона 66, иначе None.
|
"""`value` как city_hint, если это узнаваемое имя города `REGIONS[region_code]`, иначе None.
|
||||||
|
|
||||||
|
#3051: `region_code` (дефолт 66) — параметризация под трек «Москва»: словарь
|
||||||
|
городов берётся из `REGIONS[region_code].cities`, а не жёстко из
|
||||||
|
`SVERDLOVSK_OBLAST_CITIES`. Для `region_code=66` (дефолт, все существующие
|
||||||
|
вызовы без аргумента) — byte-identical прежнему поведению: `REGIONS[66].cities
|
||||||
|
is SVERDLOVSK_OBLAST_CITIES` (тот же frozenset-объект, см. модульный уровень).
|
||||||
|
Неизвестный `region_code` → ValueError (явная ошибка, не молчаливый None).
|
||||||
|
|
||||||
Для callers, которые берут город из КОЛОНКИ БД и передают его в `geocode()`
|
Для callers, которые берут город из КОЛОНКИ БД и передают его в `geocode()`
|
||||||
(#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте
|
(#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте
|
||||||
|
|
@ -200,7 +214,14 @@ def known_city_hint(value: str | None) -> str | None:
|
||||||
"""
|
"""
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
return value if " ".join(value.lower().split()) in SVERDLOVSK_OBLAST_CITIES else None
|
if region_code == 66:
|
||||||
|
cities = SVERDLOVSK_OBLAST_CITIES
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
cities = _ALL_REGIONS[region_code].cities
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"unknown region_code={region_code!r}") from exc
|
||||||
|
return value if " ".join(value.lower().split()) in cities else None
|
||||||
|
|
||||||
|
|
||||||
# Значение для DaData-констрейнта `locations: [{"region": ...}]`.
|
# Значение для DaData-констрейнта `locations: [{"region": ...}]`.
|
||||||
|
|
@ -231,20 +252,41 @@ _OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
|
||||||
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р-н", "р-он", "район", "жк"})
|
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р-н", "р-он", "район", "жк"})
|
||||||
|
|
||||||
|
|
||||||
def _has_oblast_marker(text_lower: str) -> bool:
|
# region_code → скомпилированный regex городов региона (word-boundary), кэш по
|
||||||
"""True если текст уже содержит упоминание области/города региона 66.
|
# коду. 66 — literal reuse `_OBLAST_CITY_RE` (тот же объект, byte-identical),
|
||||||
|
# остальные регионы строятся из `REGIONS[region_code].cities` при первом
|
||||||
|
# обращении (#3051).
|
||||||
|
_REGION_CITY_RE: dict[int, re.Pattern[str]] = {66: _OBLAST_CITY_RE}
|
||||||
|
|
||||||
Используется чтобы НЕ навязывать "Екатеринбург, " в запрос, когда адрес
|
|
||||||
уже привязан к другому городу/области — иначе получим двойной город
|
|
||||||
("Екатеринбург, Нижний Тагил, Ленина 10") и провайдер вернёт мусор/пусто.
|
|
||||||
|
|
||||||
Матчинг — по границе слова/фразы (см. `_OBLAST_CITY_RE`), НЕ substring —
|
def _region_city_re(region_code: int) -> re.Pattern[str]:
|
||||||
и с исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
|
cached = _REGION_CITY_RE.get(region_code)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
cities = _ALL_REGIONS[region_code].cities
|
||||||
|
compiled = re.compile(r"\b(?:" + "|".join(re.escape(c) for c in cities) + r")\b")
|
||||||
|
_REGION_CITY_RE[region_code] = compiled
|
||||||
|
return compiled
|
||||||
|
|
||||||
|
|
||||||
|
def _has_oblast_marker(text_lower: str, region_code: int = 66) -> bool:
|
||||||
|
"""True если текст уже содержит упоминание области/города `region_code`.
|
||||||
|
|
||||||
|
Используется чтобы НЕ навязывать "Екатеринбург, "/"Москва, " в запрос,
|
||||||
|
когда адрес уже привязан к другому городу/области — иначе получим двойной
|
||||||
|
город ("Екатеринбург, Нижний Тагил, Ленина 10" / "Москва, Москва, Тверская
|
||||||
|
1", #3051 п. б) и провайдер вернёт мусор/пусто.
|
||||||
|
|
||||||
|
Матчинг — по границе слова/фразы (`_region_city_re`), НЕ substring — и с
|
||||||
|
исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
|
||||||
|
`region_code=66` (дефолт) дополнительно матчит "свердловск*" — областной
|
||||||
|
маркер без города; у прочих регионов такого обобщённого маркера нет,
|
||||||
|
город региона уже покрывает случай (для 77 — "москва" в `region.cities`).
|
||||||
"""
|
"""
|
||||||
normalized = " ".join(text_lower.split())
|
normalized = " ".join(text_lower.split())
|
||||||
if _OBLAST_MARKER_RE.search(normalized):
|
if region_code == 66 and _OBLAST_MARKER_RE.search(normalized):
|
||||||
return True
|
return True
|
||||||
for m in _OBLAST_CITY_RE.finditer(normalized):
|
for m in _region_city_re(region_code).finditer(normalized):
|
||||||
prefix_words = normalized[: m.start()].split()
|
prefix_words = normalized[: m.start()].split()
|
||||||
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
|
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
|
||||||
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
|
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
|
||||||
|
|
@ -252,12 +294,14 @@ def _has_oblast_marker(text_lower: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]:
|
def _resolve_city_for_geocode(
|
||||||
|
address: str, city_hint: str | None, region_code: int = 66
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
"""Определяет, какой город подставлять в запрос внешнему провайдеру
|
"""Определяет, какой город подставлять в запрос внешнему провайдеру
|
||||||
(Nominatim), когда сам текст адреса города не называет.
|
(Nominatim), когда сам текст адреса города не называет.
|
||||||
|
|
||||||
Приоритет:
|
Приоритет:
|
||||||
1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`)
|
1. Адрес уже содержит маркер города/области `region_code` (`_has_oblast_marker`)
|
||||||
→ город уже указан пользователем в тексте адреса, ничего подставлять не
|
→ город уже указан пользователем в тексте адреса, ничего подставлять не
|
||||||
нужно. Возвращает (None, True).
|
нужно. Возвращает (None, True).
|
||||||
2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный
|
2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный
|
||||||
|
|
@ -266,15 +310,18 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str
|
||||||
— для жителей других городов области это давало уверенно неверную цену
|
— для жителей других городов области это давало уверенно неверную цену
|
||||||
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
|
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
|
||||||
обе улицы называются одинаково). Теперь НЕ подставляем никакой город —
|
обе улицы называются одинаково). Теперь НЕ подставляем никакой город —
|
||||||
провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без
|
провайдер ищет по region-viewbox/bbox (см. `OBLAST66_VIEWBOX`,
|
||||||
привязки к конкретному городу. Возвращает
|
`_region_viewbox`), без привязки к конкретному городу. Возвращает
|
||||||
(None, False) — второй элемент False сигнализирует, что город
|
(None, False) — второй элемент False сигнализирует, что город
|
||||||
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
|
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
|
||||||
|
|
||||||
|
`region_code` (дефолт 66, #3051) — byte-identical прежнему поведению для
|
||||||
|
всех вызовов без аргумента.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(city_or_none, city_specified_by_user).
|
(city_or_none, city_specified_by_user).
|
||||||
"""
|
"""
|
||||||
if _has_oblast_marker(address.lower()):
|
if _has_oblast_marker(address.lower(), region_code):
|
||||||
return None, True
|
return None, True
|
||||||
hint = (city_hint or "").strip()
|
hint = (city_hint or "").strip()
|
||||||
if hint:
|
if hint:
|
||||||
|
|
@ -678,13 +725,15 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
|
||||||
|
|
||||||
|
|
||||||
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
|
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
|
||||||
def _nominatim_region_ok(item: dict) -> bool | None:
|
def _nominatim_region_ok(item: dict, region_code: int = 66) -> bool | None:
|
||||||
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
|
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
|
||||||
|
|
||||||
True/False если state однозначно про/не про Свердловскую область. None если
|
True/False если state однозначно про/не про регион `region_code`. None если
|
||||||
поле отсутствует/не строка — тогда accept-логика падает обратно на bbox.
|
поле отсутствует/не строка, ИЛИ регион не имеет записи в `_REGION_STATE_MARKERS`
|
||||||
|
— тогда accept-логика падает обратно на bbox.
|
||||||
Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри генерального OBLAST66_BBOX
|
Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри генерального OBLAST66_BBOX
|
||||||
(специально щедрого), но их state явно другой регион.
|
(специально щедрого), но их state явно другой регион. `region_code=66`
|
||||||
|
(дефолт) — byte-identical прежнему поведению (`"свердловск" in state.lower()`).
|
||||||
"""
|
"""
|
||||||
addr = item.get("address")
|
addr = item.get("address")
|
||||||
if not isinstance(addr, dict):
|
if not isinstance(addr, dict):
|
||||||
|
|
@ -692,22 +741,41 @@ def _nominatim_region_ok(item: dict) -> bool | None:
|
||||||
state = addr.get("state")
|
state = addr.get("state")
|
||||||
if not isinstance(state, str) or not state:
|
if not isinstance(state, str) or not state:
|
||||||
return None
|
return None
|
||||||
return "свердловск" in state.lower()
|
marker = _REGION_STATE_MARKERS.get(region_code)
|
||||||
|
if marker is None:
|
||||||
|
return None
|
||||||
|
return marker in state.lower()
|
||||||
|
|
||||||
|
|
||||||
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
|
def _region_viewbox(region: Region) -> str:
|
||||||
|
"""Nominatim `viewbox` (lon_min,lat_max,lon_max,lat_min) из `region.bbox_region`.
|
||||||
|
|
||||||
|
Для region 66 см. `OBLAST66_VIEWBOX["viewbox"]` — литеральная константа
|
||||||
|
(byte-identical), эта функция для 66 не вызывается.
|
||||||
|
"""
|
||||||
|
lat_min, lat_max, lon_min, lon_max = region.bbox_region
|
||||||
|
return f"{lon_min},{lat_max},{lon_max},{lat_min}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _nominatim_query(
|
||||||
|
client: httpx.AsyncClient, address: str, region_code: int = 66
|
||||||
|
) -> dict | None:
|
||||||
"""Single Nominatim search. Возвращает лучший item или None.
|
"""Single Nominatim search. Возвращает лучший item или None.
|
||||||
|
|
||||||
ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при
|
ВАЖНО: фильтруем результаты по bbox региона `region_code` прямо тут, чтобы
|
||||||
опечатках не возвращать Пермский край / Челябинск — но не резать легитимные
|
при опечатках не возвращать Пермский край / Челябинск — но не резать
|
||||||
Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области).
|
легитимные Нижний Тагил / Серов и т.д. (генеральный bbox всего региона).
|
||||||
|
|
||||||
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
|
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
|
||||||
ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его
|
bbox региона (byte-identical для region_code=66, даже если Nominatim
|
||||||
не первым) — иначе первый кандидат внутри OBLAST66. Плюс region cross-check
|
ранжировал его не первым) — иначе первый кандидат внутри генерального bbox
|
||||||
(`address.state`) — отсекает кандидатов ЯВНО из другого региона (Тюмень и
|
региона. Плюс region cross-check (`address.state`) — отсекает кандидатов
|
||||||
т.п.), даже если координаты попали в генеральный bbox.
|
ЯВНО из другого региона (Тюмень и т.п.), даже если координаты попали в
|
||||||
|
генеральный bbox. `region_code=66` (дефолт) — byte-identical прежнему
|
||||||
|
поведению (те же bbox-значения и та же viewbox-строка).
|
||||||
"""
|
"""
|
||||||
|
region = _ALL_REGIONS[region_code]
|
||||||
|
viewbox = OBLAST66_VIEWBOX["viewbox"] if region_code == 66 else _region_viewbox(region)
|
||||||
await _nominatim_throttle()
|
await _nominatim_throttle()
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"https://nominatim.openstreetmap.org/search",
|
"https://nominatim.openstreetmap.org/search",
|
||||||
|
|
@ -717,8 +785,8 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
||||||
"limit": "3",
|
"limit": "3",
|
||||||
"countrycodes": "ru",
|
"countrycodes": "ru",
|
||||||
"addressdetails": "1",
|
"addressdetails": "1",
|
||||||
"viewbox": OBLAST66_VIEWBOX["viewbox"],
|
"viewbox": viewbox,
|
||||||
"bounded": "1", # строго в пределах области (region 66)
|
"bounded": "1", # строго в пределах региона
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
@ -730,11 +798,11 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
||||||
lon_f = float(item["lon"])
|
lon_f = float(item["lon"])
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if _nominatim_region_ok(item) is False:
|
if _nominatim_region_ok(item, region_code) is False:
|
||||||
continue # регион явно не Свердловская область — не рассматриваем
|
continue # регион явно не тот, что запрошен — не рассматриваем
|
||||||
if is_within_ekb_bbox(lat_f, lon_f):
|
if is_within_bbox(lat_f, lon_f, region.bbox_tight):
|
||||||
return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше
|
return item # tight-bbox приоритетнее — тот же результат, что и раньше
|
||||||
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
|
if oblast_fallback is None and is_within_bbox(lat_f, lon_f, region.bbox_region):
|
||||||
oblast_fallback = item
|
oblast_fallback = item
|
||||||
return oblast_fallback
|
return oblast_fallback
|
||||||
|
|
||||||
|
|
@ -756,21 +824,25 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
||||||
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
|
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
|
||||||
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit).
|
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit).
|
||||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
|
||||||
async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None:
|
async def _nominatim_lookup(
|
||||||
|
address: str, city_hint: str | None = None, region_code: int = 66
|
||||||
|
) -> GeocodeResult | None:
|
||||||
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
||||||
|
|
||||||
Бан-policy: User-Agent с email обязателен.
|
Бан-policy: User-Agent с email обязателен.
|
||||||
Tier 1: bounded область (region 66) на оригинальный адрес.
|
Tier 1: bounded регион `region_code` на оригинальный адрес.
|
||||||
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга → Цвиллинга).
|
Tier 2: bounded регион `region_code` на typo-варианты (Цвилинга → Цвиллинга).
|
||||||
|
|
||||||
#2580 (C): city_hint, если известен, подставляется в текст запроса — без
|
#2580 (C): city_hint, если известен, подставляется в текст запроса — без
|
||||||
него `_nominatim_query` полагается ТОЛЬКО на oblast66-bbox фильтр + tie-break
|
него `_nominatim_query` полагается ТОЛЬКО на region-bbox фильтр + tie-break
|
||||||
(предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона
|
(предпочитает tight bbox региона), который для одноимённых улиц ВНУТРИ
|
||||||
(напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не различает город.
|
региона (напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не
|
||||||
Эмпирически подтверждено: "Ленина 1" без города → случайное село внутри
|
различает город. Эмпирически подтверждено: "Ленина 1" без города →
|
||||||
области; "Нижний Тагил, Ленина 1" → корректно резолвится. Nominatim —
|
случайное село внутри области; "Нижний Тагил, Ленина 1" → корректно
|
||||||
единственный живой внешний провайдер (#2593: Yandex Geocoder удалён) —
|
резолвится. Nominatim — единственный живой внешний провайдер (#2593:
|
||||||
city_hint должен реально влиять на его результат, не только на кэш-ключ.
|
Yandex Geocoder удалён) — city_hint должен реально влиять на его результат,
|
||||||
|
не только на кэш-ключ. `region_code=66` (дефолт, #3051) — byte-identical
|
||||||
|
прежнему поведению для всех вызовов без аргумента.
|
||||||
"""
|
"""
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||||||
|
|
@ -778,18 +850,18 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco
|
||||||
"Accept-Language": "ru,en;q=0.8",
|
"Accept-Language": "ru,en;q=0.8",
|
||||||
"Referer": "https://tradein-mvp.local/",
|
"Referer": "https://tradein-mvp.local/",
|
||||||
}
|
}
|
||||||
city, _ = _resolve_city_for_geocode(address, city_hint)
|
city, _ = _resolve_city_for_geocode(address, city_hint, region_code)
|
||||||
query = f"{city}, {address}" if city else address
|
query = f"{city}, {address}" if city else address
|
||||||
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
|
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
|
||||||
# Tier 1: оригинал
|
# Tier 1: оригинал
|
||||||
item = await _nominatim_query(client, query)
|
item = await _nominatim_query(client, query, region_code)
|
||||||
|
|
||||||
# Tier 2: typo-variants
|
# Tier 2: typo-variants
|
||||||
if item is None:
|
if item is None:
|
||||||
for variant in _typo_variants(address, limit=4):
|
for variant in _typo_variants(address, limit=4):
|
||||||
variant_city, _ = _resolve_city_for_geocode(variant, city_hint)
|
variant_city, _ = _resolve_city_for_geocode(variant, city_hint, region_code)
|
||||||
variant_query = f"{variant_city}, {variant}" if variant_city else variant
|
variant_query = f"{variant_city}, {variant}" if variant_city else variant
|
||||||
item = await _nominatim_query(client, variant_query)
|
item = await _nominatim_query(client, variant_query, region_code)
|
||||||
if item is not None:
|
if item is not None:
|
||||||
logger.info("nominatim typo-fixed: %s → %s", address, variant)
|
logger.info("nominatim typo-fixed: %s → %s", address, variant)
|
||||||
break
|
break
|
||||||
|
|
@ -1753,7 +1825,9 @@ async def suggest(
|
||||||
|
|
||||||
|
|
||||||
# ── Public API ───────────────────────────────────────────────────────────────
|
# ── Public API ───────────────────────────────────────────────────────────────
|
||||||
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
|
async def geocode(
|
||||||
|
address: str, db: Session, city_hint: str | None = None, region_code: int = 66
|
||||||
|
) -> GeocodeResult | None:
|
||||||
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
|
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
|
||||||
|
|
||||||
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
|
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
|
||||||
|
|
@ -1771,8 +1845,21 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
|
||||||
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
|
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
|
||||||
за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и
|
за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и
|
||||||
видимая, а не отказ.
|
видимая, а не отказ.
|
||||||
|
|
||||||
|
`region_code` (дефолт 66, #3051) — какой `REGIONS`-регион искать (bbox,
|
||||||
|
city-словарь, ЕКБ-only локальные тиры). Неизвестный код → `ValueError`
|
||||||
|
сразу, а не глубоко внутри `_nominatim_query`. Все существующие вызовы без
|
||||||
|
аргумента получают region_code=66 — byte-identical прежнему поведению.
|
||||||
|
`_city_substituted` region_code не принимает: инвариант завязан на
|
||||||
|
ЕКБ-bbox координат результата (`is_within_ekb_bbox`), который для другого
|
||||||
|
региона (Москва и т.п.) структурно не совпадает — условие 3 инварианта
|
||||||
|
никогда не сработает, ложного понижения confidence до "locality" не будет.
|
||||||
"""
|
"""
|
||||||
result = await _geocode_resolve(address, db, city_hint)
|
try:
|
||||||
|
_ALL_REGIONS[region_code]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"geocode: unknown region_code={region_code!r}") from exc
|
||||||
|
result = await _geocode_resolve(address, db, city_hint, region_code)
|
||||||
if result is None or not _city_substituted(address, result):
|
if result is None or not _city_substituted(address, result):
|
||||||
return result
|
return result
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -1788,7 +1875,7 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
|
||||||
|
|
||||||
|
|
||||||
async def _geocode_resolve(
|
async def _geocode_resolve(
|
||||||
address: str, db: Session, city_hint: str | None = None
|
address: str, db: Session, city_hint: str | None = None, region_code: int = 66
|
||||||
) -> GeocodeResult | None:
|
) -> GeocodeResult | None:
|
||||||
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
|
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
|
||||||
|
|
||||||
|
|
@ -1802,6 +1889,10 @@ async def _geocode_resolve(
|
||||||
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
|
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
|
||||||
разных городов по одному и тому же тексту адреса не перезатирали
|
разных городов по одному и тому же тексту адреса не перезатирали
|
||||||
друг друга.
|
друг друга.
|
||||||
|
region_code: регион покрытия (дефолт 66, #3051). ЕКБ-only локальные
|
||||||
|
тиры (geoportal/cad_buildings/houses) применяются ТОЛЬКО при 66 —
|
||||||
|
это ЕКБ-специфичные реестры, у других регионов данных в них нет.
|
||||||
|
Прокидывается в Nominatim-тир (bbox/viewbox/city-словарь).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
GeocodeResult или None если ни один провайдер не отвечает.
|
GeocodeResult или None если ни один провайдер не отвечает.
|
||||||
|
|
@ -1812,7 +1903,7 @@ async def _geocode_resolve(
|
||||||
if not address or len(address.strip()) < 3:
|
if not address or len(address.strip()) < 3:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_, city_specified = _resolve_city_for_geocode(address, city_hint)
|
_, city_specified = _resolve_city_for_geocode(address, city_hint, region_code)
|
||||||
city_ambiguous = not city_specified
|
city_ambiguous = not city_specified
|
||||||
|
|
||||||
addr_norm = _cache_key(normalize_address(address), city_hint)
|
addr_norm = _cache_key(normalize_address(address), city_hint)
|
||||||
|
|
@ -1838,7 +1929,14 @@ async def _geocode_resolve(
|
||||||
# Раньше решение по тексту адреса принималось от противного (список из 37
|
# Раньше решение по тексту адреса принималось от противного (список из 37
|
||||||
# городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион
|
# городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион
|
||||||
# РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#2582).
|
# РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#2582).
|
||||||
use_local_ekb = _ekb_local_tiers_allowed(address, city_hint)
|
#
|
||||||
|
# #3051: `region_code != 66` закрывает эти тиры целиком, ДО вызова
|
||||||
|
# `_ekb_local_tiers_allowed` — geoportal/cad_buildings/houses физически не
|
||||||
|
# содержат данных других регионов (не "город не распознан словарём 66", а
|
||||||
|
# "реестра для этого региона нет вовсе"), а сама `_ekb_local_tiers_allowed`
|
||||||
|
# (её ЕКБ-словари: `_names_non_ekb_city`/`_names_unrecognized_locality`)
|
||||||
|
# region_code не принимает — умышленно не трогаем её сигнатуру.
|
||||||
|
use_local_ekb = region_code == 66 and _ekb_local_tiers_allowed(address, city_hint)
|
||||||
|
|
||||||
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
|
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
|
||||||
if use_local_ekb and parsed is not None:
|
if use_local_ekb and parsed is not None:
|
||||||
|
|
@ -1914,7 +2012,7 @@ async def _geocode_resolve(
|
||||||
|
|
||||||
# 3. Nominatim fallback
|
# 3. Nominatim fallback
|
||||||
try:
|
try:
|
||||||
result = await _nominatim_lookup(address, city_hint)
|
result = await _nominatim_lookup(address, city_hint, region_code)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
result = replace(result, city_ambiguous=city_ambiguous)
|
result = replace(result, city_ambiguous=city_ambiguous)
|
||||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,14 @@ class TBankClient:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
response = await client.post(url, json=body)
|
response = await client.post(url, json=body)
|
||||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
# Родитель всех транспортных отказов, а не пара TimeoutException +
|
||||||
|
# NetworkError: RemoteProtocolError (банк оборвал ответ), ProxyError и
|
||||||
|
# UnsupportedProtocol мимо той пары летели наружу голым httpx-исключением.
|
||||||
|
# Вызывающая сторона ловит только TBankApiError, поэтому строка платежа
|
||||||
|
# оставалась NEW без payment_url — то есть невидимой для
|
||||||
|
# _find_live_payment, но видимой предикату UNIQUE миграции 279, и
|
||||||
|
# покупатель запирался на _ABANDONED_AFTER_MINUTES из-за сбоя банка.
|
||||||
|
except httpx.TransportError as exc:
|
||||||
if attempt > max_retries:
|
if attempt > max_retries:
|
||||||
logger.error(
|
logger.error(
|
||||||
"tbank client: %s — network error после %d попыток: %s",
|
"tbank client: %s — network error после %d попыток: %s",
|
||||||
|
|
|
||||||
|
|
@ -367,7 +367,27 @@ async def _job_yandex_newbuilding_sweep(
|
||||||
# исход — назвать прогон неуспешным, а не дотянуть succeeded до ненуля.
|
# исход — назвать прогон неуспешным, а не дотянуть succeeded до ненуля.
|
||||||
processed = int(counters.get("processed") or 0)
|
processed = int(counters.get("processed") or 0)
|
||||||
succeeded = int(counters.get("succeeded") or 0)
|
succeeded = int(counters.get("succeeded") or 0)
|
||||||
if processed > 0 and succeeded == 0:
|
if result.no_proxy_stop:
|
||||||
|
# #3197: прогон оборван на пустом пуле — к площадке не ходили вовсе.
|
||||||
|
# Это отказ нашей инфраструктуры, а не «ЖК не разрешились»: называть
|
||||||
|
# такой прогон успешным нельзя, и причина должна быть отличима.
|
||||||
|
#
|
||||||
|
# INFO, как у соседей (scheduler.py:175, avito_detail_backfill.py:1050,
|
||||||
|
# domclick:626): причина уже записана в mark_failed + counters.no_proxy_stop=1,
|
||||||
|
# а ERROR ставил её в один разряд с падением задачи (logger.exception ниже).
|
||||||
|
logger.info(
|
||||||
|
"yandex_newbuilding_sweep run_id=%d: пул прокси пуст — прогон оборван "
|
||||||
|
"(обработано %d)",
|
||||||
|
run_id,
|
||||||
|
processed,
|
||||||
|
)
|
||||||
|
ctx.runs.mark_failed(
|
||||||
|
db,
|
||||||
|
run_id,
|
||||||
|
"пул прокси пуст — прогон оборван, к площадке не ходили",
|
||||||
|
counters,
|
||||||
|
)
|
||||||
|
elif processed > 0 and succeeded == 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"yandex_newbuilding_sweep run_id=%d: обработано %d, разрешено 0 — "
|
"yandex_newbuilding_sweep run_id=%d: обработано %d, разрешено 0 — "
|
||||||
"помечаю прогон неуспешным",
|
"помечаю прогон неуспешным",
|
||||||
|
|
@ -524,6 +544,19 @@ async def _job_osm_poi_ekb_refresh(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── dtp_stat_refresh — sync ZIP-скачивание+парс ДТП в executor ────────────────
|
||||||
|
async def _job_dtp_stat_refresh(
|
||||||
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
) -> None:
|
||||||
|
from app.tasks.dtp_stat_refresh import run_dtp_stat_refresh
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: run_dtp_stat_refresh(db, run_id=run_id, params=params),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── house_imv_backfill — async Avito-IMV с heartbeat, lifecycle в job ─────────
|
# ── house_imv_backfill — async Avito-IMV с heartbeat, lifecycle в job ─────────
|
||||||
async def _job_house_imv_backfill(
|
async def _job_house_imv_backfill(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
|
@ -641,6 +674,133 @@ async def _job_domrf_kapremont_load(
|
||||||
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
|
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
|
||||||
|
|
||||||
|
|
||||||
|
# ── fns_opendata_load — bulk-дампы ФНС по юрлицам → fns_legal_entity_facts ────
|
||||||
|
# Открытые данные ФНС по ЮРЛИЦАМ (лицензия nalog.gov.ru/opendata разрешает
|
||||||
|
# переработку/перераспространение) — легальный обход того, что открытых данных
|
||||||
|
# ЕГРН по правообладателям-физлицам не существует (218-ФЗ ст. 62). Потребителя у
|
||||||
|
# fns_legal_entity_facts на момент добавления НЕТ (см. docstring
|
||||||
|
# app/services/fns_opendata_loader.py и fns_lookup.py) — расписание сидируется
|
||||||
|
# enabled=false, включение отдельным осознанным шагом.
|
||||||
|
async def _job_fns_opendata_load(
|
||||||
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
) -> None:
|
||||||
|
"""Скачать наборы ФНС opendata (revexp/sshr2019/debtam/snr) → fns_legal_entity_facts.
|
||||||
|
|
||||||
|
Тело переиспользует load_dataset (тот же дизайн-инвариант модуля, что у
|
||||||
|
_job_domrf_kapremont_load: не дублируем логику CLI). commit() после каждого
|
||||||
|
набора — сбой на debtam не должен откатывать уже загруженный revexp.
|
||||||
|
"""
|
||||||
|
from app.services.fns_opendata_loader import DATASET_SLUGS, load_dataset
|
||||||
|
|
||||||
|
datasets = params.get("datasets") or list(DATASET_SLUGS)
|
||||||
|
|
||||||
|
def _run() -> dict[str, int]:
|
||||||
|
total_records = 0
|
||||||
|
total_upserted = 0
|
||||||
|
for slug in datasets:
|
||||||
|
result = load_dataset(db, slug)
|
||||||
|
db.commit()
|
||||||
|
total_records += int(result["records"])
|
||||||
|
total_upserted += int(result["upserted"])
|
||||||
|
return {
|
||||||
|
"records": total_records,
|
||||||
|
"upserted": total_upserted,
|
||||||
|
# см. докстринг _job_domrf_kapremont_load: выделенные колонки прогона +
|
||||||
|
# гейт «три подряд нулевых прогона».
|
||||||
|
"total_seen": total_records,
|
||||||
|
"new_count": total_upserted,
|
||||||
|
}
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
counters = await loop.run_in_executor(None, _run)
|
||||||
|
ctx.runs.mark_done(db, run_id, counters)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("scheduler: fns_opendata_load crashed run_id=%d", run_id)
|
||||||
|
db.rollback()
|
||||||
|
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
|
||||||
|
|
||||||
|
|
||||||
|
# ── frt_mkd_load — АИС ППК ФРТ, реестр МКД region 66 (issue #frt-mkd) ────────
|
||||||
|
async def _job_frt_mkd_load(
|
||||||
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
) -> None:
|
||||||
|
"""Скачать реестр МКД АИС ФРТ (node_id) → staging frt_mkd → backfill houses.
|
||||||
|
|
||||||
|
Тело переиспользует те же функции, что и CLI (app/tasks/frt_mkd_load.py) — не
|
||||||
|
дублируем логику. node_id/region_code берутся из scrape_schedules.default_params
|
||||||
|
(мигр. 291), с фоллбеком на дефолты loader'а.
|
||||||
|
"""
|
||||||
|
from app.services.frt_mkd_loader import (
|
||||||
|
DEFAULT_NODE_ID,
|
||||||
|
DEFAULT_REGION_CODE,
|
||||||
|
backfill_houses,
|
||||||
|
load_frt_mkd,
|
||||||
|
)
|
||||||
|
|
||||||
|
node_id = int(params.get("node_id", DEFAULT_NODE_ID))
|
||||||
|
region_code = int(params.get("region_code", DEFAULT_REGION_CODE))
|
||||||
|
|
||||||
|
def _run() -> dict[str, int]:
|
||||||
|
load_counts = load_frt_mkd(db, node_id=node_id, region_code=region_code)
|
||||||
|
db.commit()
|
||||||
|
houses_counts = backfill_houses(db)
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"rows": load_counts["rows"],
|
||||||
|
"upserted": load_counts["upserted"],
|
||||||
|
"houses_updated": houses_counts["houses_updated"],
|
||||||
|
"total_seen": load_counts["rows"],
|
||||||
|
"new_count": houses_counts["houses_updated"],
|
||||||
|
}
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
counters = await loop.run_in_executor(None, _run)
|
||||||
|
ctx.runs.mark_done(db, run_id, counters)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("scheduler: frt_mkd_load crashed run_id=%d", run_id)
|
||||||
|
db.rollback()
|
||||||
|
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
|
||||||
|
|
||||||
|
|
||||||
|
# ── cbr_macro_pull — макро-ряды ЦБ РФ (ипотека по субъектам + ключевая ставка) ─
|
||||||
|
async def _job_cbr_macro_pull(
|
||||||
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
) -> None:
|
||||||
|
"""Ипотечные ряды (XLSX) + ключевая ставка (SOAP) → cbr_mortgage_series/cbr_key_rate.
|
||||||
|
|
||||||
|
Тело переиспользует те же функции, что и CLI (app/tasks/cbr_macro_pull.py) —
|
||||||
|
дизайн-инвариант модуля: не дублируем логику. Lifecycle не свой —
|
||||||
|
mark_done/mark_failed здесь, как у _job_domrf_kapremont_load.
|
||||||
|
"""
|
||||||
|
from app.services.cbr_macro import load_key_rate, pull_cbr_mortgage
|
||||||
|
|
||||||
|
def _run() -> dict[str, int]:
|
||||||
|
mortgage_counts = pull_cbr_mortgage(db)
|
||||||
|
db.commit()
|
||||||
|
key_rate_counts = load_key_rate(db)
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"mortgage_upserted": mortgage_counts["upserted"],
|
||||||
|
"mortgage_skipped": mortgage_counts["skipped"],
|
||||||
|
"mortgage_errors": mortgage_counts["errors"],
|
||||||
|
"key_rate_upserted": key_rate_counts["upserted"],
|
||||||
|
# выделенные колонки прогона + гейт «три подряд нулевых прогона» (#2625).
|
||||||
|
"total_seen": mortgage_counts["upserted"] + key_rate_counts["rows"],
|
||||||
|
"new_count": mortgage_counts["upserted"] + key_rate_counts["upserted"],
|
||||||
|
}
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
counters = await loop.run_in_executor(None, _run)
|
||||||
|
ctx.runs.mark_done(db, run_id, counters)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("scheduler: cbr_macro_pull crashed run_id=%d", run_id)
|
||||||
|
db.rollback()
|
||||||
|
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
|
||||||
|
|
||||||
|
|
||||||
# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
|
# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
|
||||||
async def _job_purge_expired_trade_in_data(
|
async def _job_purge_expired_trade_in_data(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
|
@ -730,6 +890,13 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
||||||
post_claim=reschedule_after_minutes(param="interval_minutes", default=360),
|
post_claim=reschedule_after_minutes(param="interval_minutes", default=360),
|
||||||
),
|
),
|
||||||
"rosreestr_dkp_import": Handler(_job_rosreestr_dkp, "rosreestr_dkp_import"),
|
"rosreestr_dkp_import": Handler(_job_rosreestr_dkp, "rosreestr_dkp_import"),
|
||||||
|
# Wildcard (#3051 п.3): rosreestr_dkp_import_77 (Москва, миграция 288) и любой
|
||||||
|
# будущий region_code-суффикс из той же семьи резолвятся сюда через
|
||||||
|
# resolve_handler по префиксу (тот же механизм, что deactivate_stale_* /
|
||||||
|
# avito_city_sweep_* — см. scraper_kit.orchestration.scheduler.resolve_handler).
|
||||||
|
# region_code берётся из default_params строки расписания (import_rosreestr_dkp
|
||||||
|
# сам валидирует его через app.services.regions.REGIONS), Handler-тело общее.
|
||||||
|
"rosreestr_dkp_import_*": Handler(_job_rosreestr_dkp, "rosreestr_dkp_import_*"),
|
||||||
"listing_source_snapshot": Handler(_job_listing_source_snapshot, "listing_source_snapshot"),
|
"listing_source_snapshot": Handler(_job_listing_source_snapshot, "listing_source_snapshot"),
|
||||||
"asking_to_sold_ratio_refresh": Handler(
|
"asking_to_sold_ratio_refresh": Handler(
|
||||||
_job_asking_to_sold_ratio, "asking_to_sold_ratio_refresh"
|
_job_asking_to_sold_ratio, "asking_to_sold_ratio_refresh"
|
||||||
|
|
@ -803,9 +970,13 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
||||||
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
|
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
|
||||||
"cadastral_geo_match": Handler(_job_cadastral_geo_match, "cadastral_geo_match"),
|
"cadastral_geo_match": Handler(_job_cadastral_geo_match, "cadastral_geo_match"),
|
||||||
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
|
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
|
||||||
|
"dtp_stat_refresh": Handler(_job_dtp_stat_refresh, "dtp_stat_refresh"),
|
||||||
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
|
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
|
||||||
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
|
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
|
||||||
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),
|
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),
|
||||||
|
"frt_mkd_load": Handler(_job_frt_mkd_load, "frt_mkd_load"),
|
||||||
|
"cbr_macro_pull": Handler(_job_cbr_macro_pull, "cbr_macro_pull"),
|
||||||
|
"fns_opendata_load": Handler(_job_fns_opendata_load, "fns_opendata_load"),
|
||||||
"purge_expired_trade_in_data": Handler(
|
"purge_expired_trade_in_data": Handler(
|
||||||
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
|
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,10 @@ Self-healing (#2600):
|
||||||
WARNING (пул надо пополнять, #2638).
|
WARNING (пул надо пополнять, #2638).
|
||||||
- Ручное снятие — `clear_source_bans` (ложный бан детектора капчи, #2642) плюс
|
- Ручное снятие — `clear_source_bans` (ложный бан детектора капчи, #2642) плюс
|
||||||
автоматическое после успешной ротации exit-IP: бан привязан к proxy_id, а банился
|
автоматическое после успешной ротации exit-IP: бан привязан к proxy_id, а банился
|
||||||
IP, поэтому смена адреса делает строку недействительной.
|
IP, поэтому смена адреса делает строку недействительной. С #3404 «снятие» гасит
|
||||||
|
строку (`banned_until = now()`, `ban_count = 0`, `cleared_at`/`cleared_reason`),
|
||||||
|
а не удаляет её — строка живёт до штатного purge (SOURCE_BAN_PURGE_DAYS), но для
|
||||||
|
выдачи и для эскалации следующего бана это неотличимо от прежнего DELETE.
|
||||||
|
|
||||||
Ручное выключение vs авто-выключение (#2610):
|
Ручное выключение vs авто-выключение (#2610):
|
||||||
- scrape_proxies.disabled_reason (миграция 209) различает ДВЕ разные причины
|
- scrape_proxies.disabled_reason (миграция 209) различает ДВЕ разные причины
|
||||||
|
|
@ -143,6 +146,7 @@ __all__ = [
|
||||||
"STALE_LEASE_MINUTES",
|
"STALE_LEASE_MINUTES",
|
||||||
"ProxyLease",
|
"ProxyLease",
|
||||||
"acquire",
|
"acquire",
|
||||||
|
"attribute_run_proxy",
|
||||||
"clear_source_bans",
|
"clear_source_bans",
|
||||||
"mark_banned",
|
"mark_banned",
|
||||||
"mark_browser_health",
|
"mark_browser_health",
|
||||||
|
|
@ -441,6 +445,11 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
||||||
{"run_id": lease_marker, "id": proxy_id},
|
{"run_id": lease_marker, "id": proxy_id},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if run_id is not None and run_id != NON_RUN_LEASE_MARKER:
|
||||||
|
# #3404: одна точка, покрывающая ВСЕ пути выдачи (curl — acquire на каждый
|
||||||
|
# вызов, браузер — sticky lease на весь прогон, ре-acquire при ротации узла
|
||||||
|
# mid-run) — см. attribute_run_proxy docstring.
|
||||||
|
attribute_run_proxy(db, run_id, proxy_id)
|
||||||
if fallback_used:
|
if fallback_used:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"proxy_pool: leased proxy id=%d provider=%s by=%s — FALLBACK affinity "
|
"proxy_pool: leased proxy id=%d provider=%s by=%s — FALLBACK affinity "
|
||||||
|
|
@ -474,6 +483,78 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def attribute_run_proxy(db: Session, run_id: int, proxy_id: int) -> None:
|
||||||
|
"""Записать узел, через который идёт прогон run_id, в scrape_runs (#3404).
|
||||||
|
|
||||||
|
Единственный писатель — `acquire()` сразу после выдачи lease'а: покрывает и
|
||||||
|
curl-путь (acquire на каждый вызов), и браузерный sticky lease (один acquire на
|
||||||
|
весь прогон), и ре-acquire при ротации узла mid-run (`browser_fetcher`,
|
||||||
|
`_LEASE_ROTATE_AFTER_FAILS`) — то есть смена узла ЗА прогон фиксируется сама,
|
||||||
|
без отдельного вызова с чьей-либо стороны.
|
||||||
|
|
||||||
|
`scrape_runs.proxy_id` — ПОСЛЕДНИЙ использованный узел (перезаписывается при
|
||||||
|
каждой новой выдаче); полная цепочка узлов, если она менялась, — в
|
||||||
|
`counters.proxy_ids` (список id, без дублей). Пишем через `||`-мерж
|
||||||
|
`counters` (тот же контракт, что у `runs.update_heartbeat`/`mark_done`) —
|
||||||
|
чужие ключи (чекпоинт, метка interrupted) не затираются.
|
||||||
|
|
||||||
|
Идемпотентно: повторная выдача ТОГО ЖЕ узла не дублирует его в `proxy_ids`
|
||||||
|
(`@>`-проверка перед append). Best-effort: любой сбой (например, run_id уже
|
||||||
|
не существует — гонка с финализацией) логируется WARNING и проглатывается —
|
||||||
|
атрибуция прогону не должна ронять выдачу прокси, это диагностика, а не
|
||||||
|
часть контракта lease'а. 0 rows (run_id не найден) — DEBUG, не ошибка: сама
|
||||||
|
выдача при этом уже произошла и коммитнута предыдущим db.commit() в acquire().
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Строку прогона параллельно обновляет heartbeat/финализатор из ДРУГОЙ сессии
|
||||||
|
# (короткие транзакции, каждая со своим commit). Пересечение маловероятно, но
|
||||||
|
# ждать на блокировке в пути выдачи прокси нельзя — диагностика не должна
|
||||||
|
# тормозить сбор. Не дождались за 2с — уходим в except ниже (WARNING, lease цел).
|
||||||
|
db.execute(text("SET LOCAL lock_timeout = '2s'"))
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE scrape_runs
|
||||||
|
SET proxy_id = CAST(:proxy_id AS bigint),
|
||||||
|
counters = COALESCE(counters, '{}'::jsonb) || jsonb_build_object(
|
||||||
|
'proxy_ids',
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(counters -> 'proxy_ids', '[]'::jsonb)
|
||||||
|
@> to_jsonb(CAST(:proxy_id AS bigint))
|
||||||
|
THEN COALESCE(counters -> 'proxy_ids', '[]'::jsonb)
|
||||||
|
ELSE COALESCE(counters -> 'proxy_ids', '[]'::jsonb)
|
||||||
|
|| jsonb_build_array(CAST(:proxy_id AS bigint))
|
||||||
|
END
|
||||||
|
)
|
||||||
|
WHERE id = CAST(:run_id AS bigint)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"proxy_id": proxy_id, "run_id": run_id},
|
||||||
|
).first()
|
||||||
|
db.commit()
|
||||||
|
if row is None:
|
||||||
|
logger.debug(
|
||||||
|
"proxy_pool: attribute_run_proxy no-op — run_id=%d not found (already "
|
||||||
|
"finalized?)",
|
||||||
|
run_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Best-effort (см. docstring) — атрибуция диагностическая, не часть
|
||||||
|
# контракта lease'а: lease уже выдан и не должен теряться из-за неё.
|
||||||
|
logger.warning(
|
||||||
|
"proxy_pool: attribute_run_proxy failed run_id=%d proxy_id=%d — lease "
|
||||||
|
"issued regardless",
|
||||||
|
run_id,
|
||||||
|
proxy_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def release(db: Session, proxy_id: int) -> None:
|
def release(db: Session, proxy_id: int) -> None:
|
||||||
"""Освободить прокси (leased_by/leased_at → NULL). Идемпотентно (0-row если уже свободен)."""
|
"""Освободить прокси (leased_by/leased_at → NULL). Идемпотентно (0-row если уже свободен)."""
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -935,15 +1016,21 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None =
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
ON CONFLICT (proxy_id, source) DO UPDATE
|
ON CONFLICT (proxy_id, source) DO UPDATE
|
||||||
SET ban_count = scrape_proxy_source_bans.ban_count + 1,
|
SET ban_count = scrape_proxy_source_bans.ban_count + 1,
|
||||||
banned_until = now() + make_interval(hours => CAST(
|
banned_until = now() + make_interval(hours => CAST(
|
||||||
LEAST(
|
LEAST(
|
||||||
CAST(:base_hours AS integer)
|
CAST(:base_hours AS integer)
|
||||||
* power(2, LEAST(scrape_proxy_source_bans.ban_count, 16)),
|
* power(2, LEAST(scrape_proxy_source_bans.ban_count, 16)),
|
||||||
CAST(:max_hours AS integer)
|
CAST(:max_hours AS integer)
|
||||||
) AS integer)),
|
) AS integer)),
|
||||||
reason = CAST(:reason AS text),
|
reason = CAST(:reason AS text),
|
||||||
updated_at = now()
|
-- #3404: строка могла быть погашена clear_source_bans (banned_until
|
||||||
|
-- в прошлом попадает в WHERE ниже) — новый бан затирает её метки
|
||||||
|
-- гашения, иначе на СНОВА забаненной паре висели бы cleared_at/
|
||||||
|
-- cleared_reason от предыдущего, уже неактуального гашения.
|
||||||
|
cleared_at = NULL,
|
||||||
|
cleared_reason = NULL,
|
||||||
|
updated_at = now()
|
||||||
-- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет),
|
-- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет),
|
||||||
-- свою же (обычная эскалация) или перебиваем боевым сбором — он сильнее
|
-- свою же (обычная эскалация) или перебиваем боевым сбором — он сильнее
|
||||||
-- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803).
|
-- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803).
|
||||||
|
|
@ -1058,12 +1145,25 @@ def clear_source_bans(
|
||||||
банила IP, а строка бана привязана к proxy_id и пережила бы смену адреса,
|
банила IP, а строка бана привязана к proxy_id и пережила бы смену адреса,
|
||||||
держа узел вне выдачи уже без причины.
|
держа узел вне выдачи уже без причины.
|
||||||
|
|
||||||
source=None — снять все баны узла; конкретный source — только его. DELETE, а не
|
source=None — снять все баны узла; конкретный source — только его. Гасим строку
|
||||||
`banned_until = now()`: строка живёт ещё и ради `ban_count` (память об эскалации),
|
(`banned_until = now()`, `ban_count = 0`, `cleared_at`/`cleared_reason`), а НЕ
|
||||||
а здесь мы как раз объявляем историю недействительной — новый бан начнётся с базовых
|
удаляем (#3404, было DELETE): строка доживает до штатного purge'а
|
||||||
SOURCE_BAN_BASE_HOURS.
|
(`run_proxy_healthcheck`, SOURCE_BAN_PURGE_DAYS), но перестаёт блокировать
|
||||||
|
выдачу немедленно и перестаёт нести историю эскалации — `ban_count = 0` даёт
|
||||||
|
следующему бану той же пары ровно те же SOURCE_BAN_BASE_HOURS, что и раньше
|
||||||
|
после DELETE (формула `mark_banned` берёт ПРЕДЫДУЩИЙ ban_count показателем
|
||||||
|
степени: 0 → база, без множителя). Причина держать строку — трассируемость
|
||||||
|
(видно, что бан БЫЛ и когда/кем снят), а не поведение: для читателей ниже
|
||||||
|
погашенная строка неотличима от отсутствующей (см. риски в шапке PR #3404).
|
||||||
|
|
||||||
`reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»).
|
Идемпотентно и в другую сторону: повторный вызов на уже погашенной строке
|
||||||
|
(последний предикат в WHERE) её не трогает — 0 rows, `banned_until` НЕ
|
||||||
|
сдвигается вперёд. Без этого условия повторный `PATCH enabled=true` двигал бы
|
||||||
|
`banned_until` на каждый вызов и отодвигал бы purge на неопределённый срок.
|
||||||
|
|
||||||
|
`reason` идёт в лог (человекочитаемый повод — «manual enable», «ip rotated») и
|
||||||
|
теперь ЕЩЁ в колонку `cleared_reason` — постоянный след того, кто и почему
|
||||||
|
погасил бан.
|
||||||
|
|
||||||
`only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые
|
`only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые
|
||||||
написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое
|
написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое
|
||||||
|
|
@ -1075,14 +1175,22 @@ def clear_source_bans(
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
DELETE FROM scrape_proxy_source_bans
|
UPDATE scrape_proxy_source_bans
|
||||||
|
SET banned_until = now(),
|
||||||
|
ban_count = 0,
|
||||||
|
cleared_at = now(),
|
||||||
|
cleared_reason = CAST(:reason AS text),
|
||||||
|
updated_at = now()
|
||||||
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
||||||
AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text))
|
AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text))
|
||||||
AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text))
|
AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text))
|
||||||
|
-- Уже погашенная строка (гейт покоя, см. докстринг) — не трогаем: без
|
||||||
|
-- него повторный вызов сдвигал бы banned_until вперёд и отодвигал purge.
|
||||||
|
AND NOT (cleared_at IS NOT NULL AND ban_count = 0 AND banned_until <= now())
|
||||||
RETURNING source
|
RETURNING source
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"proxy_id": proxy_id, "source": source, "only_reason": only_reason},
|
{"proxy_id": proxy_id, "source": source, "only_reason": only_reason, "reason": reason},
|
||||||
).fetchall()
|
).fetchall()
|
||||||
db.commit()
|
db.commit()
|
||||||
if rows:
|
if rows:
|
||||||
|
|
@ -1465,6 +1573,11 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
# Снесём раньше — узел, который площадка банит каждые сутки, каждый раз начинал бы с
|
# Снесём раньше — узел, который площадка банит каждые сутки, каждый раз начинал бы с
|
||||||
# 6 часов и никогда не доходил до длинных пауз. Отложенный purge и есть механизм сброса:
|
# 6 часов и никогда не доходил до длинных пауз. Отложенный purge и есть механизм сброса:
|
||||||
# неделя без нового бана = пара считается чистой, эскалация с нуля. НЕ «оптимизировать».
|
# неделя без нового бана = пара считается чистой, эскалация с нуля. НЕ «оптимизировать».
|
||||||
|
# #3404: под этот же порог теперь попадают и ПОГАШЕННЫЕ clear_source_bans строки —
|
||||||
|
# для них banned_until == момент гашения (== cleared_at), т.е. таймер до purge
|
||||||
|
# отсчитывается от гашения, а не от исходного истечения бана. ban_count у них уже
|
||||||
|
# 0 к моменту гашения, так что покидающий purge их не «сбрасывает» повторно —
|
||||||
|
# он просто убирает уже неактуальный след из таблицы.
|
||||||
purged = len(
|
purged = len(
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,15 @@ class Region:
|
||||||
Регион без тира должен деградировать ЯВНО (потребитель
|
Регион без тира должен деградировать ЯВНО (потребитель
|
||||||
спрашивает unsupported_tier_reason и логирует/маркирует),
|
спрашивает unsupported_tier_reason и логирует/маркирует),
|
||||||
а не молча считать дальше без источника.
|
а не молча считать дальше без источника.
|
||||||
|
canonical_city — #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city`
|
||||||
|
строк, приходящих из источника без надёжного city-поля
|
||||||
|
(Росреестр по Москве отдаёт муниципальный округ/поселение
|
||||||
|
вместо города — «Раменки», «Сосенское» — а не «Москва»).
|
||||||
|
None — источник несёт свой city как есть, без override
|
||||||
|
(регион 66: byte-for-byte прежнее поведение). Not-None —
|
||||||
|
потребитель (import_rosreestr_dkp) подставляет это имя
|
||||||
|
вместо city источника и НЕ фильтрует по city IS NOT NULL
|
||||||
|
(иначе на 77 теряется ~10% строк с пустым city).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
code: int
|
code: int
|
||||||
|
|
@ -58,6 +67,7 @@ class Region:
|
||||||
city_token: str
|
city_token: str
|
||||||
cities: frozenset[str]
|
cities: frozenset[str]
|
||||||
enrichment_tiers: frozenset[str]
|
enrichment_tiers: frozenset[str]
|
||||||
|
canonical_city: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
|
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
|
||||||
|
|
@ -144,6 +154,10 @@ REGIONS: dict[int, Region] = {
|
||||||
# sber_index покрывают регион 66. Пустое множество здесь — не заглушка,
|
# sber_index покрывают регион 66. Пустое множество здесь — не заглушка,
|
||||||
# а ФАКТ, который потребители обязаны озвучивать (см. класс-докстринг).
|
# а ФАКТ, который потребители обязаны озвучивать (см. класс-докстринг).
|
||||||
enrichment_tiers=frozenset(),
|
enrichment_tiers=frozenset(),
|
||||||
|
# #3051: Росреестр по Москве отдаёт в city муниципальный округ/поселение
|
||||||
|
# ("муниципальный округ Раменки", "поселение Сосенское"), не сам город —
|
||||||
|
# import_rosreestr_dkp подставляет каноничное имя вместо city источника.
|
||||||
|
canonical_city="Москва",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,16 @@ Zombie-reap, advisory-lock claim и tick-loop теперь целиком в
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# kit_runs.update_heartbeat (в отличие от локального runs_mod.update_heartbeat) мержит
|
# kit_runs — ТОТ ЖЕ модуль, что и runs_mod ниже: с #3390 `app.services.scrape_runs`
|
||||||
# counters (`counters || :counters`) вместо замены — нужен для чекпоинта курсора
|
# его алиас, реализация одна и counters везде МЕРЖАТСЯ (`counters || :counters`). До
|
||||||
# import_rosreestr_dkp (issue #3168), чтобы resume-вердикт, записанный на старте, не
|
# #3390 копии было две, и app-копия counters ЗАМЕНЯЛА — тогда чекпоинт курсора
|
||||||
# затирался последующими per-batch heartbeat'ами того же прогона.
|
# import_rosreestr_dkp (#3168) обязан был писаться именно kit-именем, иначе resume-вердикт
|
||||||
|
# со старта затирался первым же per-batch пульсом. Имя оставлено как есть: теперь это
|
||||||
|
# один объект, и переименование в runs_mod ничего не чинит и ничего не ломает.
|
||||||
from scraper_kit.orchestration import runs as kit_runs
|
from scraper_kit.orchestration import runs as kit_runs
|
||||||
|
|
||||||
# compute_next_run_at жил здесь ВТОРОЙ, побайтово одинаковой копией kit-версии (#2674).
|
# compute_next_run_at жил здесь ВТОРОЙ, побайтово одинаковой копией kit-версии (#2674).
|
||||||
|
|
@ -42,11 +45,13 @@ from scraper_kit.orchestration import runs as kit_runs
|
||||||
# такта снова разъедется по одному из них. Re-export (а не правка импорта у вызывающих)
|
# такта снова разъедется по одному из них. Re-export (а не правка импорта у вызывающих)
|
||||||
# сохраняет `from app.services.scheduler import compute_next_run_at` в admin.py и тестах.
|
# сохраняет `from app.services.scheduler import compute_next_run_at` в admin.py и тестах.
|
||||||
from scraper_kit.orchestration.scheduler import compute_next_run_at
|
from scraper_kit.orchestration.scheduler import compute_next_run_at
|
||||||
|
from scraper_kit.proxy_errors import caused_by_no_proxy
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.shutdown import shutdown_requested
|
from app.core.shutdown import shutdown_requested
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
|
from app.services.regions import REGIONS
|
||||||
|
|
||||||
__all__ = ["compute_next_run_at", "has_running_run"]
|
__all__ = ["compute_next_run_at", "has_running_run"]
|
||||||
|
|
||||||
|
|
@ -126,8 +131,16 @@ async def _execute_cian_backfill(
|
||||||
"""Сигнал живости из середины батча. Best-effort: сбой heartbeat не должен
|
"""Сигнал живости из середины батча. Best-effort: сбой heartbeat не должен
|
||||||
ронять уже идущую работу — прогон в худшем случае вернётся к прежнему
|
ронять уже идущую работу — прогон в худшем случае вернётся к прежнему
|
||||||
поведению (пометка 'zombie' на 6-м часу)."""
|
поведению (пометка 'zombie' на 6-м часу)."""
|
||||||
|
nonlocal counters
|
||||||
|
# #3384: снимок измеренного едет не только в БД, но и в `counters` — этот словарь
|
||||||
|
# уезжает в mark_failed из общего except ниже. Мерж (#3390) спасает лишь ключи,
|
||||||
|
# которых в payload нет; одноимённые он ПЕРЕЗАПИСЫВАЕТ, поэтому предынициализированные
|
||||||
|
# нули без этого присваивания легли бы поверх измеренного, и SQL-разбор простоя
|
||||||
|
# (#3288/#3367) прочитал бы «к площадке не ходили» про прогон, который ходил.
|
||||||
|
# Присваивание ДО записи в БД: сбой heartbeat'а не должен стирать сам факт замера.
|
||||||
|
counters = _counters(progress)
|
||||||
try:
|
try:
|
||||||
runs_mod.update_heartbeat(db, run_id, _counters(progress))
|
runs_mod.update_heartbeat(db, run_id, counters)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"scheduler: cian_history_backfill run_id=%d heartbeat failed (ignored)",
|
"scheduler: cian_history_backfill run_id=%d heartbeat failed (ignored)",
|
||||||
|
|
@ -135,6 +148,8 @@ async def _execute_cian_backfill(
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Стартовые нули: прогон виден в админке до первого прогресса. Держатся здесь ровно
|
||||||
|
# до первого `_heartbeat` — дальше в `counters` лежит измеренное (см. выше).
|
||||||
counters: dict[str, int] = {
|
counters: dict[str, int] = {
|
||||||
"listings_processed": 0,
|
"listings_processed": 0,
|
||||||
"listings_succeeded": 0,
|
"listings_succeeded": 0,
|
||||||
|
|
@ -161,6 +176,28 @@ async def _execute_cian_backfill(
|
||||||
# #3196: отказ detail-фетча теперь несёт диагноз (HTTP-статус последнего ответа
|
# #3196: отказ detail-фетча теперь несёт диагноз (HTTP-статус последнего ответа
|
||||||
# сайдкара). В 'banned' переводим ТОЛЬКО прогон, который отказы видел и не
|
# сайдкара). В 'banned' переводим ТОЛЬКО прогон, который отказы видел и не
|
||||||
# обогатил НИЧЕГО, — частичный успех остаётся 'done', как и был.
|
# обогатил НИЧЕГО, — частичный успех остаётся 'done', как и был.
|
||||||
|
if result.no_proxy_stop:
|
||||||
|
# #3197 (как #3288 у avito / #3283 у домклика): остановка из-за пустого пула —
|
||||||
|
# НЕ блок, поэтому и не mark_banned: иначе прогон уйдёт в 'banned' и запись
|
||||||
|
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
|
||||||
|
counters["no_proxy_stop"] = 1
|
||||||
|
runs_mod.mark_failed(
|
||||||
|
db, run_id, "пул прокси пуст — к площадке не ходили (#3197)", counters
|
||||||
|
)
|
||||||
|
# INFO, как у соседей (avito_detail_backfill.py:1050, domclick:626): причина
|
||||||
|
# уже записана в mark_failed + counters.no_proxy_stop, а ERROR на финальной
|
||||||
|
# строке ставил в один разряд с падением задачи (logger.exception ниже).
|
||||||
|
logger.info(
|
||||||
|
"scheduler: cian_history_backfill run_id=%d СТОП (пул пуст) — "
|
||||||
|
"listings=%d/%d houses=%d/%d %.1fs",
|
||||||
|
run_id,
|
||||||
|
result.listings_succeeded,
|
||||||
|
result.listings_total,
|
||||||
|
result.houses_succeeded,
|
||||||
|
result.houses_total,
|
||||||
|
result.duration_sec,
|
||||||
|
)
|
||||||
|
return
|
||||||
if result.ban_kinds and (result.listings_succeeded + result.houses_succeeded) == 0:
|
if result.ban_kinds and (result.listings_succeeded + result.houses_succeeded) == 0:
|
||||||
counters["blocked"] = result.listings_blocked
|
counters["blocked"] = result.listings_blocked
|
||||||
# Полная перепись диагнозов, а не только доминирующий вид (#3196) — иначе
|
# Полная перепись диагнозов, а не только доминирующий вид (#3196) — иначе
|
||||||
|
|
@ -186,12 +223,41 @@ async def _execute_cian_backfill(
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("scheduler: cian_history_backfill run_id=%d failed", run_id)
|
logger.exception("scheduler: cian_history_backfill run_id=%d failed", run_id)
|
||||||
|
if caused_by_no_proxy(exc):
|
||||||
|
# #3384: пул был пуст ещё ДО первого объявления — lease берётся в
|
||||||
|
# BrowserFetcher.__aenter__, поэтому NoProxyAvailableError вылетает из
|
||||||
|
# самого `async with` (cian_history_backfill.py:218) мимо стоп-механики
|
||||||
|
# внутри цикла, которая и ставит no_proxy_stop. Без этого ключа прогон,
|
||||||
|
# который к площадке не ходил ВООБЩЕ, неотличим от любого другого падения:
|
||||||
|
# причина только в тексте, а разбор простоя идёт SQL'ём по
|
||||||
|
# counters.no_proxy_stop (#3288/#3367). Ключ тот же, что у ветки выше.
|
||||||
|
# Флаг ДОБАВЛЯЕТСЯ к последнему снимку `_heartbeat`, а не подменяет его:
|
||||||
|
# опустевший между стадиями пул — это отказ ПОСЛЕ реальной работы.
|
||||||
|
counters["no_proxy_stop"] = 1
|
||||||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
_DKP_SOURCE = "rosreestr_dkp_import"
|
_DKP_SOURCE = "rosreestr_dkp_import"
|
||||||
|
|
||||||
|
|
||||||
|
def _dkp_source_for_region(region_code: int) -> str:
|
||||||
|
"""Имя scrape_runs.source для чекпоинта данного региона (#3051 п.3).
|
||||||
|
|
||||||
|
66 — байт-в-байт прежнее имя ('rosreestr_dkp_import'), под которым годами
|
||||||
|
писались scrape_runs. Остальные регионы получают суффикс кода — тот же
|
||||||
|
формат, что и у строки scrape_schedules ('rosreestr_dkp_import_77',
|
||||||
|
seed — миграция 289), которую резолвит wildcard 'rosreestr_dkp_import_*'
|
||||||
|
в product_handlers.py. Изоляция чекпоинтов между регионами держится именно
|
||||||
|
на разных source: _resume_dkp_cursor ищет ПРЕДЫДУЩИЙ прогон с ТЕМ ЖЕ source,
|
||||||
|
поэтому курсор региона 77 никогда не подхватит last_id региона 66 (и
|
||||||
|
наоборот) — они просто разные строки в scrape_runs.source.
|
||||||
|
"""
|
||||||
|
if region_code == 66:
|
||||||
|
return _DKP_SOURCE
|
||||||
|
return f"{_DKP_SOURCE}_{region_code}"
|
||||||
|
|
||||||
|
|
||||||
# Потолок возраста чекпоинта: старше — last_id прошлого прогона не подхватываем, прогон
|
# Потолок возраста чекпоинта: старше — last_id прошлого прогона не подхватываем, прогон
|
||||||
# стартует с id=0 (issue #3168). У предиката `id > last_id` нет протухания в смысле
|
# стартует с id=0 (issue #3168). У предиката `id > last_id` нет протухания в смысле
|
||||||
# свипов (он остаётся корректным сколь угодно долго), но апстрим
|
# свипов (он остаётся корректным сколь угодно долго), но апстрим
|
||||||
|
|
@ -217,7 +283,9 @@ _DKP_RESUME_CANDIDATE_SQL = text("""
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
def _resume_dkp_cursor(db: Session, run_id: int) -> tuple[int, dict[str, Any]]:
|
def _resume_dkp_cursor(
|
||||||
|
db: Session, run_id: int, source: str = _DKP_SOURCE
|
||||||
|
) -> tuple[int, dict[str, Any]]:
|
||||||
"""Продолжить last_id прошлого прогона или начать с 0 — решение + explain (issue #3168).
|
"""Продолжить last_id прошлого прогона или начать с 0 — решение + explain (issue #3168).
|
||||||
|
|
||||||
last_id раньше жил только в памяти процесса (init на 0 при каждом запуске): heartbeat
|
last_id раньше жил только в памяти процесса (init на 0 при каждом запуске): heartbeat
|
||||||
|
|
@ -225,7 +293,14 @@ def _resume_dkp_cursor(db: Session, run_id: int) -> tuple[int, dict[str, Any]]:
|
||||||
обратно. Обрыв (деплой/OOM/рестарт хоста) откатывал прогресс на 0 и заставлял
|
обратно. Обрыв (деплой/OOM/рестарт хоста) откатывал прогресс на 0 и заставлял
|
||||||
пере-сканировать источник с начала.
|
пере-сканировать источник с начала.
|
||||||
|
|
||||||
Кандидат — ПОСЛЕДНИЙ прогон этого source (тот же принцип, что и
|
`source` (#3051 п.3) — per-region ключ чекпоинта (см. _dkp_source_for_region):
|
||||||
|
дефолт _DKP_SOURCE сохраняет прежнее поведение вызовов без явного аргумента
|
||||||
|
(регион 66). Кандидат ищется СТРОГО по этому source — прогон региона 77
|
||||||
|
(source='rosreestr_dkp_import_77') никогда не видит last_id региона 66
|
||||||
|
(source='rosreestr_dkp_import') и наоборот: разные регионы физически не
|
||||||
|
матчат друг друга в WHERE source = :source ниже.
|
||||||
|
|
||||||
|
Кандидат — ПОСЛЕДНИЙ прогон ЭТОГО source (тот же принцип, что и
|
||||||
scraper_kit.orchestration.scheduler._pick_resume, локальная копия ладдера — контракт
|
scraper_kit.orchestration.scheduler._pick_resume, локальная копия ладдера — контракт
|
||||||
другой: нет params/interval_days, курсор числовой, а не bucket-set):
|
другой: нет params/interval_days, курсор числовой, а не bucket-set):
|
||||||
- 'running' / 'zombie' — прогон, которого не завершили штатно.
|
- 'running' / 'zombie' — прогон, которого не завершили штатно.
|
||||||
|
|
@ -239,7 +314,7 @@ def _resume_dkp_cursor(db: Session, run_id: int) -> tuple[int, dict[str, Any]]:
|
||||||
кодом (kit_runs.update_heartbeat — merge, не замена), чтобы решение было видно в
|
кодом (kit_runs.update_heartbeat — merge, не замена), чтобы решение было видно в
|
||||||
scrape_runs, а не только в логе.
|
scrape_runs, а не только в логе.
|
||||||
"""
|
"""
|
||||||
row = db.execute(_DKP_RESUME_CANDIDATE_SQL, {"source": _DKP_SOURCE, "rid": run_id}).fetchone()
|
row = db.execute(_DKP_RESUME_CANDIDATE_SQL, {"source": source, "rid": run_id}).fetchone()
|
||||||
verdict: dict[str, Any] = {"resume_from": None}
|
verdict: dict[str, Any] = {"resume_from": None}
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|
@ -276,48 +351,92 @@ def import_rosreestr_dkp(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Import ДКП-сделок из gendesign rosreestr_deals через postgres_fdw.
|
"""Import ДКП-сделок из gendesign rosreestr_deals через postgres_fdw.
|
||||||
|
|
||||||
Python-порт import-rosreestr.sh (Variant C из #563).
|
Python-порт import-rosreestr.sh (Variant C из #563). #3051 п.3: параметризовано
|
||||||
|
по региону (params["region_code"], реестр — app.services.regions.REGIONS) —
|
||||||
|
было хардкод region_code=66.
|
||||||
|
|
||||||
Источник: foreign table gendesign_rosreestr_deals (создана в migration 072).
|
Источник: foreign table gendesign_rosreestr_deals (создана в migration 072,
|
||||||
|
okato/quarter_cad_number/district добавлены миграцией 289).
|
||||||
SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql.
|
SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql.
|
||||||
USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader).
|
USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader).
|
||||||
|
|
||||||
Область покрытия: вся Свердловская область (region_code=66), не только Екатеринбург —
|
Область покрытия задаётся параметром, а не литералом (#3051 п.6): region_code
|
||||||
прежний ILIKE-фильтр по подстроке города (ограничивавший импорт одним Екатеринбургом)
|
приходит из params, дефолт 66 = вся Свердловская область (не только Екатеринбург —
|
||||||
снят (Mera trade-in расширяется на весь регион, unlocks +47183 сделок вне ЕКБ уже
|
прежний ILIKE-фильтр по подстроке города снят, unlocks +47183 сделок вне ЕКБ уже
|
||||||
сидящих в source foreign table). address и deals.city строятся из реального city
|
сидящих в source foreign table). Неизвестный код региона (нет в REGIONS) — ValueError,
|
||||||
источника (не хардкод "Екатеринбург"), deals.region_code заполняется из строки
|
прогон падает явно, а не молча импортирует мусор с чужим region_code.
|
||||||
источника (= 66 при текущем фильтре).
|
|
||||||
|
region_code=66 (регион БЕЗ canonical_city в реестре) — поведение байт-в-байт
|
||||||
|
прежнее: city/address строятся из city источника, обязателен фильтр
|
||||||
|
city IS NOT NULL AND trim(city) != ''.
|
||||||
|
|
||||||
|
Регион С canonical_city (77 — Москва): Росреестр отдаёт в city муниципальный
|
||||||
|
округ/поселение ("муниципальный округ Раменки", "поселение Сосенское"), НЕ
|
||||||
|
город — city/address подставляют region.canonical_city, а не city источника;
|
||||||
|
фильтр city IS NOT NULL НЕ применяется (иначе теряется ~10% строк с пустым
|
||||||
|
city источника). Исходные city/okato/quarter_cad_number/district уходят в
|
||||||
|
raw_payload (jsonb) — единственная ветка SQL решает это через bind-параметр
|
||||||
|
:canonical_city (CASE WHEN ... IS NOT NULL), а не отдельный Python if/else на
|
||||||
|
конкретный код региона.
|
||||||
|
|
||||||
|
Типы документов тоже параметр (#3051 п.3): doc_types, дефолт ['ДКП'] = прежнее
|
||||||
|
поведение (только вторичка — #549 / Fix_Rosreestr_Dkp_Filter_May24). Для Москвы
|
||||||
|
ДДУ идут по ценам котлована и медиану развалят, поэтому смешивать их с ДКП можно
|
||||||
|
только осознанно и с колонкой deals.doc_type (миграция 288), которая теперь
|
||||||
|
заполняется на импорте.
|
||||||
|
|
||||||
Фильтры (совпадают с import-rosreestr.sh + Fix_Rosreestr_Dkp_Filter_May24):
|
Фильтры (совпадают с import-rosreestr.sh + Fix_Rosreestr_Dkp_Filter_May24):
|
||||||
- region_code = 66 (вся Свердловская область, все города)
|
- region_code = :region_code (параметризовано, было хардкод 66)
|
||||||
- city IS NOT NULL AND trim(city) != '' (непустой город → корректный address)
|
- city IS NOT NULL AND trim(city) != '' — ТОЛЬКО если у региона нет canonical_city
|
||||||
- realestate_type_code = '002001003000' (квартира)
|
- realestate_type_code = '002001003000' (квартира)
|
||||||
- area BETWEEN 18 AND 200
|
- area BETWEEN 18 AND 200
|
||||||
- deal_price BETWEEN 1000000 AND 100000000
|
- deal_price BETWEEN 1000000 AND 100000000
|
||||||
- street IS NOT NULL AND trim(street) != ''
|
- street IS NOT NULL AND trim(street) != ''
|
||||||
- doc_type = 'ДКП' (только вторичка — #549 / Fix_Rosreestr_Dkp_Filter_May24)
|
- doc_type = ANY(:doc_types) (param, default ['ДКП'])
|
||||||
- period_start_date >= since (default '2024-01-01')
|
- period_start_date >= since (default '2024-01-01')
|
||||||
|
|
||||||
dedup_hash: 'ros:dkp:' || id — плоский натуральный ключ (инъективный, без коллизий,
|
dedup_hash: 'ros:dkp:' || id — плоский натуральный ключ (инъективный, без коллизий,
|
||||||
human-readable). До #576 здесь был md5('ros:dkp:' || id); миграция 077 конвертировала
|
human-readable). До #576 здесь был md5('ros:dkp:' || id); миграция 077 конвертировала
|
||||||
существующие строки. source_id хранит исходный rosreestr id (дедуп переустанавливаем).
|
существующие строки. source_id хранит исходный rosreestr id (дедуп переустанавливаем).
|
||||||
|
Префикс ':dkp:' НАМЕРЕННО оставлен неизменным после параметризации doc_types: id
|
||||||
|
уникален в источнике сам по себе, независимо от типа документа, поэтому ключ и без
|
||||||
|
того не коллизирует; а вот смена формы ключа осиротила бы все уже загруженные строки
|
||||||
|
(их пришлось бы конвертировать ещё одной миграцией — ровно то, что делала 077).
|
||||||
Rooms: выводятся из площади (Росреестр не отдаёт кол-во комнат).
|
Rooms: выводятся из площади (Росреестр не отдаёт кол-во комнат).
|
||||||
|
|
||||||
Batch-процессинг: читаем из FDW батчами по batch_size через cursor-based пагинацию
|
Batch-процессинг: читаем из FDW батчами по batch_size через cursor-based пагинацию
|
||||||
(WHERE id > last_id ORDER BY id). Heartbeat обновляется каждый батч (= checkpoint),
|
(WHERE id > last_id ORDER BY id). Heartbeat обновляется каждый батч (= checkpoint),
|
||||||
мержем (kit_runs.update_heartbeat), а не заменой. На старте _resume_dkp_cursor решает
|
мержем (kit_runs.update_heartbeat), а не заменой. На старте _resume_dkp_cursor решает
|
||||||
продолжить с last_id прошлого прогона или начать с 0 — чекпоинт переживает рестарт
|
продолжить с last_id прошлого прогона или начать с 0 — чекпоинт переживает рестарт
|
||||||
процесса (деплой/OOM/SIGTERM), пока не старше суток (issue #3168).
|
процесса (деплой/OOM/SIGTERM), пока не старше суток (issue #3168). Курсор — ПЕР
|
||||||
|
РЕГИОН (#3051 п.3): _resume_dkp_cursor вызывается с source=_dkp_source_for_region
|
||||||
|
(region_code), поэтому last_id региона 77 никогда не подхватывает last_id региона
|
||||||
|
66 — они разные scrape_runs.source ('rosreestr_dkp_import' vs
|
||||||
|
'rosreestr_dkp_import_77'), см. докстринг _dkp_source_for_region.
|
||||||
SAVEPOINT per row — один сбойный row не откатывает батч.
|
SAVEPOINT per row — один сбойный row не откатывает батч.
|
||||||
|
|
||||||
Координаты: NULL после импорта — геокодинг остаётся follow-up (geocode-deals).
|
Координаты: NULL после импорта — геокодинг остаётся follow-up (geocode-deals).
|
||||||
TODO (follow-up): запустить geocode backfill после import.
|
TODO (follow-up): запустить geocode backfill после import.
|
||||||
|
|
||||||
Cleanup: удаляет legacy строки address='Екатеринбург, реальная сделка' (pre-#549).
|
Cleanup: удаляет legacy строки address='Екатеринбург, реальная сделка' (pre-#549,
|
||||||
|
region-agnostic — синтетические строки существовали только для ЕКБ).
|
||||||
"""
|
"""
|
||||||
since: str = str(params.get("since", "2024-01-01"))
|
since: str = str(params.get("since", "2024-01-01"))
|
||||||
batch_size: int = int(params.get("batch_size", 2000))
|
batch_size: int = int(params.get("batch_size", 2000))
|
||||||
|
# #3051 п.6: регион — параметр, дефолт 66 сохраняет текущее прод-поведение
|
||||||
|
# (расписание получает явный region_code в миграции 288).
|
||||||
|
region_code: int = int(params.get("region_code", 66))
|
||||||
|
# #3051 п.3: типы документов — параметр, дефолт ['ДКП'] = прежний литерал.
|
||||||
|
doc_types: list[str] = [str(t) for t in params.get("doc_types") or ["ДКП"]]
|
||||||
|
region = REGIONS.get(region_code)
|
||||||
|
if region is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"rosreestr_dkp_import: region_code={region_code} не найден в "
|
||||||
|
f"app.services.regions.REGIONS (известны: {sorted(REGIONS)}) — "
|
||||||
|
"прогон остановлен, чтобы не импортировать сделки с неизвестным "
|
||||||
|
"региональным контекстом (city/address-правила для него не определены)"
|
||||||
|
)
|
||||||
|
dkp_source = _dkp_source_for_region(region_code)
|
||||||
|
|
||||||
counters: dict[str, int] = {
|
counters: dict[str, int] = {
|
||||||
"rows_fetched": 0,
|
"rows_fetched": 0,
|
||||||
|
|
@ -354,7 +473,7 @@ def import_rosreestr_dkp(
|
||||||
)
|
)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
||||||
last_id, resume_verdict = _resume_dkp_cursor(db, run_id)
|
last_id, resume_verdict = _resume_dkp_cursor(db, run_id, source=dkp_source)
|
||||||
total_batches = 0
|
total_batches = 0
|
||||||
kit_runs.update_heartbeat(db, run_id, resume_verdict)
|
kit_runs.update_heartbeat(db, run_id, resume_verdict)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -404,9 +523,20 @@ def import_rosreestr_dkp(
|
||||||
id,
|
id,
|
||||||
id AS source_id_src,
|
id AS source_id_src,
|
||||||
'ros:dkp:' || CAST(id AS text) AS dedup_hash,
|
'ros:dkp:' || CAST(id AS text) AS dedup_hash,
|
||||||
trim(city) || ', ' || trim(street) AS address,
|
-- #3051: регион с canonical_city (Москва) подставляет его вместо
|
||||||
|
-- city источника (округ/поселение, не город) — CASE на bind-параметре,
|
||||||
|
-- не Python if/else на код региона.
|
||||||
|
CASE
|
||||||
|
WHEN CAST(:canonical_city AS text) IS NOT NULL
|
||||||
|
THEN CAST(:canonical_city AS text) || ', ' || trim(street)
|
||||||
|
ELSE trim(city) || ', ' || trim(street)
|
||||||
|
END AS address,
|
||||||
region_code,
|
region_code,
|
||||||
trim(city) AS city,
|
CASE
|
||||||
|
WHEN CAST(:canonical_city AS text) IS NOT NULL
|
||||||
|
THEN CAST(:canonical_city AS text)
|
||||||
|
ELSE trim(city)
|
||||||
|
END AS city,
|
||||||
CASE
|
CASE
|
||||||
WHEN area < 30 THEN 0
|
WHEN area < 30 THEN 0
|
||||||
WHEN area < 44 THEN 1
|
WHEN area < 44 THEN 1
|
||||||
|
|
@ -426,21 +556,45 @@ def import_rosreestr_dkp(
|
||||||
year_build AS year_built,
|
year_build AS year_built,
|
||||||
round(deal_price)::bigint AS price_rub,
|
round(deal_price)::bigint AS price_rub,
|
||||||
round(price_per_sqm)::int AS price_per_m2,
|
round(price_per_sqm)::int AS price_per_m2,
|
||||||
period_start_date AS deal_date
|
period_start_date AS deal_date,
|
||||||
|
doc_type,
|
||||||
|
-- Исходный city/okato/quarter_cad_number/district — ТОЛЬКО когда
|
||||||
|
-- city перезаписан canonical_city выше (иначе NULL, регион 66
|
||||||
|
-- byte-for-byte прежний: raw_payload не заполнялся и не заполняется).
|
||||||
|
CASE
|
||||||
|
WHEN CAST(:canonical_city AS text) IS NOT NULL THEN
|
||||||
|
jsonb_build_object(
|
||||||
|
'src_city', city,
|
||||||
|
'okato', okato,
|
||||||
|
'quarter_cad_number', quarter_cad_number,
|
||||||
|
'district', district
|
||||||
|
)
|
||||||
|
ELSE NULL
|
||||||
|
END AS raw_payload
|
||||||
FROM gendesign_rosreestr_deals
|
FROM gendesign_rosreestr_deals
|
||||||
WHERE region_code = 66
|
WHERE region_code = CAST(:region_code AS int)
|
||||||
AND city IS NOT NULL AND trim(city) <> ''
|
AND (
|
||||||
|
CAST(:canonical_city AS text) IS NOT NULL
|
||||||
|
OR (city IS NOT NULL AND trim(city) <> '')
|
||||||
|
)
|
||||||
AND realestate_type_code = '002001003000'
|
AND realestate_type_code = '002001003000'
|
||||||
AND area BETWEEN 18 AND 200
|
AND area BETWEEN 18 AND 200
|
||||||
AND deal_price BETWEEN 1000000 AND 100000000
|
AND deal_price BETWEEN 1000000 AND 100000000
|
||||||
AND street IS NOT NULL AND trim(street) <> ''
|
AND street IS NOT NULL AND trim(street) <> ''
|
||||||
AND doc_type = 'ДКП'
|
AND doc_type = ANY(CAST(:doc_types AS text[]))
|
||||||
AND period_start_date >= CAST(:since AS date)
|
AND period_start_date >= CAST(:since AS date)
|
||||||
AND id > CAST(:last_id AS bigint)
|
AND id > CAST(:last_id AS bigint)
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
LIMIT CAST(:batch_size AS int)
|
LIMIT CAST(:batch_size AS int)
|
||||||
"""),
|
"""),
|
||||||
{"since": since, "last_id": last_id, "batch_size": batch_size},
|
{
|
||||||
|
"since": since,
|
||||||
|
"last_id": last_id,
|
||||||
|
"batch_size": batch_size,
|
||||||
|
"region_code": region_code,
|
||||||
|
"canonical_city": region.canonical_city,
|
||||||
|
"doc_types": doc_types,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
|
|
@ -478,7 +632,7 @@ def import_rosreestr_dkp(
|
||||||
INSERT INTO deals (
|
INSERT INTO deals (
|
||||||
source, dedup_hash, source_id, address, region_code, city,
|
source, dedup_hash, source_id, address, region_code, city,
|
||||||
rooms, area_m2, floor, year_built, price_rub, price_per_m2,
|
rooms, area_m2, floor, year_built, price_rub, price_per_m2,
|
||||||
deal_date
|
deal_date, doc_type, raw_payload
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
'rosreestr',
|
'rosreestr',
|
||||||
|
|
@ -493,7 +647,9 @@ def import_rosreestr_dkp(
|
||||||
CAST(:year_built AS int),
|
CAST(:year_built AS int),
|
||||||
CAST(:price_rub AS bigint),
|
CAST(:price_rub AS bigint),
|
||||||
CAST(:price_per_m2 AS int),
|
CAST(:price_per_m2 AS int),
|
||||||
CAST(:deal_date AS date)
|
CAST(:deal_date AS date),
|
||||||
|
CAST(:doc_type AS text),
|
||||||
|
CAST(:raw_payload AS jsonb)
|
||||||
)
|
)
|
||||||
ON CONFLICT (dedup_hash) DO UPDATE SET
|
ON CONFLICT (dedup_hash) DO UPDATE SET
|
||||||
address = EXCLUDED.address,
|
address = EXCLUDED.address,
|
||||||
|
|
@ -505,7 +661,9 @@ def import_rosreestr_dkp(
|
||||||
year_built = EXCLUDED.year_built,
|
year_built = EXCLUDED.year_built,
|
||||||
price_rub = EXCLUDED.price_rub,
|
price_rub = EXCLUDED.price_rub,
|
||||||
price_per_m2 = EXCLUDED.price_per_m2,
|
price_per_m2 = EXCLUDED.price_per_m2,
|
||||||
deal_date = EXCLUDED.deal_date
|
deal_date = EXCLUDED.deal_date,
|
||||||
|
doc_type = EXCLUDED.doc_type,
|
||||||
|
raw_payload = EXCLUDED.raw_payload
|
||||||
WHERE deals.address IS DISTINCT FROM EXCLUDED.address
|
WHERE deals.address IS DISTINCT FROM EXCLUDED.address
|
||||||
OR deals.region_code IS DISTINCT FROM EXCLUDED.region_code
|
OR deals.region_code IS DISTINCT FROM EXCLUDED.region_code
|
||||||
OR deals.city IS DISTINCT FROM EXCLUDED.city
|
OR deals.city IS DISTINCT FROM EXCLUDED.city
|
||||||
|
|
@ -516,6 +674,8 @@ def import_rosreestr_dkp(
|
||||||
OR deals.price_rub IS DISTINCT FROM EXCLUDED.price_rub
|
OR deals.price_rub IS DISTINCT FROM EXCLUDED.price_rub
|
||||||
OR deals.price_per_m2 IS DISTINCT FROM EXCLUDED.price_per_m2
|
OR deals.price_per_m2 IS DISTINCT FROM EXCLUDED.price_per_m2
|
||||||
OR deals.deal_date IS DISTINCT FROM EXCLUDED.deal_date
|
OR deals.deal_date IS DISTINCT FROM EXCLUDED.deal_date
|
||||||
|
OR deals.doc_type IS DISTINCT FROM EXCLUDED.doc_type
|
||||||
|
OR deals.raw_payload IS DISTINCT FROM EXCLUDED.raw_payload
|
||||||
RETURNING (xmax = 0) AS was_inserted
|
RETURNING (xmax = 0) AS was_inserted
|
||||||
"""),
|
"""),
|
||||||
{
|
{
|
||||||
|
|
@ -531,6 +691,12 @@ def import_rosreestr_dkp(
|
||||||
"price_rub": row["price_rub"],
|
"price_rub": row["price_rub"],
|
||||||
"price_per_m2": row["price_per_m2"],
|
"price_per_m2": row["price_per_m2"],
|
||||||
"deal_date": row["deal_date"],
|
"deal_date": row["deal_date"],
|
||||||
|
"doc_type": row["doc_type"],
|
||||||
|
"raw_payload": (
|
||||||
|
json.dumps(row["raw_payload"], ensure_ascii=False)
|
||||||
|
if row["raw_payload"] is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
},
|
},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if result is None:
|
if result is None:
|
||||||
|
|
@ -564,8 +730,9 @@ def import_rosreestr_dkp(
|
||||||
counters["last_id"] = last_id # type: ignore[assignment]
|
counters["last_id"] = last_id # type: ignore[assignment]
|
||||||
|
|
||||||
# Heartbeat = checkpoint: allows zombie detection + resume visibility.
|
# Heartbeat = checkpoint: allows zombie detection + resume visibility.
|
||||||
# kit_runs (merge, не замена) — иначе этот REPLACE стёр бы resume_verdict,
|
# Пульс МЕРЖИТ counters (`counters || :counters`), поэтому resume_verdict,
|
||||||
# записанный _resume_dkp_cursor'ом перед циклом (issue #3168).
|
# записанный _resume_dkp_cursor'ом перед циклом, переживает per-batch запись
|
||||||
|
# (issue #3168; с #3390 мерж — единственная семантика, см. scrape_runs).
|
||||||
kit_runs.update_heartbeat(db, run_id, counters)
|
kit_runs.update_heartbeat(db, run_id, counters)
|
||||||
logger.info(
|
logger.info(
|
||||||
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d "
|
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d "
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -221,10 +221,17 @@ class RealProxyProvider:
|
||||||
|
|
||||||
def acquire(self, provider: str) -> ProxyLease | None:
|
def acquire(self, provider: str) -> ProxyLease | None:
|
||||||
from scraper_kit.contracts import ProxyLease as _KitProxyLease
|
from scraper_kit.contracts import ProxyLease as _KitProxyLease
|
||||||
|
from scraper_kit.orchestration.run_context import current_run_id
|
||||||
|
|
||||||
|
# #3404: протокол ProxyProvider.acquire(provider) не несёт run_id (этот
|
||||||
|
# адаптер — один объект на весь scheduler_main.py), поэтому берём его из
|
||||||
|
# ContextVar, который выставляет runs.create_run. None — вызов вне прогона
|
||||||
|
# (health-check, эстиматор) — proxy_pool.acquire в этом случае лизит под
|
||||||
|
# NON_RUN_LEASE_MARKER, как и раньше, атрибуцию в scrape_runs не пишет.
|
||||||
|
run_id = current_run_id.get()
|
||||||
db = _SessionLocal()
|
db = _SessionLocal()
|
||||||
try:
|
try:
|
||||||
lease = _proxy_pool.acquire(db, provider)
|
lease = _proxy_pool.acquire(db, provider, run_id=run_id)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
if lease is None:
|
if lease is None:
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,16 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
|
||||||
{"listing_id": listing_id},
|
{"listing_id": listing_id},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
# ПОТОЛОК: гейта сдвига разряда (#3376) здесь НЕТ, и он бы тут не
|
||||||
|
# сработал. drop_decimal_slips требует двух свидетелей — скачка ×10
|
||||||
|
# к предыдущей точке и подтверждения у следующей. На этом пути серия
|
||||||
|
# максимум из двух точек (последняя лежащая в БД + текущая), у
|
||||||
|
# последней точки свидетель — текущая цена лота, а она и ЕСТЬ эта
|
||||||
|
# точка: свидетель совпадает с подозреваемым, отношение всегда 1.0.
|
||||||
|
# То есть проводка была бы декорацией: ветка, которая по построению
|
||||||
|
# не может выбросить ни одной точки. Отлов ×10 у yandex требует
|
||||||
|
# другого механизма — сравнения со СЛЕДУЮЩИМ наблюдением, а значит
|
||||||
|
# DELETE уже вставленной строки. Отдельная задача: #3385.
|
||||||
if latest is None:
|
if latest is None:
|
||||||
# История пуста — seed (+ опционально previous-точка).
|
# История пуста — seed (+ опционально previous-точка).
|
||||||
prev = lot.price_previous_rub
|
prev = lot.price_previous_rub
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ from scraper_kit.providers.avito.detail import (
|
||||||
save_detail_enrichment,
|
save_detail_enrichment,
|
||||||
)
|
)
|
||||||
from scraper_kit.providers.avito.serp import AvitoScraper
|
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||||
|
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||||
from scraper_kit.snapshot_writer import upsert_listing_snapshot
|
from scraper_kit.snapshot_writer import upsert_listing_snapshot
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -173,6 +174,34 @@ def _top_failure(census: Counter[str]) -> str | None:
|
||||||
return f"{reason} ({hits} из {sum(census.values())})"
|
return f"{reason} ({hits} из {sum(census.values())})"
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_causes(exc: BaseException) -> list[BaseException]:
|
||||||
|
"""Цепочка причин исключения, без зацикливания (копия приёма из
|
||||||
|
domclick_detail_backfill — там же он и обкатан на #3283)."""
|
||||||
|
seen: set[int] = set()
|
||||||
|
out: list[BaseException] = []
|
||||||
|
cur: BaseException | None = exc
|
||||||
|
while cur is not None and id(cur) not in seen:
|
||||||
|
out.append(cur)
|
||||||
|
seen.add(id(cur))
|
||||||
|
cur = cur.__cause__ or cur.__context__
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _caused_by_empty_pool(exc: BaseException) -> bool:
|
||||||
|
"""Прячется ли за этим «блоком» пустой пул прокси (#3288, как #3283 у домклика).
|
||||||
|
|
||||||
|
`NoProxyAvailableError` документирован ровно как «НАША инфраструктура, не
|
||||||
|
внешний блок», и поднимается ДО HTTP-запроса: к площадке мы не ходили вовсе.
|
||||||
|
Сюда он попадает под видом блокировки, потому что fetch_detail заворачивает
|
||||||
|
в `AvitoSidecarUnavailableError` любое исключение фетча.
|
||||||
|
|
||||||
|
Опора — ТИП в цепочке `__cause__`/`__context__`, а не подстрока «no proxy
|
||||||
|
available» в тексте: текст обёртки её действительно содержит, но ровно так
|
||||||
|
#3272 уже один раз объявил блоком пользовательское описание квартиры.
|
||||||
|
"""
|
||||||
|
return any(isinstance(c, NoProxyAvailableError) for c in _iter_causes(exc))
|
||||||
|
|
||||||
|
|
||||||
# Провайдер mobileproxy.space почти всегда возвращает "rt" (секунды на переподключение
|
# Провайдер mobileproxy.space почти всегда возвращает "rt" (секунды на переподключение
|
||||||
# канала после ротации) в RotationResult.reconnect_delay_s -- см. app.services.
|
# канала после ротации) в RotationResult.reconnect_delay_s -- см. app.services.
|
||||||
# proxy_rotation docstring. Дефолт нужен ТОЛЬКО если провайдер его не прислал
|
# proxy_rotation docstring. Дефолт нужен ТОЛЬКО если провайдер его не прислал
|
||||||
|
|
@ -529,6 +558,8 @@ async def run_avito_detail_backfill(
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
aborted_by_blocks = False
|
aborted_by_blocks = False
|
||||||
abort_reason: str | None = None
|
abort_reason: str | None = None
|
||||||
|
# #3288: прогон оборван пустым пулом прокси — «нечем ходить», а не бан.
|
||||||
|
no_proxy_stop = False
|
||||||
do_sleep = False
|
do_sleep = False
|
||||||
items_since_warm = 0
|
items_since_warm = 0
|
||||||
# Счётчик попыток (успех ИЛИ отказ — оба тратят бюджет IP одинаково, см.
|
# Счётчик попыток (успех ИЛИ отказ — оба тратят бюджет IP одинаково, см.
|
||||||
|
|
@ -684,6 +715,28 @@ async def run_avito_detail_backfill(
|
||||||
)
|
)
|
||||||
if save_detail_enrichment(db, enrichment):
|
if save_detail_enrichment(db, enrichment):
|
||||||
counters.enriched += 1
|
counters.enriched += 1
|
||||||
|
else:
|
||||||
|
# #3338 (та же дыра, что #3332 у domclick): карточка взята и
|
||||||
|
# разобрана, а UPDATE не задел ни одной строки — объявление
|
||||||
|
# удалено/деактивировано между снимком и записью. Попытка была,
|
||||||
|
# исхода не было: attempted переставал сходиться с
|
||||||
|
# enriched + blocked + gone + failed, и расхождение читается как
|
||||||
|
# потерянный отказ площадки. Исход failed: непрошедший UPDATE — не
|
||||||
|
# успех, не блок и не gone (снятие метит is_active=FALSE сам, по 404).
|
||||||
|
counters.failed += 1
|
||||||
|
# Печатаем ОБА идентификатора: WHERE в save ключуется по
|
||||||
|
# source_id из разобранного HTML (item_id), а не по row-id из
|
||||||
|
# снимка. При редиректе/подмене карточки строка listing_id
|
||||||
|
# существует и жива — не нашлась строка с source_id=item_id.
|
||||||
|
logger.warning(
|
||||||
|
"avito_detail_backfill: run_id=%d listing %s -- карточка "
|
||||||
|
"разобрана, но UPDATE не нашёл строку: listing_id=%s item_id=%s "
|
||||||
|
"(WHERE по item_id из HTML)",
|
||||||
|
run_id,
|
||||||
|
source_url,
|
||||||
|
row["id"],
|
||||||
|
enrichment.item_id,
|
||||||
|
)
|
||||||
if use_curl:
|
if use_curl:
|
||||||
items_since_warm += 1
|
items_since_warm += 1
|
||||||
breaker.record_success()
|
breaker.record_success()
|
||||||
|
|
@ -743,10 +796,33 @@ async def run_avito_detail_backfill(
|
||||||
)
|
)
|
||||||
|
|
||||||
except (AvitoBlockedError, AvitoRateLimitedError) as e:
|
except (AvitoBlockedError, AvitoRateLimitedError) as e:
|
||||||
breaker.record_block()
|
# #3288: пустой пул — не блок и не отказ площадки: запрос не
|
||||||
|
# уходил вовсе, следующая карточка упрётся ровно в то же самое.
|
||||||
|
# Сюда он приезжает под видом блокировки, потому что fetch_detail
|
||||||
|
# заворачивает в AvitoSidecarUnavailableError любое исключение
|
||||||
|
# фетча. Исход честно failed (тот же разряд, что транспортные
|
||||||
|
# сбои ниже) — тождество attempted = enriched + blocked + gone +
|
||||||
|
# failed остаётся целым (#3338), а причину несёт no_proxy_stop=1
|
||||||
|
# и mark_failed с текстом про пул, как у домклика после #3283.
|
||||||
|
if _caused_by_empty_pool(e):
|
||||||
|
counters.failed += 1
|
||||||
|
logger.error(
|
||||||
|
"avito_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
|
||||||
|
"к площадке не ходили. enriched=%d attempted=%d",
|
||||||
|
run_id,
|
||||||
|
counters.enriched,
|
||||||
|
counters.attempted,
|
||||||
|
)
|
||||||
|
no_proxy_stop = True
|
||||||
|
break
|
||||||
|
|
||||||
|
ban_kind = ban_kind_of_exception(e)
|
||||||
|
# #3288: в окно доли идёт только 'platform' — infra (отказ нашего
|
||||||
|
# сайдкара) уходит в знаменатель, см. BlockRatioBreaker.record_block.
|
||||||
|
breaker.record_block(ban_kind)
|
||||||
counters.blocked += 1
|
counters.blocked += 1
|
||||||
failure_census[_failure_signature(e)] += 1
|
failure_census[_failure_signature(e)] += 1
|
||||||
block_ban_kinds[ban_kind_of_exception(e)] += 1
|
block_ban_kinds[ban_kind] += 1
|
||||||
do_sleep = False
|
do_sleep = False
|
||||||
# #3251/#3283g: на настоящий бан ПЛОЩАДКОЙ (AvitoBlockedError и подтипы:
|
# #3251/#3283g: на настоящий бан ПЛОЩАДКОЙ (AvitoBlockedError и подтипы:
|
||||||
# AvitoContentBlockedError, AvitoWarmupCookiesMissingError; НЕ на
|
# AvitoContentBlockedError, AvitoWarmupCookiesMissingError; НЕ на
|
||||||
|
|
@ -960,6 +1036,29 @@ async def run_avito_detail_backfill(
|
||||||
# и без этого ключа "banned" опять не отличить по причине (#3178).
|
# и без этого ключа "banned" опять не отличить по причине (#3178).
|
||||||
if abort_reason is not None:
|
if abort_reason is not None:
|
||||||
current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
|
current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
|
||||||
|
if no_proxy_stop:
|
||||||
|
# #3288 (как #3283 у домклика): остановка из-за пустого пула — НЕ блок,
|
||||||
|
# поэтому и не aborted_by_blocks: иначе прогон уйдёт в 'banned' и запись
|
||||||
|
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
|
||||||
|
current_counters["no_proxy_stop"] = 1
|
||||||
|
runs_mod.mark_failed(
|
||||||
|
db,
|
||||||
|
run_id,
|
||||||
|
"пул прокси пуст — к площадке не ходили (#3288)",
|
||||||
|
current_counters,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"avito_detail_backfill: run_id=%d FINISHED (пул пуст) -- attempted=%d "
|
||||||
|
"enriched=%d blocked=%d gone=%d failed=%d duration=%.1fs",
|
||||||
|
run_id,
|
||||||
|
counters.attempted,
|
||||||
|
counters.enriched,
|
||||||
|
counters.blocked,
|
||||||
|
counters.gone,
|
||||||
|
counters.failed,
|
||||||
|
counters.duration_sec,
|
||||||
|
)
|
||||||
|
return counters
|
||||||
runs_mod.mark_backfill_finished(
|
runs_mod.mark_backfill_finished(
|
||||||
db,
|
db,
|
||||||
run_id,
|
run_id,
|
||||||
|
|
@ -989,7 +1088,16 @@ async def run_avito_detail_backfill(
|
||||||
run_id,
|
run_id,
|
||||||
counters.duration_sec,
|
counters.duration_sec,
|
||||||
)
|
)
|
||||||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters.to_dict())
|
current_counters = counters.to_dict()
|
||||||
|
if _caused_by_empty_pool(exc):
|
||||||
|
# #3384: пул был пуст ещё ДО первой карточки — lease берётся в
|
||||||
|
# BrowserFetcher.__aenter__ (строка 427), поэтому NoProxyAvailableError
|
||||||
|
# вылетает мимо стоп-механики цикла, которая и ставит no_proxy_stop. Без
|
||||||
|
# ключа такой прогон (attempted=0, к площадке не ходили) неотличим от
|
||||||
|
# любого другого падения: разбор простоя идёт SQL'ём по
|
||||||
|
# counters.no_proxy_stop (#3288/#3367), а не грепом текста ошибки.
|
||||||
|
current_counters["no_proxy_stop"] = 1
|
||||||
|
runs_mod.mark_failed(db, run_id, str(exc)[:1000], current_counters)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
95
tradein-mvp/backend/app/tasks/cbr_macro_pull.py
Normal file
95
tradein-mvp/backend/app/tasks/cbr_macro_pull.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""CLI: макро-ряды ЦБ РФ — ипотека по субъектам + ключевая ставка (#issue см. vault).
|
||||||
|
|
||||||
|
Запуск из контейнера:
|
||||||
|
python -m app.tasks.cbr_macro_pull # всё: ипотека + ставка
|
||||||
|
python -m app.tasks.cbr_macro_pull --dry-run
|
||||||
|
python -m app.tasks.cbr_macro_pull --series rate_rub debt_rub
|
||||||
|
python -m app.tasks.cbr_macro_pull --skip-mortgage --from-date 2024-01-01 --to-date 2024-12-31
|
||||||
|
python -m app.tasks.cbr_macro_pull --skip-key-rate
|
||||||
|
|
||||||
|
Делегирует всю логику app.services.cbr_macro (download → parse → upsert); этот
|
||||||
|
модуль — только argparse + lifecycle сессии + commit между стадиями.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.cbr_macro import (
|
||||||
|
CBR_MORTGAGE_SERIES,
|
||||||
|
load_key_rate,
|
||||||
|
pull_cbr_mortgage,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SERIES_SLUGS = [s.slug for s in CBR_MORTGAGE_SERIES]
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="Загрузка макро-рядов ЦБ РФ")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="только посчитать, без upsert")
|
||||||
|
parser.add_argument(
|
||||||
|
"--series",
|
||||||
|
nargs="*",
|
||||||
|
choices=_SERIES_SLUGS,
|
||||||
|
default=None,
|
||||||
|
help="подмножество серий ипотеки (по умолчанию — все)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--skip-mortgage", action="store_true", help="не тянуть ипотечные серии")
|
||||||
|
parser.add_argument("--skip-key-rate", action="store_true", help="не тянуть ключевую ставку")
|
||||||
|
parser.add_argument(
|
||||||
|
"--from-date", type=date.fromisoformat, default=None, help="ключевая ставка: ISO-дата от"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--to-date", type=date.fromisoformat, default=None, help="ключевая ставка: ISO-дата до"
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||||
|
)
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.skip_mortgage and args.skip_key_rate:
|
||||||
|
parser.error("--skip-mortgage и --skip-key-rate вместе не имеют смысла")
|
||||||
|
|
||||||
|
mortgage_counts: dict[str, int] = {}
|
||||||
|
key_rate_counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if not args.skip_mortgage:
|
||||||
|
series_list = None
|
||||||
|
if args.series:
|
||||||
|
series_list = [s for s in CBR_MORTGAGE_SERIES if s.slug in args.series]
|
||||||
|
mortgage_counts = pull_cbr_mortgage(
|
||||||
|
db, series_list=series_list, dry_run=args.dry_run
|
||||||
|
)
|
||||||
|
if not args.dry_run:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if not args.skip_key_rate:
|
||||||
|
key_rate_counts = load_key_rate(
|
||||||
|
db, from_date=args.from_date, to_date=args.to_date, dry_run=args.dry_run
|
||||||
|
)
|
||||||
|
if not args.dry_run:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"cbr_macro_pull DONE: dry_run=%s mortgage=%s key_rate=%s",
|
||||||
|
args.dry_run,
|
||||||
|
mortgage_counts,
|
||||||
|
key_rate_counts,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -38,8 +38,11 @@ from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
|
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
|
||||||
|
from scraper_kit.orchestration.pipeline import ban_kind_of_exception
|
||||||
|
from scraper_kit.orchestration.runs import BAN_KIND_UNKNOWN
|
||||||
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
|
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
|
||||||
from scraper_kit.providers.cian.valuation import estimate_via_cian_valuation
|
from scraper_kit.providers.cian.valuation import estimate_via_cian_valuation
|
||||||
|
from scraper_kit.proxy_errors import caused_by_no_proxy
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -77,6 +80,10 @@ class CianBackfillResult:
|
||||||
# у него не проставлялся вовсе.
|
# у него не проставлялся вовсе.
|
||||||
listings_blocked: int = 0
|
listings_blocked: int = 0
|
||||||
ban_kinds: Counter[str] = field(default_factory=Counter)
|
ban_kinds: Counter[str] = field(default_factory=Counter)
|
||||||
|
# #3197: прогон оборван, потому что пул прокси пуст — к площадке не ходили вовсе.
|
||||||
|
# Не блок и не отказ Циана: caller (scheduler) обязан пометить прогон failed, а не
|
||||||
|
# banned, иначе запись утверждает про площадку то, чего не было.
|
||||||
|
no_proxy_stop: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ban_kind(self) -> str:
|
def ban_kind(self) -> str:
|
||||||
|
|
@ -91,18 +98,32 @@ class CianBackfillResult:
|
||||||
return _dominant_ban_kind(self.ban_kinds)
|
return _dominant_ban_kind(self.ban_kinds)
|
||||||
|
|
||||||
|
|
||||||
def _note_refusal(result: CianBackfillResult, status: int | None) -> str | None:
|
def _note_refusal(
|
||||||
"""Записать отказ detail-фетча, если его природа диагностируема по HTTP-статусу (#3196).
|
result: CianBackfillResult, status: int | None, exc: BaseException | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""Записать отказ detail-фетча, если его природа установлена — типом или статусом.
|
||||||
|
|
||||||
Инвариант: непустой `ban_kinds` ⟺ мы видели ответ с 403/429/5xx. `ban_kind_from_status`
|
Инвариант (#3196) прежний: непустой `ban_kinds` ⟺ отказ был ПОКАЗАН, а не назначен.
|
||||||
отвечает диагнозом только на такие статусы; на всё прочее (в т.ч. статуса нет — сайдкар
|
Установить его можно двумя способами, и статуса одного мало:
|
||||||
не дошёл до навигации, или это HTTP 200 с промахом нашего парсера — дрейф разметки, а
|
|
||||||
не отказ площадки) возвращает None, и тогда эта функция НЕ инкрементит ни
|
* ТИП исключения (#3402 follow-up) — `CianBlockedError` (капча Циана, отказ
|
||||||
`listings_blocked`, ни `ban_kinds` — только возвращает None вызывающему. Раньше
|
сайдкара `SidecarBanPageError`, WAF-403) наследует `ProxyBanError`, то есть по
|
||||||
недиагностируемые случаи писались как 'unknown' и это в scrape_runs.mark_banned
|
построению означает «площадка себя показала»: `ban_kind_of_exception` даёт
|
||||||
(scheduler.py) превращало наши собственные сбои в фиктивный бан площадки.
|
'platform'. Капча приезжает с HTTP 200 (свой детект по <title>) или вообще без
|
||||||
|
статуса (сайдкар не дошёл до навигации), поэтому по статусу она диагностировалась
|
||||||
|
как «не разобрали»: рос только `listings_failed_fetch`, `ban_kinds` оставался
|
||||||
|
пустым — и следующая капча-волна снова выглядела бы дрейфом нашей разметки;
|
||||||
|
* HTTP-статус ответа (403/429/5xx) — прежний путь для всего остального.
|
||||||
|
|
||||||
|
Всё, что не установлено ни тем, ни другим ('unknown' по типу И None по статусу), НЕ
|
||||||
|
инкрементит ни `listings_blocked`, ни `ban_kinds`: недиагностируемые случаи, записанные
|
||||||
|
как 'unknown', в scrape_runs.mark_banned (scheduler.py) превращали наши собственные
|
||||||
|
сбои в фиктивный бан площадки (#2764).
|
||||||
"""
|
"""
|
||||||
kind = ban_kind_from_status(status)
|
kind = ban_kind_of_exception(exc) if exc is not None else BAN_KIND_UNKNOWN
|
||||||
|
if kind == BAN_KIND_UNKNOWN:
|
||||||
|
# Тип ничего не доказал — спрашиваем статус (прежнее поведение).
|
||||||
|
kind = ban_kind_from_status(status)
|
||||||
if kind is None:
|
if kind is None:
|
||||||
return None
|
return None
|
||||||
result.listings_blocked += 1
|
result.listings_blocked += 1
|
||||||
|
|
@ -203,7 +224,20 @@ async def backfill_cian_history(
|
||||||
else:
|
else:
|
||||||
# One BrowserFetcher instance shared across all listings in this batch.
|
# One BrowserFetcher instance shared across all listings in this batch.
|
||||||
# priceChanges requires JS rendering — curl_cffi returns empty list (#1574).
|
# priceChanges requires JS rendering — curl_cffi returns empty list (#1574).
|
||||||
async with BrowserFetcher(source="cian", endpoint=settings.browser_http_endpoint) as bf:
|
# proxy_provider/use_pool/environment (#3197, шаг A2): без этих трёх сайдкар
|
||||||
|
# берёт свой env-прокси (SCRAPER_PROXY_URL) — прогон шёл мимо пула из 4 узлов
|
||||||
|
# целиком (ни выбора узла, ни scrape_proxy_source_bans, ни ротации), а
|
||||||
|
# прод-отказ «пул пуст → не ходить на env/direct» (#2616) на этом пути был
|
||||||
|
# мёртв: он смотрит на environment, который сюда не доезжал. Образец —
|
||||||
|
# domclick_detail_backfill.py:403 и house_imv_backfill.py:749 (#2698/#3197).
|
||||||
|
_cfg = RealScraperConfig()
|
||||||
|
async with BrowserFetcher(
|
||||||
|
source="cian",
|
||||||
|
endpoint=settings.browser_http_endpoint,
|
||||||
|
proxy_provider=RealProxyProvider(),
|
||||||
|
use_pool=_cfg.use_proxy_pool_browser,
|
||||||
|
environment=_cfg.environment,
|
||||||
|
) as bf:
|
||||||
for row in rows:
|
for row in rows:
|
||||||
listing_id: int = row["id"]
|
listing_id: int = row["id"]
|
||||||
source_url: str = row["source_url"]
|
source_url: str = row["source_url"]
|
||||||
|
|
@ -215,7 +249,26 @@ async def backfill_cian_history(
|
||||||
try:
|
try:
|
||||||
enrichment = await fetch_detail(source_url, browser_fetcher=bf)
|
enrichment = await fetch_detail(source_url, browser_fetcher=bf)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
kind = _note_refusal(result, bf.last_response_status)
|
# #3197: пустой пул — не отказ площадки: запрос не уходил вовсе,
|
||||||
|
# следующее объявление упрётся ровно в то же самое (иначе батч
|
||||||
|
# крутит впустую весь список). Опора — тип в цепочке причин, а не
|
||||||
|
# текст: fetch_detail заворачивает сбой фетча в своё исключение.
|
||||||
|
if caused_by_no_proxy(exc):
|
||||||
|
result.no_proxy_stop = True
|
||||||
|
result.listings_failed_fetch += 1
|
||||||
|
logger.error(
|
||||||
|
"cian_history_backfill: СТОП — пул прокси пуст, к площадке "
|
||||||
|
"не ходили. listing_id=%s processed=%d succeeded=%d",
|
||||||
|
listing_id,
|
||||||
|
result.listings_processed,
|
||||||
|
result.listings_succeeded,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
# exc, а не только статус: капча Циана — подтверждённый отказ
|
||||||
|
# площадки (CianBlockedError), но приходит с HTTP 200/без статуса
|
||||||
|
# (#3402 follow-up). Без типа волна капчи писалась в счётчик
|
||||||
|
# «не разобрали» и прогон отдавал пустой ban_kinds.
|
||||||
|
kind = _note_refusal(result, bf.last_response_status, exc)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"cian_detail fetch failed for listing_id=%s url=%s: %s "
|
"cian_detail fetch failed for listing_id=%s url=%s: %s "
|
||||||
"(http=%s ban_kind=%s)",
|
"(http=%s ban_kind=%s)",
|
||||||
|
|
@ -272,7 +325,9 @@ async def backfill_cian_history(
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
# ── 2. Houses: missing houses_price_dynamics ──────────────────────────────
|
# ── 2. Houses: missing houses_price_dynamics ──────────────────────────────
|
||||||
if do_houses:
|
# no_proxy_stop (#3197): пул пуст — дома идут через тот же пул (fetch_newbuilding с
|
||||||
|
# RealProxyProvider ниже), крутить их незачем.
|
||||||
|
if do_houses and not result.no_proxy_stop:
|
||||||
# Kit's fetch_newbuilding() now accepts config= (issue #2322 fixed) — pass
|
# Kit's fetch_newbuilding() now accepts config= (issue #2322 fixed) — pass
|
||||||
# RealScraperConfig() at the call site below so BrowserFetcher gets a real
|
# RealScraperConfig() at the call site below so BrowserFetcher gets a real
|
||||||
# endpoint instead of degrading to endpoint=None (#2397 Part D2).
|
# endpoint instead of degrading to endpoint=None (#2397 Part D2).
|
||||||
|
|
@ -363,7 +418,7 @@ async def backfill_cian_history(
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
# ── 3. Cian listings без external_valuations (price prediction backfill) ──
|
# ── 3. Cian listings без external_valuations (price prediction backfill) ──
|
||||||
if do_valuations:
|
if do_valuations and not result.no_proxy_stop: # #3197: пул пуст — см. блок домов
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text("""
|
text("""
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,29 @@ asking_to_sold_ratio_refresh (06:00-07:00 UTC), чтобы бэнды счита
|
||||||
свежему срезу deals, что и ratio-таблица того же дня.
|
свежему срезу deals, что и ratio-таблица того же дня.
|
||||||
|
|
||||||
SQL derivation ниже — БАЙТ-В-БАЙТ та же логика, что seed в
|
SQL derivation ниже — БАЙТ-В-БАЙТ та же логика, что seed в
|
||||||
data/sql/194_deal_city_price_bands_tiers.sql (region_stats / city_stats / tiered:
|
data/sql/298_deal_city_price_bands_region.sql (region_stats / city_stats / tiered:
|
||||||
трёхуровневая схема full N>=30 / rough N 10-29 / region_fallback N 1-9, см.
|
трёхуровневая схема full N>=30 / rough N 10-29 / region_fallback N 1-9, см.
|
||||||
комментарий в 194 для полного обоснования тиров и hard floor/ceiling клампов).
|
комментарий в 194/298 для полного обоснования тиров и hard floor/ceiling клампов).
|
||||||
|
|
||||||
|
#3051 «Москва» (298): ключ (region_code, city) вместо (city) — region_stats и
|
||||||
|
city_stats теперь группируются ПО РЕГИОНУ (region_code), а не по всей таблице
|
||||||
|
deals целиком. Без этого пул для tier='region_fallback' одного региона
|
||||||
|
подмешивал бы сделки другого (Москва в deals region_code=77 иначе тянула бы
|
||||||
|
p1-floor малых городов Свердловской обл. region_code=66 вверх). Для
|
||||||
|
region_code=66 derivation байт-в-байт прежняя (194): фильтр
|
||||||
|
NOT (region_code = 66 AND city = 'Екатеринбург') — тот же инвариант, что
|
||||||
|
раньше `city <> 'Екатеринбург'`; region_stats/city_stats для региона 66 видят
|
||||||
|
ТУ ЖЕ популяцию строк, что видели до появления региона 77 в deals.
|
||||||
|
|
||||||
Нет DELETE перед re-derive (в отличие от asking_to_sold_ratio.py true-mirror
|
Нет DELETE перед re-derive (в отличие от asking_to_sold_ratio.py true-mirror
|
||||||
паттерна) — множество городов монотонно растёт (rosreestr_dkp_import только
|
паттерна) — множество (region_code, city) монотонно растёт
|
||||||
INSERT/ON CONFLICT DO UPDATE, никогда не удаляет сделки), поэтому merge-по-city
|
(rosreestr_dkp_import только INSERT/ON CONFLICT DO UPDATE, никогда не удаляет
|
||||||
(ON CONFLICT DO UPDATE) достаточен: город, перешедший в другой tier, просто
|
сделки), поэтому merge-по-ключу (ON CONFLICT DO UPDATE) достаточен: город,
|
||||||
перезаписывается на следующем refresh. Екатеринбург НЕ включён (WHERE city <>
|
перешедший в другой tier, просто перезаписывается на следующем refresh.
|
||||||
'Екатеринбург') — estimator.py fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2
|
Екатеринбург НЕ включён для региона 66 (WHERE NOT (region_code = 66 AND
|
||||||
для ЕКБ остаётся byte-identical (invariant из 178/194 сохранён).
|
city = 'Екатеринбург')) — estimator.py fallback на глобальные
|
||||||
|
DEAL_MIN_PPM2/DEAL_MAX_PPM2 для ЕКБ остаётся byte-identical (invariant из
|
||||||
|
178/194/298 сохранён).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -39,22 +51,28 @@ from app.services import scrape_runs as runs_mod
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 194) ─────────────────────────────────
|
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 298, region-aware) ──────────────────
|
||||||
_REDERIVE_SQL = text(
|
_REDERIVE_SQL = text(
|
||||||
"""
|
"""
|
||||||
WITH region_stats AS (
|
WITH region_stats AS (
|
||||||
SELECT GREATEST(
|
SELECT
|
||||||
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
|
region_code,
|
||||||
8000
|
GREATEST(
|
||||||
) AS region_ppm2_min
|
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
|
||||||
|
8000
|
||||||
|
) AS region_ppm2_min
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
|
AND doc_type = 'ДКП'
|
||||||
AND price_per_m2 IS NOT NULL
|
AND price_per_m2 IS NOT NULL
|
||||||
AND city IS NOT NULL
|
AND city IS NOT NULL
|
||||||
AND city <> 'Екатеринбург'
|
AND region_code IS NOT NULL
|
||||||
|
AND NOT (region_code = 66 AND city = 'Екатеринбург')
|
||||||
|
GROUP BY region_code
|
||||||
),
|
),
|
||||||
city_stats AS (
|
city_stats AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
region_code,
|
||||||
city,
|
city,
|
||||||
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
|
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
|
||||||
AS ppm2_p1,
|
AS ppm2_p1,
|
||||||
|
|
@ -63,13 +81,15 @@ _REDERIVE_SQL = text(
|
||||||
count(*) AS n_deals
|
count(*) AS n_deals
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
|
AND doc_type = 'ДКП'
|
||||||
AND price_per_m2 IS NOT NULL
|
AND price_per_m2 IS NOT NULL
|
||||||
AND city IS NOT NULL
|
AND city IS NOT NULL
|
||||||
AND city <> 'Екатеринбург'
|
AND region_code IS NOT NULL
|
||||||
GROUP BY city
|
AND NOT (region_code = 66 AND city = 'Екатеринбург')
|
||||||
|
GROUP BY region_code, city
|
||||||
),
|
),
|
||||||
tiered AS (
|
tiered AS (
|
||||||
SELECT city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
|
SELECT region_code, city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
|
||||||
'full'::text AS tier
|
'full'::text AS tier
|
||||||
FROM city_stats
|
FROM city_stats
|
||||||
WHERE n_deals >= 30
|
WHERE n_deals >= 30
|
||||||
|
|
@ -77,23 +97,24 @@ _REDERIVE_SQL = text(
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
SELECT city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals,
|
SELECT region_code, city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max,
|
||||||
'rough'::text AS tier
|
n_deals, 'rough'::text AS tier
|
||||||
FROM city_stats
|
FROM city_stats
|
||||||
WHERE n_deals BETWEEN 10 AND 29
|
WHERE n_deals BETWEEN 10 AND 29
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
SELECT c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals,
|
SELECT c.region_code, c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max,
|
||||||
'region_fallback'::text AS tier
|
c.n_deals, 'region_fallback'::text AS tier
|
||||||
FROM city_stats c
|
FROM city_stats c
|
||||||
CROSS JOIN region_stats r
|
JOIN region_stats r ON r.region_code = c.region_code
|
||||||
WHERE c.n_deals < 10
|
WHERE c.n_deals < 10
|
||||||
)
|
)
|
||||||
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
|
INSERT INTO deal_city_price_bands
|
||||||
SELECT city, ppm2_min, ppm2_max, n_deals, tier, now()
|
(region_code, city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
|
||||||
|
SELECT region_code, city, ppm2_min, ppm2_max, n_deals, tier, now()
|
||||||
FROM tiered
|
FROM tiered
|
||||||
ON CONFLICT (city) DO UPDATE
|
ON CONFLICT (region_code, city) DO UPDATE
|
||||||
SET ppm2_min = EXCLUDED.ppm2_min,
|
SET ppm2_min = EXCLUDED.ppm2_min,
|
||||||
ppm2_max = EXCLUDED.ppm2_max,
|
ppm2_max = EXCLUDED.ppm2_max,
|
||||||
n_deals = EXCLUDED.n_deals,
|
n_deals = EXCLUDED.n_deals,
|
||||||
|
|
@ -103,13 +124,18 @@ _REDERIVE_SQL = text(
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Post-insert counters ──────────────────────────────────────────────────────
|
# ── Post-insert counters ──────────────────────────────────────────────────────
|
||||||
|
# #3051 (298): regions — число различных region_code в таблице после re-derive
|
||||||
|
# (Свердловская обл. + Москва после включения региона 77). Добавлено в конец
|
||||||
|
# SELECT-списка, прежние 4 счётчика на тех же местах — контракт _COUNTERS_SQL
|
||||||
|
# (rows_written/full_rows/rough_rows/region_fallback_rows) не ломается.
|
||||||
_COUNTERS_SQL = text(
|
_COUNTERS_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS rows_written,
|
COUNT(*) AS rows_written,
|
||||||
COUNT(*) FILTER (WHERE tier = 'full') AS full_rows,
|
COUNT(*) FILTER (WHERE tier = 'full') AS full_rows,
|
||||||
COUNT(*) FILTER (WHERE tier = 'rough') AS rough_rows,
|
COUNT(*) FILTER (WHERE tier = 'rough') AS rough_rows,
|
||||||
COUNT(*) FILTER (WHERE tier = 'region_fallback') AS region_fallback_rows
|
COUNT(*) FILTER (WHERE tier = 'region_fallback') AS region_fallback_rows,
|
||||||
|
COUNT(DISTINCT region_code) AS regions
|
||||||
FROM deal_city_price_bands
|
FROM deal_city_price_bands
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
@ -124,13 +150,15 @@ def refresh_deal_city_price_bands(db: Session, run_id: int) -> dict[str, int]:
|
||||||
|
|
||||||
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
||||||
|
|
||||||
Returns {"rows_written": N, "full_rows": .., "rough_rows": .., "region_fallback_rows": ..}.
|
Returns {"rows_written": N, "full_rows": .., "rough_rows": .., "region_fallback_rows": ..,
|
||||||
|
"regions": ..}.
|
||||||
"""
|
"""
|
||||||
counters: dict[str, int] = {
|
counters: dict[str, int] = {
|
||||||
"rows_written": 0,
|
"rows_written": 0,
|
||||||
"full_rows": 0,
|
"full_rows": 0,
|
||||||
"rough_rows": 0,
|
"rough_rows": 0,
|
||||||
"region_fallback_rows": 0,
|
"region_fallback_rows": 0,
|
||||||
|
"regions": 0,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
db.execute(_REDERIVE_SQL)
|
db.execute(_REDERIVE_SQL)
|
||||||
|
|
@ -141,17 +169,19 @@ def refresh_deal_city_price_bands(db: Session, run_id: int) -> dict[str, int]:
|
||||||
counters["full_rows"] = int(row["full_rows"] or 0)
|
counters["full_rows"] = int(row["full_rows"] or 0)
|
||||||
counters["rough_rows"] = int(row["rough_rows"] or 0)
|
counters["rough_rows"] = int(row["rough_rows"] or 0)
|
||||||
counters["region_fallback_rows"] = int(row["region_fallback_rows"] or 0)
|
counters["region_fallback_rows"] = int(row["region_fallback_rows"] or 0)
|
||||||
|
counters["regions"] = int(row["regions"] or 0)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
runs_mod.mark_done(db, run_id, counters)
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
logger.info(
|
logger.info(
|
||||||
"refresh_deal_city_price_bands run_id=%d done: "
|
"refresh_deal_city_price_bands run_id=%d done: "
|
||||||
"rows_written=%d full=%d rough=%d region_fallback=%d",
|
"rows_written=%d full=%d rough=%d region_fallback=%d regions=%d",
|
||||||
run_id,
|
run_id,
|
||||||
counters["rows_written"],
|
counters["rows_written"],
|
||||||
counters["full_rows"],
|
counters["full_rows"],
|
||||||
counters["rough_rows"],
|
counters["rough_rows"],
|
||||||
counters["region_fallback_rows"],
|
counters["region_fallback_rows"],
|
||||||
|
counters["regions"],
|
||||||
)
|
)
|
||||||
return counters
|
return counters
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,19 @@ async def run_domclick_detail_backfill(
|
||||||
enrichment = await fetch_detail(source_url, browser_fetcher=bf, cookies=cookies)
|
enrichment = await fetch_detail(source_url, browser_fetcher=bf, cookies=cookies)
|
||||||
if save_detail_enrichment(db, listing_id, enrichment):
|
if save_detail_enrichment(db, listing_id, enrichment):
|
||||||
counters.enriched += 1
|
counters.enriched += 1
|
||||||
|
else:
|
||||||
|
# #3332, та же дыра в тождестве, что и у пустого пула: карточку
|
||||||
|
# забрали, а строки уже нет (удалена/деактивирована между
|
||||||
|
# снимком и UPDATE) — попытка была, исхода не было. Тихо
|
||||||
|
# терять её нельзя: расхождение читается как потерянный блок.
|
||||||
|
counters.failed += 1
|
||||||
|
logger.warning(
|
||||||
|
"domclick_detail_backfill: run_id=%d listing %s — карточка "
|
||||||
|
"разобрана, но UPDATE не нашёл строку id=%d",
|
||||||
|
run_id,
|
||||||
|
source_url,
|
||||||
|
listing_id,
|
||||||
|
)
|
||||||
consecutive_blocks = 0
|
consecutive_blocks = 0
|
||||||
consecutive_soft = 0
|
consecutive_soft = 0
|
||||||
|
|
||||||
|
|
@ -471,6 +484,15 @@ async def run_domclick_detail_backfill(
|
||||||
# Прогон 5399 умер именно так: три «блока» подряд, из них два
|
# Прогон 5399 умер именно так: три «блока» подряд, из них два
|
||||||
# 500 от сайдкара и один пустой пул, отказов площадки — ноль.
|
# 500 от сайдкара и один пустой пул, отказов площадки — ноль.
|
||||||
if _caused_by_empty_pool(e):
|
if _caused_by_empty_pool(e):
|
||||||
|
# #3332: попытка уже посчитана в attempted (строка выше), а этот
|
||||||
|
# выход из цикла шёл мимо ВСЕХ исходов — тождество
|
||||||
|
# attempted = enriched + failed + blocked ломалось ровно на 1
|
||||||
|
# (прод: 5 прогонов с diff=1, каждый оборванный пустым пулом).
|
||||||
|
# Исход честно failed, а не blocked: к площадке не ходили, это
|
||||||
|
# отказ нашей стороны — тот же разряд, что у транспортных сбоев
|
||||||
|
# ниже. Причина не теряется: в записи прогона стоит
|
||||||
|
# no_proxy_stop=1 и mark_failed с текстом про пул.
|
||||||
|
counters.failed += 1
|
||||||
logger.error(
|
logger.error(
|
||||||
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
|
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
|
||||||
"к площадке не ходили. enriched=%d attempted=%d",
|
"к площадке не ходили. enriched=%d attempted=%d",
|
||||||
|
|
@ -620,5 +642,14 @@ async def run_domclick_detail_backfill(
|
||||||
run_id,
|
run_id,
|
||||||
counters.duration_sec,
|
counters.duration_sec,
|
||||||
)
|
)
|
||||||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters.to_dict())
|
current_counters = counters.to_dict()
|
||||||
|
if _caused_by_empty_pool(exc):
|
||||||
|
# #3384: пул был пуст ещё ДО первой карточки — lease берётся в
|
||||||
|
# BrowserFetcher.__aenter__, поэтому NoProxyAvailableError вылетает из
|
||||||
|
# самого `async with` (строка 403) мимо стоп-механики цикла, которая и
|
||||||
|
# ставит no_proxy_stop. Без ключа такой прогон (attempted=0, к площадке не
|
||||||
|
# ходили) неотличим от любого другого падения: разбор простоя идёт SQL'ём
|
||||||
|
# по counters.no_proxy_stop (#3283/#3367), а не грепом текста ошибки.
|
||||||
|
current_counters["no_proxy_stop"] = 1
|
||||||
|
runs_mod.mark_failed(db, run_id, str(exc)[:1000], current_counters)
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
119
tradein-mvp/backend/app/tasks/dtp_stat_refresh.py
Normal file
119
tradein-mvp/backend/app/tasks/dtp_stat_refresh.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"""ДТП (dtp-stat.ru) refresh — CLI + scheduler run-lifecycle wrapper (#3410).
|
||||||
|
|
||||||
|
Наполняет `dtp_incidents` (пустая при деплое, миграция 294) через
|
||||||
|
`app/services/dtp_stat_loader.load_dtp_incidents` (скачивание ZIP + потоковый парс +
|
||||||
|
TRUNCATE+INSERT). Scheduler source='dtp_stat_refresh' (seed 293, enabled=false —
|
||||||
|
источник заморожен, см. докстринг loader'а; первый прогон ручной).
|
||||||
|
|
||||||
|
Запуск из контейнера:
|
||||||
|
python -m app.tasks.dtp_stat_refresh
|
||||||
|
python -m app.tasks.dtp_stat_refresh --dry-run
|
||||||
|
python -m app.tasks.dtp_stat_refresh --src-path /path/to/local.zip
|
||||||
|
|
||||||
|
psycopg v3: `CAST(:x AS type)`, никогда `:x::type`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.services import scrape_runs as runs_mod
|
||||||
|
from app.services.dtp_stat_loader import load_dtp_incidents
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DtpStatRefreshResult:
|
||||||
|
parsed: int = 0
|
||||||
|
inserted: int = 0
|
||||||
|
duration_sec: float = field(default=0.0)
|
||||||
|
|
||||||
|
def to_counters(self) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"parsed": self.parsed,
|
||||||
|
"inserted": self.inserted,
|
||||||
|
"duration_sec": int(self.duration_sec),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_dtp_stat_refresh(db: Session, *, run_id: int, params: dict) -> DtpStatRefreshResult:
|
||||||
|
"""Run-lifecycle wrapper для scheduler'а (source='dtp_stat_refresh') и ручного прогона.
|
||||||
|
|
||||||
|
params:
|
||||||
|
dry_run — bool, по умолчанию False. True: парсит и считает, БД не трогает.
|
||||||
|
src_path — str, локальный путь к ZIP (для отладки/тестов, минуя скачивание).
|
||||||
|
|
||||||
|
Финализирует scrape_runs (mark_done / mark_failed) со счётчиками.
|
||||||
|
"""
|
||||||
|
dry_run = bool(params.get("dry_run", False))
|
||||||
|
src_path = params.get("src_path")
|
||||||
|
|
||||||
|
counters: dict[str, int] = {"parsed": 0, "inserted": 0}
|
||||||
|
start = time.monotonic()
|
||||||
|
|
||||||
|
try:
|
||||||
|
runs_mod.update_heartbeat(db, run_id, counters)
|
||||||
|
|
||||||
|
load_counts = load_dtp_incidents(db, src_path=src_path, dry_run=dry_run)
|
||||||
|
if not dry_run:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = DtpStatRefreshResult(
|
||||||
|
parsed=load_counts["parsed"],
|
||||||
|
inserted=load_counts["inserted"],
|
||||||
|
duration_sec=time.monotonic() - start,
|
||||||
|
)
|
||||||
|
counters = result.to_counters()
|
||||||
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
|
logger.info(
|
||||||
|
"run_dtp_stat_refresh: run_id=%d DONE dry_run=%s parsed=%d inserted=%d duration=%.1fs",
|
||||||
|
run_id,
|
||||||
|
dry_run,
|
||||||
|
result.parsed,
|
||||||
|
result.inserted,
|
||||||
|
result.duration_sec,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("run_dtp_stat_refresh: run_id=%d FAILED", run_id)
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--src-path", default=None, help="Локальный ZIP вместо скачивания SRC_URL")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Парсить и считать, не писать в БД")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||||
|
)
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
counts = load_dtp_incidents(db, src_path=args.src_path, dry_run=args.dry_run)
|
||||||
|
if not args.dry_run:
|
||||||
|
db.commit()
|
||||||
|
logger.info("dtp_stat_refresh CLI DONE: dry_run=%s %s", args.dry_run, counts)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
71
tradein-mvp/backend/app/tasks/fns_opendata_load.py
Normal file
71
tradein-mvp/backend/app/tasks/fns_opendata_load.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""CLI: ФНС opendata (revexp/sshr2019/debtam/snr) → fns_legal_entity_facts.
|
||||||
|
|
||||||
|
Только загрузка + lookup (`app/services/fns_lookup.py`) — потребителя у данных пока
|
||||||
|
нет, см. docstring `app/services/fns_opendata_loader.py`.
|
||||||
|
|
||||||
|
Запуск из контейнера tradein-backend/tradein-scraper (нужен интернет к nalog.gov.ru):
|
||||||
|
|
||||||
|
python -m app.tasks.fns_opendata_load # все 4 набора
|
||||||
|
python -m app.tasks.fns_opendata_load --datasets revexp # один набор
|
||||||
|
python -m app.tasks.fns_opendata_load --dry-run # без записи, только резолв ссылки
|
||||||
|
python -m app.tasks.fns_opendata_load --force # перекачать, даже если версия та же
|
||||||
|
|
||||||
|
В режиме --dry-run скачивание/парс/запись не происходят — только резолв актуальной
|
||||||
|
ссылки со страницы каталога (проверка доступности + логирование того, что было бы
|
||||||
|
скачано).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.fns_opendata_loader import DATASET_SLUGS, load_dataset
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="ФНС opendata loader: revexp/sshr2019/debtam/snr → fns_legal_entity_facts"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--datasets",
|
||||||
|
nargs="+",
|
||||||
|
choices=list(DATASET_SLUGS),
|
||||||
|
default=list(DATASET_SLUGS),
|
||||||
|
help="список наборов (по умолчанию — все 4)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true", help="без записи в БД — только резолв ссылки/подсчёт"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force", action="store_true", help="перекачать, даже если версия совпадает с загруженной"
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
results: dict[str, object] = {}
|
||||||
|
for slug in args.datasets:
|
||||||
|
result = load_dataset(db, slug, dry_run=args.dry_run, force=args.force)
|
||||||
|
if not args.dry_run:
|
||||||
|
db.commit()
|
||||||
|
results[slug] = result
|
||||||
|
logger.info("fns_opendata_load DONE: dry_run=%s %s", args.dry_run, results)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
127
tradein-mvp/backend/app/tasks/frt_mkd_load.py
Normal file
127
tradein-mvp/backend/app/tasks/frt_mkd_load.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"""CLI: АИС ППК ФРТ (реестр МКД) → houses.area_land/foundation_type/elevators_total +
|
||||||
|
добор year_built/material_walls/total_floors/entrances/is_emergency/flat_count/
|
||||||
|
heat_supply_type/gas_supply_type/hot_water (issue #frt-mkd).
|
||||||
|
|
||||||
|
Тянет CSV реестра МКД (export/{node_id}, по умолчанию 110 = region 66) с открытых
|
||||||
|
данных АИС ППК ФРТ, UPSERT'ит в staging `frt_mkd` (мигр. 290), затем backfill_houses —
|
||||||
|
COALESCE-добор перечисленных houses.* полей (только NULL, никогда не перезаписывает).
|
||||||
|
|
||||||
|
Запуск из контейнера tradein-scraper (у него есть интернет к источнику):
|
||||||
|
|
||||||
|
python -m app.tasks.frt_mkd_load # полный прогон (node 110, region 66)
|
||||||
|
python -m app.tasks.frt_mkd_load --dry-run # без записи, только подсчёт
|
||||||
|
python -m app.tasks.frt_mkd_load --load-only # только staging, без backfill
|
||||||
|
python -m app.tasks.frt_mkd_load --backfill-only # staging уже загружена — только backfill
|
||||||
|
python -m app.tasks.frt_mkd_load --src-path /tmp/frt.csv # локальный CSV, без скачивания
|
||||||
|
|
||||||
|
В режиме --dry-run скачивание/парс (если применимо) происходят, но НИ ОДНОЙ записи
|
||||||
|
в БД не делается.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.frt_mkd_loader import (
|
||||||
|
DEFAULT_NODE_ID,
|
||||||
|
DEFAULT_REGION_CODE,
|
||||||
|
backfill_houses,
|
||||||
|
load_frt_mkd,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"АИС ППК ФРТ loader: реестр МКД → frt_mkd staging → "
|
||||||
|
"houses.area_land/foundation_type/elevators_total + добор смежных полей"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--src-path", default=None, help="локальный CSV реестра МКД (пропустить скачивание)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--work-dir",
|
||||||
|
default=None,
|
||||||
|
help="каталог для скачанного/распакованного CSV (по умолчанию — временный, "
|
||||||
|
"удаляется после)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--node-id",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_NODE_ID,
|
||||||
|
help=f"id ноды export АИС ФРТ (по умолчанию {DEFAULT_NODE_ID} = реестр МКД region 66)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--region-code",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_REGION_CODE,
|
||||||
|
help=f"код региона выгрузки для тега staging-строк (по умолчанию {DEFAULT_REGION_CODE})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true", help="без записи в БД — только скачивание/парс/подсчёт"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--load-only",
|
||||||
|
action="store_true",
|
||||||
|
help="только скачать+распарсить+UPSERT staging, без backfill houses",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--backfill-only",
|
||||||
|
action="store_true",
|
||||||
|
help="пропустить скачивание/load staging (считаем что уже загружена этим же "
|
||||||
|
"или предыдущим прогоном) — только backfill houses",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.load_only and args.backfill_only:
|
||||||
|
parser.error("--load-only и --backfill-only взаимоисключающи")
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
load_counts: dict[str, int] = {}
|
||||||
|
if not args.backfill_only:
|
||||||
|
load_counts = load_frt_mkd(
|
||||||
|
db,
|
||||||
|
src_path=args.src_path,
|
||||||
|
work_dir=args.work_dir,
|
||||||
|
node_id=args.node_id,
|
||||||
|
region_code=args.region_code,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
)
|
||||||
|
if not args.dry_run:
|
||||||
|
db.commit()
|
||||||
|
logger.info("frt_mkd_load: load stage done: %s", load_counts)
|
||||||
|
|
||||||
|
houses_counts: dict[str, int] = {}
|
||||||
|
if not args.load_only:
|
||||||
|
houses_counts = backfill_houses(db, dry_run=args.dry_run)
|
||||||
|
if not args.dry_run:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"frt_mkd_load DONE: dry_run=%s load=%s houses=%s",
|
||||||
|
args.dry_run,
|
||||||
|
load_counts,
|
||||||
|
houses_counts,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -110,10 +110,10 @@ logger = logging.getLogger(__name__)
|
||||||
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
|
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
|
||||||
# значило бы завести проверку, которая в проде не срабатывает никогда.
|
# значило бы завести проверку, которая в проде не срабатывает никогда.
|
||||||
REJECTION_RULE = (
|
REJECTION_RULE = (
|
||||||
"Строка не попадает на витрину, только если данных нет: оценщик не дал "
|
"Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал "
|
||||||
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
|
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
|
||||||
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
|
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
|
||||||
"витрина показывала бы лучший хвост, а не работу оценщика. Санитарный "
|
"витрина показывала бы лучший хвост, а не работу расчёта. Санитарный "
|
||||||
"диапазон цены сделки (30 000–600 000 ₽/м² для Екатеринбурга) применён "
|
"диапазон цены сделки (30 000–600 000 ₽/м² для Екатеринбурга) применён "
|
||||||
"к выборке до расчёта, по цене самой сделки."
|
"к выборке до расчёта, по цене самой сделки."
|
||||||
)
|
)
|
||||||
|
|
@ -123,7 +123,7 @@ NOTE = (
|
||||||
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
|
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
|
||||||
"Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): "
|
"Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): "
|
||||||
"она бывает занижена сторонами, и тогда "
|
"она бывает занижена сторонами, и тогда "
|
||||||
"строка выглядит как промах оценщика, хотя врёт документ. "
|
"строка выглядит как промах расчёта, хотя врёт документ. "
|
||||||
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
|
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
|
||||||
"есть у 2.7% строк, поэтому дом не показан и показан быть не может."
|
"есть у 2.7% строк, поэтому дом не показан и показан быть не может."
|
||||||
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "
|
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "
|
||||||
|
|
|
||||||
|
|
@ -305,9 +305,12 @@ def collect_landing_metrics(db: Session) -> list[dict[str, Any]]:
|
||||||
).first()
|
).first()
|
||||||
if row is not None and row.n:
|
if row is not None and row.n:
|
||||||
n = int(row.n)
|
n = int(row.n)
|
||||||
|
# Публичная подпись метрики: площадка не называется (норма publicLabel,
|
||||||
|
# решение 31.08.2026); фактически это Домклик — единственный источник,
|
||||||
|
# где триггер пишет стартовую цену. Текст персистится в landing_stats.note.
|
||||||
base_note = (
|
base_note = (
|
||||||
f"Только Домклик (единственный источник, где триггер пишет стартовую цену), "
|
"Только одна из площадок (единственный источник, где фиксируется "
|
||||||
f"наблюдение от {_PRICE_SPAN_DAYS} дней, изменения свыше "
|
f"стартовая цена), наблюдение от {_PRICE_SPAN_DAYS} дней, изменения свыше "
|
||||||
f"{_PRICE_MAX_ABS_PCT}% отброшены как смена объекта"
|
f"{_PRICE_MAX_ABS_PCT}% отброшены как смена объекта"
|
||||||
)
|
)
|
||||||
metrics.append(
|
metrics.append(
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,14 @@ Why curl_cffi and not YandexDetailScraper.fetch_detail:
|
||||||
non-200 ответ считается блоком, а его диагноз берётся из HTTP-статуса
|
non-200 ответ считается блоком, а его диагноз берётся из HTTP-статуса
|
||||||
(ban_kind_from_status: 403/429 → platform, 5xx → infra, прочее → unknown), а не
|
(ban_kind_from_status: 403/429 → platform, 5xx → infra, прочее → unknown), а не
|
||||||
из текстовых маркеров страницы, снятых с чужой площадки.
|
из текстовых маркеров страницы, снятых с чужой площадки.
|
||||||
|
|
||||||
|
Полнота страницы (#3191): HTTP 200 + разобравшийся parse ещё не означают карточку.
|
||||||
|
Недорендеренная страница (1,8 МБ вместо 3,9, без блока контактов) парсится молча и
|
||||||
|
раньше уезжала в БД с detail_enriched_at, выбывая из очереди навсегда. Теперь она
|
||||||
|
отсеивается ДО parse (detail_incomplete_reason: структурный маркер контактов +
|
||||||
|
размерный порог settings.yandex_detail_min_html_bytes) и считается исходом
|
||||||
|
incomplete ⊆ failed — обогащения нет, значит следующий снапшот
|
||||||
|
(detail_enriched_at IS NULL) возьмёт её снова.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -57,10 +65,15 @@ from dataclasses import dataclass, field
|
||||||
from curl_cffi.requests import AsyncSession
|
from curl_cffi.requests import AsyncSession
|
||||||
from scraper_kit.browser_fetcher import ban_kind_from_status
|
from scraper_kit.browser_fetcher import ban_kind_from_status
|
||||||
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
|
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
|
||||||
from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail_enrichment
|
from scraper_kit.providers.yandex.detail import (
|
||||||
|
YandexDetailScraper,
|
||||||
|
detail_incomplete_reason,
|
||||||
|
save_detail_enrichment,
|
||||||
|
)
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
from app.services.proxy_egress import resolve_proxy_url
|
from app.services.proxy_egress import resolve_proxy_url
|
||||||
from app.services.scrape_runs import BAN_KIND_UNKNOWN
|
from app.services.scrape_runs import BAN_KIND_UNKNOWN
|
||||||
|
|
@ -136,6 +149,11 @@ class YandexDetailBackfillResult:
|
||||||
# обогащения») не переписываем. Без этого счётчика ветка перевода прогона в
|
# обогащения») не переписываем. Без этого счётчика ветка перевода прогона в
|
||||||
# 'banned' у Яндекса недостижима по построению (#3196).
|
# 'banned' у Яндекса недостижима по построению (#3196).
|
||||||
blocked: int = 0
|
blocked: int = 0
|
||||||
|
# Недогруженные страницы среди попыток (#3191): HTTP 200, валидный HTML, но без
|
||||||
|
# блока контактов / заметно меньше нормы. Тоже подмножество failed — попытка была,
|
||||||
|
# обогащения не случилось; detail_enriched_at не проставляется, объявление остаётся
|
||||||
|
# в очереди (снапшот берётся по detail_enriched_at IS NULL).
|
||||||
|
incomplete: int = 0
|
||||||
failed: int = 0
|
failed: int = 0
|
||||||
# Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
|
# Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
|
||||||
# source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
|
# source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
|
||||||
|
|
@ -150,6 +168,7 @@ class YandexDetailBackfillResult:
|
||||||
"attempted": self.attempted,
|
"attempted": self.attempted,
|
||||||
"enriched": self.enriched,
|
"enriched": self.enriched,
|
||||||
"blocked": self.blocked,
|
"blocked": self.blocked,
|
||||||
|
"incomplete": self.incomplete,
|
||||||
"failed": self.failed,
|
"failed": self.failed,
|
||||||
"url_from_offer_id": self.url_from_offer_id,
|
"url_from_offer_id": self.url_from_offer_id,
|
||||||
"unenrichable_pending": self.unenrichable_pending,
|
"unenrichable_pending": self.unenrichable_pending,
|
||||||
|
|
@ -419,6 +438,47 @@ async def run_yandex_detail_backfill(
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Недогруз — отказ, а не успех (#3191). Проверка ДО parse: parse
|
||||||
|
# частичную страницу разберёт молча (JSON-состояние на месте), и
|
||||||
|
# объявление уедет в БД с detail_enriched_at, выбыв из очереди
|
||||||
|
# навсегда. Здесь оно исхода 'enriched' не получает, значит в
|
||||||
|
# следующем прогоне снова попадёт в снапшот (detail_enriched_at
|
||||||
|
# IS NULL). Серия таких страниц двигает consecutive_none — тот же
|
||||||
|
# брейкер, что у parse→None: вечно недогружаемая карточка упрётся
|
||||||
|
# в max_consecutive_blocks и оборвёт прогон, а не будет молотиться
|
||||||
|
# (per-listing счётчика попыток в схеме нет, см. отчёт #3191).
|
||||||
|
incomplete_reason = detail_incomplete_reason(
|
||||||
|
resp.text, min_html_bytes=settings.yandex_detail_min_html_bytes
|
||||||
|
)
|
||||||
|
if incomplete_reason is not None:
|
||||||
|
counters.incomplete += 1
|
||||||
|
counters.failed += 1
|
||||||
|
consecutive_none += 1
|
||||||
|
# Площадка ОТВЕТИЛА (HTTP 200) — серии блоков нет (#3196).
|
||||||
|
consecutive_blocks = 0
|
||||||
|
logger.warning(
|
||||||
|
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
|
||||||
|
"-> недогруженная карточка, отказ: %s (consecutive=%d)",
|
||||||
|
run_id,
|
||||||
|
listing_id,
|
||||||
|
source_url,
|
||||||
|
incomplete_reason,
|
||||||
|
consecutive_none,
|
||||||
|
)
|
||||||
|
if consecutive_none >= max_consecutive_blocks:
|
||||||
|
logger.error(
|
||||||
|
# consecutive_none смешанный (фетч-ошибка + parse-None +
|
||||||
|
# недогруз) — «недогруженных» назвало бы только один вклад.
|
||||||
|
"yandex_detail_backfill: run_id=%d ABORT -- %d подряд "
|
||||||
|
"без обогащения. enriched=%d attempted=%d",
|
||||||
|
run_id,
|
||||||
|
consecutive_none,
|
||||||
|
counters.enriched,
|
||||||
|
counters.attempted,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
enrichment = scraper.parse(resp.text, offer_url=source_url)
|
enrichment = scraper.parse(resp.text, offer_url=source_url)
|
||||||
|
|
||||||
if enrichment is None:
|
if enrichment is None:
|
||||||
|
|
@ -458,6 +518,21 @@ async def run_yandex_detail_backfill(
|
||||||
consecutive_blocks = 0
|
consecutive_blocks = 0
|
||||||
if save_detail_enrichment(db, listing_id, enrichment):
|
if save_detail_enrichment(db, listing_id, enrichment):
|
||||||
counters.enriched += 1
|
counters.enriched += 1
|
||||||
|
else:
|
||||||
|
# #3338 (та же дыра, что #3332 у domclick): страница взята и
|
||||||
|
# разобрана, а UPDATE не задел ни одной строки — объявление
|
||||||
|
# удалено/деактивировано между снимком и записью. Попытка была,
|
||||||
|
# исхода не было: attempted переставал сходиться с enriched +
|
||||||
|
# failed, и расхождение читается как потерянный отказ площадки.
|
||||||
|
# Исход failed: непрошедший UPDATE — не успех и не блок.
|
||||||
|
counters.failed += 1
|
||||||
|
logger.warning(
|
||||||
|
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
|
||||||
|
"-- карточка разобрана, но UPDATE не задел ни одной строки",
|
||||||
|
run_id,
|
||||||
|
listing_id,
|
||||||
|
source_url,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
counters.failed += 1
|
counters.failed += 1
|
||||||
|
|
@ -487,11 +562,12 @@ async def run_yandex_detail_backfill(
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"yandex_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
|
"yandex_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
|
||||||
"blocked=%d failed=%d duration=%.1fs",
|
"blocked=%d incomplete=%d failed=%d duration=%.1fs",
|
||||||
run_id,
|
run_id,
|
||||||
counters.attempted,
|
counters.attempted,
|
||||||
counters.enriched,
|
counters.enriched,
|
||||||
counters.blocked,
|
counters.blocked,
|
||||||
|
counters.incomplete,
|
||||||
counters.failed,
|
counters.failed,
|
||||||
counters.duration_sec,
|
counters.duration_sec,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,11 @@ import random
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field, fields
|
from dataclasses import dataclass, field, fields
|
||||||
|
|
||||||
|
from scraper_kit.proxy_errors import caused_by_no_proxy
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.scraper_adapters import RealScraperConfig
|
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
|
||||||
from app.services.scraper_settings import get_scraper_delay
|
from app.services.scraper_settings import get_scraper_delay
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -78,10 +79,21 @@ class YandexNewbuildingSweepResult:
|
||||||
failed_fetch: int = 0 # fetch_jk вернул None / упал
|
failed_fetch: int = 0 # fetch_jk вернул None / упал
|
||||||
rows_inserted: int = 0 # строк в market.yandex_jk_enrichment (новых/обновлённых)
|
rows_inserted: int = 0 # строк в market.yandex_jk_enrichment (новых/обновлённых)
|
||||||
|
|
||||||
|
# Пул прокси пуст → прогон оборван, к площадке не ходили (#3197).
|
||||||
|
no_proxy_stop: bool = False
|
||||||
|
|
||||||
duration_sec: float = field(default=0.0)
|
duration_sec: float = field(default=0.0)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, int | float]:
|
def to_dict(self) -> dict[str, int | float]:
|
||||||
return {f.name: getattr(self, f.name) for f in fields(self)}
|
"""Счётчики прогона; `no_proxy_stop` — 1/0, а не JSON true/false.
|
||||||
|
|
||||||
|
Формат один с соседями (`scheduler.py:168` cian, `avito_detail_backfill.py:1043`,
|
||||||
|
`domclick_detail_backfill.py:610`): SQL-монитор ищет
|
||||||
|
`counters->>'no_proxy_stop' = '1'` и мимо `true` промахнётся молча.
|
||||||
|
"""
|
||||||
|
out: dict[str, int | float] = {f.name: getattr(self, f.name) for f in fields(self)}
|
||||||
|
out["no_proxy_stop"] = int(self.no_proxy_stop)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ── SQL ────────────────────────────────────────────────────────────────────────
|
# ── SQL ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -331,9 +343,25 @@ async def enrich_yandex_newbuilding_sweep(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = await resolve_yandex_jk_slug(
|
resolved = await resolve_yandex_jk_slug(
|
||||||
ext_id, city=city, config=RealScraperConfig()
|
ext_id,
|
||||||
|
city=city,
|
||||||
|
config=RealScraperConfig(),
|
||||||
|
proxy_provider=RealProxyProvider(),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
# #3197: пустой пул — не отказ площадки: запрос не уходил вовсе, и
|
||||||
|
# следующий дом упрётся ровно в то же самое.
|
||||||
|
if caused_by_no_proxy(exc):
|
||||||
|
result.no_proxy_stop = True
|
||||||
|
result.failed_resolve += 1
|
||||||
|
logger.error(
|
||||||
|
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
|
||||||
|
"house_id=%s processed=%d succeeded=%d",
|
||||||
|
house_id,
|
||||||
|
result.processed,
|
||||||
|
result.succeeded,
|
||||||
|
)
|
||||||
|
break
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"resolve_yandex_jk_slug house_id=%s ext_id=%s raised: %s",
|
"resolve_yandex_jk_slug house_id=%s ext_id=%s raised: %s",
|
||||||
house_id,
|
house_id,
|
||||||
|
|
@ -383,9 +411,22 @@ async def enrich_yandex_newbuilding_sweep(
|
||||||
# ── Fetch через BrowserFetcher ────────────────────────────────────
|
# ── Fetch через BrowserFetcher ────────────────────────────────────
|
||||||
info = None
|
info = None
|
||||||
try:
|
try:
|
||||||
scraper = YandexNewbuildingScraper(config=RealScraperConfig())
|
scraper = YandexNewbuildingScraper(
|
||||||
|
config=RealScraperConfig(), proxy_provider=RealProxyProvider()
|
||||||
|
)
|
||||||
info = await scraper.fetch_jk(jk_slug=jk_slug, jk_id=ext_id, city=city)
|
info = await scraper.fetch_jk(jk_slug=jk_slug, jk_id=ext_id, city=city)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if caused_by_no_proxy(exc): # #3197 — см. блок resolve выше
|
||||||
|
result.no_proxy_stop = True
|
||||||
|
result.failed_fetch += 1
|
||||||
|
logger.error(
|
||||||
|
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
|
||||||
|
"house_id=%s processed=%d succeeded=%d",
|
||||||
|
house_id,
|
||||||
|
result.processed,
|
||||||
|
result.succeeded,
|
||||||
|
)
|
||||||
|
break
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"fetch_jk failed house_id=%s jk_slug=%s ext_id=%s: %s",
|
"fetch_jk failed house_id=%s jk_slug=%s ext_id=%s: %s",
|
||||||
house_id,
|
house_id,
|
||||||
|
|
|
||||||
131
tradein-mvp/backend/data/sql/284_houses_total_units_from_raw.sql
Normal file
131
tradein-mvp/backend/data/sql/284_houses_total_units_from_raw.sql
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
-- 284_houses_total_units_from_raw.sql
|
||||||
|
-- Перелить дом-поля карточек Домклика из listings.raw_payload в колонки houses (#3253).
|
||||||
|
--
|
||||||
|
-- ЧТО БЫЛО НЕ ТАК
|
||||||
|
-- Парсер карточки Домклика читает блок дома (houseInfo.info) и кладёт его ЦЕЛИКОМ
|
||||||
|
-- в listings.raw_payload: wall_type, floor_type, quarters_count, плюс серию дома,
|
||||||
|
-- энергоэффективность и число подъездов. В колонки houses не переносил ничего.
|
||||||
|
-- Замер 29.08.2026: houses.total_units = 0 у ВСЕХ источников без исключения, хотя
|
||||||
|
-- у Домклика число квартир уже лежало в собранных payload'ах. Код перенос чинит
|
||||||
|
-- для будущих карточек (providers/domclick/detail.py, тот же PR); эта миграция
|
||||||
|
-- отрабатывает задним числом по уже собранному — ни одного запроса к площадке.
|
||||||
|
--
|
||||||
|
-- СВЯЗЬ ОБЪЯВЛЕНИЕ → ДОМ
|
||||||
|
-- listings.house_id_fk — та же связь, которой пользуется код-перенос и соседний
|
||||||
|
-- avito-путь (#3036). Другой связи нет: у SERP Домклика house_source/house_ext_id
|
||||||
|
-- намеренно не заполняются (#3064), а domclick_building_guid из карточки лежит в
|
||||||
|
-- raw_payload и ключом дома пока не является.
|
||||||
|
--
|
||||||
|
-- ЧТО НЕ ДЕЛАЕМ
|
||||||
|
-- Серия дома, энергоэффективность и число подъездов — колонок под них в houses нет,
|
||||||
|
-- схему ради этого не расширяем: остаются в raw_payload.
|
||||||
|
--
|
||||||
|
-- СЛОВАРЬ СТЕН И ПЕРЕКРЫТИЙ — НЕ СЫРЬЁ ПЛОЩАДКИ
|
||||||
|
-- houses.material_walls уже заполнена словарём ДОМ.РФ (капремонт КР1.2, #2013):
|
||||||
|
-- кирпич 2662, железобетонная панель 2107, иное 1850, монолит 754. У Домклика в
|
||||||
|
-- raw_payload свой словарь. Лить его как есть — сделать колонку двухсловарной, и
|
||||||
|
-- фильтр по одному значению перестанет видеть половину строк (ровно то, что уже
|
||||||
|
-- случилось с sale_type до #2674). Тот же CASE стоит в коде-переносе
|
||||||
|
-- (canon_wall_type / canon_floor_type, providers/domclick/detail.py).
|
||||||
|
--
|
||||||
|
-- Домклик → houses.material_walls
|
||||||
|
-- Кирпичный → кирпич
|
||||||
|
-- Панельный → железобетонная панель
|
||||||
|
-- Монолитный → монолит
|
||||||
|
-- Монолитно-кирпичный → монолит (несущий каркас монолитный, кирпич —
|
||||||
|
-- заполнение; смешанного класса у ДОМ.РФ
|
||||||
|
-- нет, «иное» потеряло бы конструктив)
|
||||||
|
-- Блочный, Деревянный → иное
|
||||||
|
-- всё прочее → NULL (CASE без ELSE; сырьё не пишем)
|
||||||
|
--
|
||||||
|
-- Домклик → houses.material_floors
|
||||||
|
-- Железобетонный → Железобетонные (форма, уже лежащая в колонке — 26
|
||||||
|
-- Железобетонные → Железобетонные строк от avito)
|
||||||
|
-- всё прочее → NULL
|
||||||
|
--
|
||||||
|
-- ИДЕМПОТЕНТНОСТЬ
|
||||||
|
-- Каждый UPDATE пишет ТОЛЬКО туда, где колонка пуста (IS NULL в WHERE) — повторный
|
||||||
|
-- прогон видит уже заполненное и трогает 0 строк. Непустое значение дома не
|
||||||
|
-- затирается никогда: у houses есть конкурирующие писатели (ДОМ.РФ капремонт,
|
||||||
|
-- Houses Catalog), карточка площадки не канон.
|
||||||
|
--
|
||||||
|
-- ВЫБОР ЗНАЧЕНИЯ ПРИ НЕСКОЛЬКИХ ОБЪЯВЛЕНИЯХ В ОДНОМ ДОМЕ
|
||||||
|
-- DISTINCT ON (house_id_fk) ... ORDER BY house_id_fk, detail_enriched_at DESC NULLS LAST,
|
||||||
|
-- id DESC — берём самую свежую обогащённую карточку. NULLS LAST обязателен: в
|
||||||
|
-- Postgres DESC по умолчанию ставит NULL ПЕРВЫМИ, и без него победителем стала бы
|
||||||
|
-- карточка вообще без отметки обогащения.
|
||||||
|
BEGIN;
|
||||||
|
-- Конвенция проекта (#2752): массовый UPDATE берёт блокировки на строках houses и без
|
||||||
|
-- lock_timeout встанет в очередь за чужой сессией, утащив за собой запросы приложения.
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
before_units bigint;
|
||||||
|
before_walls bigint;
|
||||||
|
before_floors bigint;
|
||||||
|
after_units bigint;
|
||||||
|
after_walls bigint;
|
||||||
|
after_floors bigint;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) FILTER (WHERE total_units IS NOT NULL),
|
||||||
|
count(*) FILTER (WHERE material_walls IS NOT NULL),
|
||||||
|
count(*) FILTER (WHERE material_floors IS NOT NULL)
|
||||||
|
INTO before_units, before_walls, before_floors
|
||||||
|
FROM houses;
|
||||||
|
|
||||||
|
RAISE NOTICE 'houses ДО: total_units=%, material_walls=%, material_floors=%',
|
||||||
|
before_units, before_walls, before_floors;
|
||||||
|
|
||||||
|
WITH src AS (
|
||||||
|
SELECT DISTINCT ON (l.house_id_fk)
|
||||||
|
l.house_id_fk AS house_id,
|
||||||
|
NULLIF(l.raw_payload ->> 'quarters_count', '') AS quarters_count,
|
||||||
|
CASE lower(trim(NULLIF(l.raw_payload ->> 'wall_type', '')))
|
||||||
|
WHEN 'кирпичный' THEN 'кирпич'
|
||||||
|
WHEN 'панельный' THEN 'железобетонная панель'
|
||||||
|
WHEN 'монолитный' THEN 'монолит'
|
||||||
|
WHEN 'монолитно-кирпичный' THEN 'монолит'
|
||||||
|
WHEN 'блочный' THEN 'иное'
|
||||||
|
WHEN 'деревянный' THEN 'иное'
|
||||||
|
END AS wall_type,
|
||||||
|
CASE lower(trim(NULLIF(l.raw_payload ->> 'floor_type', '')))
|
||||||
|
WHEN 'железобетонный' THEN 'Железобетонные'
|
||||||
|
WHEN 'железобетонные' THEN 'Железобетонные'
|
||||||
|
END AS floor_type
|
||||||
|
FROM listings l
|
||||||
|
WHERE l.source = 'domklik'
|
||||||
|
AND l.house_id_fk IS NOT NULL
|
||||||
|
AND l.raw_payload IS NOT NULL
|
||||||
|
AND (l.raw_payload ? 'quarters_count'
|
||||||
|
OR l.raw_payload ? 'wall_type'
|
||||||
|
OR l.raw_payload ? 'floor_type')
|
||||||
|
ORDER BY l.house_id_fk, l.detail_enriched_at DESC NULLS LAST, l.id DESC
|
||||||
|
)
|
||||||
|
UPDATE houses h SET
|
||||||
|
total_units = COALESCE(
|
||||||
|
h.total_units,
|
||||||
|
CASE WHEN s.quarters_count ~ '^[0-9]+$'
|
||||||
|
THEN CAST(s.quarters_count AS integer) END
|
||||||
|
),
|
||||||
|
material_walls = COALESCE(h.material_walls, s.wall_type),
|
||||||
|
material_floors = COALESCE(h.material_floors, s.floor_type)
|
||||||
|
FROM src s
|
||||||
|
WHERE h.id = s.house_id
|
||||||
|
AND (h.total_units IS NULL AND s.quarters_count ~ '^[0-9]+$'
|
||||||
|
OR h.material_walls IS NULL AND s.wall_type IS NOT NULL
|
||||||
|
OR h.material_floors IS NULL AND s.floor_type IS NOT NULL);
|
||||||
|
|
||||||
|
SELECT count(*) FILTER (WHERE total_units IS NOT NULL),
|
||||||
|
count(*) FILTER (WHERE material_walls IS NOT NULL),
|
||||||
|
count(*) FILTER (WHERE material_floors IS NOT NULL)
|
||||||
|
INTO after_units, after_walls, after_floors
|
||||||
|
FROM houses;
|
||||||
|
|
||||||
|
RAISE NOTICE 'houses ПОСЛЕ: total_units=% (+%), material_walls=% (+%), material_floors=% (+%)',
|
||||||
|
after_units, after_units - before_units,
|
||||||
|
after_walls, after_walls - before_walls,
|
||||||
|
after_floors, after_floors - before_floors;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
-- 285_domklik_diff_percent_recompute.sql
|
||||||
|
-- Пересчитать offer_price_history.diff_percent у domklik из соседних price_rub (#3225).
|
||||||
|
--
|
||||||
|
-- ЧТО БЫЛО НЕ ТАК
|
||||||
|
-- Загрузчик карточки Домклика клал в diff_percent поле источника priceHistory.diff,
|
||||||
|
-- а там РУБЛИ, не проценты. Замер на проде 29.08.2026: 8900 из 11 075 непустых
|
||||||
|
-- значений с |diff| > 50; перцентили p05 = −600 000, p50 = −50 010, p95 = +300 000.
|
||||||
|
-- У одного и того же listing_id 406163 в колонке соседствуют −200000.00 (рубли, из
|
||||||
|
-- загрузчика) и −0.82 (настоящий процент, из триггера record_listing_price_change).
|
||||||
|
-- Код починен в том же PR (providers/domclick/detail.py + offer_price_history.py:
|
||||||
|
-- процент считается из соседних цен, неправдоподобное значение отвергается, а не
|
||||||
|
-- зажимается). Эта миграция отрабатывает задним числом по уже собранным строкам.
|
||||||
|
--
|
||||||
|
-- ТРОГАЕМ ТОЛЬКО СТРОКИ ЗАГРУЗЧИКА: change_time <> recorded_at
|
||||||
|
-- В таблице два писателя с РАЗНОЙ базой отсчёта, и смешивать их нельзя:
|
||||||
|
-- • загрузчик (domclick/detail.py) считает процент внутри истории самой карточки —
|
||||||
|
-- база это предыдущая запись priceHistory, то есть предыдущая строка загрузчика;
|
||||||
|
-- • триггер record_listing_price_change (131_fix_diff_percent_overflow.sql:76-86)
|
||||||
|
-- считает от listings.OLD.price_rub — от цены В КАРТОЧКЕ ЛИСТИНГА на момент
|
||||||
|
-- upsert'а. Эта база в offer_price_history может вообще не лежать отдельной
|
||||||
|
-- строкой.
|
||||||
|
-- Поэтому раннее утверждение «формула триггера = формула миграции, пересчёт даст то
|
||||||
|
-- же значение» ЛОЖНО, и опереться на признак происхождения как раз нужно. Без него
|
||||||
|
-- миграция портит честные данные двумя способами: (1) у листинга, чья история
|
||||||
|
-- начинается с триггерной строки, lag() = NULL → честный −0.82 перезаписывается в
|
||||||
|
-- NULL; (2) у триггерной строки, соседом которой по lag() оказалась строка
|
||||||
|
-- загрузчика, честный процент пересчитывается от чужой базы.
|
||||||
|
-- Признак точный, а не эвристический: триггер подставляет now() и в change_time, и
|
||||||
|
-- в recorded_at ОДНИМ INSERT'ом, а now() стабилен внутри транзакции → у триггерной
|
||||||
|
-- строки метки равны побайтово. Загрузчик пишет в change_time дату источника, а
|
||||||
|
-- recorded_at не указывает вовсе (DEFAULT NOW(), 023_offer_price_history.sql:19) →
|
||||||
|
-- расхождение в месяцы. Совпадение исторической даты с моментом вставки с точностью
|
||||||
|
-- до микросекунды недостижимо. Признак закреплён тестом
|
||||||
|
-- test_save_detail_enrichment_leaves_recorded_at_to_default.
|
||||||
|
--
|
||||||
|
-- ОКНО lag() — ТОЖЕ ТОЛЬКО ПО СТРОКАМ ЗАГРУЗЧИКА
|
||||||
|
-- Триггерные строки не переписываем И не используем как базу. Иначе строка
|
||||||
|
-- загрузчика получила бы базой триггерную строку, которой в истории карточки нет, —
|
||||||
|
-- и результат разошёлся бы с тем, что теперь пишет починенный код. Задача миграции
|
||||||
|
-- ровно в том, чтобы задним числом дать те же значения, что даёт код: prev — это
|
||||||
|
-- предыдущая запись priceHistory карточки. По source окно не сужаем: «предыдущая
|
||||||
|
-- цена» — это предыдущая запись листинга, а у листинга со смешанными источниками
|
||||||
|
-- фильтр по source подсунул бы не ту строку.
|
||||||
|
--
|
||||||
|
-- ФИЛЬТР ПО |x| > 100 НЕ БЕРЁМ
|
||||||
|
-- Он пропустил бы испорченные строки с мелким рублёвым diff (−50 рублей выглядит
|
||||||
|
-- как правдоподобные −50%).
|
||||||
|
--
|
||||||
|
-- АСИММЕТРИЯ: СТАРЫЕ CIAN-СТРОКИ НЕ ЧИНИМ
|
||||||
|
-- Гейт validate_diff_percent общий для всех источников (|x| > 100 → NULL + warning),
|
||||||
|
-- а миграция — только про domklik. Уже лежащие в таблице cian-строки с |x| > 100
|
||||||
|
-- остаются как есть: их история приходит из cian_price_history.py со своим полем и
|
||||||
|
-- своей базой, отдельным замером не подтверждена, а править вслепую по чужому
|
||||||
|
-- источнику — это второй #3225, а не его починка. Отдельная задача.
|
||||||
|
--
|
||||||
|
-- САМАЯ РАННЯЯ ЗАПИСЬ ЛИСТИНГА → NULL, НЕ 0
|
||||||
|
-- Предыдущей цены нет — процента не существует. Ноль здесь читался бы как «цена не
|
||||||
|
-- менялась», то есть как измерение, которого не было. Так же ведёт себя и код.
|
||||||
|
--
|
||||||
|
-- ИДЕМПОТЕНТНОСТЬ
|
||||||
|
-- Пересчёт детерминирован (те же строки → те же значения), а UPDATE ограничен
|
||||||
|
-- `IS DISTINCT FROM` — повторный прогон трогает 0 строк. Новых объектов схемы нет.
|
||||||
|
BEGIN;
|
||||||
|
-- Конвенция проекта (#2752): массовый UPDATE берёт блокировки на строках
|
||||||
|
-- offer_price_history и без lock_timeout встанет в очередь за чужой сессией,
|
||||||
|
-- утащив за собой запросы приложения.
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
before_notnull bigint;
|
||||||
|
before_bad bigint;
|
||||||
|
after_notnull bigint;
|
||||||
|
after_bad bigint;
|
||||||
|
touched bigint;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) FILTER (WHERE diff_percent IS NOT NULL),
|
||||||
|
count(*) FILTER (WHERE abs(diff_percent) > 100)
|
||||||
|
INTO before_notnull, before_bad
|
||||||
|
FROM offer_price_history
|
||||||
|
WHERE source = 'domklik'
|
||||||
|
AND change_time <> recorded_at;
|
||||||
|
|
||||||
|
RAISE NOTICE 'domklik (строки загрузчика) ДО: diff_percent непустых = %, из них |x| > 100 = %',
|
||||||
|
before_notnull, before_bad;
|
||||||
|
|
||||||
|
WITH neighbours AS (
|
||||||
|
-- Окно только по листингам, у которых есть domklik-строки: без этого сужения
|
||||||
|
-- lag() прогоняется по ВСЕЙ таблице, и под lock_timeout = 5s деплой падает.
|
||||||
|
-- Оба скана идут по индексам 023: oph_source_time_idx (source, change_time)
|
||||||
|
-- для подзапроса и oph_listing_time_idx (listing_id, change_time) для окна.
|
||||||
|
SELECT id,
|
||||||
|
source,
|
||||||
|
price_rub,
|
||||||
|
lag(price_rub) OVER (PARTITION BY listing_id ORDER BY change_time, id)
|
||||||
|
AS prev_price
|
||||||
|
FROM offer_price_history
|
||||||
|
WHERE listing_id IN (
|
||||||
|
SELECT DISTINCT listing_id
|
||||||
|
FROM offer_price_history
|
||||||
|
WHERE source = 'domklik'
|
||||||
|
)
|
||||||
|
AND change_time <> recorded_at
|
||||||
|
),
|
||||||
|
recomputed AS (
|
||||||
|
SELECT id,
|
||||||
|
CASE WHEN prev_price > 0
|
||||||
|
THEN round((price_rub - prev_price) / prev_price * 100, 2)
|
||||||
|
END AS new_diff
|
||||||
|
FROM neighbours
|
||||||
|
WHERE source = 'domklik'
|
||||||
|
)
|
||||||
|
UPDATE offer_price_history oph
|
||||||
|
SET diff_percent = r.new_diff
|
||||||
|
FROM recomputed r
|
||||||
|
WHERE oph.id = r.id
|
||||||
|
AND oph.diff_percent IS DISTINCT FROM r.new_diff;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS touched = ROW_COUNT;
|
||||||
|
|
||||||
|
SELECT count(*) FILTER (WHERE diff_percent IS NOT NULL),
|
||||||
|
count(*) FILTER (WHERE abs(diff_percent) > 100)
|
||||||
|
INTO after_notnull, after_bad
|
||||||
|
FROM offer_price_history
|
||||||
|
WHERE source = 'domklik'
|
||||||
|
AND change_time <> recorded_at;
|
||||||
|
|
||||||
|
RAISE NOTICE 'domklik (строки загрузчика) ПОСЛЕ: diff_percent непустых = % (было %), |x| > 100 = % (было %), переписано строк = %',
|
||||||
|
after_notnull, before_notnull, after_bad, before_bad, touched;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,318 @@
|
||||||
|
-- 286_offer_price_history_decimal_slips.sql
|
||||||
|
-- Удалить из offer_price_history точки с потерянным разрядом и починить diff_percent (#3376).
|
||||||
|
--
|
||||||
|
-- ЧТО НЕ ТАК
|
||||||
|
-- В priceHistory источников встречаются точки ровно ×10 / ÷10 к соседям:
|
||||||
|
-- 377 000 → 3 770 000 → 3 720 000; 19 064 000 → 190 150 000 → 19 150 000. Цена
|
||||||
|
-- возвращается к базе следующей же точкой — это потерянный разряд у источника,
|
||||||
|
-- а не рынок. Второй вид того же дефекта — в НАЧАЛЕ серии: 330 000 → 3 300 000
|
||||||
|
-- (текущая цена объявления 3 300 000), 420 000 → 4 200 000 → 4 500 000. Дефектная
|
||||||
|
-- точка первая, слева у неё базы нет, поэтому её ловит зеркальное правило.
|
||||||
|
--
|
||||||
|
-- Читатели колонки — медианный торг лендинга (#3223), админка, /scrapers,
|
||||||
|
-- публичный API. Оценщик (services/estimator.py) историю не читает, на оценку
|
||||||
|
-- эти точки не влияли.
|
||||||
|
--
|
||||||
|
-- Код починен в том же PR: гейт drop_decimal_slips в scraper_kit/offer_price_history.py
|
||||||
|
-- подключён к писателям НА ЖИВОМ ТРАКТЕ (domclick/detail.py, cian/detail.py). Эта
|
||||||
|
-- миграция отрабатывает задним числом по уже собранным строкам. Разовый
|
||||||
|
-- scripts/local-cian/playwright_history.py гейта не знает, но переиграть миграцию не
|
||||||
|
-- может: он выбирает только объявления вообще БЕЗ offer_price_history.
|
||||||
|
--
|
||||||
|
-- ВЫБОРКА КАНДИДАТОВ ЗЕРКАЛИТ ГЕЙТ 1:1, А НЕ «ТОТ ЖЕ КРИТЕРИЙ»
|
||||||
|
-- Первая редакция этого файла брала предыдущую точку через lag(), то есть СЫРУЮ
|
||||||
|
-- соседнюю строку — включая те, что удаляются этим же проходом. Гейт в коде берёт
|
||||||
|
-- предыдущую ОСТАВЛЕННУЮ. Расхождение видно на двух сериях (обе — от ревьюера):
|
||||||
|
-- 1M → 10M → 1M → 10M (текущая цена 1M): гейт оставляет [1M, 1M], lag-версия
|
||||||
|
-- оставляла [1M] — честная точка удалена;
|
||||||
|
-- 1M → 10M → 1.05M → 9.9M (текущая цена 1M): lag-версия удаляла на первом проходе
|
||||||
|
-- ещё и 1.05M (её сырая база — уже удалённая 10M), а на втором доедала 9.9M,
|
||||||
|
-- то есть НЕ была идемпотентной.
|
||||||
|
-- Поэтому кандидаты выбираются PL/pgSQL-циклом, который повторяет гейт пошагово:
|
||||||
|
-- строки по (change_time, id), переменная base — последняя ОСТАВЛЕННАЯ цена,
|
||||||
|
-- свидетель — следующая СЫРАЯ точка либо, у последней, listings.price_rub.
|
||||||
|
-- Правило первой точки — вторым, УЖЕ ПО ОСТАВШИМСЯ строкам (в гейте оно тоже
|
||||||
|
-- применяется к kept-серии: свидетели первой точки сами могут оказаться сдвигами).
|
||||||
|
--
|
||||||
|
-- КРИТЕРИЙ — ДВА СВИДЕТЕЛЯ, И У ПЕРВОЙ ТОЧКИ ТОЖЕ ДВА
|
||||||
|
-- Внутри серии: 1) скачок к предыдущей ОСТАВЛЕННОЙ точке, price/base ∈ [9.5, 10.5]
|
||||||
|
-- в любую сторону; 2) возврат к базе у следующей точки, next/base ∈ [0.9, 1.1].
|
||||||
|
-- Первая точка: 1) она ×10/÷10 ко второй оставшейся; 2) вторая подтверждена ТРЕТЬЕЙ
|
||||||
|
-- оставшейся, third/second ∈ [0.9, 1.1]. Третьей нет → кандидата нет.
|
||||||
|
-- Окно шире чистой десятки, потому что сдвиг разряда часто идёт вместе с настоящим
|
||||||
|
-- мелким изменением цены (377 000 → 3 720 000 это ×9.87). Одного скачка НЕ хватает:
|
||||||
|
-- ×10 без подтверждения — это возможное честное изменение цены (400 000 → 4 000 000 →
|
||||||
|
-- 8 000 000 — разгон, а не разряд), и такие точки остаются. Нет второго свидетеля
|
||||||
|
-- вообще (последняя точка серии, а listings.price_rub пуст) → точку не трогаем.
|
||||||
|
--
|
||||||
|
-- ПОЧЕМУ У ПЕРВОЙ ТОЧКИ НЕТ ОТКАТА НА listings.price_rub. Он был и снят сознательно:
|
||||||
|
-- у 17 из 21 кандидата первой точки (yandex 6, domklik 9, cian 2) listings.price_rub
|
||||||
|
-- ТОЧНО равна второй точке — у yandex это буквально одна переменная lot.price_rub,
|
||||||
|
-- записанная в двух местах. «Два свидетеля» там вырождаются в одного, а DELETE
|
||||||
|
-- необратим. Эти 17 строк остаются жить; их процент, если он не процент, занулит
|
||||||
|
-- финальный шаг |diff_percent| > 100 → NULL. Для yandex отлов переехал в #3385 —
|
||||||
|
-- сравнение со СЛЕДУЮЩИМ наблюдением. У ПОСЛЕДНЕЙ точки цена объявления свидетелем
|
||||||
|
-- остаётся: она там сравнивается с базой СЛЕВА, то есть с другим наблюдением.
|
||||||
|
--
|
||||||
|
-- СКОЛЬКО ЖДЁМ УДАЛЕНИЙ
|
||||||
|
-- Замер на проде 06.09.2026 ровно под ЭТИМ критерием: 34 строки внутри серий
|
||||||
|
-- (cian 12, domklik 22) + 4 первых точки по третьей точке (domklik 4) = 38.
|
||||||
|
-- По источникам: cian 12, domklik 26, yandex 0 — yandex целиком отпал вместе с
|
||||||
|
-- откатом на listings.price_rub (см. выше), его случай уехал в #3385.
|
||||||
|
-- Порог остановки 200 — примерно пятикратный запас: поймали больше — критерий
|
||||||
|
-- ловит не то, и миграция обязана упасть, а не молча вычистить историю.
|
||||||
|
--
|
||||||
|
-- ТОЛЬКО СТРОКИ ЗАГРУЗЧИКА: change_time <> recorded_at
|
||||||
|
-- Признак происхождения тот же, что в 285 (там же его доказательство): триггер
|
||||||
|
-- record_listing_price_change пишет now() и в change_time, и в recorded_at одним
|
||||||
|
-- INSERT'ом → метки равны побайтово; загрузчик кладёт в change_time дату источника,
|
||||||
|
-- а recorded_at оставляет DEFAULT NOW(). Триггерные строки (миграция 131) — это
|
||||||
|
-- зафиксированные ЖИВЫЕ смены listings.price_rub, а не разбор чужого массива; их
|
||||||
|
-- не удаляем и в цепочку не берём. Поэтому единственная avito-строка из замера
|
||||||
|
-- остаётся: она триггерная, и если цена в карточке действительно менялась ×10, то
|
||||||
|
-- это наблюдение, а не дефект разбора.
|
||||||
|
--
|
||||||
|
-- ОТСЮДА ЖЕ ТРЕТИЙ СВИДЕТЕЛЬ, КОТОРЫЙ СПАСАЕТ ОТ УДАЛЕНИЯ (L-4): если ровно такая
|
||||||
|
-- же цена есть у ТРИГГЕРНОЙ строки того же объявления — значит listings.price_rub
|
||||||
|
-- когда-то реально равнялась этому значению, и подозреваемая точка загрузчика
|
||||||
|
-- подтверждена независимым писателем. Такую не удаляем и оставляем в цепочке базой.
|
||||||
|
-- ЧЕСТНО ПРО ЕГО ВЕС: на прод-данных 06.09.2026 он не спас НИ ОДНОЙ строки (0 из 21
|
||||||
|
-- кандидата правила первой точки). Механизм проверен только на синтетике — держим
|
||||||
|
-- как страховку на будущих прогонах, а не как замеренную защиту.
|
||||||
|
--
|
||||||
|
-- ПЕРЕСЧЁТ diff_percent У СЛЕДУЮЩЕЙ СТРОКИ
|
||||||
|
-- У строки, шедшей за удалённой, предыдущая цена сменилась — процент, посчитанный
|
||||||
|
-- от фантомной базы, надо пересчитать по формуле 285: (price − prev)/prev*100,
|
||||||
|
-- prev отсутствует/нулевой → NULL (процента не существует, ноль читался бы как
|
||||||
|
-- «цена не менялась»). Цепочки подряд удалённых строк это покрывает: next_id
|
||||||
|
-- последней удалённой в цепочке и есть первая уцелевшая.
|
||||||
|
--
|
||||||
|
-- ФИНАЛЬНЫЙ ШАГ: |diff_percent| > 100 → NULL ПО ВСЕМ ИСТОЧНИКАМ
|
||||||
|
-- Ровно то, что делает validate_diff_percent на записи с #3225: такая величина —
|
||||||
|
-- уже не процент (у domklik туда клали рубли). 285 пересчитала только domklik и
|
||||||
|
-- только из соседних цен, поэтому у cian/yandex/avito-строк загрузчика мусор мог
|
||||||
|
-- остаться. Честные скачки ×2…×6 при этом СОХРАНЯЮТ строку и цену — обнуляется
|
||||||
|
-- только процент, посчитанный не от той величины.
|
||||||
|
--
|
||||||
|
-- ИДЕМПОТЕНТНОСТЬ
|
||||||
|
-- Свойство доказано НА ГЕЙТЕ, а не на этом файле: property-тест
|
||||||
|
-- test_drop_decimal_slips_is_idempotent перебирает серии длины 2-6 по алфавиту
|
||||||
|
-- {1M, 10M, 100M, 1.05M, 9.9M} × current_price ∈ {—, 1M, 10M} и требует
|
||||||
|
-- gate(gate(s)) == gate(s). Раз выборка кандидатов здесь повторяет гейт пошагово,
|
||||||
|
-- свойство переносится: второй прогон даёт 0 удалений. Пересчёт diff_percent
|
||||||
|
-- детерминирован и ограничен IS DISTINCT FROM, финальный NULL-шаг — тоже (после
|
||||||
|
-- него |x| > 100 не остаётся). Новых объектов схемы нет, временные таблицы уходят
|
||||||
|
-- по ON COMMIT DROP.
|
||||||
|
-- Прогон обеих фаз ДВАЖДЫ подряд с ROLLBACK вместо COMMIT — tradein-mvp/scripts/sql/
|
||||||
|
-- 286_dryrun.sql; второй проход обязан дать deleted=0 recomputed=0 nulled=0.
|
||||||
|
BEGIN;
|
||||||
|
-- Конвенция проекта (#2752): DELETE/UPDATE берут блокировки на строках
|
||||||
|
-- offer_price_history, а CREATE TEMP TABLE — DDL; без lock_timeout деплой встанет
|
||||||
|
-- в очередь за чужой сессией и утащит за собой запросы приложения.
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
CREATE TEMP TABLE oph_decimal_slips (
|
||||||
|
id bigint PRIMARY KEY,
|
||||||
|
listing_id bigint NOT NULL,
|
||||||
|
next_id bigint, -- строка, шедшая следом: у неё сменится база diff_percent
|
||||||
|
source text NOT NULL
|
||||||
|
) ON COMMIT DROP;
|
||||||
|
|
||||||
|
-- ФАЗА 1 — правило внутри серии, пошагово как в гейте.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r record;
|
||||||
|
cur_listing bigint := NULL;
|
||||||
|
base numeric := NULL; -- последняя ОСТАВЛЕННАЯ цена этого объявления
|
||||||
|
witness numeric;
|
||||||
|
confirmed boolean;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT oph.id,
|
||||||
|
oph.listing_id,
|
||||||
|
oph.source,
|
||||||
|
oph.price_rub,
|
||||||
|
lead(oph.price_rub) OVER w AS next_price,
|
||||||
|
lead(oph.id) OVER w AS next_id,
|
||||||
|
l.price_rub AS listing_price
|
||||||
|
FROM offer_price_history oph
|
||||||
|
LEFT JOIN listings l ON l.id = oph.listing_id
|
||||||
|
WHERE oph.change_time <> oph.recorded_at
|
||||||
|
-- Объявления с одной строкой загрузчика кандидатов дать не могут ни по
|
||||||
|
-- одному из правил (нет ни базы слева, ни второй точки справа).
|
||||||
|
AND oph.listing_id IN (
|
||||||
|
SELECT listing_id
|
||||||
|
FROM offer_price_history
|
||||||
|
WHERE change_time <> recorded_at
|
||||||
|
GROUP BY listing_id
|
||||||
|
HAVING count(*) >= 2
|
||||||
|
)
|
||||||
|
WINDOW w AS (PARTITION BY oph.listing_id ORDER BY oph.change_time, oph.id)
|
||||||
|
ORDER BY oph.listing_id, oph.change_time, oph.id
|
||||||
|
LOOP
|
||||||
|
IF cur_listing IS DISTINCT FROM r.listing_id THEN
|
||||||
|
cur_listing := r.listing_id;
|
||||||
|
base := NULL; -- новое объявление, базы слева нет
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Свидетель справа: следующая точка серии, а у последней — текущая цена
|
||||||
|
-- объявления. Нет ни того, ни другого → witness IS NULL, и сравнение ниже
|
||||||
|
-- даёт NULL, то есть «не сдвиг»: без второго свидетеля точку не трогаем.
|
||||||
|
witness := COALESCE(r.next_price, r.listing_price);
|
||||||
|
|
||||||
|
IF base IS NOT NULL
|
||||||
|
AND base > 0 AND r.price_rub > 0 AND witness > 0
|
||||||
|
AND (r.price_rub / base BETWEEN 9.5 AND 10.5
|
||||||
|
OR base / r.price_rub BETWEEN 9.5 AND 10.5)
|
||||||
|
AND witness / base BETWEEN 0.9 AND 1.1
|
||||||
|
THEN
|
||||||
|
-- L-4: цену подтверждает триггерная строка того же объявления → это
|
||||||
|
-- наблюдавшаяся listings.price_rub, а не дефект разбора.
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM offer_price_history t
|
||||||
|
WHERE t.listing_id = r.listing_id
|
||||||
|
AND t.change_time = t.recorded_at
|
||||||
|
AND t.price_rub = r.price_rub
|
||||||
|
)
|
||||||
|
INTO confirmed;
|
||||||
|
|
||||||
|
IF NOT confirmed THEN
|
||||||
|
INSERT INTO oph_decimal_slips (id, listing_id, next_id, source)
|
||||||
|
VALUES (r.id, r.listing_id, r.next_id, r.source);
|
||||||
|
CONTINUE; -- база НЕ двигается: этой точки в серии больше нет
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF r.price_rub > 0 THEN
|
||||||
|
base := r.price_rub;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ФАЗА 2 — правило первой точки, по ОСТАВШИМСЯ строкам (кандидаты фазы 1 исключены).
|
||||||
|
-- Набор множественный, состояния не требует — обычный INSERT ... SELECT.
|
||||||
|
INSERT INTO oph_decimal_slips (id, listing_id, next_id, source)
|
||||||
|
WITH kept AS (
|
||||||
|
SELECT oph.id,
|
||||||
|
oph.listing_id,
|
||||||
|
oph.source,
|
||||||
|
oph.price_rub,
|
||||||
|
row_number() OVER w AS rn,
|
||||||
|
lead(oph.id) OVER w AS second_id,
|
||||||
|
lead(oph.price_rub) OVER w AS second_price,
|
||||||
|
lead(oph.price_rub, 2) OVER w AS third_price
|
||||||
|
FROM offer_price_history oph
|
||||||
|
WHERE oph.change_time <> oph.recorded_at
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM oph_decimal_slips s WHERE s.id = oph.id)
|
||||||
|
WINDOW w AS (PARTITION BY oph.listing_id ORDER BY oph.change_time, oph.id)
|
||||||
|
)
|
||||||
|
SELECT k.id, k.listing_id, k.second_id, k.source
|
||||||
|
FROM kept k
|
||||||
|
WHERE k.rn = 1
|
||||||
|
AND k.price_rub > 0
|
||||||
|
AND k.second_price > 0
|
||||||
|
AND (k.second_price / k.price_rub BETWEEN 9.5 AND 10.5
|
||||||
|
OR k.price_rub / k.second_price BETWEEN 9.5 AND 10.5)
|
||||||
|
-- Свидетель — только ТРЕТЬЯ точка истории. listings.price_rub здесь НЕ
|
||||||
|
-- подставляется: у 17 из 21 кандидата она в точности равна второй точке,
|
||||||
|
-- то есть это то же наблюдение, а не второй свидетель (см. шапку).
|
||||||
|
AND k.third_price > 0
|
||||||
|
AND k.third_price / k.second_price BETWEEN 0.9 AND 1.1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM offer_price_history t
|
||||||
|
WHERE t.listing_id = k.listing_id
|
||||||
|
AND t.change_time = t.recorded_at
|
||||||
|
AND t.price_rub = k.price_rub
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
to_delete bigint;
|
||||||
|
per_source text;
|
||||||
|
deleted_rows bigint;
|
||||||
|
updated_rows bigint;
|
||||||
|
nulled_rows bigint;
|
||||||
|
nulled_src text;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO to_delete FROM oph_decimal_slips;
|
||||||
|
|
||||||
|
SELECT string_agg(source || '=' || cnt, ', ' ORDER BY source)
|
||||||
|
INTO per_source
|
||||||
|
FROM (
|
||||||
|
SELECT source, count(*) AS cnt
|
||||||
|
FROM oph_decimal_slips
|
||||||
|
GROUP BY source
|
||||||
|
) s;
|
||||||
|
|
||||||
|
RAISE NOTICE 'offer_price_history: сдвигов разряда найдено = % (%)',
|
||||||
|
to_delete, COALESCE(per_source, 'ни одного');
|
||||||
|
|
||||||
|
IF to_delete > 200 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'offer_price_history: к удалению % строк при замере 06.09.2026 = 38 '
|
||||||
|
'(34 внутри серий + 4 первых точки; cian 12, domklik 26, yandex 0) — '
|
||||||
|
'критерий ловит не то, миграция остановлена', to_delete;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM offer_price_history oph
|
||||||
|
USING oph_decimal_slips s
|
||||||
|
WHERE oph.id = s.id;
|
||||||
|
GET DIAGNOSTICS deleted_rows = ROW_COUNT;
|
||||||
|
|
||||||
|
-- Пересчёт СТРОГО у строк, шедших за удалёнными: только у них сменился prev.
|
||||||
|
WITH neighbours AS (
|
||||||
|
-- Окно сужено до затронутых листингов: без этого lag() гонится по всей
|
||||||
|
-- таблице, а под lock_timeout = 5s это лишний риск на деплое.
|
||||||
|
SELECT id,
|
||||||
|
price_rub,
|
||||||
|
lag(price_rub) OVER (PARTITION BY listing_id ORDER BY change_time, id)
|
||||||
|
AS prev_price
|
||||||
|
FROM offer_price_history
|
||||||
|
WHERE change_time <> recorded_at
|
||||||
|
AND listing_id IN (SELECT DISTINCT listing_id FROM oph_decimal_slips)
|
||||||
|
),
|
||||||
|
recomputed AS (
|
||||||
|
SELECT n.id,
|
||||||
|
CASE
|
||||||
|
WHEN n.prev_price > 0
|
||||||
|
AND abs((n.price_rub - n.prev_price) / n.prev_price * 100) <= 100
|
||||||
|
THEN round((n.price_rub - n.prev_price) / n.prev_price * 100, 2)
|
||||||
|
END AS new_diff
|
||||||
|
FROM neighbours n
|
||||||
|
WHERE n.id IN (SELECT next_id FROM oph_decimal_slips WHERE next_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
UPDATE offer_price_history oph
|
||||||
|
SET diff_percent = r.new_diff
|
||||||
|
FROM recomputed r
|
||||||
|
WHERE oph.id = r.id
|
||||||
|
AND oph.diff_percent IS DISTINCT FROM r.new_diff;
|
||||||
|
GET DIAGNOSTICS updated_rows = ROW_COUNT;
|
||||||
|
|
||||||
|
RAISE NOTICE 'offer_price_history: удалено строк = %, пересчитано diff_percent у соседей = %',
|
||||||
|
deleted_rows, updated_rows;
|
||||||
|
|
||||||
|
-- Финальный шаг: величина не того рода → NULL. Правило validate_diff_percent,
|
||||||
|
-- применённое ко ВСЕМ источникам и только к строкам загрузчика.
|
||||||
|
SELECT string_agg(source || '=' || cnt, ', ' ORDER BY source)
|
||||||
|
INTO nulled_src
|
||||||
|
FROM (
|
||||||
|
SELECT source, count(*) AS cnt
|
||||||
|
FROM offer_price_history
|
||||||
|
WHERE change_time <> recorded_at
|
||||||
|
AND abs(diff_percent) > 100
|
||||||
|
GROUP BY source
|
||||||
|
) s;
|
||||||
|
|
||||||
|
UPDATE offer_price_history
|
||||||
|
SET diff_percent = NULL
|
||||||
|
WHERE change_time <> recorded_at
|
||||||
|
AND abs(diff_percent) > 100;
|
||||||
|
GET DIAGNOSTICS nulled_rows = ROW_COUNT;
|
||||||
|
|
||||||
|
RAISE NOTICE 'offer_price_history: |diff_percent| > 100 обнулено = % (%)',
|
||||||
|
nulled_rows, COALESCE(nulled_src, 'ни одной');
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
107
tradein-mvp/backend/data/sql/287_proxy_run_attribution.sql
Normal file
107
tradein-mvp/backend/data/sql/287_proxy_run_attribution.sql
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
-- 287_proxy_run_attribution.sql
|
||||||
|
-- scrape_runs.proxy_id — узел прогона (#3404 A) + soft-clear банов по источнику (#3404 B).
|
||||||
|
--
|
||||||
|
-- Dependencies: 015_scrape_runs.sql (scrape_runs), 157_scrape_proxies.sql (scrape_proxies),
|
||||||
|
-- 210_scrape_proxy_source_bans.sql (scrape_proxy_source_bans).
|
||||||
|
-- Apply after: 286_offer_price_history_decimal_slips.sql
|
||||||
|
--
|
||||||
|
-- ЧАСТЬ A — WHY:
|
||||||
|
-- До сих пор ни одна строка scrape_runs не знала, через какой узел пула шёл прогон:
|
||||||
|
-- ProxyProvider.acquire(provider) run_id не принимает, а leased_by у боевого пути —
|
||||||
|
-- NON_RUN_LEASE_MARKER. Разбор исхода прогона по узлу (кто плодит баны/провалы)
|
||||||
|
-- был возможен только вручную, по времени. Код-часть (app/services/proxy_pool.py:
|
||||||
|
-- attribute_run_proxy) пишет сюда после каждой выдачи лиза; здесь только схема.
|
||||||
|
--
|
||||||
|
-- ЧАСТЬ A — WHAT:
|
||||||
|
-- proxy_id — узел, через который шёл прогон. Если за прогон узел МЕНЯЛСЯ (ротация
|
||||||
|
-- при повторных провалах в browser_fetcher, либо curl-путь берёт лиз на каждый вызов
|
||||||
|
-- в providers/_proxy.py), здесь остаётся ПОСЛЕДНИЙ; полная цепочка узлов копится в
|
||||||
|
-- scrape_runs.counters->'proxy_ids' (jsonb-массив, пишет тот же attribute_run_proxy).
|
||||||
|
-- ON DELETE SET NULL, а не CASCADE — узел из пула может быть выведен/удалён оператором,
|
||||||
|
-- история прогонов (аналитика, отчёты) не должна пропадать вместе с ним.
|
||||||
|
-- Индекс (proxy_id, started_at DESC) — под разрез «исход прогона по узлу за период»
|
||||||
|
-- (WHERE proxy_id = ... ORDER BY started_at DESC); partial по proxy_id IS NOT NULL не
|
||||||
|
-- делаем, потому что колонка сортировки (started_at) в самом индексе — Postgres и так
|
||||||
|
-- не будет использовать индекс без него для прогонов без узла.
|
||||||
|
--
|
||||||
|
-- НИЧЕГО НЕ БЭКФИЛЛИТСЯ: связать уже прошедшие прогоны с конкретным узлом задним
|
||||||
|
-- числом нечем — leased_by исторически = NON_RUN_LEASE_MARKER, а лог выдачи лизов
|
||||||
|
-- не хранит run_id. Для всех строк scrape_runs, созданных ДО этой миграции,
|
||||||
|
-- proxy_id остаётся NULL навсегда — это не «прогон без прокси», а «прогон, для
|
||||||
|
-- которого атрибуция не собиралась». Врать восстановленным/угаданным значением
|
||||||
|
-- нельзя, поэтому backfill-UPDATE здесь сознательно отсутствует.
|
||||||
|
--
|
||||||
|
-- ЧАСТЬ B — WHY:
|
||||||
|
-- clear_source_bans() (proxy_pool.py) сейчас делает DELETE строки
|
||||||
|
-- scrape_proxy_source_bans. Это стирает историю эскалации (ban_count) и не оставляет
|
||||||
|
-- следа, что бан был снят ДОСРОЧНО (оператором/успешной ротацией exit-IP), в отличие
|
||||||
|
-- от бана, который просто истёк сам. Код-часть переводит функцию на UPDATE
|
||||||
|
-- (гашение: banned_until=now(), ban_count=0, cleared_at/cleared_reason проставляются),
|
||||||
|
-- строка доживает до штатного purge в run_proxy_healthcheck (SOURCE_BAN_PURGE_DAYS).
|
||||||
|
-- Эскалация при повторном бане той же пары сохраняется 1:1: формула в mark_banned
|
||||||
|
-- берёт СТАРЫЙ ban_count как показатель степени (base * 2^ban_count), при
|
||||||
|
-- ban_count=0 после гашения это ровно SOURCE_BAN_BASE_HOURS=6ч — байт-в-байт как
|
||||||
|
-- свежий INSERT после DELETE.
|
||||||
|
--
|
||||||
|
-- ЧАСТЬ B — WHAT:
|
||||||
|
-- cleared_at — момент досрочного снятия бана (не путать с истечением banned_until
|
||||||
|
-- само по себе: NULL значит «бан снят не был / истёк сам», не-NULL — снят
|
||||||
|
-- оператором или ротацией exit-IP до истечения срока или сразу после).
|
||||||
|
-- cleared_reason — свободный текст причины снятия (тот же 'reason', что передаётся
|
||||||
|
-- в clear_source_bans).
|
||||||
|
--
|
||||||
|
-- ИДЕМПОТЕНТНОСТЬ:
|
||||||
|
-- ADD COLUMN IF NOT EXISTS × 3, CREATE INDEX IF NOT EXISTS, COMMENT ON COLUMN
|
||||||
|
-- (безусловны, но идемпотентны сами по себе — просто перезаписывают тот же текст).
|
||||||
|
-- Backfill-DML в файле нет вовсе, поэтому повторный прогон — чистый no-op.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
-- ── Часть A: scrape_runs.proxy_id ────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE scrape_runs
|
||||||
|
ADD COLUMN IF NOT EXISTS proxy_id bigint REFERENCES scrape_proxies(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scrape_runs_proxy_id_started_at
|
||||||
|
ON scrape_runs (proxy_id, started_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN scrape_runs.proxy_id IS
|
||||||
|
'Узел пула (scrape_proxies.id), через который шёл прогон. NULL = прогон без '
|
||||||
|
'прокси (эстиматор, admin-инициированные вызовы с NON_RUN_LEASE_MARKER) либо '
|
||||||
|
'прогон ДО применения миграции 287 (backfill не делался — связать нечем). '
|
||||||
|
'Если узел менялся mid-run (ротация после серии провалов в browser_fetcher, '
|
||||||
|
'либо curl-путь берёт лиз заново на каждый вызов) — здесь ПОСЛЕДНИЙ выданный '
|
||||||
|
'узел, полная цепочка — counters->''proxy_ids'' (jsonb-массив id, в порядке '
|
||||||
|
'первой выдачи). ON DELETE SET NULL: удаление узла из пула не должно уносить '
|
||||||
|
'историю прогонов.';
|
||||||
|
|
||||||
|
-- ── Часть B: soft-clear в scrape_proxy_source_bans ───────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE scrape_proxy_source_bans
|
||||||
|
ADD COLUMN IF NOT EXISTS cleared_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS cleared_reason text;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN scrape_proxy_source_bans.cleared_at IS
|
||||||
|
'Момент досрочного снятия бана (proxy_pool.clear_source_bans, #3404) — '
|
||||||
|
'оператором или успешной ротацией exit-IP. NULL = бан не снимался вручную '
|
||||||
|
'(либо ещё активен, либо истёк сам по banned_until). Строка при гашении НЕ '
|
||||||
|
'удаляется — доживает до штатного purge (SOURCE_BAN_PURGE_DAYS), таймер '
|
||||||
|
'которого для погашенных строк отсчитывается от banned_until = момент гашения.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN scrape_proxy_source_bans.cleared_reason IS
|
||||||
|
'Причина досрочного снятия бана (тот же текст, что передан в '
|
||||||
|
'clear_source_bans(reason=...)). NULL, если строка не гасилась вручную.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN scrape_proxy_source_bans.ban_count IS
|
||||||
|
'Сколько раз эта пара банилась. Срок ТЕКУЩЕГО бана (banned_until - banned_at) = '
|
||||||
|
'base * 2^(ban_count-1), потолок SOURCE_BAN_MAX_HOURS: ban_count=1 → 6ч, 2 → 12ч, '
|
||||||
|
'3 → 24ч и т.д. Сбрасывается либо purge''ем через SOURCE_BAN_PURGE_DAYS после '
|
||||||
|
'истечения, либо proxy_pool.clear_source_bans (#3404: досрочное ГАШЕНИЕ строки —'
|
||||||
|
' banned_until=now(), ban_count=0, cleared_at/cleared_reason проставляются; '
|
||||||
|
'строка НЕ удаляется, живёт до purge). Оба пути одинаково обнуляют ban_count, '
|
||||||
|
'поэтому следующий бан той же пары в обоих случаях стартует заново с '
|
||||||
|
'SOURCE_BAN_BASE_HOURS.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
67
tradein-mvp/backend/data/sql/288_deals_doc_type.sql
Normal file
67
tradein-mvp/backend/data/sql/288_deals_doc_type.sql
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
-- 288_deals_doc_type.sql
|
||||||
|
-- deals.doc_type — тип документа сделки Росреестра (#3051 п.3, п.6).
|
||||||
|
--
|
||||||
|
-- Dependencies: 002_core_tables.sql (deals), 015_scrape_runs.sql + 072_scrape_schedules_seed_cian_rosreestr.sql
|
||||||
|
-- (scrape_schedules, строка source='rosreestr_dkp_import')
|
||||||
|
-- Apply after: 287_proxy_run_attribution.sql
|
||||||
|
--
|
||||||
|
-- ЧАСТЬ A — WHY:
|
||||||
|
-- Импорт Росреестра до сих пор ронял тип документа на пол: фильтр doc_type = 'ДКП'
|
||||||
|
-- стоял литералом в WHERE, а в deals не приезжало ничего. Пока скоуп был один
|
||||||
|
-- (Свердловская обл., только вторичка) это было безобидно — все строки источника
|
||||||
|
-- 'rosreestr' по построению ДКП. С расширением на Москву безобидность кончается:
|
||||||
|
-- в источнике за 2024 по region_code=77 лежит 30 627 ДДУ с медианой 112 743 ₽/м²
|
||||||
|
-- против 107 005 ДКП с медианой 256 250 ₽/м² — это цены котлована, и смешать их
|
||||||
|
-- в одной таблице без различимого признака значит развалить любую оценку.
|
||||||
|
-- Колонка нужна ДО того, как импорт начнёт тянуть больше одного типа.
|
||||||
|
--
|
||||||
|
-- ЧАСТЬ A — WHAT:
|
||||||
|
-- doc_type text NULLable — источник (foreign table gendesign_rosreestr_deals.doc_type)
|
||||||
|
-- тоже text и NULL допускает; строгий NOT NULL сломал бы не-росреестровые источники
|
||||||
|
-- (etazhi / domklik_history), у которых понятия «тип документа» нет вовсе.
|
||||||
|
-- Индекс НЕ добавляем: селективность низкая (2-3 значения), а все живые выборки
|
||||||
|
-- по deals идут по region_code/deal_date/geom — doc_type там в лучшем случае
|
||||||
|
-- довесок к уже отобранному диапазону. Появится запрос, который реально режет
|
||||||
|
-- по doc_type на большом наборе — заведём частичный индекс тогда, по EXPLAIN.
|
||||||
|
--
|
||||||
|
-- ЧАСТЬ B — бэкфилл:
|
||||||
|
-- Всё, что лежит в deals с source='rosreestr', прошло через WHERE doc_type = 'ДКП'
|
||||||
|
-- (и в scheduler.import_rosreestr_dkp, и в deploy/import-rosreestr.sh, и в
|
||||||
|
-- backfill-миграции 077) — других типов там физически быть не может. Поэтому
|
||||||
|
-- проставить 'ДКП' задним числом корректно, а не эвристика.
|
||||||
|
-- Остальные источники остаются NULL осознанно.
|
||||||
|
--
|
||||||
|
-- ЧАСТЬ C — расписание:
|
||||||
|
-- scrape_schedules.default_params для 'rosreestr_dkp_import' получает явный
|
||||||
|
-- region_code=66. Раньше регион был неявным дефолтом в коде; после параметризации
|
||||||
|
-- (params.get('region_code', 66)) неявность становится ловушкой — прод-строка должна
|
||||||
|
-- сама говорить, какой регион она тянет. doc_types в default_params НЕ пишем:
|
||||||
|
-- дефолт ['ДКП'] в коде и есть текущее поведение, а запись его в расписание
|
||||||
|
-- создала бы второе место, где надо не забыть поменять.
|
||||||
|
--
|
||||||
|
-- Идемпотентна, безопасна к повторному запуску.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- #2752: блокирующий DDL не должен вставать в очередь за чужой сессией и уводить
|
||||||
|
-- за собой запросы приложения — лучше упасть по таймауту и повторить деплой.
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
-- A: колонка
|
||||||
|
ALTER TABLE deals ADD COLUMN IF NOT EXISTS doc_type text;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN deals.doc_type IS
|
||||||
|
'Тип документа сделки Росреестра (ДКП / ДДУ). NULL для источников без этого понятия.';
|
||||||
|
|
||||||
|
-- B: бэкфилл — все rosreestr-строки прошли фильтр ДКП на импорте
|
||||||
|
UPDATE deals
|
||||||
|
SET doc_type = 'ДКП'
|
||||||
|
WHERE source = 'rosreestr'
|
||||||
|
AND doc_type IS NULL;
|
||||||
|
|
||||||
|
-- C: явный регион в расписании импорта вместо неявного дефолта в коде
|
||||||
|
UPDATE scrape_schedules
|
||||||
|
SET default_params = default_params || '{"region_code": 66}'::jsonb
|
||||||
|
WHERE source = 'rosreestr_dkp_import';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
-- 289_rosreestr_fdw_msk_columns_seed77.sql
|
||||||
|
-- gendesign_rosreestr_deals: колонки okato/quarter_cad_number/district + disabled
|
||||||
|
-- seed-строка scrape_schedules для региона 77 (Москва) — #3051 п.3.
|
||||||
|
--
|
||||||
|
-- Dependencies: 072_scrape_schedules_seed_cian_rosreestr.sql (gendesign_rosreestr_deals,
|
||||||
|
-- scrape_schedules).
|
||||||
|
-- Apply after: 288_deals_doc_type.sql
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- Трек 2 подготовки Mera к Москве — импорт сделок Росреестра параметризуется по
|
||||||
|
-- региону (66 Свердловская обл. / 77 Москва, code-часть в scheduler.py). Для
|
||||||
|
-- региона С canonical_city (Москва) исходные city/okato/quarter_cad_number/district
|
||||||
|
-- уходят в deals.raw_payload (jsonb), т.к. city источника там — муниципальный
|
||||||
|
-- округ/поселение, не город, и обычный city/address его не покрывают. Foreign
|
||||||
|
-- table расширена тремя колонками источника; существование их в
|
||||||
|
-- public.rosreestr_deals на gendesign-стороне проверено live (2026-09-08, prod psql).
|
||||||
|
--
|
||||||
|
-- Seed-строка rosreestr_dkp_import_77 — ВЫКЛЮЧЕНА (enabled=false): миграция
|
||||||
|
-- только заводит расписание, включение и первый прогон по Москве — отдельное
|
||||||
|
-- решение main-сессии после ревью кода-части.
|
||||||
|
--
|
||||||
|
-- ИДЕМПОТЕНТНОСТЬ: ADD COLUMN IF NOT EXISTS × 3, ON CONFLICT (source) DO NOTHING
|
||||||
|
-- для seed — повторный прогон no-op.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
-- ── gendesign_rosreestr_deals: колонки под региональный raw_payload (#3051) ────
|
||||||
|
-- ALTER FOREIGN TABLE ADD COLUMN — только локальные метаданные (не трогает
|
||||||
|
-- реальную remote-таблицу), безопасно как ALTER TABLE ADD COLUMN без DEFAULT.
|
||||||
|
|
||||||
|
ALTER FOREIGN TABLE gendesign_rosreestr_deals
|
||||||
|
ADD COLUMN IF NOT EXISTS okato text;
|
||||||
|
|
||||||
|
ALTER FOREIGN TABLE gendesign_rosreestr_deals
|
||||||
|
ADD COLUMN IF NOT EXISTS quarter_cad_number text;
|
||||||
|
|
||||||
|
ALTER FOREIGN TABLE gendesign_rosreestr_deals
|
||||||
|
ADD COLUMN IF NOT EXISTS district text;
|
||||||
|
|
||||||
|
-- ── Seed: rosreestr_dkp_import_77 (выключено) ──────────────────────────────────
|
||||||
|
|
||||||
|
INSERT INTO scrape_schedules (
|
||||||
|
source,
|
||||||
|
enabled,
|
||||||
|
window_start_hour,
|
||||||
|
window_end_hour,
|
||||||
|
default_params
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'rosreestr_dkp_import_77',
|
||||||
|
false,
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
'{"region_code": 77, "since": "2024-01-01", "batch_size": 2000}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (source) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
100
tradein-mvp/backend/data/sql/290_frt_mkd_staging.sql
Normal file
100
tradein-mvp/backend/data/sql/290_frt_mkd_staging.sql
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
-- 290_frt_mkd_staging.sql
|
||||||
|
-- АИС ППК «ФРТ» (бывш. Реформа ЖКХ) — реестр МКД, обогащение houses (issue #frt-mkd).
|
||||||
|
--
|
||||||
|
-- КОНТЕКСТ: houses.area_land / foundation_type отсутствуют вовсе; elevators_count в
|
||||||
|
-- источнике — ОБЩЕЕ число лифтов, а в houses раздельно passenger/cargo — отдельная
|
||||||
|
-- колонка elevators_total, НЕ маппинг на passenger_elevators (иначе задвоение/потеря
|
||||||
|
-- типа). year_built/material_walls/total_floors/entrances/is_emergency/flat_count/
|
||||||
|
-- heat_supply_type/gas_supply_type/hot_water уже существуют — этот источник их
|
||||||
|
-- ДОБИРАЕТ (COALESCE, только NULL), не переопределяет.
|
||||||
|
--
|
||||||
|
-- ИСТОЧНИК: https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/110 (node 110 =
|
||||||
|
-- реестр МКД региона 66), zip → CSV `export-reestrmkd-66-YYYYMMDD.csv`, UTF-8 BOM,
|
||||||
|
-- разделитель ';', 60 колонок, ~41.8k строк по СО. Проверено живьём 08.09.2026.
|
||||||
|
--
|
||||||
|
-- ЧТО НЕ ДЕЛАЕМ (осознанно, см. промпт задачи):
|
||||||
|
-- * project_type НЕ мапим в houses.series_name — свободный текст, дубль материала
|
||||||
|
-- стен (топ: пусто, «кирпичный», «нет данных», «панельный», разнобой регистра).
|
||||||
|
-- * energy_efficiency НЕ добавляем в houses — миграция 284 уже решила НЕ заводить
|
||||||
|
-- это поле; у ~61% домов значение «Не присвоен».
|
||||||
|
-- * playground/sportsground — id справочника (498/499/500), НЕ булев флаг; в staging
|
||||||
|
-- как есть, в houses.has_playground не пишем.
|
||||||
|
--
|
||||||
|
-- Идемпотентно: CREATE TABLE/ADD COLUMN IF NOT EXISTS, повторный прогон — no-op.
|
||||||
|
-- lock_timeout (#2752): ALTER houses берёт ACCESS EXCLUSIVE — SET LOCAL только внутри BEGIN.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS frt_mkd (
|
||||||
|
houseguid text PRIMARY KEY, -- ФИАС GUID дома (АИС ФРТ houseguid)
|
||||||
|
region_code smallint NOT NULL, -- код региона выгрузки (66 = Свердловская обл.)
|
||||||
|
address text,
|
||||||
|
built_year smallint,
|
||||||
|
exploitation_start_year smallint,
|
||||||
|
project_type text, -- сырьё; НЕ мапится в houses (см. шапку)
|
||||||
|
house_type text,
|
||||||
|
is_alarm boolean, -- «Да»/«Нет» из источника
|
||||||
|
floor_count_max smallint,
|
||||||
|
floor_count_min smallint,
|
||||||
|
entrance_count smallint,
|
||||||
|
elevators_count smallint, -- ОБЩЕЕ число лифтов (не passenger/cargo)
|
||||||
|
energy_efficiency text, -- сырьё; НЕ мапится в houses (мигр. 284)
|
||||||
|
quarters_count integer,
|
||||||
|
living_quarters_count integer,
|
||||||
|
unliving_quarters_count integer,
|
||||||
|
area_total numeric(12, 2),
|
||||||
|
area_residential numeric(12, 2),
|
||||||
|
area_non_residential numeric(12, 2),
|
||||||
|
area_common_property numeric(12, 2),
|
||||||
|
area_land numeric(12, 2),
|
||||||
|
parking_square numeric(12, 2),
|
||||||
|
playground integer, -- id справочника благоустройства, НЕ булев флаг
|
||||||
|
sportsground integer, -- id справочника благоустройства, НЕ булев флаг
|
||||||
|
other_beautification text,
|
||||||
|
foundation_type text,
|
||||||
|
floor_type text,
|
||||||
|
wall_material text,
|
||||||
|
basement_area numeric(12, 2),
|
||||||
|
chute_type text,
|
||||||
|
chute_count smallint,
|
||||||
|
heating_type text,
|
||||||
|
hot_water_type text,
|
||||||
|
cold_water_type text,
|
||||||
|
sewerage_type text,
|
||||||
|
gas_type text,
|
||||||
|
ventilation_type text,
|
||||||
|
firefighting_type text,
|
||||||
|
drainage_type text,
|
||||||
|
management_organization_id bigint,
|
||||||
|
method_of_forming_overhaul_fund text,
|
||||||
|
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE frt_mkd IS
|
||||||
|
'АИС ППК ФРТ (бывш. Реформа ЖКХ) — реестр МКД, open data export/110, region 66. '
|
||||||
|
'Ключ houseguid (ФИАС GUID). Заполняется app/services/frt_mkd_loader.py, '
|
||||||
|
'мигр. 290. Матчится к houses через COALESCE(gar_house_guid, house_fias_id, '
|
||||||
|
'zhkh_house_guid).';
|
||||||
|
|
||||||
|
ALTER TABLE houses ADD COLUMN IF NOT EXISTS area_land numeric(12, 2);
|
||||||
|
COMMENT ON COLUMN houses.area_land IS
|
||||||
|
'мигр. 290: площадь земельного участка, м2, источник АИС ППК ФРТ (frt_mkd.area_land).';
|
||||||
|
|
||||||
|
ALTER TABLE houses ADD COLUMN IF NOT EXISTS foundation_type text;
|
||||||
|
COMMENT ON COLUMN houses.foundation_type IS
|
||||||
|
'мигр. 290: тип фундамента, источник АИС ППК ФРТ (frt_mkd.foundation_type).';
|
||||||
|
|
||||||
|
ALTER TABLE houses ADD COLUMN IF NOT EXISTS elevators_total integer;
|
||||||
|
COMMENT ON COLUMN houses.elevators_total IS
|
||||||
|
'мигр. 290: ОБЩЕЕ число лифтов (без разбивки passenger/cargo), источник АИС ППК ФРТ '
|
||||||
|
'(frt_mkd.elevators_count). НЕ путать с passenger_elevators/cargo_elevators — те '
|
||||||
|
'раздельные и из других источников.';
|
||||||
|
|
||||||
|
ALTER TABLE houses ADD COLUMN IF NOT EXISTS frt_matched_at timestamptz;
|
||||||
|
COMMENT ON COLUMN houses.frt_matched_at IS
|
||||||
|
'мигр. 290: момент, когда backfill_houses() из frt_mkd_loader.py нашёл матч по '
|
||||||
|
'houseguid для этого дома (проставляется один раз, даже если полей для дозаполнения '
|
||||||
|
'уже не осталось).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
-- 291_scrape_schedules_seed_frt_mkd_load.sql
|
||||||
|
-- Расписание для frt_mkd_load (АИС ППК ФРТ, реестр МКД region 66, issue #frt-mkd).
|
||||||
|
--
|
||||||
|
-- Источник обновляется нерегулярно (реестр МКД, не суточный поток) — interval_days=30
|
||||||
|
-- по аналогии с ДОМ.РФ капремонт (мигр. 176/... каденция месячная). node_id/region_code
|
||||||
|
-- в default_params — параметры handler'а (app/services/product_handlers.py
|
||||||
|
-- _job_frt_mkd_load), сам loader их же принимает как аргументы load_frt_mkd().
|
||||||
|
--
|
||||||
|
-- ENABLED=false — как у domrf_kapremont_load и domclick_detail_backfill: включение
|
||||||
|
-- отдельным осознанным шагом после деплоя и дымовой пробы (ZIP 3.2 МБ / CSV 41 МБ —
|
||||||
|
-- смотрим таймауты и нагрузку на первом ручном прогоне, не в фоне).
|
||||||
|
--
|
||||||
|
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 290_frt_mkd_staging.sql.
|
||||||
|
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO scrape_schedules (
|
||||||
|
source,
|
||||||
|
enabled,
|
||||||
|
window_start_hour,
|
||||||
|
window_end_hour,
|
||||||
|
next_run_at,
|
||||||
|
default_params
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'frt_mkd_load',
|
||||||
|
false,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
|
||||||
|
'{"node_id": 110, "region_code": 66, "interval_days": 30}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (source) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
87
tradein-mvp/backend/data/sql/292_cbr_macro_series.sql
Normal file
87
tradein-mvp/backend/data/sql/292_cbr_macro_series.sql
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
-- 292_cbr_macro_series.sql
|
||||||
|
-- Макро-ряды ЦБ РФ: ипотека по субъектам + ключевая ставка.
|
||||||
|
--
|
||||||
|
-- Context:
|
||||||
|
-- ЦБ РФ публикует месячную статистику по ипотечным жилищным кредитам (объём выдач,
|
||||||
|
-- ставки, задолженность) в разрезе субъектов РФ — три XLSX-дашборда на cbr.ru
|
||||||
|
-- (см. app/services/cbr_macro.py::CBR_MORTGAGE_SERIES) — и дневную ключевую ставку
|
||||||
|
-- через SOAP-сервис DailyInfoWebServ. Обе серии нужны как макро-контекст для
|
||||||
|
-- продукта (региональная динамика ипотечного рынка); загрузку в estimator этот
|
||||||
|
-- PR не делает — только данные и их сбор (app/tasks/cbr_macro_pull.py).
|
||||||
|
--
|
||||||
|
-- Schema notes:
|
||||||
|
-- cbr_mortgage_series
|
||||||
|
-- * region — территория как в источнике (РФ целиком / федеральный округ /
|
||||||
|
-- субъект), без нормализации кодов ОКАТО.
|
||||||
|
-- * period_month — первое число отчётного месяца (шапка XLSX «Месяц Год»).
|
||||||
|
-- * series — slug серии (rate_rub / new_loans_rub / debt_rub, конфиг —
|
||||||
|
-- CBR_MORTGAGE_SERIES в коде).
|
||||||
|
-- * PRIMARY KEY (region, period_month, series) — одно значение на территорию×месяц×серию.
|
||||||
|
-- * fetched_at — «когда мы ВПЕРВЫЕ увидели этот период» (не время последней
|
||||||
|
-- загрузки — источник даёт всю серию целиком, отсечки по
|
||||||
|
-- периоду нет). Урок #2846 у sber_price_index: НЕ обновлять
|
||||||
|
-- fetched_at в ON CONFLICT DO UPDATE, иначе один прогон
|
||||||
|
-- перезаписывает метку у всех периодов, включая 2019-01.
|
||||||
|
-- cbr_key_rate
|
||||||
|
-- * rate_date — дата действия ключевой ставки (PRIMARY KEY).
|
||||||
|
-- * rate — значение ставки, % годовых.
|
||||||
|
-- * fetched_at — по той же логике, что и выше: не трогаем в DO UPDATE.
|
||||||
|
--
|
||||||
|
-- Idempotent: CREATE TABLE IF NOT EXISTS — безопасно переприменять. Только ADDITIVE.
|
||||||
|
--
|
||||||
|
-- Dependencies: нет (самостоятельные таблицы).
|
||||||
|
-- Deploy order: применить до app/services/cbr_macro.py + app/tasks/cbr_macro_pull.py
|
||||||
|
-- + миграции 291 (сид scrape_schedules).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cbr_mortgage_series (
|
||||||
|
region text NOT NULL,
|
||||||
|
period_month date NOT NULL,
|
||||||
|
series text NOT NULL,
|
||||||
|
value double precision NOT NULL,
|
||||||
|
source text NOT NULL DEFAULT 'cbr',
|
||||||
|
fetched_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (region, period_month, series)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE cbr_mortgage_series IS
|
||||||
|
'Месячные ряды ипотечной статистики ЦБ РФ по территориям (РФ / ФО / субъекты): '
|
||||||
|
'объём выдач, ставки, задолженность. Источник — XLSX-дашборды cbr.ru/vfs/statistics/'
|
||||||
|
'BankSector/Mortgage/. Загружается app/tasks/cbr_macro_pull.py через in-app '
|
||||||
|
'планировщик (scrape_schedules.source=''cbr_macro_pull'').';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN cbr_mortgage_series.region IS
|
||||||
|
'Территория как в источнике (например «РОССИЙСКАЯ ФЕДЕРАЦИЯ», «ЦЕНТРАЛЬНЫЙ '
|
||||||
|
'ФЕДЕРАЛЬНЫЙ ОКРУГ», «Свердловская область»). Без нормализации ОКАТО/ISO.';
|
||||||
|
COMMENT ON COLUMN cbr_mortgage_series.period_month IS
|
||||||
|
'Первое число отчётного месяца (нормализовано из шапки «Месяц Год» XLSX).';
|
||||||
|
COMMENT ON COLUMN cbr_mortgage_series.series IS
|
||||||
|
'Slug серии, см. CBR_MORTGAGE_SERIES в app/services/cbr_macro.py '
|
||||||
|
'(rate_rub, new_loans_rub, debt_rub).';
|
||||||
|
COMMENT ON COLUMN cbr_mortgage_series.value IS
|
||||||
|
'Значение серии в единицах источника (% годовых для ставки, млн руб для объёмов).';
|
||||||
|
COMMENT ON COLUMN cbr_mortgage_series.fetched_at IS
|
||||||
|
'Когда мы ВПЕРВЫЕ увидели этот период (не время последней загрузки). '
|
||||||
|
'НЕ обновляется в ON CONFLICT DO UPDATE — см. урок #2846 у sber_price_index.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cbr_mortgage_series_region_series_period
|
||||||
|
ON cbr_mortgage_series (region, series, period_month DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cbr_key_rate (
|
||||||
|
rate_date date PRIMARY KEY,
|
||||||
|
rate double precision NOT NULL,
|
||||||
|
fetched_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE cbr_key_rate IS
|
||||||
|
'Ключевая ставка ЦБ РФ по датам действия. Источник — SOAP-метод KeyRate '
|
||||||
|
'сервиса https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx. Загружается '
|
||||||
|
'app/tasks/cbr_macro_pull.py.';
|
||||||
|
COMMENT ON COLUMN cbr_key_rate.rate_date IS 'Дата, с которой действует значение ставки.';
|
||||||
|
COMMENT ON COLUMN cbr_key_rate.rate IS 'Ключевая ставка, % годовых.';
|
||||||
|
COMMENT ON COLUMN cbr_key_rate.fetched_at IS
|
||||||
|
'Когда мы ВПЕРВЫЕ увидели эту дату. НЕ обновляется в ON CONFLICT DO UPDATE.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
-- 293_scrape_schedules_seed_cbr_macro_pull.sql
|
||||||
|
-- Сид расписания для нового продуктового источника cbr_macro_pull (ипотека по
|
||||||
|
-- субъектам + ключевая ставка ЦБ РФ, см. 292_cbr_macro_series.sql).
|
||||||
|
--
|
||||||
|
-- Такт: interval_days=7 — источник обновляет XLSX-дашборды ипотеки раз в месяц,
|
||||||
|
-- ключевую ставку — по решениям Совета директоров (раз в 6 недель), недельный
|
||||||
|
-- такт достаточен и не создаёт нагрузки (три XLSX ~800 КБ суммарно + один SOAP-запрос).
|
||||||
|
--
|
||||||
|
-- Окно 07:00–08:00 UTC — свободно на момент миграции (проверены соседние сиды:
|
||||||
|
-- 180 sber_freshness_monitor 09-10, 275 landing_stats_refresh 05-06,
|
||||||
|
-- 289 rosreestr_dkp_import_77 04-06); до утра по Екатеринбургу (UTC+5 → 12:00).
|
||||||
|
--
|
||||||
|
-- ENABLED=false — как и у прочих новых источников (домрф/sber): включение —
|
||||||
|
-- отдельный осознанный шаг после деплоя и дымовой пробы.
|
||||||
|
--
|
||||||
|
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 290 (таблицы данных).
|
||||||
|
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
INSERT INTO scrape_schedules (
|
||||||
|
source,
|
||||||
|
enabled,
|
||||||
|
window_start_hour,
|
||||||
|
window_end_hour,
|
||||||
|
next_run_at,
|
||||||
|
default_params
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'cbr_macro_pull',
|
||||||
|
false,
|
||||||
|
7,
|
||||||
|
8,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
|
||||||
|
'{"interval_days": 7}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (source) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
79
tradein-mvp/backend/data/sql/294_dtp_incidents.sql
Normal file
79
tradein-mvp/backend/data/sql/294_dtp_incidents.sql
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
-- 294_dtp_incidents.sql
|
||||||
|
-- ДТП (dtp-stat.ru, первоисточник — открытые данные ГИБДД), Свердловская обл. (#3410).
|
||||||
|
--
|
||||||
|
-- WHY: слой ДТП нужен как радиусный "фактор безопасности" района (по образцу
|
||||||
|
-- osm_poi_ekb_local / 169_osm_poi_ekb_local.sql — та же архитектура: пустая таблица +
|
||||||
|
-- индексы на деплое, наполнение отдельным рефреш-джобом, никакого чтения внешнего
|
||||||
|
-- источника при прогоне миграций).
|
||||||
|
--
|
||||||
|
-- ИСТОЧНИК: https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip — открытый
|
||||||
|
-- агрегатор данных ГИБДД, без auth. Дамп заморожен (Last-Modified 26.02.2026 на момент
|
||||||
|
-- проверки 08.09.2026) — см. докстринг app/services/dtp_stat_loader.py, пустая дельта на
|
||||||
|
-- рефреше это НЕ баг, а факт источника.
|
||||||
|
--
|
||||||
|
-- ПДн (важно): properties.vehicles[].participants[] источника содержит пол/роль/нарушения
|
||||||
|
-- физлиц-участников ДТП. Эта таблица их СОЗНАТЕЛЬНО НЕ хранит — ни колонкой, ни в jsonb.
|
||||||
|
-- Загрузчик (dtp_stat_loader.py) участников/транспорт из парсинга просто не извлекает.
|
||||||
|
-- Берём только агрегаты (dead/injured count) и не-персональные атрибуты происшествия.
|
||||||
|
--
|
||||||
|
-- ИНДЕКСЫ (урок 270_houses_geog_gist_idx.sql / 134_listings_geom_geography_gist.sql): весь
|
||||||
|
-- код МЕРЫ ходит радиусными запросами через ST_DWithin(geom::geography, …) — обычный GIST
|
||||||
|
-- по geometry планировщик под такой предикат не берёт (прод-замер houses: 70.7 мс на
|
||||||
|
-- bitmap-фильтр всех строк; после функционального индекса по geography — 38 мс на listings).
|
||||||
|
-- Поэтому сразу оба индекса: geometry (на будущее/для прочих операторов) и функциональный
|
||||||
|
-- по (geom::geography) — под фактический предикат dtp_index.py.
|
||||||
|
--
|
||||||
|
-- Идемпотентно: CREATE TABLE/INDEX IF NOT EXISTS. Безопасно перезапускать.
|
||||||
|
BEGIN;
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dtp_incidents (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
source_id text UNIQUE,
|
||||||
|
dtp_at timestamptz,
|
||||||
|
category text,
|
||||||
|
severity text,
|
||||||
|
dead integer,
|
||||||
|
injured integer,
|
||||||
|
address text,
|
||||||
|
light text,
|
||||||
|
weather text[],
|
||||||
|
nearby text[],
|
||||||
|
lat double precision,
|
||||||
|
lon double precision,
|
||||||
|
geom geometry(Point, 4326),
|
||||||
|
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE dtp_incidents IS
|
||||||
|
'ДТП по Свердловской обл. (dtp-stat.ru, первоисточник — открытые данные ГИБДД, #3410). '
|
||||||
|
'Пишет app/tasks/dtp_stat_refresh.py (TRUNCATE+INSERT, scheduler source=dtp_stat_refresh, '
|
||||||
|
'seed 293, enabled=false — включение вручную). Читает app/services/dtp_index.py — '
|
||||||
|
'радиусные агрегаты (число ДТП/тяжёлых/погибших/раненых) для карточки района. '
|
||||||
|
'ПДн участников (vehicles/participants: пол, роль, нарушения физлиц) осознанно НЕ '
|
||||||
|
'хранятся — ни колонкой, ни в jsonb; источник даёт их построчно на транспорт/участника, '
|
||||||
|
'загрузчик их не извлекает вовсе.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN dtp_incidents.source_id IS 'properties.id из dtp-stat GeoJSON — ключ идемпотентности (UNIQUE).';
|
||||||
|
COMMENT ON COLUMN dtp_incidents.dtp_at IS 'properties.datetime, распарсено в timestamptz.';
|
||||||
|
COMMENT ON COLUMN dtp_incidents.category IS 'properties.category — тип ДТП ("Столкновение" и т.п.).';
|
||||||
|
COMMENT ON COLUMN dtp_incidents.severity IS 'properties.severity — тяжесть ("Тяжёлый"/"Лёгкий"/…).';
|
||||||
|
COMMENT ON COLUMN dtp_incidents.dead IS 'properties.dead_count — погибшие, агрегат, без ФИО/пола участников.';
|
||||||
|
COMMENT ON COLUMN dtp_incidents.injured IS 'properties.injured_count — раненые, агрегат.';
|
||||||
|
COMMENT ON COLUMN dtp_incidents.weather IS 'properties.weather — список погодных условий на момент ДТП.';
|
||||||
|
COMMENT ON COLUMN dtp_incidents.nearby IS 'properties.nearby — список объектов УДС/притяжения рядом с местом ДТП.';
|
||||||
|
COMMENT ON COLUMN dtp_incidents.geom IS 'ST_SetSRID(ST_MakePoint(lon, lat), 4326) — строится явно в загрузчике, без триггера (как osm_poi_ekb_local).';
|
||||||
|
|
||||||
|
-- GIST по geometry — на будущее / прочие geometry-операторы.
|
||||||
|
CREATE INDEX IF NOT EXISTS dtp_incidents_geom_idx
|
||||||
|
ON dtp_incidents USING GIST (geom);
|
||||||
|
|
||||||
|
-- Функциональный GIST по (geom::geography) — под фактический предикат
|
||||||
|
-- ST_DWithin(geom::geography, …) в app/services/dtp_index.py (см. WHY выше).
|
||||||
|
CREATE INDEX IF NOT EXISTS dtp_incidents_geog_idx
|
||||||
|
ON dtp_incidents USING GIST ((geom::geography));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS dtp_incidents_dtp_at_idx
|
||||||
|
ON dtp_incidents (dtp_at);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
-- 295_scrape_schedules_seed_dtp_stat_refresh.sql
|
||||||
|
-- Scheduler seed for the ДТП local-mirror refresh job (#3410).
|
||||||
|
--
|
||||||
|
-- WHAT (source='dtp_stat_refresh'):
|
||||||
|
-- trigger_dtp_stat_refresh_run (scheduler.py) → run_dtp_stat_refresh
|
||||||
|
-- (app/tasks/dtp_stat_refresh.py) → app/services/dtp_stat_loader.py: download ZIP
|
||||||
|
-- (dtp-stat.ru), потоковый парс GeoJSON, TRUNCATE dtp_incidents; INSERT — построчно
|
||||||
|
-- строим geom из lon/lat явно (ST_SetSRID(ST_MakePoint(...))).
|
||||||
|
--
|
||||||
|
-- ПОЧЕМУ enabled=false (по образцу house_dedup_merge, seed 135): источник — открытый
|
||||||
|
-- годовой дамп, а не живой поток; на момент проверки 08.09.2026 Last-Modified
|
||||||
|
-- 26.02.2026 — дельта между рефрешами пустая по построению, пустая дельта НЕ баг
|
||||||
|
-- (см. докстринг app/services/dtp_stat_loader.py). Первый прогон — вручную/через
|
||||||
|
-- orchestrator, включение регулярного расписания — отдельное решение продукта.
|
||||||
|
--
|
||||||
|
-- interval_days=30 (default_params, НЕ отдельная колонка — compute_next_run_at читает
|
||||||
|
-- именно default_params.interval_days, ровно как в 135/212): месячный дамп двигается
|
||||||
|
-- редко, ежемесячная проверка — с запасом.
|
||||||
|
--
|
||||||
|
-- Window 11:00-12:00 UTC (14:00-15:00 МСК): после osm_poi_ekb_refresh (10:00-11:00
|
||||||
|
-- UTC, 170), не пересекает другие DB-only/сетевые джобы. Внешний HTTP на dtp-stat.ru,
|
||||||
|
-- но разовый один ZIP-запрос ~5 МБ — не конкурирует за прокси-пул (обычные площадки
|
||||||
|
-- не трогает).
|
||||||
|
--
|
||||||
|
-- next_run_at = завтра 11:00 UTC (NOT NULL — конвенция сидов; безвредно при
|
||||||
|
-- enabled=false, т.к. get_due_schedules сначала фильтрует enabled=true).
|
||||||
|
--
|
||||||
|
-- DEPENDENCIES: 052_scrape_schedules.sql (table + UNIQUE(source)), 294_dtp_incidents.sql.
|
||||||
|
-- Idempotent: ON CONFLICT (source) DO NOTHING.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO scrape_schedules (
|
||||||
|
source,
|
||||||
|
enabled,
|
||||||
|
window_start_hour,
|
||||||
|
window_end_hour,
|
||||||
|
next_run_at,
|
||||||
|
default_params
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'dtp_stat_refresh',
|
||||||
|
false,
|
||||||
|
11,
|
||||||
|
12,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
|
||||||
|
'{"interval_days": 30}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (source) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
71
tradein-mvp/backend/data/sql/296_fns_legal_entity_facts.sql
Normal file
71
tradein-mvp/backend/data/sql/296_fns_legal_entity_facts.sql
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
-- 296_fns_legal_entity_facts.sql
|
||||||
|
-- ФНС open data по юрлицам: доходы/расходы, среднесписочная численность, недоимка,
|
||||||
|
-- спецрежимы (issue: bulk-дампы ФНС → lookup по ИНН, PR "feat/fns-legal-entities").
|
||||||
|
--
|
||||||
|
-- ПОЧЕМУ ЭТОТ ИСТОЧНИК
|
||||||
|
-- Открытых данных ЕГРН по правообладателям-физлицам не существует (218-ФЗ ст. 62).
|
||||||
|
-- По ЮРЛИЦАМ данные открыты, лицензия ФНС (nalog.gov.ru/opendata) разрешает
|
||||||
|
-- переработку и перераспределение. Наборы (все по маске 7707329152-<slug>):
|
||||||
|
-- revexp (доходы и расходы), sshr2019 (среднесписочная численность), debtam
|
||||||
|
-- (недоимка и задолженность), snr (спецрежимы). ЕГРЮЛ (адрес/ОКВЭД/учредители) в
|
||||||
|
-- этих наборах НЕТ — только ИНН + наименование + показатели набора.
|
||||||
|
--
|
||||||
|
-- ЭТОТ PR — ТОЛЬКО ЗАГРУЗКА И LOOKUP ПО ИНН. Потребителя (скоринг застройщика/УК в
|
||||||
|
-- оценке) здесь нет и не планируется в рамках этого PR — подключение отдельной
|
||||||
|
-- задачей, если решение об этом будет принято отдельно.
|
||||||
|
--
|
||||||
|
-- ФОРМА ХРАНЕНИЯ: одна узкая long-format таблица на все 4 набора, а не таблица на
|
||||||
|
-- набор. Наборы различаются только НАБОРОМ показателей (атрибутов XML), сама форма
|
||||||
|
-- «ИНН + показатель + период + значение» у всех одинаковая — long-format не плодит
|
||||||
|
-- 4 почти идентичные таблицы и не требует миграции при добавлении 5-го набора в
|
||||||
|
-- будущем (не в этом PR).
|
||||||
|
--
|
||||||
|
-- PRIMARY KEY (inn, dataset, series, period), А НЕ (inn, series, period) — так
|
||||||
|
-- предлагал скелет задачи, но series здесь = имя XML-атрибута источника, и без
|
||||||
|
-- dataset в ключе одноимённые атрибуты в двух разных наборах молча схлопнутся в
|
||||||
|
-- одну строку (мы НЕ проверяли по XSD, что имена атрибутов между revexp/sshr2019/
|
||||||
|
-- debtam/snr не пересекаются, поэтому закладываемся на худший случай).
|
||||||
|
--
|
||||||
|
-- Идемпотентно: CREATE TABLE/INDEX IF NOT EXISTS. lock_timeout — на случай, если
|
||||||
|
-- таблицы уже существуют от прогона на другой ветке (ALTER не планируется, но
|
||||||
|
-- дисциплина сохраняется).
|
||||||
|
BEGIN;
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fns_legal_entity_facts (
|
||||||
|
inn text NOT NULL,
|
||||||
|
dataset text NOT NULL, -- 'revexp' | 'sshr2019' | 'debtam' | 'snr'
|
||||||
|
series text NOT NULL, -- имя показателя (атрибут XML источника)
|
||||||
|
period date NOT NULL, -- период показателя; см. fns_opendata_loader.py
|
||||||
|
value numeric NOT NULL,
|
||||||
|
org_name text, -- наименование юрлица на момент загрузки (не ЕГРЮЛ, provenance)
|
||||||
|
loaded_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (inn, dataset, series, period)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE fns_legal_entity_facts IS
|
||||||
|
'ФНС opendata (nalog.gov.ru/opendata/7707329152-*) по юрлицам: показатели наборов '
|
||||||
|
'revexp/sshr2019/debtam/snr. Long-format: одна строка = один показатель одного ИНН '
|
||||||
|
'за один период. ЕГРЮЛ-данных (адрес/ОКВЭД/учредители) здесь нет. Без потребителя '
|
||||||
|
'на момент создания — см. app/services/fns_lookup.py.';
|
||||||
|
COMMENT ON COLUMN fns_legal_entity_facts.inn IS 'ИНН юрлица (10 цифр), как в XML источника';
|
||||||
|
COMMENT ON COLUMN fns_legal_entity_facts.dataset IS 'slug набора ФНС opendata: revexp/sshr2019/debtam/snr';
|
||||||
|
COMMENT ON COLUMN fns_legal_entity_facts.series IS 'имя показателя = имя XML-атрибута источника (не переименовываем)';
|
||||||
|
COMMENT ON COLUMN fns_legal_entity_facts.period IS 'период показателя: дата публикации набора (см. fns_dataset_versions.published_on)';
|
||||||
|
COMMENT ON COLUMN fns_legal_entity_facts.value IS 'значение показателя как есть из XML (numeric — источник не различает валюту/единицы в атрибуте)';
|
||||||
|
COMMENT ON COLUMN fns_legal_entity_facts.org_name IS 'наименование юрлица на момент загрузки, provenance only — НЕ авторитетный источник (используй ЕГРЮЛ)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fns_dataset_versions (
|
||||||
|
dataset text PRIMARY KEY, -- 'revexp' | 'sshr2019' | 'debtam' | 'snr'
|
||||||
|
file_url text NOT NULL, -- прямая ссылка на .zip, снятая со страницы каталога
|
||||||
|
published_on date, -- дата публикации, распарсенная из имени файла (data-YYYYMMDD-...)
|
||||||
|
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE fns_dataset_versions IS
|
||||||
|
'Последняя загруженная версия каждого набора ФНС opendata — гейт от перекачки '
|
||||||
|
'уже загруженной версии (URL меняется с каждой публикацией, дата в имени файла).';
|
||||||
|
COMMENT ON COLUMN fns_dataset_versions.file_url IS 'резолвится динамически со страницы каталога набора — НЕ хардкодить, протухает';
|
||||||
|
COMMENT ON COLUMN fns_dataset_versions.published_on IS 'дата публикации из имени файла (data-YYYYMMDD-structure-...)';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- 297_scrape_schedules_seed_fns_opendata_load.sql
|
||||||
|
-- Расписание для fns_opendata_load (см. 296_fns_legal_entity_facts.sql — контекст источника).
|
||||||
|
--
|
||||||
|
-- enabled=false: как domclick_detail_backfill (мигр. 175) — включение отдельным
|
||||||
|
-- осознанным шагом после деплоя, дымовой пробы и ручного --dry-run прогона. Наборы
|
||||||
|
-- ФНС весят десятки-сотни МБ каждый, первый прогон надо смотреть глазами.
|
||||||
|
--
|
||||||
|
-- interval_days=30: наборы ФНС публикуются раз в квартал/год (revexp — по итогам
|
||||||
|
-- отчётного периода), суточный/недельный опрос каталога бессмысленно частый.
|
||||||
|
-- Реализовано через default_params (interval_minutes читает post_claim
|
||||||
|
-- reschedule_after_minutes, здесь используем ту же схему в днях: 30 * 24 * 60).
|
||||||
|
--
|
||||||
|
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 294 (таблицы факта).
|
||||||
|
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO scrape_schedules (
|
||||||
|
source,
|
||||||
|
enabled,
|
||||||
|
window_start_hour,
|
||||||
|
window_end_hour,
|
||||||
|
next_run_at,
|
||||||
|
default_params
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'fns_opendata_load',
|
||||||
|
false,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day')) AT TIME ZONE 'UTC',
|
||||||
|
'{"interval_minutes": 43200, "datasets": ["revexp", "sshr2019", "debtam", "snr"]}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (source) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
-- 298_deal_city_price_bands_region.sql
|
||||||
|
-- deal_city_price_bands: ключ (region_code, city) вместо (city) — #3051, sub-PR B.
|
||||||
|
--
|
||||||
|
-- Dependencies: 178_deal_city_price_bands.sql (таблица, PK(city)), 194_deal_city_price_bands_tiers.sql
|
||||||
|
-- (tier-схема, derivation), 288_deals_doc_type.sql (deals.doc_type).
|
||||||
|
-- Apply after: 289_rosreestr_fdw_msk_columns_seed77.sql
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- В deals уже 212 937 строк region_code=77 city='Москва' рядом со 108 623
|
||||||
|
-- строками region_code=66 (Свердловская обл.) — Москва в той же таблице
|
||||||
|
-- `deals`, что и область. Старая derivation (178/194) ключует бэнды ТОЛЬКО
|
||||||
|
-- по city и считает region_stats (пул для tier='region_fallback') ПО ВСЕЙ
|
||||||
|
-- таблице deals без фильтра региона. С двумя регионами в одной таблице это
|
||||||
|
-- ломает две вещи одновременно:
|
||||||
|
-- 1. city='Москва' коллизирует c одноимёнными city в других регионах
|
||||||
|
-- (маловероятно для этой пары, но ключ (city) в принципе не region-safe).
|
||||||
|
-- 2. region_stats-пул для 'region_fallback' смешивает 212k строк Москвы в
|
||||||
|
-- p1-перцентиль floor для тонких городов Свердловской обл. — Москва на
|
||||||
|
-- порядок дороже, пул сдвигается вверх, floor малых городов области
|
||||||
|
-- задирается выше их реального рынка.
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- - deal_city_price_bands.region_code int NOT NULL DEFAULT 66 — бэкфилл
|
||||||
|
-- существующих 383 строк на 66 (таблица до этой миграции была исключительно
|
||||||
|
-- Свердловская обл., см. 178/194 WHERE-фильтры без region_code).
|
||||||
|
-- - PK (city) → PK (region_code, city) — через DO-guard по pg_get_constraintdef,
|
||||||
|
-- идемпотентно (см. ниже).
|
||||||
|
-- - Re-seed той же derivation, что 194/refresh-таск, НО region_stats и
|
||||||
|
-- city_stats группируются по (region_code[, city]) — Москва больше не
|
||||||
|
-- утекает в пул малых городов области, и наоборот.
|
||||||
|
--
|
||||||
|
-- БАЙТ-ИДЕНТИЧНОСТЬ ДЛЯ region_code=66:
|
||||||
|
-- - Фильтр `NOT (region_code = 66 AND city = 'Екатеринбург')` — тот же
|
||||||
|
-- инвариант, что `city <> 'Екатеринбург'` в 178/194, ограниченный явно на
|
||||||
|
-- регион 66 (ЕКБ существует только там).
|
||||||
|
-- - `doc_type = 'ДКП'` — НЕ сужает выборку region_code=66: backfill 288
|
||||||
|
-- проставил doc_type='ДКП' на 100% строк source='rosreestr' (на момент
|
||||||
|
-- миграции 288 других doc_type для source='rosreestr' не было и не могло
|
||||||
|
-- быть — импорт всегда фильтровал ДКП на входе).
|
||||||
|
-- - `region_stats` теперь GROUP BY region_code — для region_code=66 пул p1
|
||||||
|
-- считается ИСКЛЮЧИТЕЛЬНО по строкам региона 66 (та же популяция, что была
|
||||||
|
-- у 194 ДО появления в deals региона 77), т.е. Москва (region_code=77)
|
||||||
|
-- физически не участвует в агрегате region_stats-строки region_code=66.
|
||||||
|
-- - `city_stats` теперь GROUP BY region_code, city — для региона 66 разбивка
|
||||||
|
-- по городам не меняется (раньше GROUP BY city неявно уже был per-region,
|
||||||
|
-- т.к. в deals была только область).
|
||||||
|
-- - Клампы (8000/800000/700000), tier-пороги (30/10) и p1/p99-перцентили —
|
||||||
|
-- формулы дословно как в 194/refresh, только с добавленным region_code в
|
||||||
|
-- SELECT/GROUP BY/JOIN.
|
||||||
|
-- - `tiered` JOIN region_stats теперь по region_code (вместо CROSS JOIN на
|
||||||
|
-- единственную строку) — для региона 66 эквивалентно прежнему CROSS JOIN,
|
||||||
|
-- т.к. region_stats содержит ровно одну строку на регион.
|
||||||
|
--
|
||||||
|
-- ИДЕМПОТЕНТНОСТЬ:
|
||||||
|
-- ADD COLUMN IF NOT EXISTS; PK-guard проверяет ТЕКУЩЕЕ определение PK через
|
||||||
|
-- pg_get_constraintdef и меняет его только если оно ещё (city) — повторный
|
||||||
|
-- прогон видит PK(region_code, city) и не трогает его. INSERT ... ON CONFLICT
|
||||||
|
-- (region_code, city) DO UPDATE — повторный прогон рефрешит бэнды под свежие
|
||||||
|
-- сделки (та же семантика 178/194).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
ALTER TABLE deal_city_price_bands
|
||||||
|
ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN deal_city_price_bands.region_code IS
|
||||||
|
'Регион ключа бэнда (deals.region_code). 66 = Свердловская обл. (Екатеринбург '
|
||||||
|
'намеренно исключён из таблицы для ЭТОГО региона — estimator fallback на '
|
||||||
|
'глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2). Часть составного PK (region_code, city).';
|
||||||
|
|
||||||
|
-- PK (city) → PK (region_code, city), идемпотентно: смотрим ТЕКУЩЕЕ определение
|
||||||
|
-- PK и меняем его только если это ещё старый PK(city).
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_pk_def text;
|
||||||
|
BEGIN
|
||||||
|
SELECT pg_get_constraintdef(oid) INTO v_pk_def
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid = 'deal_city_price_bands'::regclass
|
||||||
|
AND contype = 'p';
|
||||||
|
|
||||||
|
IF v_pk_def = 'PRIMARY KEY (city)' THEN
|
||||||
|
ALTER TABLE deal_city_price_bands DROP CONSTRAINT deal_city_price_bands_pkey;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'deal_city_price_bands'::regclass
|
||||||
|
AND contype = 'p'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE deal_city_price_bands
|
||||||
|
ADD CONSTRAINT deal_city_price_bands_pkey PRIMARY KEY (region_code, city);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Страховка: если region_code=66 город='Москва' когда-либо просочился (не
|
||||||
|
-- должен — таблица была исключительно Свердловской обл.), убираем перед re-seed,
|
||||||
|
-- чтобы ON CONFLICT ниже не унаследовал мусорную строку под чужим regionʼом.
|
||||||
|
DELETE FROM deal_city_price_bands WHERE region_code = 66 AND city = 'Москва';
|
||||||
|
|
||||||
|
-- Re-seed — та же derivation, что 194/refresh-таск, региональная (см. WHY/WHAT).
|
||||||
|
WITH region_stats AS (
|
||||||
|
SELECT
|
||||||
|
region_code,
|
||||||
|
GREATEST(
|
||||||
|
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
|
||||||
|
8000
|
||||||
|
) AS region_ppm2_min
|
||||||
|
FROM deals
|
||||||
|
WHERE source = 'rosreestr'
|
||||||
|
AND doc_type = 'ДКП'
|
||||||
|
AND price_per_m2 IS NOT NULL
|
||||||
|
AND city IS NOT NULL
|
||||||
|
AND region_code IS NOT NULL
|
||||||
|
AND NOT (region_code = 66 AND city = 'Екатеринбург')
|
||||||
|
GROUP BY region_code
|
||||||
|
),
|
||||||
|
city_stats AS (
|
||||||
|
SELECT
|
||||||
|
region_code,
|
||||||
|
city,
|
||||||
|
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
|
||||||
|
AS ppm2_p1,
|
||||||
|
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
|
||||||
|
AS ppm2_p99,
|
||||||
|
count(*) AS n_deals
|
||||||
|
FROM deals
|
||||||
|
WHERE source = 'rosreestr'
|
||||||
|
AND doc_type = 'ДКП'
|
||||||
|
AND price_per_m2 IS NOT NULL
|
||||||
|
AND city IS NOT NULL
|
||||||
|
AND region_code IS NOT NULL
|
||||||
|
AND NOT (region_code = 66 AND city = 'Екатеринбург')
|
||||||
|
GROUP BY region_code, city
|
||||||
|
),
|
||||||
|
tiered AS (
|
||||||
|
SELECT region_code, city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
|
||||||
|
'full'::text AS tier
|
||||||
|
FROM city_stats
|
||||||
|
WHERE n_deals >= 30
|
||||||
|
AND ppm2_p99 >= 8000
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT region_code, city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals,
|
||||||
|
'rough'::text AS tier
|
||||||
|
FROM city_stats
|
||||||
|
WHERE n_deals BETWEEN 10 AND 29
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT c.region_code, c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals,
|
||||||
|
'region_fallback'::text AS tier
|
||||||
|
FROM city_stats c
|
||||||
|
JOIN region_stats r ON r.region_code = c.region_code
|
||||||
|
WHERE c.n_deals < 10
|
||||||
|
)
|
||||||
|
INSERT INTO deal_city_price_bands (region_code, city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
|
||||||
|
SELECT region_code, city, ppm2_min, ppm2_max, n_deals, tier, now()
|
||||||
|
FROM tiered
|
||||||
|
ON CONFLICT (region_code, city) DO UPDATE
|
||||||
|
SET ppm2_min = EXCLUDED.ppm2_min,
|
||||||
|
ppm2_max = EXCLUDED.ppm2_max,
|
||||||
|
n_deals = EXCLUDED.n_deals,
|
||||||
|
tier = EXCLUDED.tier,
|
||||||
|
refreshed_at = EXCLUDED.refreshed_at;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -31,6 +31,9 @@ dependencies = [
|
||||||
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
|
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
|
||||||
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
|
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
|
||||||
"prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078)
|
"prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078)
|
||||||
|
"openpyxl>=3.1.0", # ЦБ РФ XLSX-дашборды ипотеки (app/services/cbr_macro.py)
|
||||||
|
"ijson>=3.3.0", # потоковый парс ~71 МБ GeoJSON dtp-stat.ru —
|
||||||
|
# без json.load() целиком в память
|
||||||
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
|
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
|
||||||
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
|
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
|
||||||
# Docker-context = tradein-mvp root, uv sync ставит editable.
|
# Docker-context = tradein-mvp root, uv sync ставит editable.
|
||||||
|
|
|
||||||
|
|
@ -1233,14 +1233,19 @@ _CANDIDATES_SQL = text(
|
||||||
)
|
)
|
||||||
|
|
||||||
# (oblast D) Per-city PPM2 sanity band — same table the estimator's
|
# (oblast D) Per-city PPM2 sanity band — same table the estimator's
|
||||||
# _fetch_dkp_corridor COALESCEs against (migration 178). Looked up only when
|
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
|
||||||
# --city is set (see _resolve_city_ppm2_band); the default (city=None) path
|
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
|
||||||
# never issues this query.
|
# _resolve_city_ppm2_band); the default (city=None) path never issues this
|
||||||
|
# query. region_code defaults to 66 (Свердловская обл.) — this script has no
|
||||||
|
# region CLI flag yet, so every call is scoped to the oblast, matching every
|
||||||
|
# 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
|
||||||
FROM deal_city_price_bands
|
FROM deal_city_price_bands
|
||||||
WHERE city = CAST(:city AS text)
|
WHERE region_code = CAST(:region_code AS int)
|
||||||
|
AND city = CAST(:city AS text)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1294,22 +1299,32 @@ def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float]:
|
def _resolve_city_ppm2_band(
|
||||||
"""Per-city PPM2 sanity band (oblast D) — falls back to the module globals.
|
db: Session, city: str | None, region_code: int = 66
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
"""Per-(region, city) PPM2 sanity band (oblast D, #3051) — falls back to module globals.
|
||||||
|
|
||||||
``city is None`` → ``(PPM2_MIN, PPM2_MAX)``, unchanged default behaviour,
|
``city is None`` → ``(PPM2_MIN, PPM2_MAX)``, unchanged default behaviour,
|
||||||
NO extra query. Otherwise looks up ``deal_city_price_bands`` for that
|
NO extra query. Otherwise looks up ``deal_city_price_bands`` for that
|
||||||
city's own ``[ppm2_min, ppm2_max]`` — a region-66 town can have a
|
(region_code, city)'s own ``[ppm2_min, ppm2_max]`` — a region-66 town can
|
||||||
materially different sane ₽/m² range than the EKB-tuned 30k..600k globals
|
have a materially different sane ₽/m² range than the EKB-tuned 30k..600k
|
||||||
(e.g. Нижний Тагил: 16 955..108 175). No row for the city (e.g.
|
globals (e.g. Нижний Тагил: 16 955..108 175). No row for the (region, city)
|
||||||
Екатеринбург — intentionally excluded from the table, mirrors
|
(e.g. Екатеринбург region_code=66 — intentionally excluded from the table,
|
||||||
estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB error →
|
mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB
|
||||||
the globals; read-only best-effort, never raises.
|
error → the globals; read-only best-effort, never raises.
|
||||||
|
|
||||||
|
``region_code`` defaults to 66 (Свердловская обл.) — this script has no
|
||||||
|
region CLI flag yet (out of scope, #3051 sub-PR B); every existing
|
||||||
|
--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)
|
||||||
try:
|
try:
|
||||||
row = db.execute(_CITY_PPM2_BAND_SQL, {"city": city}).mappings().first()
|
row = (
|
||||||
|
db.execute(_CITY_PPM2_BAND_SQL, {"city": city, "region_code": region_code})
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
except Exception as exc: # pragma: no cover — defensive, read-only best-effort
|
except Exception as exc: # pragma: no cover — defensive, read-only best-effort
|
||||||
logger.warning("city PPM2 band lookup failed for %r (fallback to global): %s", city, exc)
|
logger.warning("city PPM2 band lookup failed for %r (fallback to global): %s", city, exc)
|
||||||
return float(PPM2_MIN), float(PPM2_MAX)
|
return float(PPM2_MIN), float(PPM2_MAX)
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,12 @@ Flags:
|
||||||
plus the geocode hit/miss split. No DB writes.
|
plus the geocode hit/miss split. No DB writes.
|
||||||
--batch LABEL log label (default `deals_nominatim_YYYY-MM-DD`).
|
--batch LABEL log label (default `deals_nominatim_YYYY-MM-DD`).
|
||||||
--stale-days N retry addresses last tried more than N days ago (default 30).
|
--stale-days N retry addresses last tried more than N days ago (default 30).
|
||||||
|
--region-code N which `deals.region_code` to geocode (default 66, Sverdlovsk
|
||||||
|
oblast). #3051: 77 (Москва) — every deal there has
|
||||||
|
address='Москва, <street>' with no city already recognised by
|
||||||
|
`known_city_hint`'s region-66 default, so this flag threads
|
||||||
|
through both the SQL filter (`AND region_code = N`) and
|
||||||
|
`geocoder.{geocode,known_city_hint}(..., region_code=N)`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -182,7 +188,9 @@ class Stats:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> list[AddressGroup]:
|
def _select_pending_addresses(
|
||||||
|
db: Session, *, limit: int, stale_days: int, region_code: int = 66
|
||||||
|
) -> list[AddressGroup]:
|
||||||
"""Distinct (address, city) pairs still needing coords — resume-safe set.
|
"""Distinct (address, city) pairs still needing coords — resume-safe set.
|
||||||
|
|
||||||
Combines the `deals_geocode_pending_idx` partial index predicate
|
Combines the `deals_geocode_pending_idx` partial index predicate
|
||||||
|
|
@ -215,6 +223,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li
|
||||||
" WHERE lat IS NULL "
|
" WHERE lat IS NULL "
|
||||||
" AND address IS NOT NULL "
|
" AND address IS NOT NULL "
|
||||||
" AND length(trim(address)) >= 3 "
|
" AND length(trim(address)) >= 3 "
|
||||||
|
" AND region_code = CAST(:region_code AS int) "
|
||||||
" AND (geocode_tried_at IS NULL "
|
" AND (geocode_tried_at IS NULL "
|
||||||
" OR geocode_tried_at < NOW() "
|
" OR geocode_tried_at < NOW() "
|
||||||
" - make_interval(days => CAST(:stale_days AS int))) "
|
" - make_interval(days => CAST(:stale_days AS int))) "
|
||||||
|
|
@ -223,7 +232,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li
|
||||||
"WHERE running_rows - deals_count < CAST(:limit AS int) "
|
"WHERE running_rows - deals_count < CAST(:limit AS int) "
|
||||||
"ORDER BY deals_count DESC, address ASC, city ASC NULLS FIRST"
|
"ORDER BY deals_count DESC, address ASC, city ASC NULLS FIRST"
|
||||||
),
|
),
|
||||||
{"limit": limit, "stale_days": stale_days},
|
{"limit": limit, "stale_days": stale_days, "region_code": region_code},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
|
|
@ -238,7 +247,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
|
def _count_pending_total(db: Session, *, stale_days: int, region_code: int = 66) -> tuple[int, int]:
|
||||||
"""Full backlog: (distinct (address, city) pairs, total rows) this pass.
|
"""Full backlog: (distinct (address, city) pairs, total rows) this pass.
|
||||||
|
|
||||||
Denominators for the dry-run projection — counts every lat-IS-NULL deal
|
Denominators for the dry-run projection — counts every lat-IS-NULL deal
|
||||||
|
|
@ -257,11 +266,12 @@ def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
|
||||||
"WHERE lat IS NULL "
|
"WHERE lat IS NULL "
|
||||||
" AND address IS NOT NULL "
|
" AND address IS NOT NULL "
|
||||||
" AND length(trim(address)) >= 3 "
|
" AND length(trim(address)) >= 3 "
|
||||||
|
" AND region_code = CAST(:region_code AS int) "
|
||||||
" AND (geocode_tried_at IS NULL "
|
" AND (geocode_tried_at IS NULL "
|
||||||
" OR geocode_tried_at < NOW() "
|
" OR geocode_tried_at < NOW() "
|
||||||
" - make_interval(days => CAST(:stale_days AS int)))"
|
" - make_interval(days => CAST(:stale_days AS int)))"
|
||||||
),
|
),
|
||||||
{"stale_days": stale_days},
|
{"stale_days": stale_days, "region_code": region_code},
|
||||||
).first()
|
).first()
|
||||||
if row is None:
|
if row is None:
|
||||||
return (0, 0)
|
return (0, 0)
|
||||||
|
|
@ -274,9 +284,15 @@ def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
|
||||||
|
|
||||||
|
|
||||||
def _update_deals_geocoded(
|
def _update_deals_geocoded(
|
||||||
db: Session, *, address: str, city: str | None, lat: float, lon: float
|
db: Session,
|
||||||
|
*,
|
||||||
|
address: str,
|
||||||
|
city: str | None,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
region_code: int = 66,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""UPDATE every lat-IS-NULL deal on (address, city); geom via trigger.
|
"""UPDATE every lat-IS-NULL deal on (address, city, region_code); geom via trigger.
|
||||||
|
|
||||||
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
|
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
|
||||||
(002_core_tables.sql, reuses listings_set_geom()) populates geom from the
|
(002_core_tables.sql, reuses listings_set_geom()) populates geom from the
|
||||||
|
|
@ -290,6 +306,12 @@ def _update_deals_geocoded(
|
||||||
FROM` treats NULL=NULL as a match while staying strict for a real city, so
|
FROM` treats NULL=NULL as a match while staying strict for a real city, so
|
||||||
the same street text in another city keeps its own coords (#2603).
|
the same street text in another city keeps its own coords (#2603).
|
||||||
|
|
||||||
|
`region_code` (#3051) — an extra guard on top of (address, city): the same
|
||||||
|
street text can legitimately exist in two different regions (e.g. a
|
||||||
|
"Ленина" in both oblast 66 and Moscow's region-77 corpus), so a run scoped
|
||||||
|
to one region must never touch the other's rows even if address/city
|
||||||
|
happen to collide.
|
||||||
|
|
||||||
Returns the number of deal rows updated.
|
Returns the number of deal rows updated.
|
||||||
"""
|
"""
|
||||||
result = db.execute(
|
result = db.execute(
|
||||||
|
|
@ -300,14 +322,15 @@ def _update_deals_geocoded(
|
||||||
" geocode_tried_at = NOW() "
|
" geocode_tried_at = NOW() "
|
||||||
" WHERE address = CAST(:addr AS text) "
|
" WHERE address = CAST(:addr AS text) "
|
||||||
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
|
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
|
||||||
|
" AND region_code = CAST(:region_code AS int) "
|
||||||
" AND lat IS NULL"
|
" AND lat IS NULL"
|
||||||
),
|
),
|
||||||
{"addr": address, "city": city, "lat": lat, "lon": lon},
|
{"addr": address, "city": city, "lat": lat, "lon": lon, "region_code": region_code},
|
||||||
)
|
)
|
||||||
return result.rowcount
|
return result.rowcount
|
||||||
|
|
||||||
|
|
||||||
def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int:
|
def _mark_deals_tried(db: Session, *, address: str, city: str | None, region_code: int = 66) -> int:
|
||||||
"""Stamp `geocode_tried_at = NOW()` WITHOUT touching lat/lon (geocode miss).
|
"""Stamp `geocode_tried_at = NOW()` WITHOUT touching lat/lon (geocode miss).
|
||||||
|
|
||||||
Critical for resume: an address the geocoder can't resolve must still drop
|
Critical for resume: an address the geocoder can't resolve must still drop
|
||||||
|
|
@ -316,8 +339,9 @@ def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int:
|
||||||
it for `stale_days`. The `AND lat IS NULL` guard means a concurrent success
|
it for `stale_days`. The `AND lat IS NULL` guard means a concurrent success
|
||||||
can't be downgraded.
|
can't be downgraded.
|
||||||
|
|
||||||
Scoped to the (address, city) pair for the same reason as the coords write
|
Scoped to the (address, city, region_code) pair for the same reason as the
|
||||||
— a miss in one city must not defer the other city's retry (#2603).
|
coords write — a miss in one city/region must not defer another
|
||||||
|
region's retry (#2603, #3051).
|
||||||
|
|
||||||
Returns the number of deal rows stamped.
|
Returns the number of deal rows stamped.
|
||||||
"""
|
"""
|
||||||
|
|
@ -327,9 +351,10 @@ def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int:
|
||||||
" SET geocode_tried_at = NOW() "
|
" SET geocode_tried_at = NOW() "
|
||||||
" WHERE address = CAST(:addr AS text) "
|
" WHERE address = CAST(:addr AS text) "
|
||||||
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
|
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
|
||||||
|
" AND region_code = CAST(:region_code AS int) "
|
||||||
" AND lat IS NULL"
|
" AND lat IS NULL"
|
||||||
),
|
),
|
||||||
{"addr": address, "city": city},
|
{"addr": address, "city": city, "region_code": region_code},
|
||||||
)
|
)
|
||||||
return result.rowcount
|
return result.rowcount
|
||||||
|
|
||||||
|
|
@ -345,6 +370,7 @@ async def _run_backfill(
|
||||||
*,
|
*,
|
||||||
batch: str,
|
batch: str,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
|
region_code: int = 66,
|
||||||
) -> Stats:
|
) -> Stats:
|
||||||
"""For each (address, city) pair: geocode once, then UPDATE all its deals.
|
"""For each (address, city) pair: geocode once, then UPDATE all its deals.
|
||||||
|
|
||||||
|
|
@ -357,17 +383,21 @@ async def _run_backfill(
|
||||||
retry/backoff), so we don't add our own sleep here — that would double the
|
retry/backoff), so we don't add our own sleep here — that would double the
|
||||||
walltime. A geocode that raises is treated as a failure for THIS run but is
|
walltime. A geocode that raises is treated as a failure for THIS run but is
|
||||||
NOT stamped (left for the next pass to retry sooner than a clean miss).
|
NOT stamped (left for the next pass to retry sooner than a clean miss).
|
||||||
|
|
||||||
|
`region_code` (#3051, default 66) threads into `known_city_hint`/`geocode`
|
||||||
|
(region's own city dictionary + bbox/viewbox) and into the two DB writers
|
||||||
|
(extra WHERE guard, matches `_select_pending_addresses`'s filter).
|
||||||
"""
|
"""
|
||||||
stats = Stats()
|
stats = Stats()
|
||||||
|
|
||||||
for i, group in enumerate(groups, start=1):
|
for i, group in enumerate(groups, start=1):
|
||||||
address = group.address
|
address = group.address
|
||||||
city = group.city
|
city = group.city
|
||||||
# Only a recognised oblast-66 city is fed to the geocoder; junk Rosreestr
|
# Only a recognised city of `region_code` is fed to the geocoder; junk
|
||||||
# values degrade to None (geocoder.known_city_hint — shared with the
|
# Rosreestr values degrade to None (geocoder.known_city_hint — shared
|
||||||
# other DB-column callers). The raw `city` is still used for the UPDATE
|
# with the other DB-column callers). The raw `city` is still used for
|
||||||
# scope — it identifies the group either way.
|
# the UPDATE scope — it identifies the group either way.
|
||||||
hint = known_city_hint(city)
|
hint = known_city_hint(city, region_code)
|
||||||
|
|
||||||
# The geocoder itself rejects <3 chars, but skip here too so the dry-run
|
# The geocoder itself rejects <3 chars, but skip here too so the dry-run
|
||||||
# report and counters stay honest (no phantom "processed" address).
|
# report and counters stay honest (no phantom "processed" address).
|
||||||
|
|
@ -377,7 +407,7 @@ async def _run_backfill(
|
||||||
|
|
||||||
result: GeocodeResult | None = None
|
result: GeocodeResult | None = None
|
||||||
try:
|
try:
|
||||||
result = await geocode(address, db, city_hint=hint)
|
result = await geocode(address, db, city_hint=hint, region_code=region_code)
|
||||||
except Exception as exc: # defensive — one geocode error must not kill batch
|
except Exception as exc: # defensive — one geocode error must not kill batch
|
||||||
stats.geocode_failed += 1
|
stats.geocode_failed += 1
|
||||||
stats.processed += 1
|
stats.processed += 1
|
||||||
|
|
@ -407,7 +437,7 @@ async def _run_backfill(
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
_mark_deals_tried(db, address=address, city=city)
|
_mark_deals_tried(db, address=address, city=city, region_code=region_code)
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception as exc: # defensive — isolate one bad UPDATE
|
except Exception as exc: # defensive — isolate one bad UPDATE
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
@ -441,7 +471,12 @@ async def _run_backfill(
|
||||||
try:
|
try:
|
||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
n = _update_deals_geocoded(
|
n = _update_deals_geocoded(
|
||||||
db, address=address, city=city, lat=result.lat, lon=result.lon
|
db,
|
||||||
|
address=address,
|
||||||
|
city=city,
|
||||||
|
lat=result.lat,
|
||||||
|
lon=result.lon,
|
||||||
|
region_code=region_code,
|
||||||
)
|
)
|
||||||
# Per-address commit so resume picks up exactly where we crashed.
|
# Per-address commit so resume picks up exactly where we crashed.
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -562,6 +597,16 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
f"{_DEFAULT_STALE_DAYS}). Bounds retries on un-geocodable addresses."
|
f"{_DEFAULT_STALE_DAYS}). Bounds retries on un-geocodable addresses."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--region-code",
|
||||||
|
type=int,
|
||||||
|
default=66,
|
||||||
|
help=(
|
||||||
|
"deals.region_code to geocode (default 66, Sverdlovsk oblast). #3051: "
|
||||||
|
"77 (Moscow) threads through the SQL filter, geocoder.geocode/"
|
||||||
|
"known_city_hint, and the DB writers' WHERE guard."
|
||||||
|
),
|
||||||
|
)
|
||||||
return p.parse_args(argv)
|
return p.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -575,34 +620,44 @@ async def main(argv: list[str] | None = None) -> int:
|
||||||
"""
|
"""
|
||||||
args = _parse_args(argv)
|
args = _parse_args(argv)
|
||||||
logger.info(
|
logger.info(
|
||||||
"starting batch=%s limit=%s stale_days=%s dry_run=%s",
|
"starting batch=%s limit=%s stale_days=%s region_code=%s dry_run=%s",
|
||||||
args.batch,
|
args.batch,
|
||||||
args.limit,
|
args.limit,
|
||||||
args.stale_days,
|
args.stale_days,
|
||||||
|
args.region_code,
|
||||||
args.dry_run,
|
args.dry_run,
|
||||||
)
|
)
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
groups = _select_pending_addresses(db, limit=args.limit, stale_days=args.stale_days)
|
groups = _select_pending_addresses(
|
||||||
|
db, limit=args.limit, stale_days=args.stale_days, region_code=args.region_code
|
||||||
|
)
|
||||||
total_rows = sum(g.deals_count for g in groups)
|
total_rows = sum(g.deals_count for g in groups)
|
||||||
logger.info(
|
logger.info(
|
||||||
"loaded %d distinct (address, city) groups (%d deal rows) needing coords",
|
"loaded %d distinct (address, city) groups (%d deal rows) needing coords "
|
||||||
|
"region_code=%s",
|
||||||
len(groups),
|
len(groups),
|
||||||
total_rows,
|
total_rows,
|
||||||
|
args.region_code,
|
||||||
)
|
)
|
||||||
if not groups:
|
if not groups:
|
||||||
logger.info(
|
logger.info(
|
||||||
"nothing to do — no deals with lat IS NULL eligible (all tried "
|
"nothing to do — no deals with lat IS NULL eligible (all tried "
|
||||||
"within the last %d days, or no addressable rows)",
|
"within the last %d days, or no addressable rows) region_code=%s",
|
||||||
args.stale_days,
|
args.stale_days,
|
||||||
|
args.region_code,
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
stats = await _run_backfill(db, groups, batch=args.batch, dry_run=args.dry_run)
|
stats = await _run_backfill(
|
||||||
|
db, groups, batch=args.batch, dry_run=args.dry_run, region_code=args.region_code
|
||||||
|
)
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
total_streets, backlog_rows = _count_pending_total(db, stale_days=args.stale_days)
|
total_streets, backlog_rows = _count_pending_total(
|
||||||
|
db, stale_days=args.stale_days, region_code=args.region_code
|
||||||
|
)
|
||||||
_report_dry_run(
|
_report_dry_run(
|
||||||
stats,
|
stats,
|
||||||
total_streets=total_streets,
|
total_streets=total_streets,
|
||||||
|
|
@ -611,9 +666,10 @@ async def main(argv: list[str] | None = None) -> int:
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"done: batch=%s processed=%d geocoded=%d geocode_failed=%d "
|
"done: batch=%s region_code=%s processed=%d geocoded=%d geocode_failed=%d "
|
||||||
"skipped=%d deals_updated=%d cache=(hit=%d miss=%d)",
|
"skipped=%d deals_updated=%d cache=(hit=%d miss=%d)",
|
||||||
args.batch,
|
args.batch,
|
||||||
|
args.region_code,
|
||||||
stats.processed,
|
stats.processed,
|
||||||
stats.geocoded,
|
stats.geocoded,
|
||||||
stats.geocode_failed,
|
stats.geocode_failed,
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@ importers) — `scraper_kit.domclick_exceptions` остаётся единств
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
|
@ -40,7 +42,11 @@ import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from scraper_kit.browser_fetcher import SidecarBanPageError
|
from scraper_kit.browser_fetcher import SidecarBanPageError
|
||||||
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
||||||
from scraper_kit.offer_price_history import clamp_diff_percent
|
from scraper_kit.offer_price_history import (
|
||||||
|
drop_decimal_slips,
|
||||||
|
recompute_diff_percent,
|
||||||
|
validate_diff_percent,
|
||||||
|
)
|
||||||
from scraper_kit.providers.domclick.detail import (
|
from scraper_kit.providers.domclick.detail import (
|
||||||
DomClickDetailEnrichment,
|
DomClickDetailEnrichment,
|
||||||
_extract_ssr_state,
|
_extract_ssr_state,
|
||||||
|
|
@ -86,6 +92,7 @@ _SSR_LITERAL = """{
|
||||||
"priceInfo": {
|
"priceInfo": {
|
||||||
"priceHistory": [
|
"priceHistory": [
|
||||||
{"date": "2026-04-15T09:02:26.548728+03:00", "price": 13400000, "diff": -400000, "state": "less"},
|
{"date": "2026-04-15T09:02:26.548728+03:00", "price": 13400000, "diff": -400000, "state": "less"},
|
||||||
|
{"date": "2026-03-01T09:00:00+03:00", "price": 13800000, "diff": -200000, "state": "less"},
|
||||||
{"date": "not-a-date", "price": 4800000, "diff": -1.0, "state": "less"},
|
{"date": "not-a-date", "price": 4800000, "diff": -1.0, "state": "less"},
|
||||||
{"date": "2026-05-01T10:00:00Z", "price": null, "diff": 0, "state": "more"}
|
{"date": "2026-05-01T10:00:00Z", "price": null, "diff": 0, "state": "more"}
|
||||||
]
|
]
|
||||||
|
|
@ -219,15 +226,34 @@ def test_parse_detail_html_full() -> None:
|
||||||
|
|
||||||
def test_parse_detail_html_price_changes() -> None:
|
def test_parse_detail_html_price_changes() -> None:
|
||||||
e = parse_detail_html(_HTML, _CARD_URL)
|
e = parse_detail_html(_HTML, _CARD_URL)
|
||||||
# entry1 ок; entry2 bad-date skip; entry3 price=null skip → 1 запись
|
# 2 валидные записи; bad-date skip; price=null skip. Порядок — по change_time.
|
||||||
assert len(e.price_changes) == 1
|
assert len(e.price_changes) == 2
|
||||||
change = e.price_changes[0]
|
first, second = e.price_changes
|
||||||
assert change["price_rub"] == 13400000
|
assert [c["price_rub"] for c in e.price_changes] == [13800000, 13400000]
|
||||||
assert change["diff_percent"] == -400000
|
# Процент считается из соседних price_rub, поле diff площадки (рубли) игнорируется
|
||||||
assert isinstance(change["change_time"], datetime)
|
# (#3225): 13 400 000 / 13 800 000 − 1 = −2.9%, а НЕ −400000.
|
||||||
|
assert first["diff_percent"] is None # самая ранняя запись: предыдущей цены нет
|
||||||
|
assert second["diff_percent"] == -2.9
|
||||||
|
assert isinstance(second["change_time"], datetime)
|
||||||
# ISO8601-with-offset: tz-aware, offset СОХРАНЁН (+03:00), НЕ сконвертирован в UTC.
|
# ISO8601-with-offset: tz-aware, offset СОХРАНЁН (+03:00), НЕ сконвертирован в UTC.
|
||||||
assert change["change_time"].tzinfo is not None
|
assert second["change_time"].tzinfo is not None
|
||||||
assert change["change_time"].utcoffset() == timedelta(hours=3)
|
assert second["change_time"].utcoffset() == timedelta(hours=3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_detail_html_price_changes_single_entry_has_no_percent() -> None:
|
||||||
|
html = _wrap(
|
||||||
|
{
|
||||||
|
"productCard": {
|
||||||
|
"priceInfo": {
|
||||||
|
"priceHistory": [
|
||||||
|
{"date": "2026-04-15T09:00:00+03:00", "price": 9000000, "diff": -300000}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
e = parse_detail_html(html, _CARD_URL)
|
||||||
|
assert [c["diff_percent"] for c in e.price_changes] == [None]
|
||||||
|
|
||||||
|
|
||||||
def test_parse_detail_html_raw_extra() -> None:
|
def test_parse_detail_html_raw_extra() -> None:
|
||||||
|
|
@ -463,13 +489,18 @@ def _sidecar_ban_page_error(upstream_status: int | None = 401) -> SidecarBanPage
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fetch_detail_reports_ban_on_sidecar_ban_page() -> None:
|
async def test_fetch_detail_does_not_report_ban_again_on_sidecar_ban_page() -> None:
|
||||||
|
"""Sidecar-бан рапортует сам фетчер (`report_platform_ban`, #3288) — здесь уже нет.
|
||||||
|
|
||||||
|
Повтор отсюда приходит ПОСЛЕ возможной ротации lease по fail-streak и банил бы
|
||||||
|
свежий узел. Ветка `parse_detail_html` ниже — другой случай: тот детект наш,
|
||||||
|
фетчер его не видит, и там report_ban остаётся (см. тест следом за этим блоком).
|
||||||
|
"""
|
||||||
bf = MagicMock()
|
bf = MagicMock()
|
||||||
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
|
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
|
||||||
with pytest.raises(DomClickBlockedError):
|
with pytest.raises(DomClickBlockedError):
|
||||||
await fetch_detail(_CARD_URL, browser_fetcher=bf)
|
await fetch_detail(_CARD_URL, browser_fetcher=bf)
|
||||||
bf.report_ban.assert_called_once()
|
bf.report_ban.assert_not_called()
|
||||||
assert _CARD_URL in bf.report_ban.call_args.args[0]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -552,6 +583,37 @@ def test_save_detail_enrichment_writes_columns() -> None:
|
||||||
db.commit.assert_called_once()
|
db.commit.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_detail_enrichment_leaves_recorded_at_to_default() -> None:
|
||||||
|
"""Строка загрузчика обязана иметь change_time <> recorded_at.
|
||||||
|
|
||||||
|
По этому признаку миграция 285 отличает строки загрузчика от строк триггера
|
||||||
|
record_listing_price_change (тот пишет обе метки одним now(), то есть равными) и
|
||||||
|
пересчитывает diff_percent только у первых. У триггера ДРУГАЯ база отсчёта —
|
||||||
|
listings.OLD.price_rub, — поэтому пересчёт его строк ломает честные значения.
|
||||||
|
Признак держится на двух вещах: change_time = дата источника (глубоко в прошлом),
|
||||||
|
а recorded_at загрузчик не указывает вовсе → DEFAULT NOW() на вставке.
|
||||||
|
Пропишут recorded_at в этот INSERT — признак сломается и тест покраснеет.
|
||||||
|
"""
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.rowcount = 1
|
||||||
|
ct = datetime(2026, 4, 1, tzinfo=UTC)
|
||||||
|
e = DomClickDetailEnrichment(
|
||||||
|
item_id="2075729321",
|
||||||
|
source_url=_CARD_URL,
|
||||||
|
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -2.5}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert save_detail_enrichment(db, 999, e) is True
|
||||||
|
|
||||||
|
insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])]
|
||||||
|
assert len(insert_calls) == 1
|
||||||
|
insert_sql = str(insert_calls[0][0][0])
|
||||||
|
assert "recorded_at" not in insert_sql
|
||||||
|
# change_time — историческая дата источника, а не момент вставки.
|
||||||
|
assert insert_calls[0][0][1]["ct"] == ct
|
||||||
|
assert ct < datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
def test_save_detail_enrichment_listing_not_found() -> None:
|
def test_save_detail_enrichment_listing_not_found() -> None:
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
db.execute.return_value.rowcount = 0
|
db.execute.return_value.rowcount = 0
|
||||||
|
|
@ -583,20 +645,23 @@ def _insert_diff_param(db: MagicMock) -> float | None:
|
||||||
return insert_calls[0][0][1]["diff"]
|
return insert_calls[0][0][1]["diff"]
|
||||||
|
|
||||||
|
|
||||||
def test_save_detail_enrichment_clamps_extreme_positive_diff() -> None:
|
def test_save_detail_enrichment_rejects_rubles_as_diff(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Величина не того рода (рубли) → NULL + warning, НЕ 999999.99 (#3225)."""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
db.execute.return_value.rowcount = 1
|
db.execute.return_value.rowcount = 1
|
||||||
ct = datetime(2026, 4, 1, tzinfo=UTC)
|
ct = datetime(2026, 4, 1, tzinfo=UTC)
|
||||||
e = DomClickDetailEnrichment(
|
e = DomClickDetailEnrichment(
|
||||||
item_id="x",
|
item_id="x",
|
||||||
source_url=_CARD_URL,
|
source_url=_CARD_URL,
|
||||||
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": 5000000}],
|
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": 250000}],
|
||||||
)
|
)
|
||||||
save_detail_enrichment(db, 1, e)
|
with caplog.at_level(logging.WARNING):
|
||||||
assert _insert_diff_param(db) == 999999.99
|
save_detail_enrichment(db, 777, e)
|
||||||
|
assert _insert_diff_param(db) is None
|
||||||
|
assert any("250000" in r.getMessage() and "777" in r.getMessage() for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
def test_save_detail_enrichment_clamps_extreme_negative_diff() -> None:
|
def test_save_detail_enrichment_rejects_extreme_negative_diff() -> None:
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
db.execute.return_value.rowcount = 1
|
db.execute.return_value.rowcount = 1
|
||||||
ct = datetime(2026, 4, 1, tzinfo=UTC)
|
ct = datetime(2026, 4, 1, tzinfo=UTC)
|
||||||
|
|
@ -606,7 +671,7 @@ def test_save_detail_enrichment_clamps_extreme_negative_diff() -> None:
|
||||||
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -5000000}],
|
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -5000000}],
|
||||||
)
|
)
|
||||||
save_detail_enrichment(db, 1, e)
|
save_detail_enrichment(db, 1, e)
|
||||||
assert _insert_diff_param(db) == -999999.99
|
assert _insert_diff_param(db) is None
|
||||||
|
|
||||||
|
|
||||||
def test_save_detail_enrichment_normal_diff_unchanged() -> None:
|
def test_save_detail_enrichment_normal_diff_unchanged() -> None:
|
||||||
|
|
@ -635,31 +700,288 @@ def test_save_detail_enrichment_none_diff_unchanged() -> None:
|
||||||
assert _insert_diff_param(db) is None
|
assert _insert_diff_param(db) is None
|
||||||
|
|
||||||
|
|
||||||
# ── clamp_diff_percent (scraper_kit.offer_price_history) — standalone unit ────
|
# ── validate_diff_percent (scraper_kit.offer_price_history) — standalone unit ─
|
||||||
# Нет отдельной tests/ директории для scraper-kit-пакета (см. audit перед фиксом) —
|
# Нет отдельной tests/ директории для scraper-kit-пакета (см. audit перед фиксом) —
|
||||||
# прямые unit-тесты хелпера живут здесь, рядом с save_detail_enrichment-тестами,
|
# прямые unit-тесты хелпера живут здесь, рядом с save_detail_enrichment-тестами,
|
||||||
# которые его же и используют.
|
# которые его же и используют.
|
||||||
|
|
||||||
|
|
||||||
def test_clamp_diff_percent_extreme_positive() -> None:
|
def test_validate_diff_percent_rejects_rubles(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
assert clamp_diff_percent(5000000) == 999999.99
|
with caplog.at_level(logging.WARNING):
|
||||||
|
assert validate_diff_percent(250000, listing_id=406163) is None
|
||||||
|
assert any("250000" in r.getMessage() and "406163" in r.getMessage() for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
def test_clamp_diff_percent_extreme_negative() -> None:
|
def test_validate_diff_percent_rejects_extreme_negative() -> None:
|
||||||
assert clamp_diff_percent(-5000000) == -999999.99
|
assert validate_diff_percent(-5000000) is None
|
||||||
|
|
||||||
|
|
||||||
def test_clamp_diff_percent_in_range_unchanged() -> None:
|
def test_validate_diff_percent_boundary_kept() -> None:
|
||||||
assert clamp_diff_percent(-2.5) == -2.5
|
assert validate_diff_percent(100) == 100.0
|
||||||
|
assert validate_diff_percent(-100) == -100.0
|
||||||
|
assert validate_diff_percent(100.01) is None
|
||||||
|
|
||||||
|
|
||||||
def test_clamp_diff_percent_none() -> None:
|
def test_validate_diff_percent_in_range_unchanged() -> None:
|
||||||
assert clamp_diff_percent(None) is None
|
assert validate_diff_percent(-2.5) == -2.5
|
||||||
|
assert validate_diff_percent(-0.82) == -0.82
|
||||||
|
|
||||||
|
|
||||||
def test_clamp_diff_percent_bool_treated_as_none() -> None:
|
def test_validate_diff_percent_none() -> None:
|
||||||
assert clamp_diff_percent(True) is None
|
assert validate_diff_percent(None) is None
|
||||||
assert clamp_diff_percent(False) is None
|
|
||||||
|
|
||||||
|
def test_validate_diff_percent_bool_treated_as_none() -> None:
|
||||||
|
assert validate_diff_percent(True) is None
|
||||||
|
assert validate_diff_percent(False) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── drop_decimal_slips (#3376) — сдвиг разряда в priceHistory источника ───────
|
||||||
|
# Прод-замер 06.09.2026 под ЭТИМ критерием: 38 точек (cian 12, domklik 26,
|
||||||
|
# yandex 0). Вид: 377 000 → 3 770 000 → 3 720 000 — цена возвращается к базе
|
||||||
|
# следующей же точкой.
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_decimal_slips_drops_spike_up(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Точка ×10 с возвратом к базе выброшена; соседи по бокам целы."""
|
||||||
|
series = [19064000, 190150000, 19150000]
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
kept, dropped = drop_decimal_slips(series, lambda p: p, listing_id=406163)
|
||||||
|
assert kept == [19064000, 19150000]
|
||||||
|
assert dropped == 1
|
||||||
|
assert any("406163" in r.getMessage() and "19064000" in r.getMessage() for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_decimal_slips_drops_spike_down() -> None:
|
||||||
|
"""Тот же критерий в другую сторону: ÷10 и возврат к базе (377 000 при базе 3.7 млн)."""
|
||||||
|
kept, dropped = drop_decimal_slips([3720000, 377000, 3770000], lambda p: p)
|
||||||
|
assert kept == [3720000, 3770000]
|
||||||
|
assert dropped == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_decimal_slips_keeps_honest_doubling() -> None:
|
||||||
|
"""Рост ×2 — не сдвиг разряда, серия не тронута."""
|
||||||
|
series = [5000000, 10000000, 9900000]
|
||||||
|
assert drop_decimal_slips(series, lambda p: p) == (series, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_decimal_slips_keeps_tenfold_without_return_to_base() -> None:
|
||||||
|
"""×10 БЕЗ возврата к базе — возможное честное изменение цены, точку не трогаем.
|
||||||
|
|
||||||
|
Хвост 1 020 000 → 10 200 000 → 10 150 000 намеренно стоит НЕ первым: ровно та
|
||||||
|
же тройка в начале серии — это уже сдвиг по правилу первой точки (см.
|
||||||
|
test_drop_decimal_slips_drops_first_point_witnessed_by_third), и правила здесь
|
||||||
|
не спорят, а смотрят на разные концы серии. Внутри серии у 10 200 000 есть
|
||||||
|
база слева, и она НЕ подтверждена следующей точкой (9.95 ≠ 1) — точка честная.
|
||||||
|
"""
|
||||||
|
series = [1000000, 1020000, 10200000, 10150000]
|
||||||
|
assert drop_decimal_slips(series, lambda p: p) == (series, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_decimal_slips_last_point_checked_against_current_price() -> None:
|
||||||
|
"""У последней точки следующей нет — свидетель это текущая цена объявления."""
|
||||||
|
kept, dropped = drop_decimal_slips([1020000, 10200000], lambda p: p, current_price=1020000)
|
||||||
|
assert kept == [1020000]
|
||||||
|
assert dropped == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_decimal_slips_last_point_kept_without_current_price() -> None:
|
||||||
|
"""Второго свидетеля нет вообще → точка остаётся (могла быть честной сменой цены)."""
|
||||||
|
series = [1020000, 10200000]
|
||||||
|
assert drop_decimal_slips(series, lambda p: p, current_price=None) == (series, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ── правило ПЕРВОЙ точки (#3376, прод-разбор 06.09.2026) ─────────────────────
|
||||||
|
# Серии, у которых дефектная точка первая: 420 000 → 4 200 000 → 4 500 000. Слева
|
||||||
|
# базы нет, основное правило такую точку не видит. Свидетелей по-прежнему двое, и
|
||||||
|
# оба — из самой истории: вторая точка и ТРЕТЬЯ. Кандидатов первой точки на проде
|
||||||
|
# было 21, свидетеля из истории имеют 4 (все domklik).
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_decimal_slips_drops_first_point_witnessed_by_third() -> None:
|
||||||
|
"""420 000 → 4 200 000 → 4 500 000: вторую точку подтверждает третья."""
|
||||||
|
kept, dropped = drop_decimal_slips([420000, 4200000, 4500000], lambda p: p)
|
||||||
|
assert (kept, dropped) == ([4200000, 4500000], 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_decimal_slips_keeps_first_point_when_series_runs_away() -> None:
|
||||||
|
"""Первая ×10 ко второй, но третья ушла ЕЩЁ дальше — это разгон цены, не разряд."""
|
||||||
|
series = [400000, 4000000, 8000000]
|
||||||
|
assert drop_decimal_slips(series, lambda p: p) == (series, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_decimal_slips_keeps_first_point_without_third_point() -> None:
|
||||||
|
"""330 000 → 3 300 000: третьей точки нет — свидетеля нет, ДАЖЕ с ценой лота.
|
||||||
|
|
||||||
|
Цена объявления в правиле первой точки не участвует сознательно: у 17 из 21
|
||||||
|
прод-кандидата (yandex 6, domklik 9, cian 2) listings.price_rub в точности
|
||||||
|
равнялась второй точке — у yandex это буквально одна переменная lot.price_rub,
|
||||||
|
записанная в двух местах. Такой «второй свидетель» — то же наблюдение, а
|
||||||
|
удаление точки необратимо. Верни COALESCE на current_price в правило первой
|
||||||
|
точки — первый assert покраснеет.
|
||||||
|
"""
|
||||||
|
series = [330000, 3300000]
|
||||||
|
assert drop_decimal_slips(series, lambda p: p, current_price=3300000) == (series, 0)
|
||||||
|
assert drop_decimal_slips(series, lambda p: p, current_price=None) == (series, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_decimal_slips_first_point_decided_on_kept_series() -> None:
|
||||||
|
"""Свидетели первой точки — ОСТАВШИЕСЯ соседи, а не сырые.
|
||||||
|
|
||||||
|
1M → 10M → 100M → 10M: 100M выбрасывает основное правило, и только после этого
|
||||||
|
видно, что первая точка ÷10 к оставшейся серии [10M, 10M]. Считай правило по
|
||||||
|
сырым соседям — первый проход оставил бы 1M, а второй выбросил, то есть гейт
|
||||||
|
перестал бы быть идемпотентным (перебор ниже ловит 1116 таких прогонов, и эта
|
||||||
|
серия — первый из них).
|
||||||
|
"""
|
||||||
|
kept, dropped = drop_decimal_slips(
|
||||||
|
[1_000_000, 10_000_000, 100_000_000, 10_000_000], lambda p: p
|
||||||
|
)
|
||||||
|
assert (kept, dropped) == ([10_000_000, 10_000_000], 2)
|
||||||
|
|
||||||
|
|
||||||
|
# ── база = предыдущая ОСТАВЛЕННАЯ точка (фальсификаторы ревьюера) ────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_decimal_slips_alternating_series_keeps_honest_points() -> None:
|
||||||
|
"""1M → 10M → 1M → 10M при цене 1M: обе честные точки на месте.
|
||||||
|
|
||||||
|
Первая редакция миграции 286 брала базой СЫРУЮ предыдущую строку (lag) и на
|
||||||
|
этой серии оставляла [1M] вместо [1M, 1M] — удаляла честную точку.
|
||||||
|
"""
|
||||||
|
kept, dropped = drop_decimal_slips(
|
||||||
|
[1_000_000, 10_000_000, 1_000_000, 10_000_000], lambda p: p, current_price=1_000_000
|
||||||
|
)
|
||||||
|
assert (kept, dropped) == ([1_000_000, 1_000_000], 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_decimal_slips_base_is_previous_kept_not_raw() -> None:
|
||||||
|
"""1M → 10M → 1.05M → 9.9M при цене 1M: после выброса 10M база для 1.05M — это 1M.
|
||||||
|
|
||||||
|
По сырой базе 1.05M тоже стала бы кандидатом (10M/1.05M = 9.52), а следующий
|
||||||
|
прогон доел бы 9.9M — ровно та неидемпотентность, которую нашёл ревьюер.
|
||||||
|
"""
|
||||||
|
kept, dropped = drop_decimal_slips(
|
||||||
|
[1_000_000, 10_000_000, 1_050_000, 9_900_000], lambda p: p, current_price=1_000_000
|
||||||
|
)
|
||||||
|
assert (kept, dropped) == ([1_000_000, 1_050_000, 9_900_000], 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── свойство: gate(gate(s)) == gate(s) ───────────────────────────────────────
|
||||||
|
|
||||||
|
_SLIP_ALPHABET = (1_000_000, 10_000_000, 100_000_000, 1_050_000, 9_900_000)
|
||||||
|
_SLIP_CURRENTS = (None, 1_000_000, 10_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_decimal_slips_is_idempotent(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Повторный прогон гейта не трогает ничего — на всех сериях длины 2-6.
|
||||||
|
|
||||||
|
Свойство несущее, а не декоративное: миграция 286 зеркалит эту функцию
|
||||||
|
пошагово, а доказать идемпотентность на самом SQL-файле нечем — прод-прогон
|
||||||
|
один. Раз выборка кандидатов повторяет гейт, свойство переносится на неё.
|
||||||
|
|
||||||
|
ФАЛЬСИФИЦИРУЕМОСТЬ (перемерено 06.09.2026 на этом же переборе — 19 525 серий
|
||||||
|
× 3 цены = 58 575 прогонов):
|
||||||
|
• база = сырая предыдущая точка вместо оставленной → 136 неидемпотентных
|
||||||
|
прогонов, первый же — (1M, 10M, 1.05M, 9.9M) при цене 1M: [1M, 9.9M] → [1M];
|
||||||
|
• решение по первой точке на сырых соседях вместо оставшихся → 1116 прогонов,
|
||||||
|
первый — (1M, 10M, 100M, 10M) без текущей цены: [1M, 10M, 10M] → [10M, 10M].
|
||||||
|
"""
|
||||||
|
# Гейт логирует каждый выброс; на таком переборе это десятки тысяч записей.
|
||||||
|
caplog.set_level(logging.CRITICAL, logger="scraper_kit.offer_price_history")
|
||||||
|
for length in range(2, 7):
|
||||||
|
for series in itertools.product(_SLIP_ALPHABET, repeat=length):
|
||||||
|
for current in _SLIP_CURRENTS:
|
||||||
|
once, _ = drop_decimal_slips(list(series), lambda p: p, current_price=current)
|
||||||
|
twice, dropped_again = drop_decimal_slips(once, lambda p: p, current_price=current)
|
||||||
|
assert (twice, dropped_again) == (once, 0), (
|
||||||
|
f"серия {series} при текущей цене {current}: {once} → {twice}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── recompute_diff_percent — общий пересчёт после выброса ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_recompute_diff_percent_from_surviving_neighbours() -> None:
|
||||||
|
"""Процент считается от оставшейся базы; у первой точки базы нет → NULL."""
|
||||||
|
changes: list[dict[str, object]] = [
|
||||||
|
{"price_rub": 3720000, "diff_percent": -89.87},
|
||||||
|
{"price_rub": 3770000, "diff_percent": 900.0},
|
||||||
|
]
|
||||||
|
recompute_diff_percent(changes)
|
||||||
|
assert [c["diff_percent"] for c in changes] == [None, 1.34]
|
||||||
|
|
||||||
|
|
||||||
|
def test_recompute_diff_percent_survives_none_price() -> None:
|
||||||
|
"""price_rub=None не роняет пересчёт: ручной ingest кладёт price_changes сырьём.
|
||||||
|
|
||||||
|
scripts/ingest_domclick_jsonl.py:108 берёт rec["price_changes"] из JSONL без
|
||||||
|
валидации — арифметика по None здесь дала бы TypeError на всю запись.
|
||||||
|
"""
|
||||||
|
changes: list[dict[str, object]] = [
|
||||||
|
{"price_rub": 5_000_000, "diff_percent": 12.0},
|
||||||
|
{"price_rub": None, "diff_percent": 900.0},
|
||||||
|
{"price_rub": 5_100_000, "diff_percent": -90.0},
|
||||||
|
]
|
||||||
|
recompute_diff_percent(changes)
|
||||||
|
assert [c["diff_percent"] for c in changes] == [None, None, 2.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_detail_enrichment_drops_decimal_slip_before_insert() -> None:
|
||||||
|
"""Писатель не отправляет спайк в INSERT, а соседу пересчитывает diff_percent."""
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.rowcount = 1
|
||||||
|
# SELECT price_rub FROM listings — текущая цена объявления (свидетель).
|
||||||
|
db.execute.return_value.fetchone.return_value = MagicMock(price_rub=3770000)
|
||||||
|
times = [datetime(2026, 4, d, tzinfo=UTC) for d in (1, 2, 3)]
|
||||||
|
e = DomClickDetailEnrichment(
|
||||||
|
item_id="2075729321",
|
||||||
|
source_url=_CARD_URL,
|
||||||
|
price_changes=[
|
||||||
|
{"change_time": times[0], "price_rub": 3720000, "diff_percent": None},
|
||||||
|
# Сдвиг разряда: ÷10 к предыдущей, следующая возвращается к базе.
|
||||||
|
{"change_time": times[1], "price_rub": 377000, "diff_percent": -89.87},
|
||||||
|
{"change_time": times[2], "price_rub": 3770000, "diff_percent": 900.0},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert save_detail_enrichment(db, 999, e) is True
|
||||||
|
|
||||||
|
insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])]
|
||||||
|
assert [c[0][1]["price"] for c in insert_calls] == [3720000, 3770000]
|
||||||
|
# У точки-соседа diff_percent парсер считал от исчезнувшей базы 377 000 (+900%,
|
||||||
|
# такое validate_diff_percent отвергает). От настоящей базы 3 720 000 это +1.34%.
|
||||||
|
assert insert_calls[1][0][1]["diff"] == 1.34
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_detail_enrichment_witness_comes_only_from_listing_price() -> None:
|
||||||
|
"""Серия из ДВУХ точек: второго свидетеля даёт исключительно цена листинга.
|
||||||
|
|
||||||
|
Здесь чтение цены не декорация, а единственный вход гейта: у последней точки
|
||||||
|
следующей нет. Сломай его (RETURNING убран / fetchone → None) — свидетеля не
|
||||||
|
станет, спайк 10 200 000 уедет в offer_price_history, и тест покраснеет на
|
||||||
|
втором INSERT'е. Проверено подменой fetchone → None.
|
||||||
|
"""
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.rowcount = 1
|
||||||
|
db.execute.return_value.fetchone.return_value = MagicMock(price_rub=1020000)
|
||||||
|
times = [datetime(2026, 4, d, tzinfo=UTC) for d in (1, 2)]
|
||||||
|
e = DomClickDetailEnrichment(
|
||||||
|
item_id="x",
|
||||||
|
source_url=_CARD_URL,
|
||||||
|
price_changes=[
|
||||||
|
{"change_time": times[0], "price_rub": 1020000, "diff_percent": None},
|
||||||
|
{"change_time": times[1], "price_rub": 10200000, "diff_percent": 900.0},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert save_detail_enrichment(db, 999, e) is True
|
||||||
|
|
||||||
|
insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])]
|
||||||
|
assert [c[0][1]["price"] for c in insert_calls] == [1020000]
|
||||||
|
# Цену отдаёт тот же UPDATE — отдельного SELECT по PK больше нет.
|
||||||
|
assert "RETURNING price_rub" in str(db.execute.call_args_list[0][0][0])
|
||||||
|
assert not [c for c in db.execute.call_args_list if "SELECT price_rub" in str(c[0][0])]
|
||||||
|
|
||||||
|
|
||||||
# ── canon_sale_type (#2674) ───────────────────────────────────────────────────
|
# ── canon_sale_type (#2674) ───────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,7 @@ def test_update_deals_geocoded_sets_lat_lon_tried_at_not_geom():
|
||||||
"city": "Екатеринбург",
|
"city": "Екатеринбург",
|
||||||
"lat": 56.1,
|
"lat": 56.1,
|
||||||
"lon": 60.2,
|
"lon": 60.2,
|
||||||
|
"region_code": 66,
|
||||||
}
|
}
|
||||||
assert n == 7
|
assert n == 7
|
||||||
|
|
||||||
|
|
@ -492,7 +493,7 @@ async def test_run_backfill_passes_known_city_as_hint():
|
||||||
) as mock_geo:
|
) as mock_geo:
|
||||||
await _run_backfill(db, groups, batch="b", dry_run=False)
|
await _run_backfill(db, groups, batch="b", dry_run=False)
|
||||||
|
|
||||||
mock_geo.assert_called_once_with("Победы, 30", db, city_hint="Нижний Тагил")
|
mock_geo.assert_called_once_with("Победы, 30", db, city_hint="Нижний Тагил", region_code=66)
|
||||||
assert coord_updates[0]["city"] == "Нижний Тагил"
|
assert coord_updates[0]["city"] == "Нижний Тагил"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -507,7 +508,7 @@ async def test_run_backfill_junk_city_geocodes_without_hint_but_scopes_update():
|
||||||
) as mock_geo:
|
) as mock_geo:
|
||||||
await _run_backfill(db, groups, batch="b", dry_run=False)
|
await _run_backfill(db, groups, batch="b", dry_run=False)
|
||||||
|
|
||||||
mock_geo.assert_called_once_with("Бессонова, 11", db, city_hint=None)
|
mock_geo.assert_called_once_with("Бессонова, 11", db, city_hint=None, region_code=66)
|
||||||
assert coord_updates[0]["city"] == "Бессонова"
|
assert coord_updates[0]["city"] == "Бессонова"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -547,7 +548,7 @@ async def test_run_backfill_miss_marks_only_its_own_city():
|
||||||
await _run_backfill(db, groups, batch="b", dry_run=False)
|
await _run_backfill(db, groups, batch="b", dry_run=False)
|
||||||
|
|
||||||
assert coord_updates == []
|
assert coord_updates == []
|
||||||
assert tried_updates == [{"addr": "Победы, 30", "city": "Нижний Тагил"}]
|
assert tried_updates == [{"addr": "Победы, 30", "city": "Нижний Тагил", "region_code": 66}]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -438,21 +438,38 @@ class FakeSession:
|
||||||
[{"ban_count": ban["ban_count"], "banned_until": ban["banned_until"]}]
|
[{"ban_count": ban["ban_count"], "banned_until": ban["banned_until"]}]
|
||||||
)
|
)
|
||||||
|
|
||||||
if "DELETE FROM scrape_proxy_source_bans" in sql and "proxy_id = CAST" in sql:
|
if "UPDATE scrape_proxy_source_bans" in sql and "cleared_reason" in sql:
|
||||||
# clear_source_bans: снять баны узла (все либо один source), #2600 п.2.
|
# clear_source_bans (#3404): гасим строку (banned_until=now(), ban_count=0,
|
||||||
# Фильтр по reason (#2800) гейтим по подстроке боевого SQL — как ban-фильтры
|
# cleared_at/cleared_reason) вместо DELETE — трассируемость снятия бана, строка
|
||||||
# в acquire-ветке: иначе мок «чинил» бы код, который фильтра не содержит, и
|
# доживает до штатного purge. Фильтр по reason (#2800) гейтим по подстроке
|
||||||
# тест на «успешная проба не гасит чужой бан» остался бы зелёным на сломанном.
|
# боевого SQL — как ban-фильтры в acquire-ветке: иначе мок «чинил» бы код,
|
||||||
|
# который фильтра не содержит, и тест на «успешная проба не гасит чужой бан»
|
||||||
|
# остался бы зелёным на сломанном.
|
||||||
filters_reason = "reason = CAST(:only_reason AS text)" in sql
|
filters_reason = "reason = CAST(:only_reason AS text)" in sql
|
||||||
only_reason = p.get("only_reason") if filters_reason else None
|
only_reason = p.get("only_reason") if filters_reason else None
|
||||||
cleared = [
|
now = datetime.now(UTC)
|
||||||
b
|
cleared: list[dict[str, Any]] = []
|
||||||
for b in self.bans
|
for b in self.bans:
|
||||||
if b["proxy_id"] == p["proxy_id"]
|
if b["proxy_id"] != p["proxy_id"]:
|
||||||
and (p["source"] is None or b["source"] == p["source"])
|
continue
|
||||||
and (only_reason is None or b.get("reason") == only_reason)
|
if p["source"] is not None and b["source"] != p["source"]:
|
||||||
]
|
continue
|
||||||
self.bans = [b for b in self.bans if b not in cleared]
|
if only_reason is not None and b.get("reason") != only_reason:
|
||||||
|
continue
|
||||||
|
# Гейт покоя (реальный WHERE): уже погашенную строку повторно не трогаем —
|
||||||
|
# без него повторный вызов сдвигал бы banned_until вперёд.
|
||||||
|
if (
|
||||||
|
b.get("cleared_at") is not None
|
||||||
|
and b["ban_count"] == 0
|
||||||
|
and b["banned_until"] <= now
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
b["banned_until"] = now
|
||||||
|
b["ban_count"] = 0
|
||||||
|
b["cleared_at"] = now
|
||||||
|
b["cleared_reason"] = p["reason"]
|
||||||
|
b["updated_at"] = now
|
||||||
|
cleared.append(b)
|
||||||
return _FakeResult([{"source": b["source"]} for b in cleared])
|
return _FakeResult([{"source": b["source"]} for b in cleared])
|
||||||
|
|
||||||
if "DELETE FROM scrape_proxy_source_bans" in sql: # purge истёкших (#2600 п.2)
|
if "DELETE FROM scrape_proxy_source_bans" in sql: # purge истёкших (#2600 п.2)
|
||||||
|
|
@ -1419,6 +1436,10 @@ async def test_healthcheck_purges_long_expired_bans_only(
|
||||||
# До п.2 ложный бан лечился PATCH enabled=true (обнулял disabled_reason). Теперь бан
|
# До п.2 ложный бан лечился PATCH enabled=true (обнулял disabled_reason). Теперь бан
|
||||||
# в отдельной таблице и истекает только по таймеру (до 72ч при эскалации) — без этой
|
# в отдельной таблице и истекает только по таймеру (до 72ч при эскалации) — без этой
|
||||||
# ручки ложное срабатывание детектора капчи (#2642) снималось бы только руками в SQL.
|
# ручки ложное срабатывание детектора капчи (#2642) снималось бы только руками в SQL.
|
||||||
|
#
|
||||||
|
# #3404: снятие гасит строку (banned_until=now(), ban_count=0, cleared_at/cleared_reason),
|
||||||
|
# а не удаляет её — строка живёт для трассируемости до штатного purge, но для выдачи и
|
||||||
|
# для эскалации следующего бана неотличима от прежнего DELETE.
|
||||||
|
|
||||||
|
|
||||||
def _active_ban(pid: int, source: str, ban_count: int = 1) -> dict[str, Any]:
|
def _active_ban(pid: int, source: str, ban_count: int = 1) -> dict[str, Any]:
|
||||||
|
|
@ -1428,17 +1449,37 @@ def _active_ban(pid: int, source: str, ban_count: int = 1) -> dict[str, Any]:
|
||||||
"ban_count": ban_count,
|
"ban_count": ban_count,
|
||||||
"banned_until": datetime.now(UTC) + timedelta(hours=SOURCE_BAN_MAX_HOURS),
|
"banned_until": datetime.now(UTC) + timedelta(hours=SOURCE_BAN_MAX_HOURS),
|
||||||
"reason": f"banned:{source}",
|
"reason": f"banned:{source}",
|
||||||
|
"cleared_at": None,
|
||||||
|
"cleared_reason": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_clear_source_bans_removes_all_bans_of_node() -> None:
|
def test_clear_source_bans_gates_all_bans_of_node() -> None:
|
||||||
db = FakeSession(
|
db = FakeSession(
|
||||||
[_proxy(1, affinity="any"), _proxy(2, affinity="any")],
|
[_proxy(1, affinity="any"), _proxy(2, affinity="any")],
|
||||||
bans=[_active_ban(1, "avito"), _active_ban(1, "cian"), _active_ban(2, "avito")],
|
bans=[_active_ban(1, "avito"), _active_ban(1, "cian"), _active_ban(2, "avito")],
|
||||||
)
|
)
|
||||||
cleared = proxy_pool.clear_source_bans(db, 1, reason="manual enable") # type: ignore[arg-type]
|
cleared = proxy_pool.clear_source_bans(db, 1, reason="manual enable") # type: ignore[arg-type]
|
||||||
assert cleared == 2
|
assert cleared == 2
|
||||||
assert [(b["proxy_id"], b["source"]) for b in db.bans] == [(2, "avito")] # чужой цел
|
# #3404: строки НЕ удаляются — все три остаются в таблице (трассируемость).
|
||||||
|
assert {(b["proxy_id"], b["source"]) for b in db.bans} == {
|
||||||
|
(1, "avito"),
|
||||||
|
(1, "cian"),
|
||||||
|
(2, "avito"),
|
||||||
|
}
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
node1_bans = [b for b in db.bans if b["proxy_id"] == 1]
|
||||||
|
assert len(node1_bans) == 2
|
||||||
|
for b in node1_bans:
|
||||||
|
assert b["banned_until"] <= now
|
||||||
|
assert b["ban_count"] == 0
|
||||||
|
assert b["cleared_at"] is not None
|
||||||
|
assert b["cleared_reason"] == "manual enable"
|
||||||
|
# чужой бан (proxy_id=2) не тронут — остаётся активным
|
||||||
|
other_ban = next(b for b in db.bans if b["proxy_id"] == 2)
|
||||||
|
assert other_ban["banned_until"] > now
|
||||||
|
assert other_ban["ban_count"] == 1
|
||||||
|
assert other_ban["cleared_at"] is None
|
||||||
# узел снова выдаётся источнику, который его банил
|
# узел снова выдаётся источнику, который его банил
|
||||||
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
|
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
|
||||||
assert lease is not None and lease.id == 1
|
assert lease is not None and lease.id == 1
|
||||||
|
|
@ -1453,7 +1494,16 @@ def test_clear_source_bans_single_source_keeps_others() -> None:
|
||||||
db, 1, source="avito", reason="ip rotated"
|
db, 1, source="avito", reason="ip rotated"
|
||||||
)
|
)
|
||||||
assert cleared == 1
|
assert cleared == 1
|
||||||
assert [b["source"] for b in db.bans] == ["cian"]
|
# обе строки остаются (#3404), но только "avito" погашена
|
||||||
|
avito_ban = db._ban(1, "avito")
|
||||||
|
cian_ban = db._ban(1, "cian")
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
assert avito_ban["banned_until"] <= now
|
||||||
|
assert avito_ban["ban_count"] == 0
|
||||||
|
assert avito_ban["cleared_reason"] == "ip rotated"
|
||||||
|
assert cian_ban["banned_until"] > now
|
||||||
|
assert cian_ban["ban_count"] == 1
|
||||||
|
assert cian_ban["cleared_at"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_clear_source_bans_noop_when_nothing_to_clear() -> None:
|
def test_clear_source_bans_noop_when_nothing_to_clear() -> None:
|
||||||
|
|
@ -1462,8 +1512,8 @@ def test_clear_source_bans_noop_when_nothing_to_clear() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_clear_source_bans_resets_escalation() -> None:
|
def test_clear_source_bans_resets_escalation() -> None:
|
||||||
"""DELETE, а не banned_until=now(): снятие обнуляет и ban_count — следующий бан
|
"""Гашение (banned_until=now(), ban_count=0), а не удаление строки: следующий бан той
|
||||||
начинается с базовых SOURCE_BAN_BASE_HOURS, а не продолжает эскалацию."""
|
же пары начинается с базовых SOURCE_BAN_BASE_HOURS, а не продолжает эскалацию (#3404)."""
|
||||||
assert mark_banned is not None
|
assert mark_banned is not None
|
||||||
db = FakeSession(
|
db = FakeSession(
|
||||||
[_proxy(1, affinity="any"), _proxy(2, affinity="any")],
|
[_proxy(1, affinity="any"), _proxy(2, affinity="any")],
|
||||||
|
|
|
||||||
|
|
@ -105,9 +105,19 @@ class FakeSession:
|
||||||
)
|
)
|
||||||
return _FakeResult([])
|
return _FakeResult([])
|
||||||
|
|
||||||
if "DELETE FROM scrape_proxy_source_bans" in sql: # clear_source_bans (#2600 п.2)
|
if "UPDATE scrape_proxy_source_bans" in sql and "cleared_reason" in sql:
|
||||||
cleared = [b for b in self.source_bans if b["proxy_id"] == p["proxy_id"]]
|
# clear_source_bans (#3404): гасит строку (cleared_reason=reason), а НЕ
|
||||||
self.source_bans = [b for b in self.source_bans if b not in cleared]
|
# удаляет — строка остаётся для трассируемости. Фейк мутирует найденные
|
||||||
|
# записи в месте, не убирая их из self.source_bans.
|
||||||
|
target_source = p.get("source")
|
||||||
|
cleared = [
|
||||||
|
b
|
||||||
|
for b in self.source_bans
|
||||||
|
if b["proxy_id"] == p["proxy_id"]
|
||||||
|
and (target_source is None or b["source"] == target_source)
|
||||||
|
]
|
||||||
|
for b in cleared:
|
||||||
|
b["cleared_reason"] = p["reason"]
|
||||||
return _FakeResult([{"source": b["source"]} for b in cleared])
|
return _FakeResult([{"source": b["source"]} for b in cleared])
|
||||||
|
|
||||||
if "UPDATE scrape_proxies" in sql and "exit_ip" in sql:
|
if "UPDATE scrape_proxies" in sql and "exit_ip" in sql:
|
||||||
|
|
@ -404,7 +414,13 @@ async def test_successful_rotation_clears_source_bans(monkeypatch: pytest.Monkey
|
||||||
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
assert result.ok is True
|
assert result.ok is True
|
||||||
assert db.source_bans == [{"proxy_id": 2, "source": "avito"}]
|
# #3404: строки не удаляются — гасятся (cleared_reason проставлен), остаются для
|
||||||
|
# трассируемости. Чужой узел (proxy_id=2) не тронут вообще.
|
||||||
|
proxy1_bans = [b for b in db.source_bans if b["proxy_id"] == 1]
|
||||||
|
assert len(proxy1_bans) == 2
|
||||||
|
assert all(b.get("cleared_reason") == "exit ip rotated (status=200)" for b in proxy1_bans)
|
||||||
|
other_ban = next(b for b in db.source_bans if b["proxy_id"] == 2)
|
||||||
|
assert other_ban.get("cleared_reason") is None
|
||||||
|
|
||||||
|
|
||||||
async def test_failed_rotation_keeps_source_bans(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_failed_rotation_keeps_source_bans(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|
@ -471,10 +487,13 @@ async def test_token_never_appears_in_reason_success(monkeypatch: pytest.MonkeyP
|
||||||
fake_client, _ = _fake_async_client(response=(200, {"ip": "1.1.1.1"}), exception=None)
|
fake_client, _ = _fake_async_client(response=(200, {"ip": "1.1.1.1"}), exception=None)
|
||||||
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||||
|
|
||||||
db = FakeSession(_proxy_row())
|
db = FakeSession(_proxy_row(), source_bans=[{"proxy_id": 1, "source": "avito"}])
|
||||||
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
assert SECRET_TOKEN not in (result.reason or "")
|
assert SECRET_TOKEN not in (result.reason or "")
|
||||||
assert SECRET_TOKEN not in (result.new_ip or "")
|
assert SECRET_TOKEN not in (result.new_ip or "")
|
||||||
|
# #3404: успешная ротация гасит бан и пишет cleared_reason — секрет не должен
|
||||||
|
# попасть и туда (та же гигиена, что для reason/note/логов).
|
||||||
|
assert SECRET_TOKEN not in (db.source_bans[0].get("cleared_reason") or "")
|
||||||
|
|
||||||
|
|
||||||
async def test_token_never_appears_in_reason_on_401(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_token_never_appears_in_reason_on_401(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|
@ -574,7 +593,10 @@ async def test_token_never_appears_in_log_messages_or_sentry_text(
|
||||||
else:
|
else:
|
||||||
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
|
||||||
|
|
||||||
db = FakeSession(_proxy_row(rotate_url=rotate_url))
|
db = FakeSession(
|
||||||
|
_proxy_row(rotate_url=rotate_url),
|
||||||
|
source_bans=[{"proxy_id": 1, "source": "avito"}],
|
||||||
|
)
|
||||||
with caplog.at_level(logging.DEBUG):
|
with caplog.at_level(logging.DEBUG):
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
@ -583,6 +605,12 @@ async def test_token_never_appears_in_log_messages_or_sentry_text(
|
||||||
assert SECRET_TOKEN not in record.getMessage(), (
|
assert SECRET_TOKEN not in record.getMessage(), (
|
||||||
f"scenario={name}: token leaked into log message args"
|
f"scenario={name}: token leaked into log message args"
|
||||||
)
|
)
|
||||||
|
if name == "success":
|
||||||
|
# #3404: успешная ротация гасит бан и пишет cleared_reason — секрет не
|
||||||
|
# должен попасть и туда.
|
||||||
|
assert SECRET_TOKEN not in (db.source_bans[0].get("cleared_reason") or ""), (
|
||||||
|
f"scenario={name}: token leaked into cleared_reason"
|
||||||
|
)
|
||||||
|
|
||||||
assert sentry_texts, "expected at least one Sentry capture (401 scenario)"
|
assert sentry_texts, "expected at least one Sentry capture (401 scenario)"
|
||||||
assert all(SECRET_TOKEN not in text for text in sentry_texts)
|
assert all(SECRET_TOKEN not in text for text in sentry_texts)
|
||||||
|
|
@ -646,7 +674,9 @@ async def test_mobileproxy_success_clears_source_bans(monkeypatch: pytest.Monkey
|
||||||
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
assert result.ok is True
|
assert result.ok is True
|
||||||
assert db.source_bans == []
|
# #3404: гашение, не удаление — строка остаётся, cleared_reason помечает снятие.
|
||||||
|
assert len(db.source_bans) == 1
|
||||||
|
assert db.source_bans[0]["cleared_reason"] == "exit ip rotated (mobileproxy status=200)"
|
||||||
|
|
||||||
|
|
||||||
async def test_mobileproxy_non_ok_status_is_failure_and_consumes_quota(
|
async def test_mobileproxy_non_ok_status_is_failure_and_consumes_quota(
|
||||||
|
|
@ -737,7 +767,10 @@ async def test_mobileproxy_key_never_leaks_in_reason_or_db_or_logs(
|
||||||
fake_client, _ = _fake_async_client_get(response=response, exception=exception)
|
fake_client, _ = _fake_async_client_get(response=response, exception=exception)
|
||||||
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||||
|
|
||||||
db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL))
|
db = FakeSession(
|
||||||
|
_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL),
|
||||||
|
source_bans=[{"proxy_id": 1, "source": "avito"}],
|
||||||
|
)
|
||||||
with caplog.at_level(logging.DEBUG):
|
with caplog.at_level(logging.DEBUG):
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
@ -749,6 +782,10 @@ async def test_mobileproxy_key_never_leaks_in_reason_or_db_or_logs(
|
||||||
assert MOBILEPROXY_KEY not in record.getMessage(), (
|
assert MOBILEPROXY_KEY not in record.getMessage(), (
|
||||||
f"scenario={name}: proxy_key leaked into log message args"
|
f"scenario={name}: proxy_key leaked into log message args"
|
||||||
)
|
)
|
||||||
|
if name == "success":
|
||||||
|
# #3404: успешная ротация гасит бан и пишет cleared_reason — ключ не
|
||||||
|
# должен попасть и туда.
|
||||||
|
assert MOBILEPROXY_KEY not in (db.source_bans[0].get("cleared_reason") or ""), name
|
||||||
|
|
||||||
|
|
||||||
async def test_mobileproxy_unknown_query_shape_still_masks_url_in_refusal(
|
async def test_mobileproxy_unknown_query_shape_still_masks_url_in_refusal(
|
||||||
|
|
|
||||||
|
|
@ -806,7 +806,7 @@ async def test_admin_geocode_missing_passes_city_hint(target: str) -> None:
|
||||||
target=target, # type: ignore[arg-type]
|
target=target, # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил")
|
mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил", region_code=66)
|
||||||
assert result["geocoded"] == 1
|
assert result["geocoded"] == 1
|
||||||
assert result["skipped"] == 0
|
assert result["skipped"] == 0
|
||||||
# SELECT адресован именно запрошенной таблице (обе несут колонку city).
|
# SELECT адресован именно запрошенной таблице (обе несут колонку city).
|
||||||
|
|
@ -854,7 +854,7 @@ async def test_admin_geocode_missing_drops_junk_city_hint(target: str) -> None:
|
||||||
target=target, # type: ignore[arg-type]
|
target=target, # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None)
|
mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None, region_code=66)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,14 @@ def _mock_db(
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
||||||
def _make_resp(status: int = 200, text: str = "<html>ok</html>") -> MagicMock:
|
# HTTP 200 обязан выглядеть ПОЛНОЙ карточкой (#3191): недогруженная страница (без
|
||||||
|
# блока контактов / меньше settings.yandex_detail_min_html_bytes) теперь отсеивается
|
||||||
|
# ДО parse и считается отказом, поэтому заглушка «<html>ok</html>» больше не годится
|
||||||
|
# для кейсов, где ожидается обогащение.
|
||||||
|
_FULL_PAGE_HTML = "<html>" + "x" * 1_100_000 + '"encryptedPhones":["a"]</html>'
|
||||||
|
|
||||||
|
|
||||||
|
def _make_resp(status: int = 200, text: str = _FULL_PAGE_HTML) -> MagicMock:
|
||||||
"""Fake curl_cffi response."""
|
"""Fake curl_cffi response."""
|
||||||
resp = MagicMock()
|
resp = MagicMock()
|
||||||
resp.status_code = status
|
resp.status_code = status
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,26 @@ PROD_STALE = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_row(row: Any) -> Any:
|
||||||
|
"""Та же правда в кодировке #3172: строка запроса — ПРОГОН, а не готовая свёртка.
|
||||||
|
|
||||||
|
Свежесть теперь меряется последним прогоном, ПРИНЁСШИМ ДАННЫЕ (`freshness_rows`),
|
||||||
|
поэтому запрос отдаёт сырые поля прогона; порог, который проверяет этот файл, от
|
||||||
|
правки не зависит — он считает те же `since`/`never_ok`, просто свёрнутые в Python.
|
||||||
|
"""
|
||||||
|
return SimpleNamespace(
|
||||||
|
source=row.source,
|
||||||
|
interval_days=row.interval_days,
|
||||||
|
created_at=NOW - timedelta(days=400),
|
||||||
|
finished_at=row.since,
|
||||||
|
status="done",
|
||||||
|
counters={"lots_fetched": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PROD_RUN_ROWS = [_run_row(r) for r in PROD_ROWS]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_digest_clock() -> Any:
|
def _reset_digest_clock() -> Any:
|
||||||
"""Выпуск сводки помнится в памяти модуля — сбрасываем между тестами."""
|
"""Выпуск сводки помнится в памяти модуля — сбрасываем между тестами."""
|
||||||
|
|
@ -131,7 +151,7 @@ def test_never_successful_source_is_reported_with_a_flag() -> None:
|
||||||
def test_digest_emits_one_event_listing_all_stale_sources() -> None:
|
def test_digest_emits_one_event_listing_all_stale_sources() -> None:
|
||||||
sentry = MagicMock()
|
sentry = MagicMock()
|
||||||
with patch.object(sched, "sentry_sdk", sentry):
|
with patch.object(sched, "sentry_sdk", sentry):
|
||||||
stale = sched.emit_stale_digest(_db(PROD_ROWS), now=NOW)
|
stale = sched.emit_stale_digest(_db(PROD_RUN_ROWS), now=NOW)
|
||||||
assert [s.source for s in stale] == PROD_STALE
|
assert [s.source for s in stale] == PROD_STALE
|
||||||
sentry.capture_message.assert_called_once()
|
sentry.capture_message.assert_called_once()
|
||||||
msg = sentry.capture_message.call_args[0][0]
|
msg = sentry.capture_message.call_args[0][0]
|
||||||
|
|
@ -145,7 +165,7 @@ def test_digest_covers_the_two_sources_the_ladder_cannot_reach() -> None:
|
||||||
"""Главное свойство: стрик 0 не мешает сводке — она меряет календарь, а не серию."""
|
"""Главное свойство: стрик 0 не мешает сводке — она меряет календарь, а не серию."""
|
||||||
sentry = MagicMock()
|
sentry = MagicMock()
|
||||||
with patch.object(sched, "sentry_sdk", sentry):
|
with patch.object(sched, "sentry_sdk", sentry):
|
||||||
stale = sched.emit_stale_digest(_db(PROD_ROWS), now=NOW)
|
stale = sched.emit_stale_digest(_db(PROD_RUN_ROWS), now=NOW)
|
||||||
zero_streak = {"avito_full_load_exhaustive", "cian_history_backfill"}
|
zero_streak = {"avito_full_load_exhaustive", "cian_history_backfill"}
|
||||||
assert zero_streak <= {s.source for s in stale}
|
assert zero_streak <= {s.source for s in stale}
|
||||||
|
|
||||||
|
|
@ -154,14 +174,14 @@ def test_digest_is_quiet_when_everything_is_fresh() -> None:
|
||||||
sentry = MagicMock()
|
sentry = MagicMock()
|
||||||
fresh = [_row("avito_city_sweep", None, 1.1), _row("sber_index_pull", 7, 4.1)]
|
fresh = [_row("avito_city_sweep", None, 1.1), _row("sber_index_pull", 7, 4.1)]
|
||||||
with patch.object(sched, "sentry_sdk", sentry):
|
with patch.object(sched, "sentry_sdk", sentry):
|
||||||
assert sched.emit_stale_digest(_db(fresh), now=NOW) == []
|
assert sched.emit_stale_digest(_db([_run_row(r) for r in fresh]), now=NOW) == []
|
||||||
sentry.capture_message.assert_not_called()
|
sentry.capture_message.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_digest_is_daily_not_per_tick() -> None:
|
def test_digest_is_daily_not_per_tick() -> None:
|
||||||
"""Планировщик тикает раз в минуту; сводка обязана выходить раз в сутки."""
|
"""Планировщик тикает раз в минуту; сводка обязана выходить раз в сутки."""
|
||||||
sentry = MagicMock()
|
sentry = MagicMock()
|
||||||
db = _db(PROD_ROWS)
|
db = _db(PROD_RUN_ROWS)
|
||||||
with patch.object(sched, "sentry_sdk", sentry):
|
with patch.object(sched, "sentry_sdk", sentry):
|
||||||
sched.emit_stale_digest(db, now=NOW)
|
sched.emit_stale_digest(db, now=NOW)
|
||||||
sched.emit_stale_digest(db, now=NOW + timedelta(minutes=1))
|
sched.emit_stale_digest(db, now=NOW + timedelta(minutes=1))
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None:
|
||||||
"""
|
"""
|
||||||
est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair")
|
est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair")
|
||||||
assert est.median_price_rub == int(_BASE_MEDIAN * 0.94)
|
assert est.median_price_rub == int(_BASE_MEDIAN * 0.94)
|
||||||
assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "")
|
assert "скорректирована по оценочной модели площадки" not in (est.confidence_explanation or "")
|
||||||
|
|
||||||
|
|
||||||
def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None:
|
def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None:
|
||||||
|
|
|
||||||
|
|
@ -302,7 +302,14 @@ async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) ->
|
||||||
avito = db._ban(1, "avito")
|
avito = db._ban(1, "avito")
|
||||||
assert avito is not None, "чужой бан проба снимать не имеет права"
|
assert avito is not None, "чужой бан проба снимать не имеет права"
|
||||||
assert (avito["reason"], avito["ban_count"]) == ("banned:avito", 1), "и не переписывать"
|
assert (avito["reason"], avito["ban_count"]) == ("banned:avito", 1), "и не переписывать"
|
||||||
assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять"
|
cian = db._ban(1, "cian")
|
||||||
|
# #3404: снятие теперь ГАСИТ строку, а не удаляет — вердикт пробы перестаёт
|
||||||
|
# блокировать выдачу (banned_until в прошлом, ban_count обнулён), но остаётся
|
||||||
|
# виден в истории как снятый досрочно.
|
||||||
|
assert cian is not None, "погашенная строка живёт до purge (#3404)"
|
||||||
|
assert cian["ban_count"] == 0, "свой вердикт проба обязана снять"
|
||||||
|
assert cian["banned_until"] <= datetime.now(UTC), "и снять немедленно"
|
||||||
|
assert cian["cleared_at"] is not None, "снятие должно быть отмечено"
|
||||||
assert counters["pair_cleared"] == 1
|
assert counters["pair_cleared"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,12 @@
|
||||||
kwarg'а в вызове. Красные на старом коде:
|
kwarg'а в вызове. Красные на старом коде:
|
||||||
|
|
||||||
* (1) `fetch_detail` вызывался без `proxy_provider` → lease не брался, 403 никому не
|
* (1) `fetch_detail` вызывался без `proxy_provider` → lease не брался, 403 никому не
|
||||||
сообщался: `mark_banned_calls == []`. Плюс ловушка «правка без эффекта»:
|
сообщался: `mark_banned_calls == []`. Ловушка «правка без эффекта» была в том, что
|
||||||
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper`, а ручка живёт в `backend`,
|
`USE_PROXY_POOL_CURL` задавался только контейнеру `scraper`, а ручка живёт в
|
||||||
где флага нет — один `proxy_provider=` пул бы не включил (см. `_PoolCurlConfig`).
|
`backend`, где флага не было — один `proxy_provider=` пул бы не включил. Отсюда взялся
|
||||||
|
подкласс конфига с зашитым `use_proxy_pool_curl = True`; #3387 задал переменную и
|
||||||
|
сервису `backend`, костыль снят (#3386 хвост), флаг снова управляет путём в обе
|
||||||
|
стороны — см. `test_price_history_honours_flag_off`.
|
||||||
* (2) `_provider_proxy_url(source)` возвращал `settings.scraper_proxy_url` и не имел
|
* (2) `_provider_proxy_url(source)` возвращал `settings.scraper_proxy_url` и не имел
|
||||||
параметра `db` — вызов из теста падал бы на сигнатуре, а исход «пул исчерпан»
|
параметра `db` — вызов из теста падал бы на сигнатуре, а исход «пул исчерпан»
|
||||||
выражения не имел вообще.
|
выражения не имел вообще.
|
||||||
|
|
@ -123,7 +126,10 @@ def _price_history_db(n_listings: int) -> MagicMock:
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
||||||
async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) -> Any:
|
async def _run_price_history(
|
||||||
|
pool: _SpyPool, *, status_code: int, n: int = 1, use_pool: bool = True
|
||||||
|
) -> Any:
|
||||||
|
from app.core.config import settings
|
||||||
from app.services import scraper_adapters
|
from app.services import scraper_adapters
|
||||||
from app.services.cian_price_history import backfill_cian_price_history
|
from app.services.cian_price_history import backfill_cian_price_history
|
||||||
|
|
||||||
|
|
@ -132,6 +138,7 @@ async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) ->
|
||||||
cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code)
|
cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code)
|
||||||
),
|
),
|
||||||
patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0),
|
patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0),
|
||||||
|
patch.object(settings, "use_proxy_pool_curl", use_pool),
|
||||||
patch.object(scraper_adapters, "_proxy_pool", pool),
|
patch.object(scraper_adapters, "_proxy_pool", pool),
|
||||||
patch.object(scraper_adapters, "_SessionLocal", MagicMock()),
|
patch.object(scraper_adapters, "_SessionLocal", MagicMock()),
|
||||||
):
|
):
|
||||||
|
|
@ -139,23 +146,33 @@ async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) ->
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_price_history_takes_pool_node_despite_flag_off() -> None:
|
async def test_price_history_takes_pool_node() -> None:
|
||||||
"""Узел берётся из пула даже при выключенном USE_PROXY_POOL_CURL (контейнер backend).
|
"""Узел берётся из пула, вердикт возвращается, lease не течёт.
|
||||||
|
|
||||||
Красный на старом коде дважды: не было ни `proxy_provider=`, ни принудительного
|
Красный на коде до #2830: не было `proxy_provider=` — `curl_proxy_url` уходил на
|
||||||
pool-режима — `curl_proxy_url` уходил на статичный env-узел и `acquire` не звал.
|
статичный env-узел и `acquire` не звал.
|
||||||
"""
|
"""
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
assert settings.use_proxy_pool_curl is False, (
|
|
||||||
"тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL"
|
|
||||||
)
|
|
||||||
pool = _SpyPool()
|
pool = _SpyPool()
|
||||||
await _run_price_history(pool, status_code=200)
|
await _run_price_history(pool, status_code=200)
|
||||||
assert pool.acquire_calls == ["cian"]
|
assert pool.acquire_calls == ["cian"]
|
||||||
assert pool.release_calls == [9] # lease не течёт
|
assert pool.release_calls == [9] # lease не течёт
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_history_honours_flag_off() -> None:
|
||||||
|
"""USE_PROXY_POOL_CURL=false → честный env-путь, а не пул через зашитую константу.
|
||||||
|
|
||||||
|
#3386 (хвост): пока в сервисе жил подкласс `RealScraperConfig` с
|
||||||
|
`use_proxy_pool_curl = True`, рубильник на этом пути был неотключаем — тест красный
|
||||||
|
на main (`acquire_calls == ["cian"]`). После #3387 переменная задана контейнеру
|
||||||
|
`backend` в compose, костыль лишний, и флаг снова управляет обеими сторонами.
|
||||||
|
"""
|
||||||
|
pool = _SpyPool()
|
||||||
|
result = await _run_price_history(pool, status_code=200, use_pool=False)
|
||||||
|
assert pool.acquire_calls == [], "при выключенном флаге пул не трогаем"
|
||||||
|
assert result.checked == 1, "запрос всё равно идёт — просто env-прокси, как раньше"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_price_history_403_bans_the_node_for_cian() -> None:
|
async def test_price_history_403_bans_the_node_for_cian() -> None:
|
||||||
"""403 от Циана снимает узел с выдачи ИМЕННО Циану. Красный: было `mark_banned` = []."""
|
"""403 от Циана снимает узел с выдачи ИМЕННО Циану. Красный: было `mark_banned` = []."""
|
||||||
|
|
|
||||||
|
|
@ -27,18 +27,21 @@ 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 app.services.product_handlers import _job_yandex_newbuilding_sweep
|
from app.services.product_handlers import _job_yandex_newbuilding_sweep
|
||||||
|
from app.tasks.yandex_newbuilding_sweep import YandexNewbuildingSweepResult
|
||||||
|
|
||||||
|
|
||||||
def _ctx() -> SimpleNamespace:
|
def _ctx() -> SimpleNamespace:
|
||||||
return SimpleNamespace(runs=MagicMock())
|
return SimpleNamespace(runs=MagicMock())
|
||||||
|
|
||||||
|
|
||||||
def _result(processed: int, succeeded: int) -> MagicMock:
|
def _result(processed: int, succeeded: int) -> YandexNewbuildingSweepResult:
|
||||||
r = MagicMock()
|
"""Настоящий результат задачи, а не MagicMock.
|
||||||
r.to_dict = MagicMock(
|
|
||||||
return_value={"processed": processed, "succeeded": succeeded, "total": 425}
|
У MagicMock любой не заданный атрибут (`no_proxy_stop` из #3197) — truthy, то есть
|
||||||
)
|
добавление хендлеру новой ветки по полю результата уводило бы сюда ВСЕ три случая,
|
||||||
return r
|
и тест краснел бы по причине, не имеющей отношения к #2860.
|
||||||
|
"""
|
||||||
|
return YandexNewbuildingSweepResult(processed=processed, succeeded=succeeded, total=425)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""#3051 PR-A — региональный скоуп ДКП-коридора (`_fetch_dkp_corridor` / потребители).
|
||||||
|
|
||||||
|
region_code — ОБЯЗАТЕЛЬНЫЙ фильтр (не advisory, в отличие от city) в обоих ДКП-запросах
|
||||||
|
(street и city-wide widen): без него street ILIKE ловил бы одноимённую улицу ЧУЖОГО
|
||||||
|
региона. Мок-паттерн повторяет tests/test_dkp_corridor_as_of_2846.py — db.execute
|
||||||
|
диспатчится по подстрокам SQL-текста, без живого Postgres.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.services.estimator import _fetch_dkp_corridor
|
||||||
|
|
||||||
|
_ADDRESS = "Москва, ул. Тверская, 1"
|
||||||
|
|
||||||
|
|
||||||
|
def _deal(ppm2: int | None, deal_date: date | None) -> dict[str, Any]:
|
||||||
|
return {"price_per_m2": ppm2, "deal_date": deal_date}
|
||||||
|
|
||||||
|
|
||||||
|
def _db_capturing(
|
||||||
|
street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None
|
||||||
|
) -> tuple[Any, list[tuple[str, dict[str, Any]]]]:
|
||||||
|
"""Session-двойник, попутно копящий (sql, params) КАЖДОГО execute на deal_city_price_bands."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||||||
|
sql = str(query)
|
||||||
|
result = MagicMock()
|
||||||
|
if "sber" in sql.lower():
|
||||||
|
result.mappings.return_value.all.return_value = []
|
||||||
|
elif "deal_city_price_bands" in sql and "d.address ILIKE" in sql:
|
||||||
|
calls.append((sql, params or {}))
|
||||||
|
result.mappings.return_value.all.return_value = street_rows
|
||||||
|
elif "deal_city_price_bands" in sql:
|
||||||
|
calls.append((sql, params or {}))
|
||||||
|
result.mappings.return_value.all.return_value = city_rows or []
|
||||||
|
else:
|
||||||
|
result.mappings.return_value.all.return_value = []
|
||||||
|
return result
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.side_effect = _execute
|
||||||
|
return db, calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_corridor_binds_region_in_both_queries() -> None:
|
||||||
|
"""street n=1 (< DKP_CORRIDOR_CITY_WIDE_MIN_N=3) → срабатывает city-wide widen —
|
||||||
|
|
||||||
|
ОБА execute на deal_city_price_bands несут `d.region_code = CAST(:region_code AS int)`
|
||||||
|
и bind region_code=77 при явном kwarg; без kwarg — дефолт 66 (обратная совместимость,
|
||||||
|
сегодня все сделки в БД region_code=66).
|
||||||
|
"""
|
||||||
|
street_rows = [_deal(300_000, date(2026, 1, 1))]
|
||||||
|
city_rows = [_deal(290_000, date(2025, 10, 1)), _deal(295_000, date(2025, 11, 1))]
|
||||||
|
|
||||||
|
db77, calls77 = _db_capturing(street_rows, city_rows)
|
||||||
|
raw = _fetch_dkp_corridor(
|
||||||
|
db77, address=_ADDRESS, rooms=2, area=50.0, city="нижний тагил", region_code=77
|
||||||
|
)
|
||||||
|
assert raw is not None
|
||||||
|
assert raw["count"] == 2, "widen должен был сработать (street n=1 < 3)"
|
||||||
|
assert len(calls77) == 2, "ожидались обе ветки: street + city-wide widen"
|
||||||
|
for sql, params in calls77:
|
||||||
|
assert "d.region_code = CAST(:region_code AS int)" in sql
|
||||||
|
assert params["region_code"] == 77
|
||||||
|
|
||||||
|
db66, calls66 = _db_capturing(street_rows, city_rows)
|
||||||
|
_fetch_dkp_corridor(db66, address=_ADDRESS, rooms=2, area=50.0, city="нижний тагил")
|
||||||
|
assert len(calls66) == 2
|
||||||
|
for _sql, params in calls66:
|
||||||
|
assert params["region_code"] == 66
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_paths_never_pass_none_region() -> None:
|
||||||
|
"""POST /estimate (estimate_quality) и GET rehydrate (load_estimate) обязаны
|
||||||
|
|
||||||
|
резолвить region_code через regions_mod.DEFAULT_REGION_CODE fallback, а не оставить
|
||||||
|
None: NULL в bind-параметре `= NULL` не матчит ничего и молча обнулил бы фильтр
|
||||||
|
региона. Дешёвая source-проверка вместо тяжёлых end-to-end моков обоих путей.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from app.api.v1.trade_in import load_estimate
|
||||||
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
|
load_src = inspect.getsource(load_estimate)
|
||||||
|
estimate_src = inspect.getsource(estimate_quality)
|
||||||
|
|
||||||
|
for src in (load_src, estimate_src):
|
||||||
|
assert "regions_mod.DEFAULT_REGION_CODE" in src
|
||||||
|
assert "region_code=target_region_code" in src
|
||||||
93
tradein-mvp/backend/tests/test_3051_dkp_import_moscow.py
Normal file
93
tradein-mvp/backend/tests/test_3051_dkp_import_moscow.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""#3051 п.3 — параметризация import_rosreestr_dkp по региону (Москва, region_code=77).
|
||||||
|
|
||||||
|
Чисто-юнит: ассертим SQL-текст, эмитируемый import_rosreestr_dkp (через
|
||||||
|
inspect.getsource), тем же механизмом, что и test_rosreestr_dedup_key.py — без живого
|
||||||
|
FDW / DB-фикстуры. Многострочные клозы матчатся regex с `\\s+`, а не подстрокой: SQL
|
||||||
|
внутри text(...) переносится по строкам, точный пробельный рисунок хрупок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# Импорт app.services.scheduler тянет app.core.config.Settings → требует DATABASE_URL.
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import scheduler
|
||||||
|
|
||||||
|
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||||||
|
_MIGRATION_289 = _SQL_DIR / "289_rosreestr_fdw_msk_columns_seed77.sql"
|
||||||
|
|
||||||
|
_IMPORT_SRC = inspect.getsource(scheduler.import_rosreestr_dkp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_on_canonical_city_bind() -> None:
|
||||||
|
"""canonical_city — bind-параметр CASE-выражения (address/city/raw_payload),
|
||||||
|
|
||||||
|
не Python if/else на конкретный код региона (#3051: одна ветка SQL решает и
|
||||||
|
для 66 (None), и для 77 ('Москва'), и для любого будущего региона).
|
||||||
|
"""
|
||||||
|
assert _IMPORT_SRC.count("CAST(:canonical_city AS text) IS NOT NULL") >= 3
|
||||||
|
assert '"canonical_city": region.canonical_city' in _IMPORT_SRC
|
||||||
|
|
||||||
|
|
||||||
|
def test_city_filter_bypassed_only_with_canonical_city() -> None:
|
||||||
|
"""WHERE-клоз: у региона С canonical_city city IS NOT NULL НЕ применяется —
|
||||||
|
|
||||||
|
иначе для Москвы теряется ~10% строк с пустым city источника (округ/поселение
|
||||||
|
не всегда заполнен).
|
||||||
|
"""
|
||||||
|
pattern = re.compile(
|
||||||
|
r"\(\s*CAST\(:canonical_city AS text\) IS NOT NULL\s*"
|
||||||
|
r"OR \(city IS NOT NULL AND trim\(city\) <> ''\)\s*\)"
|
||||||
|
)
|
||||||
|
assert pattern.search(_IMPORT_SRC), "не найден WHERE-клоз city IS NOT NULL bypass"
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_payload_jsonb_bind() -> None:
|
||||||
|
"""raw_payload (jsonb) — исходные city/okato/quarter_cad_number/district для
|
||||||
|
|
||||||
|
регионов с canonical_city; bind через CAST(... AS jsonb), не литерал.
|
||||||
|
"""
|
||||||
|
assert "CAST(:raw_payload AS jsonb)" in _IMPORT_SRC
|
||||||
|
assert "raw_payload = EXCLUDED.raw_payload" in _IMPORT_SRC
|
||||||
|
assert "IS DISTINCT FROM EXCLUDED.raw_payload" in _IMPORT_SRC
|
||||||
|
assert "json.dumps" in _IMPORT_SRC
|
||||||
|
|
||||||
|
|
||||||
|
def test_doc_types_param_default_dkp() -> None:
|
||||||
|
"""doc_types — параметр (было: литерал 'ДКП'), дефолт ['ДКП'] сохраняет прежнее
|
||||||
|
|
||||||
|
поведение (только вторичка).
|
||||||
|
"""
|
||||||
|
assert "ANY(CAST(:doc_types AS text[]))" in _IMPORT_SRC
|
||||||
|
assert 'params.get("doc_types") or ["ДКП"]' in _IMPORT_SRC
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_region_raises() -> None:
|
||||||
|
"""region_code без записи в REGIONS → ValueError ДО любого db.execute — прогон
|
||||||
|
|
||||||
|
не должен успеть тронуть БД с неизвестным региональным контекстом.
|
||||||
|
"""
|
||||||
|
db = MagicMock()
|
||||||
|
with pytest.raises(ValueError, match="99"):
|
||||||
|
scheduler.import_rosreestr_dkp(db, run_id=1, params={"region_code": 99})
|
||||||
|
db.execute.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dkp_source_for_region() -> None:
|
||||||
|
"""66 → байт-в-байт прежнее имя _DKP_SOURCE; 77 → суффиксное имя, совпадающее
|
||||||
|
|
||||||
|
с source seed-строки миграции 289 (изоляция чекпоинтов между регионами).
|
||||||
|
"""
|
||||||
|
assert scheduler._dkp_source_for_region(66) == scheduler._DKP_SOURCE
|
||||||
|
assert scheduler._dkp_source_for_region(77) == "rosreestr_dkp_import_77"
|
||||||
|
|
||||||
|
sql = _MIGRATION_289.read_text("utf-8")
|
||||||
|
assert "'rosreestr_dkp_import_77'" in sql, "seed-source миграции 289 разъехался с кодом"
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""#3051 п.3 — миграция 289 (FDW-колонки + disabled seed 77) и bash-путь import-rosreestr.sh.
|
||||||
|
|
||||||
|
(a) Статические ассерты содержимого миграции 289 — seed выключен, region_code=77 в
|
||||||
|
default_params, идемпотентность (ON CONFLICT DO NOTHING + 3× ADD COLUMN IF NOT EXISTS).
|
||||||
|
|
||||||
|
(b) Bash-скрипт — НЕ region-generic Python-путь (см. докстринг скрипта): валидация
|
||||||
|
REGION_CODE/DOC_TYPE обязана отбить инъекцию ДО первого docker-вызова. Проверяем через
|
||||||
|
subprocess с урезанным PATH (без `docker`) — падение должно случиться на валидации
|
||||||
|
(`[[ ... =~ ... ]]`/bash builtins), а не на "docker: command not found", иначе тест
|
||||||
|
ничего не защищает.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
_TRADEIN_DIR = _BACKEND_DIR.parent
|
||||||
|
_MIGRATION_289 = _BACKEND_DIR / "data" / "sql" / "289_rosreestr_fdw_msk_columns_seed77.sql"
|
||||||
|
_DEPLOY_SCRIPT = _TRADEIN_DIR / "deploy" / "import-rosreestr.sh"
|
||||||
|
|
||||||
|
|
||||||
|
# ── (a) миграция 289 — статика ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_289_seed_disabled_with_region_code_77() -> None:
|
||||||
|
sql = _MIGRATION_289.read_text("utf-8")
|
||||||
|
assert "'rosreestr_dkp_import_77',\n false," in sql, (
|
||||||
|
"seed-строка обязана быть enabled=false (включение — отдельное решение)"
|
||||||
|
)
|
||||||
|
assert '"region_code": 77' in sql
|
||||||
|
assert "ON CONFLICT (source) DO NOTHING" in sql
|
||||||
|
# Реальные DDL-строки (отступ 4 пробела) — не считая упоминание "× 3" в шапке-комментарии.
|
||||||
|
assert sql.count("\n ADD COLUMN IF NOT EXISTS") == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ── (b) деплой-скрипт — валидация раньше docker ──────────────────────────────────
|
||||||
|
|
||||||
|
_BASH = shutil.which("bash")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(_BASH is None, reason="bash недоступен в PATH (нет git-bash/WSL)")
|
||||||
|
def test_invalid_region_code_rejected_before_any_docker_call(tmp_path: Path) -> None:
|
||||||
|
"""REGION_CODE с инъекцией → rc=1, сообщение валидации, PATH без docker — до
|
||||||
|
|
||||||
|
первой docker-команды (line 50) скрипт не доходит вовсе.
|
||||||
|
"""
|
||||||
|
empty_bin = tmp_path / "empty_bin"
|
||||||
|
empty_bin.mkdir()
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["PATH"] = str(empty_bin)
|
||||||
|
env["REGION_CODE"] = "77;drop table x"
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[_BASH, str(_DEPLOY_SCRIPT)],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "REGION_CODE" in result.stderr
|
||||||
|
assert result.stdout == "", "stdout пуст — значит ни одна docker-команда не выполнилась"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(_BASH is None, reason="bash недоступен в PATH (нет git-bash/WSL)")
|
||||||
|
def test_invalid_doc_type_rejected_before_any_docker_call(tmp_path: Path) -> None:
|
||||||
|
"""DOC_TYPE с кавычкой → rc=1, сообщение валидации, до docker не доходит."""
|
||||||
|
empty_bin = tmp_path / "empty_bin"
|
||||||
|
empty_bin.mkdir()
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["PATH"] = str(empty_bin)
|
||||||
|
env["DOC_TYPE"] = "x'y"
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[_BASH, str(_DEPLOY_SCRIPT)],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "DOC_TYPE" in result.stderr
|
||||||
|
assert result.stdout == ""
|
||||||
|
|
@ -127,6 +127,24 @@ def test_region_by_city_folds_and_resolves() -> None:
|
||||||
assert regions.region_by_city(None) is None
|
assert regions.region_by_city(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_city_and_region_for_point() -> None:
|
||||||
|
"""canonical_city: 66 None (byte-for-byte прежнее) / 77 'Москва' (Росреестр отдаёт
|
||||||
|
|
||||||
|
округ/поселение, не город). region_for_point резолвит регион по bbox_region —
|
||||||
|
точка в Москве → 77, в ЕКБ → 66, вне обоих (Пермь) → None.
|
||||||
|
"""
|
||||||
|
assert regions.REGIONS[66].canonical_city is None
|
||||||
|
assert regions.REGIONS[77].canonical_city == "Москва"
|
||||||
|
|
||||||
|
moscow = regions.region_for_point(55.75, 37.62)
|
||||||
|
assert moscow is not None and moscow.code == 77
|
||||||
|
|
||||||
|
ekb = regions.region_for_point(56.8300, 60.6000)
|
||||||
|
assert ekb is not None and ekb.code == 66
|
||||||
|
|
||||||
|
assert regions.region_for_point(58.01, 56.25) is None # Пермь — вне охвата обоих
|
||||||
|
|
||||||
|
|
||||||
def test_unsupported_tier_degrades_loudly_not_silently() -> None:
|
def test_unsupported_tier_degrades_loudly_not_silently() -> None:
|
||||||
"""У Москвы нет ни одного тира обогащения — и это ЯВНЫЙ факт с готовой
|
"""У Москвы нет ни одного тира обогащения — и это ЯВНЫЙ факт с готовой
|
||||||
формулировкой, а не молчаливое «посчитаем без источника»."""
|
формулировкой, а не молчаливое «посчитаем без источника»."""
|
||||||
|
|
|
||||||
135
tradein-mvp/backend/tests/test_3154_query_secret_scrub.py
Normal file
135
tradein-mvp/backend/tests/test_3154_query_secret_scrub.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""Секрет из query-строки не попадает в лог процесса (#3154).
|
||||||
|
|
||||||
|
Прод-факт: uvicorn access-log печатал полный путь вместе с `?secret=<64 hex>`
|
||||||
|
(секрет вебхука GlitchTip = `TRADEIN_INTERNAL_AUTH_SECRET`), лог уезжал в Loki и
|
||||||
|
лежал там открытым. Скруббер Alloy (#3115) ловит только форму `user:pass@host`.
|
||||||
|
|
||||||
|
Проверка ПО ЗНАЧЕНИЮ: строка гоняется через настоящий handler с фильтром, в
|
||||||
|
выводе должно быть `secret=***` и НЕ должно быть самого секрета.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.log_scrub import QuerySecretFilter, install_query_secret_filter, scrub_query_secrets
|
||||||
|
|
||||||
|
_SECRET = "5fc281ce5d1bcf612417dffcae51d82fe5c3db2d7c4b4eac9a1b2c3d4e5f60718"
|
||||||
|
|
||||||
|
# Настоящая форма access-строки uvicorn (msg + args, путь лежит в args).
|
||||||
|
_ACCESS_MSG = '%s - "%s %s HTTP/%s" %d'
|
||||||
|
|
||||||
|
|
||||||
|
def _emit(logger_name: str, msg: str, *args: object) -> str:
|
||||||
|
"""Прогоняет запись через настоящий handler и возвращает текст строки."""
|
||||||
|
stream = io.StringIO()
|
||||||
|
handler = logging.StreamHandler(stream)
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
logger = logging.getLogger(logger_name)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
previous_propagate = logger.propagate
|
||||||
|
logger.propagate = False
|
||||||
|
try:
|
||||||
|
logger.info(msg, *args)
|
||||||
|
finally:
|
||||||
|
logger.propagate = previous_propagate
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
return stream.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_uvicorn_access_line_masks_secret_query_param() -> None:
|
||||||
|
install_query_secret_filter("uvicorn.access")
|
||||||
|
line = _emit(
|
||||||
|
"uvicorn.access",
|
||||||
|
_ACCESS_MSG,
|
||||||
|
"172.18.0.3:60322",
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/trade-in/ops/glitchtip-webhook?secret={_SECRET}",
|
||||||
|
"1.1",
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
assert "secret=***" in line, line
|
||||||
|
assert _SECRET not in line, line
|
||||||
|
# Остальная строка цела — иначе access-лог перестал бы годиться для разбора.
|
||||||
|
assert "POST /api/v1/trade-in/ops/glitchtip-webhook?secret=*** HTTP/1.1" in line
|
||||||
|
assert "172.18.0.3:60322" in line and "200" in line
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_logger_via_root_handler_masks_secret() -> None:
|
||||||
|
"""Фильтр стоит и на handler'ах корня — прикладные логгеры тоже закрыты."""
|
||||||
|
stream = io.StringIO()
|
||||||
|
handler = logging.StreamHandler(stream)
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
handler.addFilter(QuerySecretFilter())
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.addHandler(handler)
|
||||||
|
try:
|
||||||
|
logging.getLogger("app.some.module").warning(
|
||||||
|
"retry callback https://gendsgn.ru/hook?token=%s", _SECRET
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
root.removeHandler(handler)
|
||||||
|
out = stream.getvalue()
|
||||||
|
assert "token=***" in out, out
|
||||||
|
assert _SECRET not in out, out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw",
|
||||||
|
[
|
||||||
|
f"/hook?secret={_SECRET}",
|
||||||
|
f"/hook?SECRET={_SECRET}",
|
||||||
|
f"/hook?a=1&token={_SECRET}&b=2",
|
||||||
|
f"/hook?api_key={_SECRET}",
|
||||||
|
f"/hook?apiKey={_SECRET}",
|
||||||
|
f'"GET /hook?access_token={_SECRET} HTTP/1.1"',
|
||||||
|
# Имя с префиксом: чувствительное слово в КОНЦЕ имени параметра.
|
||||||
|
f"/hook?client_secret={_SECRET}",
|
||||||
|
f"/hook?webhook_secret={_SECRET}",
|
||||||
|
f"/hook?refresh_token={_SECRET}",
|
||||||
|
f"/hook?auth_token={_SECRET}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_sensitive_param_names_are_masked(raw: str) -> None:
|
||||||
|
scrubbed = scrub_query_secrets(raw)
|
||||||
|
assert _SECRET not in scrubbed, scrubbed
|
||||||
|
assert "***" in scrubbed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw",
|
||||||
|
[
|
||||||
|
"GET /api/v1/trade-in/offers?limit=50&city=Екатеринбург",
|
||||||
|
"https://metrics.gendsgn.ru/ingest/loki/api/v1/push",
|
||||||
|
# Имя НЕ оканчивается на чувствительное слово — маскировать нечего.
|
||||||
|
"/hook?secretary=anna",
|
||||||
|
"/hook?tokens_page=2",
|
||||||
|
# `token-info` в ПУТИ, а не в query: значения там нет вовсе.
|
||||||
|
"GET /api/v1/token-info?limit=5",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_innocent_lines_untouched(raw: str) -> None:
|
||||||
|
assert scrub_query_secrets(raw) == raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_installed_by_app_main() -> None:
|
||||||
|
"""Проводка: импорт приложения ставит фильтр на access-лог uvicorn."""
|
||||||
|
import app.main # noqa: F401 (импорт ради побочного эффекта установки фильтра)
|
||||||
|
|
||||||
|
filters = logging.getLogger("uvicorn.access").filters
|
||||||
|
assert any(isinstance(f, QuerySecretFilter) for f in filters), filters
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_is_idempotent() -> None:
|
||||||
|
install_query_secret_filter("uvicorn.access")
|
||||||
|
install_query_secret_filter("uvicorn.access")
|
||||||
|
filters = logging.getLogger("uvicorn.access").filters
|
||||||
|
assert sum(isinstance(f, QuerySecretFilter) for f in filters) == 1, filters
|
||||||
|
|
@ -23,10 +23,10 @@ import_rosreestr_dkp (source='rosreestr_dkp_import'), шестой backfill, н
|
||||||
штатным поведением).
|
штатным поведением).
|
||||||
- Потолок возраста чекпоинта — 24ч (_DKP_CHECKPOINT_STALE_HOURS): старше — курсор
|
- Потолок возраста чекпоинта — 24ч (_DKP_CHECKPOINT_STALE_HOURS): старше — курсор
|
||||||
считается негодным, прогон стартует с last_id=0, причина 'checkpoint_stale'.
|
считается негодным, прогон стартует с last_id=0, причина 'checkpoint_stale'.
|
||||||
- Вердикт пишется в scrape_runs.counters через kit_runs.update_heartbeat
|
- Вердикт пишется в scrape_runs.counters через update_heartbeat, а тот counters
|
||||||
(`counters || :counters` — merge), а не локальный runs_mod.update_heartbeat
|
МЕРЖИТ (`counters || :counters`) — иначе первый же per-batch heartbeat после старта
|
||||||
(`CAST(:counters AS jsonb)` — полная замена): иначе первый же per-batch heartbeat
|
стёр бы resume-вердикт. На момент #3168 мерж был только у kit-копии, поэтому запись
|
||||||
после старта стирает resume-вердикт.
|
шла именно kit-именем; с #3390 копия одна и семантика мержа — единственная.
|
||||||
|
|
||||||
Обратимость (см. PR summary): временный откат last_id на литерал 0 красит
|
Обратимость (см. PR summary): временный откат last_id на литерал 0 красит
|
||||||
test_resume_continues_from_saved_last_id (last_id == 123456 не совпадает с 0).
|
test_resume_continues_from_saved_last_id (last_id == 123456 не совпадает с 0).
|
||||||
|
|
@ -140,6 +140,52 @@ def test_no_previous_run_starts_from_zero() -> None:
|
||||||
assert verdict["resume_from"] is None
|
assert verdict["resume_from"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1b. Курсор — ПЕР РЕГИОН (#3051 п.3): source изолирует чекпоинты 66/77 ───────────
|
||||||
|
|
||||||
|
|
||||||
|
class _SourceCapturingDb:
|
||||||
|
"""Двойник: запоминает `source`-bind ПОСЛЕДНЕГО SELECT-запроса (_DKP_RESUME_CANDIDATE_SQL).
|
||||||
|
|
||||||
|
Отличает SELECT (несёт 'source' в params) от heartbeat-UPDATE (несёт 'counters')
|
||||||
|
тем же приёмом, что _FakeDb выше.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, row: Any) -> None:
|
||||||
|
self.row = row
|
||||||
|
self.select_params: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
if params is not None and "source" in params:
|
||||||
|
self.select_params = params
|
||||||
|
return MagicMock(fetchone=lambda: self.row)
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_uses_region_source() -> None:
|
||||||
|
"""source= (#3051 п.3) — курсор региона 77 изолирован от курсора региона 66:
|
||||||
|
|
||||||
|
явный source попадает bind-параметром в SELECT-кандидата; вызов без аргумента
|
||||||
|
сохраняет прежнее имя _DKP_SOURCE (регион 66, обратная совместимость). Сам
|
||||||
|
call site (import_rosreestr_dkp) обязан передавать именно региональный source,
|
||||||
|
не полагаться на дефолт молча.
|
||||||
|
"""
|
||||||
|
db_77 = _SourceCapturingDb(None)
|
||||||
|
sched._resume_dkp_cursor(db_77, run_id=1, source="rosreestr_dkp_import_77")
|
||||||
|
assert db_77.select_params is not None
|
||||||
|
assert db_77.select_params["source"] == "rosreestr_dkp_import_77"
|
||||||
|
|
||||||
|
db_default = _SourceCapturingDb(None)
|
||||||
|
sched._resume_dkp_cursor(db_default, run_id=2)
|
||||||
|
assert db_default.select_params is not None
|
||||||
|
assert db_default.select_params["source"] == sched._DKP_SOURCE
|
||||||
|
|
||||||
|
import_src = inspect.getsource(sched.import_rosreestr_dkp)
|
||||||
|
assert "source=dkp_source" in import_src, "call site не передаёт региональный source"
|
||||||
|
|
||||||
|
|
||||||
# ── 2. Курсор старше потолка не подхватывается — прогон стартует заново ─────────────
|
# ── 2. Курсор старше потолка не подхватывается — прогон стартует заново ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -163,20 +209,15 @@ def test_checkpoint_just_under_ceiling_is_still_accepted() -> None:
|
||||||
|
|
||||||
|
|
||||||
# ── 3. Запись курсора не затирает посторонние ключи в counters (мерж, не замена) ────
|
# ── 3. Запись курсора не затирает посторонние ключи в counters (мерж, не замена) ────
|
||||||
|
#
|
||||||
|
# Текстового гейта на имя `kit_runs.update_heartbeat` здесь больше нет (#3390): пока копий
|
||||||
|
# было две, имя выбирало семантику, и проверять его по тексту имело смысл. Теперь
|
||||||
|
# `app.services.scrape_runs` — алиас kit'а, оба имени дают ОДИН объект, и гейт краснел бы
|
||||||
|
# на переименовании, ничего при этом не защищая. Мерж проверяется по значению — тестом
|
||||||
|
# ниже и test_3390_single_runs_module.py (оба пути импорта, heartbeat + финализатор).
|
||||||
|
|
||||||
|
|
||||||
def test_cursor_write_uses_merge_not_replace_heartbeat() -> None:
|
def test_update_heartbeat_merges_into_existing_counters() -> None:
|
||||||
"""import_rosreestr_dkp обязан писать чекпоинт через kit_runs.update_heartbeat
|
|
||||||
(merge: `counters || :counters`), а не локальный runs_mod.update_heartbeat (замена:
|
|
||||||
`CAST(:counters AS jsonb)`) — иначе resume-вердикт, записанный ДО цикла, стирается
|
|
||||||
первым же per-batch heartbeat'ом того же прогона.
|
|
||||||
"""
|
|
||||||
src = inspect.getsource(sched.import_rosreestr_dkp)
|
|
||||||
assert "kit_runs.update_heartbeat" in src
|
|
||||||
assert "runs_mod.update_heartbeat" not in src
|
|
||||||
|
|
||||||
|
|
||||||
def test_kit_runs_update_heartbeat_merges_into_existing_counters() -> None:
|
|
||||||
"""Сама примитива слияния: второй write добавляет rows_fetched/last_id, НЕ стирая
|
"""Сама примитива слияния: второй write добавляет rows_fetched/last_id, НЕ стирая
|
||||||
resume_from/resume_reason, записанные первым write'ом.
|
resume_from/resume_reason, записанные первым write'ом.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
"""Недогруженная карточка Яндекса — отказ, а не успех (#3191).
|
||||||
|
|
||||||
|
Наблюдение 2026-08-28 (три карточки, прод-сайдкар, аноним):
|
||||||
|
|
||||||
|
offer 52275656 3 920 119 б encryptedPhones 65
|
||||||
|
offer 62983459 1 805 556 б encryptedPhones 0 <- недогруз
|
||||||
|
offer 12108408 4 732 884 б encryptedPhones 65
|
||||||
|
|
||||||
|
Средняя вернулась вдвое меньше нормы и БЕЗ блока контактов. Не капча и не ошибка:
|
||||||
|
HTTP 200, HTML валиден, `window.INITIAL_STATE` на месте, парсер отрабатывает.
|
||||||
|
Единственная проверка размера в провайдере (`newbuilding.py`, `len(html) < 500`)
|
||||||
|
отвечает на вопрос «пришло ли хоть что-то» — 1,8 МБ проходит её в 3600 раз, и
|
||||||
|
частичная карточка уезжала в БД с `detail_enriched_at`, выбывая из очереди навсегда.
|
||||||
|
|
||||||
|
Проверка ПО ЗНАЧЕНИЮ: сравниваются счётчики и факт вызова записи, а не «не бросило
|
||||||
|
исключение». Первый кейс — недогруз (карточка НЕ должна сохраниться), второй —
|
||||||
|
регресс на полной странице (сохраняется как раньше). Тождество attempted (#3338)
|
||||||
|
проверяется в обоих: incomplete ⊆ failed, как blocked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
_wp_mock = MagicMock()
|
||||||
|
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from app.tasks.yandex_detail_backfill import ( # noqa: E402
|
||||||
|
YandexDetailBackfillResult,
|
||||||
|
run_yandex_detail_backfill,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession"
|
||||||
|
_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse"
|
||||||
|
_SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment"
|
||||||
|
_RUNS = "app.tasks.yandex_detail_backfill.runs_mod"
|
||||||
|
_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep"
|
||||||
|
_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url"
|
||||||
|
|
||||||
|
# Размеры взяты с наблюдения: недогруз 1,8 МБ (порог в 1 МБ проходит — ловит его
|
||||||
|
# именно отсутствие блока контактов), полная карточка 3,9 МБ. Маркер написан здесь
|
||||||
|
# литералом НАРОЧНО: тест не должен падать на импорте, когда правку снимают —
|
||||||
|
# фальсификация обязана краснеть по значению («карточка успешна»), а не по ImportError.
|
||||||
|
CONTACTS_MARKER = "encryptedPhones"
|
||||||
|
UNDERRENDERED_HTML = "<html>" + "x" * 1_805_556 + "</html>"
|
||||||
|
FULL_HTML = "<html>" + "x" * 3_920_119 + f'"{CONTACTS_MARKER}":["a","b"]</html>'
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_db(n: int) -> MagicMock:
|
||||||
|
snapshot = [
|
||||||
|
{"id": i + 1, "source_url": f"https://realty.yandex.ru/offer/{i + 1}/"} for i in range(n)
|
||||||
|
]
|
||||||
|
db = MagicMock()
|
||||||
|
sel = MagicMock()
|
||||||
|
sel.mappings.return_value.all.return_value = snapshot
|
||||||
|
sel.one.return_value = SimpleNamespace(url_from_offer_id=0, unenrichable_pending=0)
|
||||||
|
db.execute.return_value = sel
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _resp(html: str) -> MagicMock:
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status_code = 200
|
||||||
|
resp.text = html
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(html: str) -> tuple[YandexDetailBackfillResult, MagicMock]:
|
||||||
|
"""Один прогон на одну карточку. parse НАРОЧНО отдаёт разобранную модель:
|
||||||
|
именно так и было на проде — частичная страница парсится успешно."""
|
||||||
|
save = MagicMock(return_value=True)
|
||||||
|
db = _mock_db(1)
|
||||||
|
session = AsyncMock()
|
||||||
|
session.get = AsyncMock(side_effect=[_resp(html)])
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.__aenter__ = AsyncMock(return_value=session)
|
||||||
|
ctx.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(_ASYNC_SESSION, MagicMock(return_value=ctx)),
|
||||||
|
patch(_PARSE, return_value=MagicMock()),
|
||||||
|
patch(_SAVE, save),
|
||||||
|
patch(_RUNS, MagicMock()),
|
||||||
|
patch(_SLEEP, new_callable=AsyncMock),
|
||||||
|
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")),
|
||||||
|
):
|
||||||
|
counters = await run_yandex_detail_backfill(
|
||||||
|
db, run_id=3191, params={"batch_size": 1, "budget_sec": 3600}
|
||||||
|
)
|
||||||
|
return counters, save
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_underrendered_page_is_refusal_not_success() -> None:
|
||||||
|
"""1,8 МБ без блока контактов → incomplete, detail_enriched_at не проставлен."""
|
||||||
|
counters, save = await _run(UNDERRENDERED_HTML)
|
||||||
|
|
||||||
|
# save_detail_enrichment — единственный писатель detail_enriched_at (UPDATE ...
|
||||||
|
# detail_enriched_at = NOW()). Не вызван = карточка осталась в очереди, её
|
||||||
|
# возьмёт следующий снапшот (WHERE detail_enriched_at IS NULL).
|
||||||
|
save.assert_not_called()
|
||||||
|
assert (counters.enriched, counters.incomplete, counters.failed) == (0, 1, 1), (
|
||||||
|
f"enriched={counters.enriched} incomplete={counters.incomplete} "
|
||||||
|
f"failed={counters.failed}: недогруз обязан быть отказом, а не успехом"
|
||||||
|
)
|
||||||
|
assert counters.blocked == 0, (
|
||||||
|
f"blocked={counters.blocked}: площадка ответила HTTP 200 — это не блок"
|
||||||
|
)
|
||||||
|
# Тождество #3338: incomplete ⊆ failed, в сумму исходов входит только failed.
|
||||||
|
assert counters.attempted == counters.enriched + counters.failed == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_page_still_enriches() -> None:
|
||||||
|
"""Регресс: полная карточка сохраняется как раньше."""
|
||||||
|
counters, save = await _run(FULL_HTML)
|
||||||
|
|
||||||
|
save.assert_called_once()
|
||||||
|
assert (counters.enriched, counters.incomplete, counters.failed) == (1, 0, 0), (
|
||||||
|
f"enriched={counters.enriched} incomplete={counters.incomplete} "
|
||||||
|
f"failed={counters.failed}: полная страница должна обогащать как раньше"
|
||||||
|
)
|
||||||
|
assert counters.attempted == counters.enriched + counters.failed == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_reason_names_the_two_signals() -> None:
|
||||||
|
"""Признака два, и любой из них — отказ."""
|
||||||
|
from scraper_kit.providers.yandex.detail import detail_incomplete_reason
|
||||||
|
|
||||||
|
assert detail_incomplete_reason(FULL_HTML) is None
|
||||||
|
# Структурный: 1,8 МБ проходит размерный порог, но контактов нет.
|
||||||
|
assert CONTACTS_MARKER in (detail_incomplete_reason(UNDERRENDERED_HTML) or "")
|
||||||
|
# Размерный: контакты есть, а страница обрезана.
|
||||||
|
reason = detail_incomplete_reason(f'"{CONTACTS_MARKER}":[]', min_html_bytes=1_000_000)
|
||||||
|
assert reason is not None and "недогружена" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirect_phones_alone_counts_as_complete() -> None:
|
||||||
|
"""Карточка «без телефона / только чат»: redirectPhones есть, encryptedPhones нет.
|
||||||
|
|
||||||
|
Класс в замер #3192 не попадал (там у всех 12 были оба ключа). Требуя именно
|
||||||
|
encryptedPhones, мы браковали бы такие карточки вечно — они возвращаются в очередь,
|
||||||
|
а ключ у них не появится. Маркеры литералами: правку снимут — тест обязан краснеть
|
||||||
|
по значению («карточка отбракована»), а не по ImportError.
|
||||||
|
"""
|
||||||
|
from scraper_kit.providers.yandex.detail import detail_incomplete_reason
|
||||||
|
|
||||||
|
body = "<html>" + "x" * 3_920_119
|
||||||
|
assert detail_incomplete_reason(body + '"redirectPhones":["a"]</html>') is None
|
||||||
|
# Фальсификация: без ОБОИХ ключей та же страница полного размера — недогруз.
|
||||||
|
assert detail_incomplete_reason(body + "</html>") is not None
|
||||||
|
|
@ -87,10 +87,16 @@ def _mock_db(n: int) -> MagicMock:
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
# HTTP 200 обязан выглядеть ПОЛНОЙ карточкой (#3191): недогруз теперь отсеивается до
|
||||||
|
# parse, и страница-заглушка превратила бы эти кейсы в тесты про полноту, а не про
|
||||||
|
# природу блока. Маркер контактов + размер выше порога — оба признака полноты.
|
||||||
|
_FULL_PAGE_HTML = "<html>" + "x" * 1_100_000 + '"encryptedPhones":["a"]</html>'
|
||||||
|
|
||||||
|
|
||||||
def _resp(status: int) -> MagicMock:
|
def _resp(status: int) -> MagicMock:
|
||||||
resp = MagicMock()
|
resp = MagicMock()
|
||||||
resp.status_code = status
|
resp.status_code = status
|
||||||
resp.text = "<html>отказ</html>"
|
resp.text = _FULL_PAGE_HTML if status == 200 else "<html>отказ</html>"
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
"""#3197 (хвост) — две служебные ручки admin.py и пул прокси: одна чинится, вторая НЕТ.
|
||||||
|
|
||||||
|
`POST /admin/scrape/domclick/debug/detail-fetch` — **настоящий фикс**. Он ходит через
|
||||||
|
`BrowserFetcher.fetch`, а `fetch`/`fetch_json` — единственные методы, которые кладут
|
||||||
|
`payload["proxy"]` в тело POST /fetch сайдкара, и сайдкар этот override читает
|
||||||
|
(`_resolve_proxy_override` → `_ensure_browser(provider, proxy_override=...)`). Прямая
|
||||||
|
конструкция `BrowserFetcher(source=, endpoint=)` не проставляла
|
||||||
|
`proxy_provider`/`use_pool`/`environment` — без них сайдкар брал свой env-узел
|
||||||
|
(`SCRAPER_PROXY_URL`, на проде это выключенный узел 9: 407 → camoufox `InvalidIP`), а
|
||||||
|
прод-отказ «пул пуст → не ходить на мёртвый env» (#2616) тут был мёртв: он смотрит на
|
||||||
|
`environment`, который до конструктора не доезжал. Тест меряет ЗНАЧЕНИЯ kwargs.
|
||||||
|
|
||||||
|
`POST /admin/scrape/cian/auto-login` — **намеренно без пула**, и это проверяется обратными
|
||||||
|
по значению утверждениями. Логин идёт не через `fetch`, а через `login` → `_post_login`,
|
||||||
|
который `payload["proxy"]` не кладёт вовсе; на приёме `login_handler`
|
||||||
|
(`browser/server.py:2814-2817`) зовёт `_no_live_proxy(provider, None)` и
|
||||||
|
`_ensure_browser(provider)` без override — то есть **сайдкар на `/login` proxy-override не
|
||||||
|
принимает** и логинится с env-узла при любом теле запроса. Аренда на этом пути была бы
|
||||||
|
холостой (взяли в `__aenter__`, отпустили в `__aexit__`, health-вердикта по узлу нет), а
|
||||||
|
при пустом пуле в production `_acquire_lease` поднимает `NoProxyAvailableError` ДО POST —
|
||||||
|
и единственная ручка ВОССТАНОВЛЕНИЯ сессии отдавала бы `502 Browser login failed` ровно во
|
||||||
|
время инцидента с пулом. Поэтому здесь `proxy_provider` не передаётся (фабрика остаётся
|
||||||
|
ради endpoint/environment из одного места); пул для логина — отдельная задача сайдкара.
|
||||||
|
|
||||||
|
Подделка ставится и на `scraper_kit.providers._base.BrowserFetcher` (путь через фабрику), и
|
||||||
|
на `app.api.v1.admin.BrowserFetcher` (прямая конструкция, как было до #3197, `create=True` —
|
||||||
|
сейчас такого имени в модуле нет). Дубль нужен, чтобы файл был запускаем и против прежних
|
||||||
|
состояний исходников: красное тогда читается как «значение не то», а не «мок не сработал».
|
||||||
|
Стаб cian — ПОДКЛАСС настоящего `BrowserFetcher`: `__aenter__`/`_acquire_lease` исполняются
|
||||||
|
по-настоящему (иначе тест на «нет ложного 502» проверял бы заглушку), застаблен только
|
||||||
|
сетевой `login`.
|
||||||
|
|
||||||
|
Сеть/БД/камуфокс замоканы; в сеть тест не ходит.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from contextlib import ExitStack
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
|
from scraper_kit.contracts import ProxyLease
|
||||||
|
|
||||||
|
from app.api.v1 import admin
|
||||||
|
from app.services import cian_session as cian_session_svc
|
||||||
|
|
||||||
|
_TARGETS = ("scraper_kit.providers._base.BrowserFetcher", "app.api.v1.admin.BrowserFetcher")
|
||||||
|
|
||||||
|
|
||||||
|
class _CapturingFetcher:
|
||||||
|
"""Собирает kwargs каждой конструкции; login() отдаёт валидный набор куки."""
|
||||||
|
|
||||||
|
captured: ClassVar[list[dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
_CapturingFetcher.captured.append(kwargs)
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _CapturingFetcher:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def login(self, **_kwargs: Any) -> dict[str, str]:
|
||||||
|
return {name: "v" for name in cian_session_svc.CIAN_REQUIRED_COOKIES}
|
||||||
|
|
||||||
|
|
||||||
|
class _RealLeaseFetcher(BrowserFetcher):
|
||||||
|
"""Настоящий `BrowserFetcher` (реальные `__aenter__`/`_acquire_lease`) без сети.
|
||||||
|
|
||||||
|
Наследование, а не заглушка: утверждение «пустой пул в production не роняет ручку»
|
||||||
|
обязано пройти через тот самый guard `_acquire_lease`, который поднимает
|
||||||
|
`NoProxyAvailableError`. Подменён только сетевой `login`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
captured: ClassVar[list[dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
_RealLeaseFetcher.captured.append(kwargs)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
async def login(self, **_kwargs: Any) -> dict[str, str]:
|
||||||
|
return {name: "v" for name in cian_session_svc.CIAN_REQUIRED_COOKIES}
|
||||||
|
|
||||||
|
|
||||||
|
class _SpyProvider:
|
||||||
|
"""Пустой пул + спай: `acquire` пишет вызовы в ClassVar и всегда отдаёт None.
|
||||||
|
|
||||||
|
ClassVar, а не поле инстанса: `_kit_proxy_provider()` конструирует провайдер сам,
|
||||||
|
и «ни разу не позвали» должно покрывать в том числе «даже не создали».
|
||||||
|
"""
|
||||||
|
|
||||||
|
acquired: ClassVar[list[str]] = []
|
||||||
|
released: ClassVar[list[int]] = []
|
||||||
|
|
||||||
|
def acquire(self, provider: str) -> ProxyLease | None:
|
||||||
|
_SpyProvider.acquired.append(provider)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def release(self, lease: ProxyLease) -> None:
|
||||||
|
_SpyProvider.released.append(lease.id)
|
||||||
|
|
||||||
|
def mark_health(self, lease: ProxyLease, *, ok: bool, error: str | None = None) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _pool_on(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Curl-флаг включён всегда: это он открывает `_kit_proxy_provider()` (гейт #2163).
|
||||||
|
|
||||||
|
Провайдер обязан доезжать до конструктора при ЛЮБОМ значении browser-флага —
|
||||||
|
`use_pool=False` фетчер его просто игнорирует, но call-site у dev и прода один.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "use_proxy_pool_curl", True)
|
||||||
|
monkeypatch.setattr(admin, "RealProxyProvider", _SpyProvider)
|
||||||
|
_CapturingFetcher.captured = []
|
||||||
|
_RealLeaseFetcher.captured = []
|
||||||
|
_SpyProvider.acquired = []
|
||||||
|
_SpyProvider.released = []
|
||||||
|
|
||||||
|
|
||||||
|
def _cian_login_settings(monkeypatch: pytest.MonkeyPatch, *, use_pool: bool, env: str) -> None:
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "use_proxy_pool_browser", use_pool)
|
||||||
|
monkeypatch.setattr(settings, "environment", env)
|
||||||
|
monkeypatch.setattr(settings, "cookie_encryption_key", "k" * 32)
|
||||||
|
monkeypatch.setattr(settings, "cian_login_email", "a@b.c")
|
||||||
|
monkeypatch.setattr(settings, "cian_login_password", "pw")
|
||||||
|
|
||||||
|
|
||||||
|
def _patched_cian_login(stack: ExitStack, fetcher_cls: type) -> None:
|
||||||
|
for target in _TARGETS:
|
||||||
|
stack.enter_context(patch(target, fetcher_cls, create=True))
|
||||||
|
stack.enter_context(
|
||||||
|
patch.object(
|
||||||
|
cian_session_svc, "verify_session", AsyncMock(return_value={"user": {"userId": 7}})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stack.enter_context(patch.object(cian_session_svc, "save_session", MagicMock()))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
|
||||||
|
@pytest.mark.usefixtures("_pool_on")
|
||||||
|
async def test_cian_auto_login_does_not_lease_from_pool(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str
|
||||||
|
) -> None:
|
||||||
|
"""Логин пул НЕ арендует: сайдкар на `/login` proxy-override не берёт (см. докстринг)."""
|
||||||
|
_cian_login_settings(monkeypatch, use_pool=use_pool, env=environment)
|
||||||
|
|
||||||
|
with ExitStack() as stack:
|
||||||
|
_patched_cian_login(stack, _RealLeaseFetcher)
|
||||||
|
result = await admin.cian_auto_login(db=MagicMock(), body=None)
|
||||||
|
|
||||||
|
assert result["ok"] is True and result["userId"] == 7
|
||||||
|
assert len(_RealLeaseFetcher.captured) == 1, "ручка обязана построить ровно один фетчер"
|
||||||
|
kwargs = _RealLeaseFetcher.captured[0]
|
||||||
|
# .get(), а не [] — красное должно читаться как «значение не то», а не KeyError.
|
||||||
|
assert kwargs.get("proxy_provider") is None, "аренда на /login холостая — провайдер не нужен"
|
||||||
|
assert _SpyProvider.acquired == [], "lease взят впустую (сайдкар его всё равно не увидит)"
|
||||||
|
# endpoint из фабрики не должен потеряться (#2322: без него TypeError).
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
assert kwargs.get("endpoint") == settings.browser_http_endpoint
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_pool_on")
|
||||||
|
async def test_cian_auto_login_survives_empty_pool_in_production(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Пустой пул на проде НЕ ломает ручку восстановления сессии (нет ложного 502).
|
||||||
|
|
||||||
|
Ровно тот сценарий, ради которого пул отсюда убран: `_acquire_lease` при
|
||||||
|
`use_pool + provider + production + пустой пул` поднимает `NoProxyAvailableError`,
|
||||||
|
`cian_auto_login` ловит любое `Exception` и отдаёт `502 Browser login failed` — то
|
||||||
|
есть инцидент с пулом закрывал бы единственный способ переполучить cian-сессию,
|
||||||
|
хотя логину пул не нужен (сайдкар proxy-override на `/login` не принимает).
|
||||||
|
"""
|
||||||
|
_cian_login_settings(monkeypatch, use_pool=True, env="production")
|
||||||
|
|
||||||
|
with ExitStack() as stack:
|
||||||
|
_patched_cian_login(stack, _RealLeaseFetcher)
|
||||||
|
try:
|
||||||
|
result = await admin.cian_auto_login(db=MagicMock(), body=None)
|
||||||
|
except HTTPException as exc:
|
||||||
|
# pytest.fail, а не re-raise: красное должно называть статус и detail.
|
||||||
|
pytest.fail(f"пустой пул уронил ручку восстановления: {exc.status_code} {exc.detail}")
|
||||||
|
|
||||||
|
assert result["ok"] is True and result["userId"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
|
||||||
|
@pytest.mark.usefixtures("_pool_on")
|
||||||
|
async def test_domclick_debug_detail_wires_proxy_pool(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str
|
||||||
|
) -> None:
|
||||||
|
"""Debug-карточка DomClick берёт узел с provider_affinity='domclick', а не env."""
|
||||||
|
from scraper_kit.providers.domclick import detail as domclick_detail
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "use_proxy_pool_browser", use_pool)
|
||||||
|
monkeypatch.setattr(settings, "environment", environment)
|
||||||
|
|
||||||
|
enrichment = SimpleNamespace(
|
||||||
|
item_id="1",
|
||||||
|
repair_state=None,
|
||||||
|
living_area_m2=None,
|
||||||
|
year_built=None,
|
||||||
|
price_changes=[],
|
||||||
|
raw_extra={},
|
||||||
|
)
|
||||||
|
body = admin.DomClickDebugDetailFetchRequest(
|
||||||
|
card_url="https://ekaterinburg.domclick.ru/card/sale__flat__1"
|
||||||
|
)
|
||||||
|
with ExitStack() as stack:
|
||||||
|
for target in _TARGETS:
|
||||||
|
stack.enter_context(patch(target, _CapturingFetcher, create=True))
|
||||||
|
stack.enter_context(
|
||||||
|
patch.object(domclick_detail, "fetch_detail", AsyncMock(return_value=enrichment))
|
||||||
|
)
|
||||||
|
stack.enter_context(
|
||||||
|
patch("app.services.domclick_session.load_session", MagicMock(return_value=None))
|
||||||
|
)
|
||||||
|
result = await admin.debug_domclick_detail_fetch(body=body, db=MagicMock())
|
||||||
|
|
||||||
|
assert result.ok is True
|
||||||
|
assert len(_CapturingFetcher.captured) == 1, "ручка обязана построить ровно один фетчер"
|
||||||
|
kwargs = _CapturingFetcher.captured[0]
|
||||||
|
assert kwargs["source"] == "domclick"
|
||||||
|
# .get(), а не [] — красное должно читаться как «значение не то», а не KeyError.
|
||||||
|
assert kwargs.get("proxy_provider") is not None, "без провайдера пул не подключится"
|
||||||
|
assert kwargs.get("use_pool") is use_pool, "флаг пула должен доезжать из конфига"
|
||||||
|
# #2616 шаг 1: без environment отказ «пул пуст» на этом пути мёртв.
|
||||||
|
assert kwargs.get("environment") == environment
|
||||||
|
# endpoint не должен потеряться при переезде на фабрику (#2322: без него TypeError).
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
assert kwargs.get("endpoint") == settings.browser_http_endpoint
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
"""#3197 (часть 1, Циан) — суточный бэкфилл ходил в сайдкар мимо прокси-пула.
|
||||||
|
|
||||||
|
`BrowserFetcher(source="cian", endpoint=...)` конструировался БЕЗ
|
||||||
|
`proxy_provider`/`use_pool`/`environment` — единственных трёх аргументов, которые
|
||||||
|
кладут "proxy" в тело POST /fetch (см. `scraper_kit.browser_fetcher`). Без них сайдкар
|
||||||
|
брал свой env-прокси (`SCRAPER_PROXY_URL`): ни выбора узла из пула, ни
|
||||||
|
`scrape_proxy_source_bans`, ни ротации, — а прод-отказ «пул пуст → не ходить на
|
||||||
|
env/direct» (#2616) на этом пути был мёртв, потому что смотрит на `environment`, который
|
||||||
|
до конструктора не доезжал. Соседи уже починены: domclick (#3197 ч.1, см.
|
||||||
|
test_3197_domclick_proxy_pool_wiring.py) и house_imv/avito (#2698).
|
||||||
|
|
||||||
|
Остальные тесты — про то, чем оживший отказ оборачивается в прогоне: пустой пул
|
||||||
|
поднимается ДО запроса, поэтому следующее объявление упрётся ровно в то же самое, и
|
||||||
|
батч обязан оборваться на первом, а не крутить весь список. Подделка у них — на уровне
|
||||||
|
`BrowserFetcher.fetch()`, НАСТОЯЩИЙ `fetch_detail`: его браузерная ветка глотала любое
|
||||||
|
исключение и возвращала None, поэтому стоп в задаче не срабатывал ни разу, а прогон,
|
||||||
|
не ходивший к площадке, финализировался как 'done' (#3197, ревью PR #3382).
|
||||||
|
|
||||||
|
Сеть/БД/камуфокс замоканы; в сеть тест не ходит.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from contextlib import ExitStack
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from scraper_kit.providers.cian.detail import fetch_detail
|
||||||
|
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||||
|
|
||||||
|
from app.tasks import cian_history_backfill as chb
|
||||||
|
|
||||||
|
_FETCH = "app.tasks.cian_history_backfill.fetch_detail"
|
||||||
|
_SAVE = "app.tasks.cian_history_backfill.save_detail_enrichment"
|
||||||
|
_SLEEP = "app.tasks.cian_history_backfill.asyncio.sleep"
|
||||||
|
_DELAY = "app.tasks.cian_history_backfill.get_scraper_delay"
|
||||||
|
|
||||||
|
|
||||||
|
class _CapturingFetcher:
|
||||||
|
"""Зеркалит _CapturingFetcher из test_3197_domclick_proxy_pool_wiring.py."""
|
||||||
|
|
||||||
|
captured: ClassVar[dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
_CapturingFetcher.captured = kwargs
|
||||||
|
self.last_response_status: int | None = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _CapturingFetcher:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _EmptyPoolFetcher:
|
||||||
|
"""BrowserFetcher, у которого пул опустел: `fetch()` поднимает `NoProxyAvailableError`.
|
||||||
|
|
||||||
|
Ровно как в проде (`browser_fetcher.py:682`): отказ поднимается ДО POST в сайдкар,
|
||||||
|
запроса к площадке не было. Подделка кончается здесь — `fetch_detail` настоящий.
|
||||||
|
"""
|
||||||
|
|
||||||
|
calls: ClassVar[list[str]] = []
|
||||||
|
|
||||||
|
def __init__(self, **_kwargs: Any) -> None:
|
||||||
|
self.last_response_status: int | None = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _EmptyPoolFetcher:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fetch(self, url: str, **_kwargs: Any) -> str:
|
||||||
|
_EmptyPoolFetcher.calls.append(url)
|
||||||
|
raise NoProxyAvailableError("cian")
|
||||||
|
|
||||||
|
|
||||||
|
class _RaisingFetcher:
|
||||||
|
"""Фетчер с заданным исключением — для прямых тестов контракта `fetch_detail`."""
|
||||||
|
|
||||||
|
def __init__(self, exc: BaseException) -> None:
|
||||||
|
self.exc = exc
|
||||||
|
self.last_response_status: int | None = None
|
||||||
|
|
||||||
|
async def fetch(self, _url: str, **_kwargs: Any) -> str:
|
||||||
|
raise self.exc
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_db(n_rows: int) -> MagicMock:
|
||||||
|
rows = [
|
||||||
|
{"id": i + 1, "source_url": f"https://ekb.cian.ru/sale/flat/{i + 1}/"}
|
||||||
|
for i in range(n_rows)
|
||||||
|
]
|
||||||
|
db = MagicMock()
|
||||||
|
sel = MagicMock()
|
||||||
|
sel.mappings.return_value.all.return_value = rows
|
||||||
|
db.execute.return_value = sel
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_listings(
|
||||||
|
db: MagicMock, fetch: Any = None, fetcher: type = _CapturingFetcher
|
||||||
|
) -> chb.CianBackfillResult:
|
||||||
|
"""Прогон listings-блока. `fetch=None` ⇒ `fetch_detail` НЕ подменяется (рабочий тракт)."""
|
||||||
|
patches = [
|
||||||
|
patch.object(chb, "BrowserFetcher", fetcher),
|
||||||
|
patch(_SAVE, return_value=True),
|
||||||
|
patch(_SLEEP, new_callable=AsyncMock),
|
||||||
|
patch(_DELAY, return_value=0.0),
|
||||||
|
]
|
||||||
|
if fetch is not None:
|
||||||
|
patches.append(patch(_FETCH, fetch))
|
||||||
|
with ExitStack() as stack:
|
||||||
|
for p in patches:
|
||||||
|
stack.enter_context(p)
|
||||||
|
return await chb.backfill_cian_history(
|
||||||
|
db, batch_size=10, do_listings=True, do_houses=False, do_valuations=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
|
||||||
|
async def test_browser_fetcher_gets_proxy_pool_wiring(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str
|
||||||
|
) -> None:
|
||||||
|
"""use_pool/proxy_provider/environment доезжают до BrowserFetcher ИЗ КОНФИГА.
|
||||||
|
|
||||||
|
Оба значения флага проверяются одним телом: `use_pool` обязан следовать конфигу, а не
|
||||||
|
быть зашитой константой, а `proxy_provider` передаётся в любом случае — при
|
||||||
|
`use_pool=False` он игнорируется фетчером, но call-site у dev и прода один и тот же.
|
||||||
|
"""
|
||||||
|
_CapturingFetcher.captured = {}
|
||||||
|
monkeypatch.setattr(chb.settings, "use_proxy_pool_browser", use_pool)
|
||||||
|
monkeypatch.setattr(chb.settings, "environment", environment)
|
||||||
|
|
||||||
|
fetch = AsyncMock(return_value=SimpleNamespace(price_changes=[]))
|
||||||
|
await _run_listings(_mock_db(1), fetch)
|
||||||
|
|
||||||
|
captured = _CapturingFetcher.captured
|
||||||
|
assert captured["source"] == "cian"
|
||||||
|
assert captured["endpoint"] == chb.settings.browser_http_endpoint
|
||||||
|
# .get(), а не [] — красное должно читаться как «значение не то», а не как KeyError.
|
||||||
|
assert captured.get("proxy_provider") is not None, "без провайдера пул не подключится"
|
||||||
|
assert captured.get("use_pool") is use_pool, "флаг пула должен доезжать до фетчера из конфига"
|
||||||
|
# #2616 шаг 1: без environment отказ «пул пуст» на этом пути мёртв.
|
||||||
|
assert captured.get("environment") == environment
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_pool_stops_the_batch_on_first_listing() -> None:
|
||||||
|
"""«Пул пуст» на первом объявлении обрывает батч, а не крутит весь список.
|
||||||
|
|
||||||
|
Замокано: БД (`_mock_db`), `save_detail_enrichment`, `asyncio.sleep`, задержка и
|
||||||
|
сам `BrowserFetcher` — подделка кончается на `fetch()`, который поднимает
|
||||||
|
`NoProxyAvailableError` вместо HTTP-запроса в сайдкар.
|
||||||
|
|
||||||
|
НЕ замокан `fetch_detail` — прогон идёт через настоящую браузерную ветку
|
||||||
|
`providers/cian/detail.py`. Прежняя редакция подменяла `fetch_detail` моком,
|
||||||
|
который сам поднимал исключение, и была зелёной по построению: живая функция
|
||||||
|
исключение ГЛОТАЛА и возвращала None, поэтому стоп не срабатывал ни разу
|
||||||
|
(ревью PR #3382 — прогон, не ходивший к площадке, уходил в 'done').
|
||||||
|
"""
|
||||||
|
_EmptyPoolFetcher.calls = []
|
||||||
|
|
||||||
|
result = await _run_listings(_mock_db(3), fetcher=_EmptyPoolFetcher)
|
||||||
|
|
||||||
|
# Сначала измеримое поведение (сколько раз пошли), потом флаг: красное на откате
|
||||||
|
# должно означать «прошли 3 строки вместо 1», а не «поля нет».
|
||||||
|
assert _EmptyPoolFetcher.calls == ["https://ekb.cian.ru/sale/flat/1/"], (
|
||||||
|
"к сайдкару ходили ровно один раз — пул пуст с первого объявления"
|
||||||
|
)
|
||||||
|
assert result.listings_processed == 1, "батч обязан оборваться, а не пройти все 3 строки"
|
||||||
|
assert getattr(result, "no_proxy_stop", False) is True
|
||||||
|
# Отказ НАШЕЙ стороны не должен маскироваться под бан площадки (иначе прогон уйдёт
|
||||||
|
# в 'banned' и запись соврёт про Циан).
|
||||||
|
assert result.ban_kinds == {}
|
||||||
|
assert result.listings_blocked == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_detail_propagates_empty_pool() -> None:
|
||||||
|
"""`fetch_detail` пробрасывает `NoProxyAvailableError`, а не гасит его в None.
|
||||||
|
|
||||||
|
Прямой контракт общей функции: на None вызывающий отвечает «не разобрали» и идёт
|
||||||
|
к следующему объявлению — то есть глотание здесь отменяет стоп у ВСЕХ вызывающих
|
||||||
|
браузерного пути. Соседний `app/services/cian_price_history.py:143` того же типа
|
||||||
|
ждёт отдельным `except`.
|
||||||
|
"""
|
||||||
|
fetcher = _RaisingFetcher(NoProxyAvailableError("cian"))
|
||||||
|
with pytest.raises(NoProxyAvailableError):
|
||||||
|
await fetch_detail("https://ekb.cian.ru/sale/flat/1/", browser_fetcher=fetcher) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_detail_still_returns_none_on_transport_error() -> None:
|
||||||
|
"""Прежний контракт цел: обычный сбой фетча по-прежнему None, а не исключение.
|
||||||
|
|
||||||
|
Иначе «пробрасываем пустой пул» превратилось бы в «пробрасываем всё», и каждый
|
||||||
|
таймаут сайдкара рвал бы батч вместо одной пропущенной карточки.
|
||||||
|
"""
|
||||||
|
fetcher = _RaisingFetcher(httpx.ConnectTimeout("sidecar timeout"))
|
||||||
|
got = await fetch_detail("https://ekb.cian.ru/sale/flat/1/", browser_fetcher=fetcher) # type: ignore[arg-type]
|
||||||
|
assert got is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_marked_failed_with_no_proxy_stop_counter() -> None:
|
||||||
|
"""Прогон с пустым пулом финализируется как failed + counters.no_proxy_stop=1."""
|
||||||
|
from app.services import scheduler as sched
|
||||||
|
|
||||||
|
runs = MagicMock()
|
||||||
|
result = chb.CianBackfillResult(
|
||||||
|
listings_total=3, listings_processed=1, listings_failed_fetch=1, no_proxy_stop=True
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(sched, "runs_mod", runs),
|
||||||
|
patch.object(chb, "backfill_cian_history", AsyncMock(return_value=result)),
|
||||||
|
):
|
||||||
|
await sched._execute_cian_backfill(MagicMock(), run_id=3197, params={"batch_size": 3})
|
||||||
|
|
||||||
|
runs.mark_done.assert_not_called()
|
||||||
|
runs.mark_banned.assert_not_called()
|
||||||
|
runs.mark_failed.assert_called_once()
|
||||||
|
_db, run_id, reason, counters = runs.mark_failed.call_args.args
|
||||||
|
assert run_id == 3197
|
||||||
|
assert "пул" in reason
|
||||||
|
assert counters["no_proxy_stop"] == 1
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
"""#3197 (часть 1, Яндекс-новостройки) — sweep ходил в сайдкар мимо прокси-пула.
|
||||||
|
|
||||||
|
Обе точки провайдера (`YandexNewbuildingScraper.fetch_jk` и `resolve_yandex_jk_slug`)
|
||||||
|
конструировали `BrowserFetcher(source="yandex", endpoint=...)` БЕЗ
|
||||||
|
`proxy_provider`/`use_pool`/`environment` — единственных трёх аргументов, которые кладут
|
||||||
|
"proxy" в тело POST /fetch. Без них сайдкар брал свой env-прокси (`SCRAPER_PROXY_URL`),
|
||||||
|
на проде это выключенный узел (407 → camoufox `InvalidIP` → `/fetch` 503, факт #3386),
|
||||||
|
а прод-отказ «пул пуст → не ходить на env/direct» (#2616) на этом пути был мёртв: он
|
||||||
|
смотрит на `environment`, который до конструктора не доезжал. Соседи уже починены:
|
||||||
|
cian (#3197, test_3197_cian_history_proxy_pool_wiring.py) и domclick/avito.
|
||||||
|
|
||||||
|
Второй тест — про то, чем оживший отказ оборачивается в прогоне: пустой пул поднимается
|
||||||
|
ДО запроса, поэтому следующий ЖК упрётся ровно в то же самое, и прогон обязан оборваться
|
||||||
|
на первом, а не перебирать весь список. Подделка — на уровне `BrowserFetcher.fetch()`,
|
||||||
|
НАСТОЯЩИЕ `resolve_yandex_jk_slug`/`fetch_jk`: их `except Exception` глотал любое
|
||||||
|
исключение и возвращал None, поэтому стоп в задаче не сработал бы ни разу (образец
|
||||||
|
дефекта — #3382, `cian/detail.py`).
|
||||||
|
|
||||||
|
Сеть/БД/камуфокс замоканы; в сеть тест не ходит.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||||
|
|
||||||
|
from app.tasks import yandex_newbuilding_sweep as sweep
|
||||||
|
|
||||||
|
_BASE_FETCHER = "scraper_kit.providers._base.BrowserFetcher"
|
||||||
|
|
||||||
|
|
||||||
|
class _CapturingFetcher:
|
||||||
|
"""Собирает kwargs КАЖДОЙ конструкции — обе точки (resolve и fetch_jk) видны разом."""
|
||||||
|
|
||||||
|
captured: ClassVar[list[dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
_CapturingFetcher.captured.append(kwargs)
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _CapturingFetcher:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fetch(self, _url: str, **_kwargs: Any) -> str:
|
||||||
|
# Короткий HTML: resolve не найдёт slug, fetch_jk отбракует как tiny — обе
|
||||||
|
# ветки идут дальше по циклу, а нам нужны только kwargs конструктора.
|
||||||
|
return "<html></html>"
|
||||||
|
|
||||||
|
|
||||||
|
class _EmptyPoolFetcher:
|
||||||
|
"""Фетчер с пустым пулом: `fetch()` поднимает `NoProxyAvailableError` ДО запроса."""
|
||||||
|
|
||||||
|
calls: ClassVar[list[str]] = []
|
||||||
|
|
||||||
|
def __init__(self, **_kwargs: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _EmptyPoolFetcher:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fetch(self, url: str, **_kwargs: Any) -> str:
|
||||||
|
_EmptyPoolFetcher.calls.append(url)
|
||||||
|
raise NoProxyAvailableError("yandex")
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_db(rows: list[dict[str, Any]]) -> MagicMock:
|
||||||
|
db = MagicMock()
|
||||||
|
res = MagicMock()
|
||||||
|
res.scalar_one.return_value = 0 # counts
|
||||||
|
res.mappings.return_value.all.return_value = rows
|
||||||
|
res.fetchone.return_value = None # idempotency-проба: ещё не обогащён
|
||||||
|
db.execute.return_value = res
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _row(house_id: int, *, slug: str | None, ext_id: str | None) -> dict[str, Any]:
|
||||||
|
return {"house_id": house_id, "yandex_jk_slug": slug, "ext_id": ext_id}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
|
||||||
|
async def test_browser_fetcher_gets_proxy_pool_wiring(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str
|
||||||
|
) -> None:
|
||||||
|
"""use_pool/proxy_provider/environment доезжают до BrowserFetcher ИЗ КОНФИГА.
|
||||||
|
|
||||||
|
Оба значения флага одним телом: `use_pool` обязан следовать конфигу, а не быть
|
||||||
|
зашитой константой, `proxy_provider` передаётся в любом случае (при use_pool=False
|
||||||
|
фетчер его игнорирует, но call-site у dev и прода один).
|
||||||
|
|
||||||
|
Строки подобраны так, чтобы прогон прошёл ОБЕ точки: дом без slug идёт в
|
||||||
|
`resolve_yandex_jk_slug`, дом со slug — в `fetch_jk`.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_CapturingFetcher.captured = []
|
||||||
|
monkeypatch.setattr(settings, "use_proxy_pool_browser", use_pool)
|
||||||
|
monkeypatch.setattr(settings, "environment", environment)
|
||||||
|
|
||||||
|
rows = [_row(1, slug=None, ext_id="111"), _row(2, slug="tatlin", ext_id="222")]
|
||||||
|
with patch(_BASE_FETCHER, _CapturingFetcher):
|
||||||
|
await sweep.enrich_yandex_newbuilding_sweep(_mock_db(rows), limit=2, request_delay_sec=0.0)
|
||||||
|
|
||||||
|
captured = _CapturingFetcher.captured
|
||||||
|
assert len(captured) == 2, "обе точки (resolve и fetch_jk) обязаны строить фетчер"
|
||||||
|
for kwargs in captured:
|
||||||
|
assert kwargs["source"] == "yandex"
|
||||||
|
# .get(), а не [] — красное должно читаться как «значение не то», а не KeyError.
|
||||||
|
assert kwargs.get("proxy_provider") is not None, "без провайдера пул не подключится"
|
||||||
|
assert kwargs.get("use_pool") is use_pool, "флаг пула должен доезжать из конфига"
|
||||||
|
# #2616 шаг 1: без environment отказ «пул пуст» на этом пути мёртв.
|
||||||
|
assert kwargs.get("environment") == environment
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("rows", "url_part"),
|
||||||
|
[
|
||||||
|
([_row(i, slug=None, ext_id=str(i)) for i in (1, 2, 3)], "-1/"),
|
||||||
|
([_row(i, slug=f"jk{i}", ext_id=str(i)) for i in (1, 2, 3)], "jk1-1/"),
|
||||||
|
],
|
||||||
|
ids=["resolve", "fetch_jk"],
|
||||||
|
)
|
||||||
|
async def test_empty_pool_stops_the_sweep_on_first_house(
|
||||||
|
rows: list[dict[str, Any]], url_part: str
|
||||||
|
) -> None:
|
||||||
|
"""«Пул пуст» на первом доме обрывает прогон, а не перебирает все три.
|
||||||
|
|
||||||
|
Замокано: БД и сам `BrowserFetcher` — подделка кончается на `fetch()`. НЕ замоканы
|
||||||
|
`resolve_yandex_jk_slug`/`fetch_jk`: прогон идёт через настоящие функции провайдера,
|
||||||
|
иначе тест был бы зелёным по построению (живые функции исключение ГЛОТАЛИ, и стоп
|
||||||
|
не срабатывал — ровно дефект #3382).
|
||||||
|
"""
|
||||||
|
_EmptyPoolFetcher.calls = []
|
||||||
|
|
||||||
|
with patch(_BASE_FETCHER, _EmptyPoolFetcher):
|
||||||
|
result = await sweep.enrich_yandex_newbuilding_sweep(
|
||||||
|
_mock_db(rows), limit=3, request_delay_sec=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сначала измеримое поведение (сколько раз пошли), потом флаг: красное на откате
|
||||||
|
# должно означать «прошли 3 дома вместо 1», а не «поля нет».
|
||||||
|
assert len(_EmptyPoolFetcher.calls) == 1, "к сайдкару ходили ровно один раз"
|
||||||
|
assert _EmptyPoolFetcher.calls[0].endswith(url_part)
|
||||||
|
assert result.processed == 1, "прогон обязан оборваться, а не пройти все 3 дома"
|
||||||
|
assert getattr(result, "no_proxy_stop", False) is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scheduler_marks_run_failed_on_empty_pool() -> None:
|
||||||
|
"""Прогон с пустым пулом финализируется как failed, а не 'done' с честным нулём."""
|
||||||
|
from app.services import product_handlers as ph
|
||||||
|
|
||||||
|
ctx = MagicMock()
|
||||||
|
result = sweep.YandexNewbuildingSweepResult(processed=1, failed_fetch=1, no_proxy_stop=True)
|
||||||
|
with patch(
|
||||||
|
"app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep",
|
||||||
|
AsyncMock(return_value=result),
|
||||||
|
):
|
||||||
|
await ph._job_yandex_newbuilding_sweep(MagicMock(), 3197, {"limit": 3}, ctx)
|
||||||
|
|
||||||
|
ctx.runs.mark_done.assert_not_called()
|
||||||
|
ctx.runs.mark_failed.assert_called_once()
|
||||||
|
_db, run_id, reason, counters = ctx.runs.mark_failed.call_args.args
|
||||||
|
assert run_id == 3197
|
||||||
|
assert "пул" in reason
|
||||||
|
# Ровно 1, а не JSON true: SQL-монитор соседей ищет counters->>'no_proxy_stop' = '1'
|
||||||
|
# (scheduler.py:168 cian, avito_detail_backfill.py:1043, domclick_detail_backfill.py:610).
|
||||||
|
# bool — подкласс int, поэтому `== 1` один сам по себе зелёный и на True: тип проверяем
|
||||||
|
# отдельно, иначе красное на откате не наступит.
|
||||||
|
assert counters["no_proxy_stop"] == 1 and not isinstance(counters["no_proxy_stop"], bool)
|
||||||
180
tradein-mvp/backend/tests/test_3253_domclick_house_fields.py
Normal file
180
tradein-mvp/backend/tests/test_3253_domclick_house_fields.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""Дом-поля карточки Домклика доезжают до houses (#3253).
|
||||||
|
|
||||||
|
Парсер читал houseInfo.info (тип стен/перекрытий, число квартир) и складывал всё
|
||||||
|
в listings.raw_payload, а в houses не переносил: на проде 400 домов-domklik против
|
||||||
|
3061 объявления, а houses.total_units = 0 у ВСЕХ источников при том, что
|
||||||
|
quarters_count уже лежал в собранных payload'ах.
|
||||||
|
|
||||||
|
(а) Значение: карточка с houseInfo → второй оператор получает total_units /
|
||||||
|
material_walls / material_floors ЧИСЛАМИ И СТРОКАМИ из фикстуры (не None).
|
||||||
|
(б) Непустое значение дома не затирается пустым из карточки — COALESCE в обе
|
||||||
|
стороны: и в SET, и в WHERE-гейте.
|
||||||
|
(в) Словарь: в material_walls едет КАНОН ДОМ.РФ («монолит»), а не сырая фраза
|
||||||
|
площадки («Монолитно-кирпичный») — иначе колонка становится двухсловарной.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
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.providers.domclick.detail import (
|
||||||
|
DomClickDetailEnrichment,
|
||||||
|
canon_floor_type,
|
||||||
|
canon_wall_type,
|
||||||
|
parse_detail_html,
|
||||||
|
save_detail_enrichment,
|
||||||
|
)
|
||||||
|
|
||||||
|
_STATE = {
|
||||||
|
"productCard": {"id": "t3253-1", "address": {"guid": "bldg-guid-1"}},
|
||||||
|
"houseInfo": {
|
||||||
|
"info": {
|
||||||
|
"wallType": "Монолитно-кирпичный",
|
||||||
|
"floorType": "Железобетонный",
|
||||||
|
"quartersCount": 168,
|
||||||
|
"entranceCount": 4,
|
||||||
|
"buildingSeries": "II-68",
|
||||||
|
"energyEfficiency": "B",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_HTML = (
|
||||||
|
"<html><body><script>window.__SSR_STATE__ = "
|
||||||
|
f"{json.dumps(_STATE, ensure_ascii=False)};</script></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeNested:
|
||||||
|
def __enter__(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __exit__(self, *exc: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
"""Пишет все execute(stmt, params); rowcount=1 у каждого оператора.
|
||||||
|
|
||||||
|
fetchone() → None: UPDATE listings отдаёт текущую цену через RETURNING — она нужна
|
||||||
|
гейту сдвигов разряда (#3376) как второй свидетель. Этой фикстуре цена не нужна,
|
||||||
|
«строки нет» гейт трактует как отсутствие свидетеля и серию не трогает; факт
|
||||||
|
«листинг найден» здесь по-прежнему приходит из rowcount, а не из RETURNING.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
self.calls.append((str(stmt), params or {}))
|
||||||
|
return SimpleNamespace(rowcount=1, fetchone=lambda: None)
|
||||||
|
|
||||||
|
def begin_nested(self) -> _FakeNested:
|
||||||
|
return _FakeNested()
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _house_call(db: _FakeSession) -> dict[str, Any]:
|
||||||
|
for sql, params in db.calls:
|
||||||
|
if "UPDATE houses" in sql:
|
||||||
|
return params
|
||||||
|
raise AssertionError(f"UPDATE houses не исполнен; операторы: {[s[:40] for s, _ in db.calls]}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_house_fields_reach_houses_upsert() -> None:
|
||||||
|
"""(а)+(в) Значения из фикстуры доезжают до апсерта дома — канонизированными."""
|
||||||
|
e = parse_detail_html(_HTML, "https://domclick.ru/card/sale__flat__t3253-1")
|
||||||
|
assert e.raw_extra["quarters_count"] == 168
|
||||||
|
# В raw_payload сырая фраза площадки остаётся как есть — канон только в колонках.
|
||||||
|
assert e.raw_extra["wall_type"] == "Монолитно-кирпичный"
|
||||||
|
|
||||||
|
db = _FakeSession()
|
||||||
|
save_detail_enrichment(db, 555, e) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
params = _house_call(db)
|
||||||
|
assert params["quarters_count"] == 168
|
||||||
|
assert params["wall_type"] == "монолит"
|
||||||
|
assert params["floor_type"] == "Железобетонные"
|
||||||
|
assert params["lid"] == 555
|
||||||
|
|
||||||
|
|
||||||
|
# ── (в) канонизация словаря (#3253) ───────────────────────────────────────────
|
||||||
|
# houses.material_walls на проде уже заполнена словарём ДОМ.РФ: кирпич 2662,
|
||||||
|
# железобетонная панель 2107, иное 1850, монолит 754. Домклик пишет свой словарь.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("raw", "expected"),
|
||||||
|
[
|
||||||
|
("Кирпичный", "кирпич"), # 2241 карточка на проде
|
||||||
|
("Панельный", "железобетонная панель"), # 1671
|
||||||
|
("Монолитный", "монолит"), # 2656
|
||||||
|
("Монолитно-кирпичный", "монолит"), # 773
|
||||||
|
("Блочный", "иное"),
|
||||||
|
("Деревянный", "иное"),
|
||||||
|
(" монолитный ", "монолит"), # регистр и пробелы не мешают
|
||||||
|
("Саманный", None), # незнакомое — сырьё в колонку НЕ пишем
|
||||||
|
("", None),
|
||||||
|
(None, None),
|
||||||
|
(42, None), # не-строка из JSON
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_canon_wall_type_maps_to_domrf_dictionary(raw: object, expected: str | None) -> None:
|
||||||
|
assert canon_wall_type(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("raw", "expected"),
|
||||||
|
[
|
||||||
|
("Железобетонный", "Железобетонные"), # форма Домклика
|
||||||
|
("Железобетонные", "Железобетонные"), # форма, уже лежащая в колонке (avito)
|
||||||
|
("Деревянные", None),
|
||||||
|
(None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_canon_floor_type_maps_to_column_form(raw: object, expected: str | None) -> None:
|
||||||
|
assert canon_floor_type(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_wall_type_is_not_written_to_houses() -> None:
|
||||||
|
"""Незнакомый тип стен не доезжает до houses — колонка не портится сырьём."""
|
||||||
|
e = DomClickDetailEnrichment(
|
||||||
|
item_id="t3253-3",
|
||||||
|
source_url="u",
|
||||||
|
raw_extra={"wall_type": "Саманный", "quarters_count": 12},
|
||||||
|
)
|
||||||
|
db = _FakeSession()
|
||||||
|
save_detail_enrichment(db, 558, e) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
params = _house_call(db)
|
||||||
|
assert params["wall_type"] is None
|
||||||
|
assert params["quarters_count"] == 12 # остальные поля едут как обычно
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_card_does_not_overwrite_house() -> None:
|
||||||
|
"""(б) Карточка без дом-полей вообще не трогает houses."""
|
||||||
|
db = _FakeSession()
|
||||||
|
e = DomClickDetailEnrichment(item_id="t3253-2", source_url="u", raw_extra={"egrn_area": 42.0})
|
||||||
|
save_detail_enrichment(db, 556, e) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert not [s for s, _ in db.calls if "UPDATE houses" in s]
|
||||||
|
|
||||||
|
|
||||||
|
def test_coalesce_protects_filled_columns() -> None:
|
||||||
|
"""(б) SET и WHERE-гейт оба COALESCE-семантика: пустым непустое не затирается."""
|
||||||
|
e = parse_detail_html(_HTML, "https://domclick.ru/card/sale__flat__t3253-1")
|
||||||
|
db = _FakeSession()
|
||||||
|
save_detail_enrichment(db, 557, e) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
sql = next(s for s, _ in db.calls if "UPDATE houses" in s)
|
||||||
|
for col in ("total_units", "material_walls", "material_floors"):
|
||||||
|
assert f"COALESCE(h.{col}," in sql
|
||||||
|
assert f"h.{col} IS NULL" in sql
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""#3283: бан-страница Авито («доступ ограничен: проблема с IP») приходит от
|
"""#3283: бан-страница Авито («доступ ограничен: проблема с IP») приходит от
|
||||||
сайдкара как SidecarBanPageError (HTTP 500 + ban_page-маркер в теле, #3239), но
|
сайдкара как SidecarBanPageError (ban_page-маркер в теле, #3239; код ответа — 403
|
||||||
у Авито её никто не читал — она заворачивалась в AvitoSidecarUnavailableError и
|
с #3288 п.4, 500 у сайдкара старой сборки), но у Авито её никто не читал — она
|
||||||
уезжала в транспортные сбои ('infra'), а не в отказ площадки ('platform').
|
заворачивалась в AvitoSidecarUnavailableError и уезжала в транспортные сбои
|
||||||
|
('infra'), а не в отказ площадки ('platform').
|
||||||
|
|
||||||
Прод-замер (прогон 5606): 64 бан-страницы, ban_kinds={"infra":26,"platform":7}, при
|
Прод-замер (прогон 5606): 64 бан-страницы, ban_kinds={"infra":26,"platform":7}, при
|
||||||
этом ни ОДНОЙ записи для source=avito в scrape_proxy_source_bans — узел не банился и
|
этом ни ОДНОЙ записи для source=avito в scrape_proxy_source_bans — узел не банился и
|
||||||
|
|
@ -26,7 +27,7 @@ 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.avito_exceptions import AvitoBlockedError, AvitoSidecarUnavailableError
|
from scraper_kit.avito_exceptions import AvitoBlockedError, AvitoSidecarUnavailableError
|
||||||
from scraper_kit.browser_fetcher import SidecarBanPageError
|
from scraper_kit.browser_fetcher import SidecarBanPageError, _raise_for_sidecar_status
|
||||||
from scraper_kit.providers.avito import serp as serp_module
|
from scraper_kit.providers.avito import serp as serp_module
|
||||||
from scraper_kit.providers.avito.detail import fetch_detail
|
from scraper_kit.providers.avito.detail import fetch_detail
|
||||||
from scraper_kit.providers.avito.serp import AvitoScraper
|
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||||
|
|
@ -36,16 +37,40 @@ from app.services.scraper_adapters import RealScraperConfig
|
||||||
_ITEM_URL = "https://www.avito.ru/ekaterinburg/kvartiry/x_1234567890"
|
_ITEM_URL = "https://www.avito.ru/ekaterinburg/kvartiry/x_1234567890"
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_ban_page_error(upstream_status: int | None = 500) -> SidecarBanPageError:
|
_SIDECAR_BAN_CODES = [403, 500]
|
||||||
"""Ровно то, что сайдкар шлёт на подтверждённую бан-страницу («доступ ограничен:
|
|
||||||
проблема с IP») — зеркало _sidecar_ban_page_error из test_domclick_detail.py."""
|
|
||||||
request = httpx.Request("POST", "http://tradein-browser:3000/fetch")
|
def _sidecar_ban_page_error(
|
||||||
response = httpx.Response(500, request=request)
|
sidecar_status: int = 403, *, upstream_status: int | None = 403
|
||||||
return SidecarBanPageError(
|
) -> SidecarBanPageError:
|
||||||
"Server error '500' | tradein-browser: BanPageDetectedError: доступ ограничен",
|
"""Ровно то, что клиент поднимает на подтверждённой бан-странице («доступ
|
||||||
request=request,
|
ограничен: проблема с IP») — зеркало _sidecar_ban_page_error из test_domclick_detail.py.
|
||||||
response=response,
|
|
||||||
upstream_status=upstream_status,
|
Исключение строим НАСТОЯЩИМ `_raise_for_sidecar_status` из ответа сайдкара, а не
|
||||||
|
литералом: текст менялся (#3288 п.4 убрал httpx-преамбулу, и захардкоженная строка
|
||||||
|
«Server error '500' | tradein-browser: …» пережила фикс, которого уже нет в коде).
|
||||||
|
|
||||||
|
`sidecar_status` — код ОТВЕТА САЙДКАРА: 403 с #3288 п.4, 500 у сайдкара старой
|
||||||
|
сборки (`tradein-browser` — отдельный образ со своим шагом деплоя, версии штатно
|
||||||
|
разъезжаются на часы). Признак бана — ТЕЛО, поэтому оба кода обязаны дать один и
|
||||||
|
тот же тип; прогон по `_SIDECAR_BAN_CODES` это и проверяет.
|
||||||
|
`upstream_status` — код, который площадка отдала браузеру.
|
||||||
|
"""
|
||||||
|
response = httpx.Response(
|
||||||
|
sidecar_status,
|
||||||
|
json={
|
||||||
|
"error": "BanPageDetectedError: доступ ограничен",
|
||||||
|
"ban_page": True,
|
||||||
|
"status": upstream_status,
|
||||||
|
},
|
||||||
|
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_raise_for_sidecar_status(response)
|
||||||
|
except SidecarBanPageError as exc:
|
||||||
|
return exc
|
||||||
|
raise AssertionError(
|
||||||
|
f"ответ сайдкара {sidecar_status} с ban_page обязан дать SidecarBanPageError"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,12 +78,15 @@ def _sidecar_ban_page_error(upstream_status: int | None = 500) -> SidecarBanPage
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fetch_detail_sidecar_ban_page_raises_platform_block_not_infra() -> None:
|
@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES)
|
||||||
|
async def test_fetch_detail_sidecar_ban_page_raises_platform_block_not_infra(
|
||||||
|
sidecar_status: int,
|
||||||
|
) -> None:
|
||||||
"""Бан-страница — ГЕНУИННЫЙ отказ площадки: AvitoBlockedError, не
|
"""Бан-страница — ГЕНУИННЫЙ отказ площадки: AvitoBlockedError, не
|
||||||
AvitoSidecarUnavailableError. Фальсификация: до фикса SidecarBanPageError падала
|
AvitoSidecarUnavailableError. Фальсификация: до фикса SidecarBanPageError падала
|
||||||
в `except Exception` (она подкласс httpx.HTTPStatusError) и уезжала сюда."""
|
в `except Exception` (она подкласс httpx.HTTPStatusError) и уезжала сюда."""
|
||||||
bf = MagicMock()
|
bf = MagicMock()
|
||||||
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
|
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(sidecar_status))
|
||||||
|
|
||||||
with pytest.raises(AvitoBlockedError) as excinfo:
|
with pytest.raises(AvitoBlockedError) as excinfo:
|
||||||
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
|
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
|
||||||
|
|
@ -67,17 +95,26 @@ async def test_fetch_detail_sidecar_ban_page_raises_platform_block_not_infra() -
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fetch_detail_sidecar_ban_page_reports_ban() -> None:
|
@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES)
|
||||||
"""Подтверждённый маркер-бан обязан попасть в scrape_proxy_source_bans через
|
async def test_fetch_detail_sidecar_ban_page_does_not_report_ban_again(
|
||||||
report_ban — иначе узел не ротируется и продолжает выдаваться в аренду."""
|
sidecar_status: int,
|
||||||
|
) -> None:
|
||||||
|
"""Бан рапортует ФЕТЧЕР (`report_platform_ban`), провайдер — уже нет (#3288).
|
||||||
|
|
||||||
|
До #3288 здесь стоял `assert_called_once()`, и это было верно, пока фетчер о
|
||||||
|
бане не знал. Теперь рапорт идёт из `_post_fetch` — РАНЬШЕ и по правильному
|
||||||
|
lease; повтор отсюда приходит после возможной ротации по fail-streak и банит
|
||||||
|
свежий узел, который к площадке не ходил. Что бан всё-таки доезжает до пула —
|
||||||
|
пин по значению в tests/test_3288_avito_ban_per_source.py (там настоящий
|
||||||
|
BrowserFetcher с фейковым пулом, а не MagicMock).
|
||||||
|
"""
|
||||||
bf = MagicMock()
|
bf = MagicMock()
|
||||||
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
|
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(sidecar_status))
|
||||||
|
|
||||||
with pytest.raises(AvitoBlockedError):
|
with pytest.raises(AvitoBlockedError):
|
||||||
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
|
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
|
||||||
|
|
||||||
bf.report_ban.assert_called_once()
|
bf.report_ban.assert_not_called()
|
||||||
assert _ITEM_URL in bf.report_ban.call_args.args[0]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -99,7 +136,10 @@ async def test_fetch_detail_transport_failure_stays_infra_no_report_ban() -> Non
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_serp_sidecar_ban_page_raises_platform_block_without_retrying() -> None:
|
@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES)
|
||||||
|
async def test_serp_sidecar_ban_page_raises_platform_block_without_retrying(
|
||||||
|
sidecar_status: int,
|
||||||
|
) -> None:
|
||||||
"""Бан-страница у SERP → AvitoBlockedError + report_ban, БЕЗ ретраев.
|
"""Бан-страница у SERP → AvitoBlockedError + report_ban, БЕЗ ретраев.
|
||||||
|
|
||||||
Детект маркерный (SidecarBanPageError.__doc__), переспрашивать его нечем, а
|
Детект маркерный (SidecarBanPageError.__doc__), переспрашивать его нечем, а
|
||||||
|
|
@ -109,7 +149,7 @@ async def test_serp_sidecar_ban_page_raises_platform_block_without_retrying() ->
|
||||||
"""
|
"""
|
||||||
scraper = AvitoScraper(RealScraperConfig())
|
scraper = AvitoScraper(RealScraperConfig())
|
||||||
scraper._browser = AsyncMock()
|
scraper._browser = AsyncMock()
|
||||||
scraper._browser.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
|
scraper._browser.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(sidecar_status))
|
||||||
scraper._browser.report_ban = MagicMock() # sync-хук, не корутина (см. test_2686)
|
scraper._browser.report_ban = MagicMock() # sync-хук, не корутина (см. test_2686)
|
||||||
|
|
||||||
sleep_mock = AsyncMock()
|
sleep_mock = AsyncMock()
|
||||||
|
|
|
||||||
387
tradein-mvp/backend/tests/test_3288_avito_ban_per_source.py
Normal file
387
tradein-mvp/backend/tests/test_3288_avito_ban_per_source.py
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
"""#3288: бан площадки снимает узел АДРЕСНО (узел×источник), а не глобально по здоровью.
|
||||||
|
|
||||||
|
Прод-замер 31.08–01.09: `resolve_proxy_url` отказывал ВСЕМ четырём источникам
|
||||||
|
(`ProxyPoolExhaustedError pool_total=7 banned_for_source=0 unhealthy_or_disabled=7`),
|
||||||
|
при том что живая проба тех же узлов проходила — они стояли ровно на потолке
|
||||||
|
`MAX_CONSECUTIVE_FAILS`. Механизм: сайдкар на бан-странице Авито («доступ ограничен:
|
||||||
|
проблема с IP») отвечает `ban_page: true` в теле (кодом 403 с #3288 п.4, кодом 500 у
|
||||||
|
сайдкара старой сборки), клиент поднимает
|
||||||
|
`SidecarBanPageError` — но она подкласс `httpx.HTTPStatusError`, и общий
|
||||||
|
`except Exception` в `_post_fetch` звал `_report_fetch_result(False)` →
|
||||||
|
`mark_health(ok=False)`. `mark_health` — решение ГЛОБАЛЬНОЕ для пула: три бан-страницы
|
||||||
|
Авито выбивали узел и у Яндекса, и у Циана, и у Домклика, которые не возражали вовсе.
|
||||||
|
|
||||||
|
Что закрепляем по ЗНАЧЕНИЮ:
|
||||||
|
(а) подтверждённая бан-страница → `AvitoBlockedError`, `ban_kind_of_exception` ==
|
||||||
|
'platform', `mark_banned(lease, source='avito')` вызван;
|
||||||
|
(б) на бан-странице `mark_health` НЕ вызван вовсе, а на настоящем транспортном сбое
|
||||||
|
(ReadTimeout) — вызван с ok=False, как и раньше;
|
||||||
|
(в) `NoProxyAvailableError` (пул пуст, к площадке не ходили) — ни health-fail, ни бан;
|
||||||
|
через обёртку `fetch_detail` он остаётся распознаваемым ПО ТИПУ в цепочке причин
|
||||||
|
(приём `_iter_causes`), а не по подстроке «no proxy available» (#3272);
|
||||||
|
(г) порядок веток `except`: ban-ветка стоит ДО общего `except Exception`. Пин по
|
||||||
|
значению — тот же HTTP 500 с маркером и без него разводит судьбу узла;
|
||||||
|
(д) при РОТАЦИИ после бана свежий узел не наказывается за чужой отказ: дедуп по
|
||||||
|
`_banned_lease_id` сбрасывается взятием нового lease, поэтому единственный
|
||||||
|
допустимый рапорт — из самого фетчера, до ротации. Второй такой же рапорт
|
||||||
|
сверху (из провайдера) банил бы узел, который к площадке не ходил;
|
||||||
|
(е) бан-страница стоит РОВНО один POST: `SidecarBanPageError` — подкласс
|
||||||
|
`httpx.HTTPError`, и ретрай `fetch()` забирал её себе, удваивая fail-streak.
|
||||||
|
|
||||||
|
Зеркалит стиль tests/test_3196_domclick_ban_kind.py и
|
||||||
|
tests/test_3283_avito_sidecar_ban_is_platform_ban.py; харнесс пула (фейковый провайдер,
|
||||||
|
подмена `_client` после `__aenter__`) — из tests/test_kit_browser_fetcher_proxy_pool.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from scraper_kit.avito_exceptions import AvitoBlockedError, AvitoSidecarUnavailableError
|
||||||
|
from scraper_kit.browser_fetcher import (
|
||||||
|
_LEASE_ROTATE_AFTER_FAILS,
|
||||||
|
BrowserFetcher,
|
||||||
|
SidecarBanPageError,
|
||||||
|
)
|
||||||
|
from scraper_kit.contracts import ProxyLease
|
||||||
|
from scraper_kit.orchestration.pipeline import ban_kind_of_exception
|
||||||
|
from scraper_kit.providers.avito.detail import fetch_detail
|
||||||
|
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||||
|
|
||||||
|
from app.services.scrape_runs import BAN_KIND_INFRA, BAN_KIND_PLATFORM
|
||||||
|
|
||||||
|
_ITEM_URL = "https://www.avito.ru/ekaterinburg/kvartiry/x_1234567890"
|
||||||
|
_LEASE = ProxyLease(id=13, url="http://node13:8080", kind="http")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePool:
|
||||||
|
"""ProxyProvider-заглушка: пишет, ЧТО именно узнал пул об исходе (зеркало
|
||||||
|
_FakeProxyProvider из test_kit_browser_fetcher_proxy_pool.py)."""
|
||||||
|
|
||||||
|
def __init__(self, lease: ProxyLease | None = _LEASE) -> None:
|
||||||
|
self._lease = lease
|
||||||
|
self.health: list[tuple[int, bool]] = []
|
||||||
|
self.banned: list[tuple[int, str]] = []
|
||||||
|
self.touched: list[int] = []
|
||||||
|
self.released: list[int] = []
|
||||||
|
|
||||||
|
def acquire(self, provider: str) -> ProxyLease | None:
|
||||||
|
return self._lease
|
||||||
|
|
||||||
|
def release(self, lease: ProxyLease) -> None:
|
||||||
|
self.released.append(lease.id)
|
||||||
|
|
||||||
|
def mark_health(self, lease: ProxyLease, ok: bool, **_: Any) -> None:
|
||||||
|
self.health.append((lease.id, ok))
|
||||||
|
|
||||||
|
def touch(self, lease: ProxyLease) -> None:
|
||||||
|
self.touched.append(lease.id)
|
||||||
|
|
||||||
|
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
|
||||||
|
self.banned.append((lease.id, source))
|
||||||
|
|
||||||
|
|
||||||
|
_SIDECAR_BAN_CODES = [403, 500]
|
||||||
|
|
||||||
|
|
||||||
|
def _ban_page_response(sidecar_status: int = 403) -> httpx.Response:
|
||||||
|
"""РОВНО то, что отдаёт сайдкар на распознанной бан-странице (browser/server.py):
|
||||||
|
`ban_page: true` + статус целевой навигации. Ответ настоящий, а не мок, — чтобы
|
||||||
|
проба шла тем же трактом, что и прод (_raise_for_sidecar_status разбирает тело).
|
||||||
|
|
||||||
|
`sidecar_status` — код ОТВЕТА САЙДКАРА, а не площадки: 403 с #3288 п.4, 500 у
|
||||||
|
сайдкара старой сборки. `tradein-browser` — отдельный образ со своим шагом
|
||||||
|
деплоя, поэтому версии сайдкара и backend штатно разъезжаются на часы; оба кода
|
||||||
|
обязаны давать один диагноз, и тесты ниже прогоняются по `_SIDECAR_BAN_CODES`.
|
||||||
|
"""
|
||||||
|
request = httpx.Request("POST", "http://browser:3000/fetch")
|
||||||
|
return httpx.Response(
|
||||||
|
sidecar_status,
|
||||||
|
json={
|
||||||
|
"error": "BanPageDetectedError: бан-страница (проблема с IP)",
|
||||||
|
"ban_page": True,
|
||||||
|
"status": 403,
|
||||||
|
},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_500_response() -> httpx.Response:
|
||||||
|
"""Настоящая внутренняя ошибка сайдкара: тот же код 500, но БЕЗ маркера бана."""
|
||||||
|
request = httpx.Request("POST", "http://browser:3000/fetch")
|
||||||
|
return httpx.Response(
|
||||||
|
500,
|
||||||
|
json={"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetcher(post_result: Any, pool: _FakePool) -> BrowserFetcher:
|
||||||
|
"""Фетчер с живым lease из пула и замоканным httpx-клиентом.
|
||||||
|
|
||||||
|
post_result — готовый httpx.Response (вернётся) либо Exception (будет брошен).
|
||||||
|
"""
|
||||||
|
bf = BrowserFetcher(
|
||||||
|
endpoint="http://browser:3000",
|
||||||
|
source="avito",
|
||||||
|
proxy_provider=pool,
|
||||||
|
use_pool=True,
|
||||||
|
environment="production",
|
||||||
|
)
|
||||||
|
await bf.__aenter__()
|
||||||
|
client = MagicMock()
|
||||||
|
if isinstance(post_result, Exception):
|
||||||
|
client.post = AsyncMock(side_effect=post_result)
|
||||||
|
else:
|
||||||
|
client.post = AsyncMock(return_value=post_result)
|
||||||
|
client.aclose = AsyncMock(return_value=None)
|
||||||
|
bf._client = client
|
||||||
|
return bf
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_causes(exc: BaseException) -> list[BaseException]:
|
||||||
|
"""Цепочка причин без зацикливания — копия приёма из domclick_detail_backfill.py."""
|
||||||
|
seen: set[int] = set()
|
||||||
|
out: list[BaseException] = []
|
||||||
|
cur: BaseException | None = exc
|
||||||
|
while cur is not None and id(cur) not in seen:
|
||||||
|
out.append(cur)
|
||||||
|
seen.add(id(cur))
|
||||||
|
cur = cur.__cause__ or cur.__context__
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── (а) бан-страница → отказ ПЛОЩАДКИ + адресный бан пары «узел×источник» ──────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES)
|
||||||
|
async def test_ban_page_raises_platform_block_and_bans_node_for_avito_only(
|
||||||
|
sidecar_status: int,
|
||||||
|
) -> None:
|
||||||
|
"""Бан-страница: AvitoBlockedError, диагноз 'platform', mark_banned(13, 'avito').
|
||||||
|
|
||||||
|
Фальсификация: до фикса SidecarBanPageError доезжала до общего except в
|
||||||
|
_post_fetch → mark_health(ok=False), а строки в scrape_proxy_source_bans не
|
||||||
|
появлялось вовсе (за 7 суток 0 записей для source=avito при 58 прогонах banned).
|
||||||
|
Вторая фальсификация (#3288 п.4): заведи в `_sidecar_ban_page_status` гейт
|
||||||
|
«признавать ban_page только при 500» — 403-кейс покраснеет по значению
|
||||||
|
(AvitoSidecarUnavailableError вместо бана), т.е. в час рассинхрона версий
|
||||||
|
отказ площадки снова уезжал бы в инфра-ветку.
|
||||||
|
"""
|
||||||
|
pool = _FakePool()
|
||||||
|
bf = await _fetcher(_ban_page_response(sidecar_status), pool)
|
||||||
|
|
||||||
|
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
|
||||||
|
with pytest.raises(AvitoBlockedError) as ei:
|
||||||
|
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
|
||||||
|
|
||||||
|
assert ban_kind_of_exception(ei.value) == BAN_KIND_PLATFORM
|
||||||
|
assert pool.banned == [(13, "avito")], (
|
||||||
|
"узел должен быть забанен РОВНО один раз и РОВНО для avito: одно событие "
|
||||||
|
"доезжает до report_ban трижды (POST, ретрай fetch(), провайдер), а каждый "
|
||||||
|
"mark_banned растит ban_count и удлиняет отдых пары"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── (б) здоровье узла: бан-страница молчит, транспортный сбой — говорит ────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES)
|
||||||
|
async def test_ban_page_does_not_mark_health_failure(sidecar_status: int) -> None:
|
||||||
|
"""Ключевое значение issue: на бан-странице mark_health НЕ вызывается вовсе.
|
||||||
|
|
||||||
|
Именно этот вызов гнал узел к MAX_CONSECUTIVE_FAILS и выбивал его из выдачи
|
||||||
|
ВСЕМ источникам (замер 01.09: узлы 9, 13, 14 на потолке при banned_for_source=0).
|
||||||
|
"""
|
||||||
|
pool = _FakePool()
|
||||||
|
bf = await _fetcher(_ban_page_response(sidecar_status), pool)
|
||||||
|
|
||||||
|
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
|
||||||
|
with pytest.raises(AvitoBlockedError):
|
||||||
|
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
|
||||||
|
|
||||||
|
assert pool.health == [], f"бан площадки не должен трогать здоровье узла: {pool.health}"
|
||||||
|
assert pool.touched, "heartbeat (touch) при этом обязан идти — аренда живая"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_real_transport_failure_still_marks_health_failure() -> None:
|
||||||
|
"""Контроль к предыдущему: настоящий транспортный сбой (таймаут) health-fail'ит,
|
||||||
|
как и до фикса. Без этой пары фикс неотличим от «выключили учёт здоровья»."""
|
||||||
|
pool = _FakePool()
|
||||||
|
request = httpx.Request("POST", "http://browser:3000/fetch")
|
||||||
|
bf = await _fetcher(httpx.ReadTimeout("sidecar timeout", request=request), pool)
|
||||||
|
|
||||||
|
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
|
||||||
|
with pytest.raises(AvitoSidecarUnavailableError):
|
||||||
|
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
|
||||||
|
|
||||||
|
assert pool.health == [(13, False), (13, False)], (
|
||||||
|
f"таймаут — сбой НАШЕГО тракта, он обязан остаться в здоровье узла "
|
||||||
|
f"(две записи: fetch() делает один ретрай): {pool.health}"
|
||||||
|
)
|
||||||
|
assert pool.banned == [], "таймаут не бан: площадка себя не показала"
|
||||||
|
|
||||||
|
|
||||||
|
# ── (в) пустой пул — ни бан, ни health-fail ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_pool_is_neither_ban_nor_health_failure() -> None:
|
||||||
|
"""`NoProxyAvailableError` поднимается ДО POST'а: узла нет, площадки не видели."""
|
||||||
|
pool = _FakePool(lease=None)
|
||||||
|
bf = BrowserFetcher(
|
||||||
|
endpoint="http://browser:3000",
|
||||||
|
source="avito",
|
||||||
|
proxy_provider=pool,
|
||||||
|
use_pool=True,
|
||||||
|
environment="production",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(NoProxyAvailableError):
|
||||||
|
await bf.__aenter__()
|
||||||
|
|
||||||
|
assert pool.health == []
|
||||||
|
assert pool.banned == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_pool_through_wrapper_stays_infra_and_recognizable_by_type() -> None:
|
||||||
|
"""Обёртка `fetch_detail` даёт AvitoSidecarUnavailableError ('infra'), но исходный
|
||||||
|
тип остаётся в цепочке причин — часть B (стоп «нечем ходить») ловит его через
|
||||||
|
`_iter_causes`, а НЕ подстрокой «no proxy available» (на ней обжёгся #3272)."""
|
||||||
|
bf = MagicMock()
|
||||||
|
bf.fetch = AsyncMock(side_effect=NoProxyAvailableError("avito"))
|
||||||
|
|
||||||
|
with pytest.raises(AvitoSidecarUnavailableError) as ei:
|
||||||
|
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
|
||||||
|
|
||||||
|
assert ban_kind_of_exception(ei.value) == BAN_KIND_INFRA
|
||||||
|
assert any(isinstance(c, NoProxyAvailableError) for c in _iter_causes(ei.value))
|
||||||
|
bf.report_ban.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── (г) порядок веток except ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidecar_ban_page_is_a_subclass_of_httpx_error() -> None:
|
||||||
|
"""Причина, по которой порядок веток вообще имеет значение (#3287): широкая ловля
|
||||||
|
забирает бан себе. Если это перестанет быть правдой — тесты выше проверяют не то,
|
||||||
|
что думают."""
|
||||||
|
assert issubclass(SidecarBanPageError, httpx.HTTPStatusError)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_same_500_different_marker_gives_different_node_fate() -> None:
|
||||||
|
"""Пин порядка веток по значению: ОДИН и тот же HTTP 500 разводит судьбу узла
|
||||||
|
ровно по маркеру `ban_page` в теле. Плоская 500 — health-fail без бана; поставь
|
||||||
|
ban-ветку после `except Exception` — и бан-страница даст тот же исход, что здесь."""
|
||||||
|
pool = _FakePool()
|
||||||
|
bf = await _fetcher(_plain_500_response(), pool)
|
||||||
|
|
||||||
|
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
|
||||||
|
with pytest.raises(AvitoSidecarUnavailableError):
|
||||||
|
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
|
||||||
|
|
||||||
|
assert pool.health == [(13, False), (13, False)]
|
||||||
|
assert pool.banned == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── (д) ротация после бана: наказан тот узел, который к площадке ходил ─────────
|
||||||
|
|
||||||
|
|
||||||
|
class _RotatingFakePool(_FakePool):
|
||||||
|
"""Пул с НЕСКОЛЬКИМИ узлами: `acquire()` выдаёт их по очереди.
|
||||||
|
|
||||||
|
Отличие от `_FakePool` выше (вечный узел 13) несущее: дедуп рапортов в
|
||||||
|
`BrowserFetcher.report_ban` держится на `_banned_lease_id`, а тот сбрасывается
|
||||||
|
взятием нового lease — на неподвижном пуле дефект «второй рапорт банит свежий
|
||||||
|
узел» физически не проявляется, и тест его не видел бы.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, leases: list[ProxyLease]) -> None:
|
||||||
|
super().__init__(lease=None)
|
||||||
|
self._queue = list(leases)
|
||||||
|
self.acquired: list[int | None] = []
|
||||||
|
|
||||||
|
def acquire(self, provider: str) -> ProxyLease | None:
|
||||||
|
lease = self._queue.pop(0) if self._queue else None
|
||||||
|
self.acquired.append(lease.id if lease is not None else None)
|
||||||
|
return lease
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rotation_after_ban_does_not_ban_the_fresh_node() -> None:
|
||||||
|
"""Узел 13 поймал бан-страницу, fail-streak сменил его на 14 — забанен ТОЛЬКО 13.
|
||||||
|
|
||||||
|
Прод-сценарий: узел уже сыпался (streak на единицу ниже потолка), бан-страница
|
||||||
|
добивает его до ротации. Фальсификация: верни `browser_fetcher.report_ban(...)`
|
||||||
|
в ветку `SidecarBanPageError` в providers/avito/detail.py — к тому кадру lease
|
||||||
|
уже сменился, дедуп по `_banned_lease_id` сброшен взятием нового lease, и
|
||||||
|
значение станет [(13, 'avito'), (14, 'avito')]: свежий узел получает 6-часовой
|
||||||
|
отдых (с эскалацией ban_count) за отказ, которого он не видел. При трёх узлах
|
||||||
|
в пуле это выбивает две трети выдачи с одной бан-страницы.
|
||||||
|
"""
|
||||||
|
pool = _RotatingFakePool([_LEASE, ProxyLease(id=14, url="http://node14:8080", kind="http")])
|
||||||
|
bf = await _fetcher(_ban_page_response(), pool)
|
||||||
|
bf._lease_fail_streak = _LEASE_ROTATE_AFTER_FAILS - 1
|
||||||
|
|
||||||
|
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
|
||||||
|
with pytest.raises(AvitoBlockedError):
|
||||||
|
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
|
||||||
|
|
||||||
|
assert pool.acquired == [13, 14], f"ротация обязана была произойти: {pool.acquired}"
|
||||||
|
assert pool.banned == [(13, "avito")], (
|
||||||
|
f"забанен должен быть узел, который сходил на площадку и получил отказ, "
|
||||||
|
f"а не тот, что пришёл ему на смену: {pool.banned}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── (е) одна бан-страница — один POST ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES)
|
||||||
|
async def test_ban_page_costs_exactly_one_post(sidecar_status: int) -> None:
|
||||||
|
"""Ретрай `fetch()` не имеет права трогать бан-страницу: это ответ площадки.
|
||||||
|
|
||||||
|
Фальсификация: убери `except SidecarBanPageError: raise` перед
|
||||||
|
`except (httpx.HTTPError, ...)` в `fetch()` — станет 2 POST'а, а с ними и +2 к
|
||||||
|
`_lease_fail_streak` вместо +1 (ротация вдвое раньше задуманного).
|
||||||
|
"""
|
||||||
|
pool = _FakePool()
|
||||||
|
bf = await _fetcher(_ban_page_response(sidecar_status), pool)
|
||||||
|
|
||||||
|
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
|
||||||
|
with pytest.raises(AvitoBlockedError):
|
||||||
|
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
|
||||||
|
|
||||||
|
assert bf._client.post.await_count == 1, (
|
||||||
|
f"бан-страницу ретраить нечем — площадка уже ответила: "
|
||||||
|
f"{bf._client.post.await_count} POST'ов"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── (ж) пустой пул при ротации не подменяет диагноз ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_pool_during_post_ban_rotation_keeps_platform_diagnosis() -> None:
|
||||||
|
"""Ротация — best-effort: её `NoProxyAvailableError` не должна съесть бан.
|
||||||
|
|
||||||
|
Второго узла в пуле нет, поэтому `_acquire_lease()` внутри ротации падает.
|
||||||
|
Фальсификация: убери `except NoProxyAvailableError` в `report_platform_ban` —
|
||||||
|
наверх уедет она вместо `SidecarBanPageError`, провайдер завернёт её в
|
||||||
|
AvitoSidecarUnavailableError, и подтверждённый отказ площадки попадёт в
|
||||||
|
`scrape_runs.ban_kind` как 'infra' (ровно та подмена, что чинил #3283).
|
||||||
|
"""
|
||||||
|
pool = _RotatingFakePool([_LEASE])
|
||||||
|
bf = await _fetcher(_ban_page_response(), pool)
|
||||||
|
bf._lease_fail_streak = _LEASE_ROTATE_AFTER_FAILS - 1
|
||||||
|
|
||||||
|
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
|
||||||
|
with pytest.raises(AvitoBlockedError) as ei:
|
||||||
|
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
|
||||||
|
|
||||||
|
assert ban_kind_of_exception(ei.value) == BAN_KIND_PLATFORM
|
||||||
|
assert pool.banned == [(13, "avito")], f"бан обязан быть отрапортован до ротации: {pool.banned}"
|
||||||
373
tradein-mvp/backend/tests/test_3288b_block_kind_ratio.py
Normal file
373
tradein-mvp/backend/tests/test_3288b_block_kind_ratio.py
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
"""Вид блока решает обрыв по доле и статус прогона (#3288, часть B).
|
||||||
|
|
||||||
|
Прогон 5425 оборвался по доле блоков (`abort_reason='ratio'`, статус `banned`) на
|
||||||
|
48 «блоках», из которых 41 был `AvitoSidecarUnavailableError` — отказ НАШЕГО тракта,
|
||||||
|
а не площадки: та же карточка не доехала до Авито, и 41 других карточек прогон
|
||||||
|
успел честно обогатить. `record_block()` вида не принимал вовсе, поэтому в числитель
|
||||||
|
скользящего окна (#3184) падало всё подряд.
|
||||||
|
|
||||||
|
Проверка ПО ЗНАЧЕНИЮ — сравниваются `abort_reason()`, счётчики и то, какой именно
|
||||||
|
финализатор дёрнут (`mark_banned` / `mark_failed` / `mark_done`), а не «не бросило
|
||||||
|
исключение». К каждому послаблению идёт контроль на противоположную ошибку: 14
|
||||||
|
настоящих `AvitoBlockedError` из 20 обязаны рвать прогон как раньше (#3184 не
|
||||||
|
меняется), а пустой пул опознаётся по ЦЕПОЧКЕ `__cause__`, не по подстроке
|
||||||
|
«no proxy available» — на подстроке уже обжёгся #3272.
|
||||||
|
|
||||||
|
Харнесс бэкфилла зеркалит tests/test_3338_backfill_counter_identity.py, оттуда же
|
||||||
|
тождество attempted = enriched + blocked + gone + failed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
_wp_mock = MagicMock()
|
||||||
|
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from scraper_kit.avito_exceptions import ( # noqa: E402
|
||||||
|
AvitoBlockedError,
|
||||||
|
AvitoSidecarUnavailableError,
|
||||||
|
)
|
||||||
|
from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402
|
||||||
|
|
||||||
|
from app.services import scrape_runs as runs_svc # noqa: E402
|
||||||
|
from app.services.backfill_block_breaker import BlockRatioBreaker # noqa: E402
|
||||||
|
from app.services.scrape_runs import ( # noqa: E402
|
||||||
|
BAN_KIND_INFRA,
|
||||||
|
BAN_KIND_PLATFORM,
|
||||||
|
BAN_KIND_UNKNOWN,
|
||||||
|
)
|
||||||
|
from app.tasks.avito_detail_backfill import ( # noqa: E402
|
||||||
|
AvitoDetailBackfillResult,
|
||||||
|
run_avito_detail_backfill,
|
||||||
|
)
|
||||||
|
|
||||||
|
_A_FETCH = "app.tasks.avito_detail_backfill.fetch_detail"
|
||||||
|
_A_SAVE = "app.tasks.avito_detail_backfill.save_detail_enrichment"
|
||||||
|
_A_RUNS = "app.tasks.avito_detail_backfill.runs_mod"
|
||||||
|
_A_SLEEP = "app.tasks.avito_detail_backfill.asyncio.sleep"
|
||||||
|
_A_SETTINGS = "app.tasks.avito_detail_backfill.settings"
|
||||||
|
_A_SESSION = "app.tasks.avito_detail_backfill.AsyncSession"
|
||||||
|
_A_SCRAPER = "app.tasks.avito_detail_backfill.AvitoScraper"
|
||||||
|
_A_BROWSER_FETCHER = "app.tasks.avito_detail_backfill.BrowserFetcher"
|
||||||
|
_A_ROTATE_PROXY = "app.tasks.avito_detail_backfill.rotate_proxy"
|
||||||
|
|
||||||
|
_WINDOW = 20
|
||||||
|
_THRESHOLD = 0.7
|
||||||
|
|
||||||
|
|
||||||
|
def _breaker(snapshot_size: int = 100) -> BlockRatioBreaker:
|
||||||
|
return BlockRatioBreaker(
|
||||||
|
window_size=_WINDOW,
|
||||||
|
ratio_threshold=_THRESHOLD,
|
||||||
|
safety_min=5,
|
||||||
|
snapshot_size=snapshot_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── брейкер: что попадает в числитель доли ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_twenty_sidecar_blocks_do_not_abort_by_ratio() -> None:
|
||||||
|
"""20 подряд отказов сайдкара при снапшоте больше окна — не 'ratio' (приёмка)."""
|
||||||
|
breaker = _breaker()
|
||||||
|
for _ in range(20):
|
||||||
|
breaker.record_block(BAN_KIND_INFRA)
|
||||||
|
|
||||||
|
assert breaker.abort_reason() is None, (
|
||||||
|
f"abort_reason={breaker.abort_reason()!r} при 20 infra-отказах: площадка "
|
||||||
|
"не отказывала ни разу, рвать прогон не по чему"
|
||||||
|
)
|
||||||
|
assert (breaker.window_blocks, breaker.window_len) == (0, 20), (
|
||||||
|
f"окно {breaker.window_blocks}/{breaker.window_len}: infra идёт в ЗНАМЕНАТЕЛЬ "
|
||||||
|
"(как record_failure), а не в числитель"
|
||||||
|
)
|
||||||
|
assert breaker.consecutive_blocks == 0, (
|
||||||
|
f"consecutive_blocks={breaker.consecutive_blocks}: infra не двигает серию, "
|
||||||
|
"иначе на коротком снапшоте прогон рвал бы safety-net по тем же отказам"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fourteen_platform_blocks_of_twenty_still_abort_by_ratio() -> None:
|
||||||
|
"""Контроль на противоположную ошибку: #3184 не меняется (14/20 = порог 0.7)."""
|
||||||
|
breaker = _breaker()
|
||||||
|
for _ in range(6):
|
||||||
|
breaker.record_success()
|
||||||
|
for _ in range(14):
|
||||||
|
breaker.record_block(BAN_KIND_PLATFORM)
|
||||||
|
|
||||||
|
assert breaker.abort_reason() == "ratio", (
|
||||||
|
f"abort_reason={breaker.abort_reason()!r} при 14 настоящих блоках из 20 — "
|
||||||
|
"критерий #3184 обязан сработать ровно на пороге"
|
||||||
|
)
|
||||||
|
assert breaker.window_blocks == 14, f"числитель {breaker.window_blocks}, ожидали 14"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infra_does_not_pad_the_numerator_to_the_threshold() -> None:
|
||||||
|
"""13 platform + 7 infra = 0.65, а не 1.0: infra числитель не добивает."""
|
||||||
|
breaker = _breaker()
|
||||||
|
for _ in range(7):
|
||||||
|
breaker.record_block(BAN_KIND_INFRA)
|
||||||
|
for _ in range(13):
|
||||||
|
breaker.record_block(BAN_KIND_PLATFORM)
|
||||||
|
|
||||||
|
assert breaker.abort_reason() is None, (
|
||||||
|
f"abort_reason={breaker.abort_reason()!r}: 13/20 = 0.65 ниже порога 0.7, "
|
||||||
|
"прогон рвётся только если infra зачли блоками"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── бэкфилл: тот же вопрос сквозь задачу ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_avito_settings() -> MagicMock:
|
||||||
|
return MagicMock(
|
||||||
|
scraper_fetch_mode="browser",
|
||||||
|
avito_detail_backfill_use_curl=False,
|
||||||
|
detail_backfill_block_ratio_window=_WINDOW,
|
||||||
|
detail_backfill_block_ratio_threshold=_THRESHOLD,
|
||||||
|
browser_http_endpoint="http://browser:9000",
|
||||||
|
avito_detail_backfill_rotate_after_attempts=15,
|
||||||
|
avito_detail_backfill_rotate_on_ban_max=0,
|
||||||
|
avito_detail_backfill_rotate_on_ban_min_gap=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_avito_db(n: int) -> MagicMock:
|
||||||
|
snapshot = [
|
||||||
|
{
|
||||||
|
"id": i + 1,
|
||||||
|
"source_url": f"https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_{i + 1}",
|
||||||
|
}
|
||||||
|
for i in range(n)
|
||||||
|
]
|
||||||
|
db = MagicMock()
|
||||||
|
sel = MagicMock()
|
||||||
|
sel.mappings.return_value.all.return_value = snapshot
|
||||||
|
db.execute.return_value = sel
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_browser_fetcher_cls() -> MagicMock:
|
||||||
|
instance = AsyncMock()
|
||||||
|
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||||
|
instance.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
instance.request_context_reset = MagicMock()
|
||||||
|
instance.lease_id = 42
|
||||||
|
return MagicMock(return_value=instance)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_avito(
|
||||||
|
fetch_results: list[Any], *, snapshot_size: int
|
||||||
|
) -> tuple[AvitoDetailBackfillResult, MagicMock]:
|
||||||
|
runs = MagicMock()
|
||||||
|
db = _mock_avito_db(snapshot_size)
|
||||||
|
with (
|
||||||
|
patch(_A_SETTINGS, _fake_avito_settings()),
|
||||||
|
patch(_A_SESSION),
|
||||||
|
patch(_A_SCRAPER),
|
||||||
|
patch(_A_RUNS, runs),
|
||||||
|
patch(_A_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
|
||||||
|
patch(_A_FETCH, AsyncMock(side_effect=fetch_results)),
|
||||||
|
patch(_A_SAVE, return_value=True),
|
||||||
|
patch(_A_ROTATE_PROXY, AsyncMock()),
|
||||||
|
patch(_A_SLEEP, new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
counters = await run_avito_detail_backfill(
|
||||||
|
db,
|
||||||
|
run_id=3288,
|
||||||
|
params={
|
||||||
|
"batch_size": snapshot_size,
|
||||||
|
"budget_sec": 3600,
|
||||||
|
"max_consecutive_blocks": 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return counters, runs
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_identity(c: AvitoDetailBackfillResult, *, expected_attempted: int) -> None:
|
||||||
|
outcomes = c.enriched + c.blocked + c.gone + c.failed
|
||||||
|
assert (c.attempted, outcomes) == (expected_attempted, expected_attempted), (
|
||||||
|
f"attempted={c.attempted} vs исходы={outcomes} "
|
||||||
|
f"(enriched={c.enriched} blocked={c.blocked} gone={c.gone} failed={c.failed}), "
|
||||||
|
f"ожидали {expected_attempted} — попытка без исхода (#3338)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sidecar_run_walks_the_whole_snapshot_and_is_not_banned() -> None:
|
||||||
|
"""20 отказов сайдкара + 10 успехов: снапшот пройден целиком, обрыва нет."""
|
||||||
|
fetches: list[Any] = [AvitoSidecarUnavailableError("sidecar 503") for _ in range(20)]
|
||||||
|
fetches += [MagicMock() for _ in range(10)]
|
||||||
|
|
||||||
|
counters, runs = await _run_avito(fetches, snapshot_size=30)
|
||||||
|
|
||||||
|
finished = runs.mark_backfill_finished.call_args
|
||||||
|
assert finished is not None, "прогон не финализирован mark_backfill_finished"
|
||||||
|
# Приёмка #3288 первой строкой: величина, по которой прогон 5425 и оборвался.
|
||||||
|
assert finished.args[2].get("abort_reason") is None, (
|
||||||
|
f"abort_reason={finished.args[2].get('abort_reason')!r} при 20 отказах сайдкара: "
|
||||||
|
"обрыва по доле быть не могло — площадка не отказала ни разу"
|
||||||
|
)
|
||||||
|
_assert_identity(counters, expected_attempted=30)
|
||||||
|
assert (counters.blocked, counters.enriched) == (20, 10), (
|
||||||
|
f"blocked={counters.blocked} enriched={counters.enriched}: прогон обязан дойти "
|
||||||
|
"до конца снапшота, отказ нашего тракта его не рвёт"
|
||||||
|
)
|
||||||
|
assert finished.kwargs["aborted_by_blocks"] is False, (
|
||||||
|
"aborted_by_blocks=True при нулевых отказах площадки — прогон уйдёт в 'banned'"
|
||||||
|
)
|
||||||
|
assert finished.kwargs["ban_kinds"] == {BAN_KIND_INFRA: 20}, (
|
||||||
|
f"ban_kinds={finished.kwargs['ban_kinds']}: перепись диагнозов не должна "
|
||||||
|
"теряться — она и решает статус"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_platform_bans_still_abort_by_ratio() -> None:
|
||||||
|
"""Контроль: 14 настоящих AvitoBlockedError из 20 рвут прогон как раньше."""
|
||||||
|
fetches: list[Any] = [MagicMock() for _ in range(6)]
|
||||||
|
fetches += [AvitoBlockedError("firewall/soft-block") for _ in range(14)]
|
||||||
|
|
||||||
|
counters, runs = await _run_avito(fetches, snapshot_size=30)
|
||||||
|
|
||||||
|
_assert_identity(counters, expected_attempted=20)
|
||||||
|
finished = runs.mark_backfill_finished.call_args
|
||||||
|
assert finished.args[2].get("abort_reason") == "ratio", (
|
||||||
|
f"counters={finished.args[2]}: 14 отказов площадки из 20 — ровно порог #3184"
|
||||||
|
)
|
||||||
|
assert finished.kwargs["aborted_by_blocks"] is True, "обрыв по доле обязан быть помечен"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_pool_stops_run_with_its_own_reason() -> None:
|
||||||
|
"""Пустой пул — не блок: прогон завершается 'нечем ходить', а не баном."""
|
||||||
|
wrapped = AvitoSidecarUnavailableError("Avito detail browser fetch failed for https://...")
|
||||||
|
wrapped.__cause__ = NoProxyAvailableError("avito")
|
||||||
|
|
||||||
|
counters, runs = await _run_avito([wrapped], snapshot_size=30)
|
||||||
|
|
||||||
|
_assert_identity(counters, expected_attempted=1)
|
||||||
|
assert (counters.blocked, counters.failed) == (0, 1), (
|
||||||
|
f"blocked={counters.blocked} failed={counters.failed}: к площадке не ходили — "
|
||||||
|
"это отказ нашей стороны, а не блок"
|
||||||
|
)
|
||||||
|
assert runs.mark_backfill_finished.call_args is None, (
|
||||||
|
"прогон с пустым пулом не должен идти через backfill-honest-status: у него своя причина"
|
||||||
|
)
|
||||||
|
failed = runs.mark_failed.call_args
|
||||||
|
assert failed is not None, "прогон не финализирован mark_failed"
|
||||||
|
assert failed.args[3]["no_proxy_stop"] == 1, f"counters={failed.args[3]}: нет no_proxy_stop"
|
||||||
|
assert "пул прокси пуст" in failed.args[2], f"причина={failed.args[2]!r}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_pool_recognised_by_cause_not_by_text() -> None:
|
||||||
|
"""#3272: подстрока «no proxy available» в тексте — не доказательство пустого пула."""
|
||||||
|
liar = AvitoBlockedError("firewall page: no proxy available for provider='avito'")
|
||||||
|
|
||||||
|
counters, runs = await _run_avito([liar, MagicMock()], snapshot_size=2)
|
||||||
|
|
||||||
|
_assert_identity(counters, expected_attempted=2)
|
||||||
|
assert (counters.blocked, counters.enriched) == (1, 1), (
|
||||||
|
f"blocked={counters.blocked} enriched={counters.enriched}: отказ площадки с "
|
||||||
|
"«no proxy available» в тексте остаётся блоком — цепочка причин пуста"
|
||||||
|
)
|
||||||
|
assert runs.mark_failed.call_args is None, (
|
||||||
|
"прогон остановлен как «пул пуст» по подстроке в тексте — ровно ошибка #3272"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── статус прогона: диагноз infra ≠ 'banned' ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _finish(ban_kinds: dict[str, int], *, enriched: int) -> tuple[MagicMock, ...]:
|
||||||
|
counters = {"attempted": 90, "enriched": enriched, "blocked": 48, "gone": 0, "failed": 1}
|
||||||
|
with (
|
||||||
|
patch.object(runs_svc, "mark_banned") as banned,
|
||||||
|
patch.object(runs_svc, "mark_failed") as failed,
|
||||||
|
patch.object(runs_svc, "mark_done") as done,
|
||||||
|
):
|
||||||
|
runs_svc.mark_backfill_finished(
|
||||||
|
MagicMock(),
|
||||||
|
5425,
|
||||||
|
counters,
|
||||||
|
source="avito_detail_backfill",
|
||||||
|
aborted_by_blocks=True,
|
||||||
|
ban_kinds=ban_kinds,
|
||||||
|
)
|
||||||
|
return banned, failed, done
|
||||||
|
|
||||||
|
|
||||||
|
def test_infra_dominant_run_is_not_marked_banned() -> None:
|
||||||
|
"""41 infra из 48 при 41 обогащённой карточке — прогон 'done', не 'banned'."""
|
||||||
|
banned, failed, done = _finish({BAN_KIND_INFRA: 41, BAN_KIND_PLATFORM: 7}, enriched=41)
|
||||||
|
|
||||||
|
assert banned.call_count == 0, (
|
||||||
|
"прогон помечен 'banned' при доминирующем infra — запись утверждает про "
|
||||||
|
"площадку то, чего не было"
|
||||||
|
)
|
||||||
|
assert (failed.call_count, done.call_count) == (0, 1), (
|
||||||
|
f"mark_failed={failed.call_count} mark_done={done.call_count}: 41 карточка "
|
||||||
|
"обогащена, прогон работу сделал"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pure_infra_run_with_result_is_not_banned() -> None:
|
||||||
|
"""Основной кейс без примеси: перепись целиком infra, карточки есть → не 'banned'."""
|
||||||
|
banned, failed, done = _finish({BAN_KIND_INFRA: 20}, enriched=10)
|
||||||
|
|
||||||
|
assert (banned.call_count, failed.call_count, done.call_count) == (0, 0, 1), (
|
||||||
|
f"mark_banned={banned.call_count} mark_failed={failed.call_count} "
|
||||||
|
f"mark_done={done.call_count}: 20 отказов сайдкара при 10 обогащённых — 'done'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_infra_dominant_run_without_result_stays_banned() -> None:
|
||||||
|
"""Контроль на ОБРАТНУЮ ошибку: ноль результата — по-прежнему 'banned' (#2764/#3196).
|
||||||
|
|
||||||
|
Диагноз infra на нулевом прогоне неотличим от бана площадки, опознанного по 5xx
|
||||||
|
(yandex, #3196): статус остаётся 'banned', честность несёт ban_kind. Понижение
|
||||||
|
до 'failed' прятало бы настоящий бан под «нашу поломку» — падало 2 теста CI.
|
||||||
|
"""
|
||||||
|
banned, failed, done = _finish({BAN_KIND_INFRA: 41, BAN_KIND_PLATFORM: 7}, enriched=0)
|
||||||
|
|
||||||
|
assert (banned.call_count, failed.call_count, done.call_count) == (1, 0, 0), (
|
||||||
|
f"mark_banned={banned.call_count} mark_failed={failed.call_count} "
|
||||||
|
f"mark_done={done.call_count}: нулевой прогон обязан остаться 'banned'"
|
||||||
|
)
|
||||||
|
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_INFRA, (
|
||||||
|
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}: диагноз обязан доехать"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_without_census_stays_banned() -> None:
|
||||||
|
"""Источник видов не различает (ban_kinds пуст) → прежнее поведение: 'banned'."""
|
||||||
|
banned, failed, done = _finish({}, enriched=41)
|
||||||
|
|
||||||
|
assert (banned.call_count, failed.call_count, done.call_count) == (1, 0, 0), (
|
||||||
|
f"mark_banned={banned.call_count} mark_failed={failed.call_count} "
|
||||||
|
f"mark_done={done.call_count}: без переписи диагноза нет — статус не меняем"
|
||||||
|
)
|
||||||
|
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_UNKNOWN, (
|
||||||
|
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}, ожидали 'unknown'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_dominant_run_is_still_banned() -> None:
|
||||||
|
"""Контроль на противоположную ошибку: настоящий бан площадки — по-прежнему 'banned'."""
|
||||||
|
banned, failed, done = _finish({BAN_KIND_PLATFORM: 41, BAN_KIND_INFRA: 7}, enriched=41)
|
||||||
|
|
||||||
|
assert banned.call_count == 1, (
|
||||||
|
f"mark_banned={banned.call_count} (failed={failed.call_count} done={done.call_count}): "
|
||||||
|
"площадка отказала в 41 блоке из 48 — это ровно 'banned'"
|
||||||
|
)
|
||||||
|
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_PLATFORM, (
|
||||||
|
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}, ожидали 'platform'"
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue