Compare commits

..

199 commits

Author SHA1 Message Date
6eb1441508 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
2026-09-09 21:47:07 +00:00
e5f31d4dc7 Merge pull request 'fix(msk-collector): ретрай заливки батча на обрыве ssh' (#3435) from fix/msk-collector-ssh-retry into main 2026-09-09 07:25:16 +00:00
bot-backend
ae70e7a39b fix(msk-collector): ретрай заливки батча на обрыве ssh
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Прогон длится часами, и ssh рвётся: живьём поймано Connection reset by peer
(ssh 255) прямо посреди заливки — умирал весь прогон, несброшенный батч
(до 1000 карточек) терялся, resume перечитывал страницы заново.

Ретрай безопасен: SQL идемпотентен целиком — batch через ON CONFLICT DO
NOTHING, карточки через ON CONFLICT (source_id,batch_id,kind) DO NOTHING.
Ретраится ТОЛЬКО транспорт (код 255); ошибка самого psql под ON_ERROR_STOP
поднимается сразу — это дефект данных или SQL, повтор его не лечит.
2026-09-09 10:13:44 +03:00
62b9d1d4a7 Merge pull request 'fix(msk-collector): страница выдачи — 50 карточек, не 60' (#3434) from fix/msk-collector-page-size into main 2026-09-09 07:00:14 +00:00
bot-backend
88f422c07b fix(msk-collector): страница выдачи — 50 карточек, не 60
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
PAGE_SIZE=60 был взят из потолка 30x60=1800, а не измерен. Реальная выдача
Москва+МО отдаёт 50 на страницу, поэтому planned_pages = ceil(count/60)
не запрашивал последние ~17% каждого коридора: по закрытым коридорам счётчик
Авито обещал 34 753, собрано 28 352.

Это ровно тот молчаливый недобор, который читается как «покрыто всё»: коридор
помечался done, дойдя до расчётной страницы, хотя выдача не кончилась. С 50
потолок пагинации даёт 1500 на запрос — совпадает с дефолтным --target-count,
так что коридоры бисекции по-прежнему добираются целиком.
2026-09-09 09:48:43 +03:00
3107712893 Merge pull request 'fix(msk-collector): пересоздавать свою вкладку каждые 75 загрузок' (#3433) from fix/msk-collector-tab-recycle into main
Reviewed-on: #3433
2026-09-09 06:45:24 +00:00
bot-backend
68b7c16d9d fix(msk-collector): пересоздавать свою вкладку каждые 75 загрузок
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Рендерер Chrome копит память по всем навигациям вкладки, а московский проход —
под тысячу страниц в одной. Живьём поймано: вкладка падает с «Опаньки… Код
ошибки: Out of Memory» при 34 ГБ свободных в системе — упирается рендерер, а не
машина. Свежая вкладка стоит одну навигацию и обнуляет счёт.

Закрывается только своя вкладка; контекст и вкладки владельца не трогаются.
2026-09-09 09:44:02 +03:00
6de69c4d05 Merge pull request 'feat(tradein/geocoder): регион-параметризация геокодера — region_code в geocode()/known_city_hint, --region-code у скрипта сделок, region_code у geocode-missing (#3051)' (#3432) from feat/3051-geocoder-region-param into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 2m11s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-08 23:58:57 +00:00
bot-backend
9898b6bc02 feat(tradein/geocoder): регион-параметризация геокодера — region_code в geocode()/known_city_hint, --region-code у скрипта сделок, region_code у admin geocode-missing (#3051)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
Геокодер был жёстко привязан к Свердловской области: viewbox 66 +
bounded=1, accept только при state ~ 'свердловск' и точке в bbox 66,
known_city_hint знал лишь города области → для 212 937 московских сделок
(address 'Москва, <улица>') геокод давал None либо ложный хит по
одноимённой улице области, а cache-ключ без города смешивал регионы.
Теперь регион приходит от вызывающего (deals.region_code): viewbox и
bbox из REGIONS[code], state-маркер per region ('свердловск'/'москва'),
ЕКБ-тиры (geoportal/cadastral/local houses) только при 66, city-хинт
через словарь региона → cache-ключ '|city=москва'. Дефолт 66 везде —
для существующих вызовов поведение байт-идентично (ревью двумя
линзами). Побочно: geocode-missing по умолчанию больше не берёт
listings с region_code NULL (16 930 неактивных чужих городов, которые
и раньше геокодились впустую).
2026-09-09 02:51:28 +03:00
12476df7a2 Merge pull request 'feat(tradein/estimator): deal_city_price_bands по ключу (region_code, city) — миграция 298, refresh per-region (#3051 sub-PR B)' (#3431) from feat/3051-bands-region-key into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m14s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m54s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-08 23:32:46 +00:00
db7eb00759 Merge pull request 'test(tradein): тесты трека «Москва» — импорт ДКП по 77, чекпоинт per-source, wildcard-хендлер, регион-скоуп коридора, миграция 289, deploy-скрипт (#3051)' (#3430) from test/3051-moscow-track-tests into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-09-08 23:30:21 +00:00
bot-backend
1d9adb4a24 feat(tradein/estimator): deal_city_price_bands по ключу (region_code, city) — миграция 298, refresh per-region, потребители (#3051 sub-PR B)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m10s
После импорта 212 937 московских ДКП (region_code=77) region_stats
refresh'а (p1 для tier region_fallback) считался бы по пулу 66+77 и
поднял бы floor ~250 малым городам области. Ключ bands становится
(region_code, city): миграция 298 (колонка DEFAULT 66, смена PK через
DO-guard, re-seed per-region, ЕКБ-исключение только для 66, doc_type=ДКП),
тот же SQL в refresh-джобе, estimator/backtest читают bands по паре.
Для 66 набор строк байт-идентичен прежнему (ревью двумя линзами).
2026-09-09 02:25:47 +03:00
bot-backend
beb487c12e test(#3051): тесты трека «Москва» — импорт ДКП по 77, per-source чекпоинт, wildcard-хендлер, регион-скоуп ДКП-коридора, миграция 289, валидация deploy-скрипта
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m2s
Тесты пишутся ПОСЛЕ живой проверки функционала (правило от 08.09):
импорт по 77 — run 6460 (212 937 строк), регион-фильтр коридора — smoke
до/после импорта. 14 тестов: SQL-текст через inspect (regex \s+ для
многострочных клозов), чистые функции, мок db по образцу 2846, subprocess
bash с урезанным PATH (падение от валидации, не от отсутствия docker).
2026-09-09 02:23:47 +03:00
e0bef636e6 feat(tradein): bulk-дампы открытых данных ФНС по юрлицам + lookup по ИНН (#3429)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m17s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 7m11s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-08 22:29:10 +00:00
50f0674977 feat(tradein): слой ДТП из dtp-stat.ru в PostGIS + радиусные агрегаты (#3428)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m9s
Deploy Trade-In / build-backend (push) Successful in 1m54s
Deploy Trade-In / deploy (push) Successful in 1m51s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-08 22:10:56 +00:00
3bd30aab87 Merge pull request 'feat(mera): макро-ряды ЦБ РФ — ипотека по субъектам и ключевая ставка' (#3427) from feat/mera-cbr-macro into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m13s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 1m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-08 21:48:59 +00:00
bot-backend
1f3566c466 merge main в feat/mera-cbr-macro — оба handler'а (frt_mkd_load + cbr_macro_pull) в реестре
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m12s
2026-09-09 00:43:13 +03:00
e7ef6bc4a2 Merge pull request 'feat(mera): загрузчик реестра МКД АИС ФРТ → обогащение houses' (#3426) from feat/mera-frt-mkd-loader into main
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m24s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Has been cancelled
2026-09-08 21:41:24 +00:00
bot-backend
2abab8218e feat(mera): макро-ряды ЦБ РФ — ипотека по субъектам и ключевая ставка
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m16s
Три XLSX ЦБ (выдачи, ставка, задолженность) в разрезе субъектов, помесячно
с 01.2019, + ключевая ставка через SOAP DailyInfo.asmx (метод KeyRate; GET
на нём не работает, только POST). Всё анонимно, без ключа.

Таблицы: cbr_mortgage_series (region, period_month, series) и cbr_key_rate.
fetched_at НЕ обновляется в DO UPDATE — по уроку #2846 колонка значит
«когда мы ВПЕРВЫЕ увидели период» и по ней меряется такт публикации источника.

Раскладка листов у ЦБ РАЗНАЯ, и это ловушка: в 02_11/02_13 шапка периодов в
строке 3 текстом («Январь 2019»), а в 02_14 (задолженность) — уже в строке 2 и
настоящими datetime. С захардкоженным индексом строки серия debt_rub молча
давала НОЛЬ строк при зелёных тестах. Теперь строка шапки ищется динамически:
берётся строка с наибольшим числом распознанных периодов среди первых шести.

Проверено на живых файлах: каждая из трёх серий даёт 8 736 точек
(96 территорий × 91 месяц, 01.2019–07.2026); по Свердловской области 91 месяц,
последняя ставка 11.49.

estimator.py не тронут: как ипотечная ставка войдёт в оценку — отдельное
продуктовое решение, этот PR только про данные.

Миграции 292 (таблицы) и 293 (сид расписания, enabled=false, interval_days 7).
Новая зависимость: openpyxl.
2026-09-09 00:33:12 +03:00
bot-backend
8aca820a46 feat(mera): загрузчик реестра МКД АИС ФРТ → обогащение houses
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m14s
Открытые данные АИС ППК «ФРТ» (бывш. Реформа ЖКХ), node 110 = реестр МКД
региона 66: 41 790 строк, houseguid (ФИАС GUID) заполнен на 100% → join к
houses.gar_house_guid без канонизации адреса. Анонимный GET, без ЕСИА.

Заполняет ТОЛЬКО NULL-поля houses: year_built, material_walls, material_floors,
total_floors, entrances, is_emergency, flat_count, heat_supply_type,
gas_supply_type, hot_water + новые area_land, foundation_type, elevators_total.

Замер по ЕКБ: wall_material 92.5% (было 75% от ДОМ.РФ), built_year 92.0%
(было 86%), area_land 86.8% и is_emergency — признаков, которых не давал
ни один из текущих источников.

Осознанно НЕ мапится:
- project_type → series_name: свободный текст и фактически дубль материала стен
  (пусто у 10 635 строк, «кирпичный» 1754, «нет данных» 1496);
- energy_efficiency: решение миграции 284 + реальный класс лишь у ~10% домов
  (у 25 595 из 41 790 значение «Не присвоен»);
- elevators_count → passenger_elevators: в источнике это ОБЩЕЕ число лифтов,
  в houses раздельно пассажирские и грузовые → отдельная колонка;
- playground/sportsground: в источнике id справочника (498/499/500), не флаг.

Дубликаты houseguid реальны и взаимодополняющи: 912 guid'ов, 968 лишних строк,
у одной строки пары заполнен area_total, у парной нет. Строки СЛИВАЮТСЯ по
полям (первое непустое побеждает), иначе бэкфилл терял бы данные ~2% домов.

estimator.py не тронут: аналоги подбираются FROM listings без JOIN к houses,
встраивание признаков в подбор когорты — отдельная задача.

Миграции 290 (staging frt_mkd + колонки houses) и 291 (сид расписания,
enabled=false, interval_days 30). robots.txt источника требует Crawl-delay 10.
2026-09-09 00:33:09 +03:00
1441e189c8 Merge pull request 'fix(msk-collector): ограниченный ретрай goto на таймауте навигации' (#3425) from fix/msk-collector-nav-timeout into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / deploy (push) Successful in 55s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-09-08 20:53:34 +00:00
bot-backend
f820c3eac5 fix(msk-collector): ограниченный ретрай goto на таймауте навигации
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Авито изредка держит соединение до упора, goto падает по 90-секундному
таймауту и прогон умирает целиком. В наблюдавшемся случае это был не отказ:
вкладка показывала нормальную выдачу, маркеров фаервола и PoW не было.

Но тихий отказ выглядит так же, поэтому ретрай не слепой: перед каждым
повтором читаем то, что есть в документе, и прогоняем через _guard — реальный
блок останавливает прогон со своей причиной. Исчерпали 3 попытки — жёсткий
стоп с причиной nav_timeout, а не бесконечный повтор.
2026-09-08 23:50:36 +03:00
1e96a6f12d Merge pull request 'fix(msk-collector): узкий ретрай page.content() на гонке перенавигации' (#3424) from fix/msk-collector-nav-race into main
All checks were successful
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 54s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-08 20:48:18 +00:00
bot-backend
3daf09c7e4 fix(msk-collector): узкий ретрай page.content() на гонке перенавигации
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Авито дорисовывает выдачу после domcontentloaded, и content() иногда попадает
ровно в смену документа — Playwright бросает "page is navigating and changing
the content". Это не отказ площадки, но прогон падал целиком: на 1110-й
карточке потеряно 100 несброшенных строк.

Ретрай узкий — только на этот текст ошибки и только 4 попытки; любая другая
ошибка поднимается как есть, гварды блокировки не ослаблены. runs/ (планы и
CSV прогонов) в .gitignore.
2026-09-08 23:44:09 +03:00
e2045582ab Merge pull request 'feat(tradein/rosreestr): импорт ДКП по Москве (77) — canonical_city, raw_payload, wildcard-расписание rosreestr_dkp_import_*, per-source чекпоинт (#3051)' (#3422) from feat/3051-rosreestr-import-region-param into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Successful in 39s
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / test (push) Successful in 4m22s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m23s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-09-08 20:28:20 +00:00
c6321c767d Merge pull request 'feat(tradein/estimator): регион-скоуп ДКП-коридора — фильтр по deals.region_code (#3051 PR-A)' (#3423) from feat/3051-estimator-dkp-region-filter into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m14s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-09-08 20:23:21 +00:00
bot-backend
fcf5887225 merge(#3051): main (#3421) в ветку импорта по региону — московская дельта поверх region_code/doc_type
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m12s
#3421 въехал в main параллельно с той же миграцией 288 (deals.doc_type,
параметры region_code/doc_types). Разрешение: 288 — целиком версия main;
наша дельта (FDW-колонки okato/quarter_cad_number/district, выключенный seed
rosreestr_dkp_import_77) переехала в 289. scheduler.py — doc_types из main +
canonical_city-маппинг/raw_payload/per-source чекпоинт. deploy-скрипт —
валидация REGION_CODE и DOC_TYPE (интерполируются в SQL текстом).
2026-09-08 23:19:47 +03:00
bot-backend
fbe85fcc75 feat(tradein/estimator): регион-скоуп ДКП-коридора — фильтр по d.region_code (#3051 PR-A)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m9s
target_city резолвится ТОЛЬКО для городов Свердловской области
(_resolve_target_city матчит SVERDLOVSK_OBLAST_CITIES) — для Москвы/любого
нового региона city=None, и street ILIKE оставался единственным скоупом
сделки: одноимённая улица чужого региона утекала в коридор. Добавлен
d.region_code = CAST(:region_code AS int) в оба ДКП-запроса
(_fetch_dkp_corridor) + region_code передаётся из обоих вызывающих (POST
/estimate через geo.lat/lon, GET-rehydrate через row.lat/lon) с гарантией
«не резолвится → DEFAULT_REGION_CODE (66)», не NULL (NULL в SQL-параметре
обнулил бы фильтр целиком). Дефолт региона и джойн deal_city_price_bands не
трогаются — следующие PR (B, F).

Regression: 5622 passed, 37 skipped (полный прогон tests/).
2026-09-08 23:12:12 +03:00
bot-backend
1f24f12af9 chore(#3051): снять новые тесты до живой проверки; валидация REGION_CODE в deploy-скрипте
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m13s
Тесты трека «Москва» пишутся отдельным заходом после живого прогона импорта по 77 —
правило проекта с 2026-09-08. Правки существующих тестов (bind-параметр вместо
литерала 66, канонический source rosreestr_dkp_import_77) остаются.
REGION_CODE в import-rosreestr.sh подставляется в SQL текстом — допускаем только целое.
2026-09-08 23:05:35 +03:00
541c4ff28d Merge pull request 'feat(#3051): deals.doc_type + регион и тип документа как параметры импорта Росреестра' (#3421) from feat/3051-deals-doc-type-region-param into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Successful in 41s
Deploy Trade-In / build-frontend (push) Successful in 2m22s
Deploy Trade-In / test (push) Successful in 4m24s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 6m35s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-09-08 20:04:29 +00:00
76d018b5a0 Merge pull request 'feat(#2989): локальный ручной сборщик SERP Авито по Москве и МО' (#3420) from feat/2989-msk-avito-local-collector into main
Some checks failed
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-08 20:04:25 +00:00
bot-backend
e9370818ea fix(#3051): lock_timeout в миграции 288 — гейт CI #2752
All checks were successful
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m11s
ALTER TABLE deals ADD COLUMN без SET LOCAL lock_timeout встаёт в очередь за
чужой сессией и уводит за собой запросы приложения. Лучше упасть по таймауту
и повторить деплой.
2026-09-08 22:58:38 +03:00
bot-backend
84ee8e5990 feat(tradein/rosreestr): параметризовать импорт ДКП по региону, deals.doc_type (#3051)
Трек 2 подготовки Mera к Москве. import_rosreestr_dkp принимает region_code из
params (default 66 — байт-в-байт прежнее поведение), валидирует его через
app.services.regions.REGIONS. Регион с canonical_city (77 — Москва, Росреестр
отдаёт округ/поселение вместо города) подставляет city/address через одну
SQL-ветку на bind-параметре :canonical_city, а не Python if/else на код региона;
city IS NOT NULL не фильтруется для такого региона (иначе теряется ~10% строк),
исходные city/okato/quarter_cad_number/district уходят в raw_payload.

Чекпоинт курсора (_resume_dkp_cursor) стал per-region: source для поиска
предыдущего прогона строится через _dkp_source_for_region (66 сохраняет
легаси-имя 'rosreestr_dkp_import', остальные — суффикс кода) — иначе прогон по
77 либо никогда не резюмился бы (source-литерал не матчил), либо, при более
наивном фиксе, унёс бы курсор чужого региона.

product_handlers регистрирует wildcard rosreestr_dkp_import_* (по образцу
deactivate_stale_*/avito_city_sweep_*), deploy/import-rosreestr.sh получил
REGION_CODE env (bash-путь не region-generic — city-override только в Python).

Migration 288: deals.doc_type + backfill 'ДКП' для source=rosreestr, foreign
table gendesign_rosreestr_deals расширена okato/quarter_cad_number/district
(проверено live на прод-БД), выключенный seed rosreestr_dkp_import_77.
2026-09-08 22:58:26 +03:00
bot-backend
bde38cdce7 fix(msk-collector): --resume берёт base_url из сохранённого плана
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Коридоры считаются под конкретный URL выдачи. При --resume код брал свежий
args.base_url, поэтому запуск без повтора --base-url молча качал другую
выдачу под тем же batch_id. Теперь URL из плана, расхождение с аргументом —
явная ошибка вместо тихого выбора одного из двух.
2026-09-08 22:54:44 +03:00
bot-backend
476fb9b77a chore(#3051): снять новые тесты — до живой проверки функционала тесты не пишем
Some checks failed
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Failing after 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
В существующем test_rosreestr_dedup_key оставлена только правка, без которой
он падает после параметризации (литералы region_code=66 / doc_type='ДКП'
ушли из SQL живого импорта). Новый тест-файл и добавленная функция сняты:
поведение ещё не проверено на живом импорте, тест зафиксировал бы догадку.
2026-09-08 22:53:20 +03:00
bot-backend
7b33ed9a45 feat(#2989): локальный ручной сборщик SERP Авито по Москве и МО
Трек 1 эпика: нужен разовый корпус вторички Москвы+МО в msk_raw, а прод-скрейпер
для этого не подходит — его расписания, прокси-пул и сайдкар держат ЕКБ и трогать
их ради ручного замера нельзя.

Поэтому скрипт: браузер — уже открытый Chrome владельца с залогиненным техаккаунтом
(connect_over_cdp, своя вкладка, чужие вкладки/контекст/браузер не трогаем и не
закрываем; своего профиля не поднимаем); парсер — импорт _parse_html /
_extract_total_count / _is_firewall_page из scraper-kit, а не копия (копия разъедется
с прод-парсером на первом же DOM-drift); заливка — поток в psql через ssh, потому что
прямого доступа к прод-Postgres с локалки нет, а одиночный psql -c ломается на
квотинге.

avito_serp_ekb_only=False обязателен: с True парсер выбрасывает всё, где в URL нет
/ekaterinburg/ — из московской выдачи не осталось бы ни одной карточки.

Потолок Авито 30x60=1800 на запрос, поэтому план ценовых коридоров с бисекцией по
ГЕОМЕТРИЧЕСКОЙ середине: цены логнормальны, арифметическая середина 1млн..100млн
даёт вырожденно-пустую верхнюю половину. Коридор, который не влезает в 1800 даже
на минимальной ширине, помечается truncated и недобор пишется в batches.notes —
молчаливое усечение читалось бы как полный охват.

Стоп на первом признаке блока (403/439, 429, firewall, PoW, 0 карточек при ненулевом
счётчике) без ретраев: ретрай по забаненному техаккаунту только углубляет бан.
Дефолт — --measure 100, полный проход только по явному --full.
2026-09-08 22:48:37 +03:00
bot-backend
cfe529fd14 feat(#3051): deals.doc_type + регион и тип документа как параметры импорта Росреестра
ПОЧЕМУ: расширение на Москву упирается в два литерала. В источнике за 2024 по региону 77
лежат 30 627 ДДУ с медианой 112 743 против 107 005 ДКП с медианой 256 250 — это цены
котлована, и без различимого признака в deals они развалят любую оценку. При этом тип
сделки терялся при загрузке вовсе (в deals колонки не было), а фильтры region_code = 66
и doc_type = 'ДКП' стояли литералами в scheduler.import_rosreestr_dkp и в двойнике
deploy/import-rosreestr.sh — сменить регион было нельзя, не правя код.

ЧТО:
- миграция 288: deals.doc_type text (idempotent) + бэкфилл 'ДКП' для source='rosreestr'
  (корректен, а не эвристика: всё загруженное прошло фильтр ДКП — и в импорте, и в 077)
  + явный region_code=66 в default_params расписания rosreestr_dkp_import вместо неявного
  дефолта в коде. Индекс НЕ добавлен: 2-3 значения, живые выборки идут по
  region_code/deal_date/geom — заведём частичный, когда появится режущий запрос;
- import_rosreestr_dkp: region_code (default 66) и doc_types (default ['ДКП']) из params,
  фильтры через bind-параметры CAST(:region_code AS int) / ANY(CAST(:doc_types AS text[])),
  doc_type едет из SELECT в INSERT и в ON CONFLICT DO UPDATE. Дефолты сохраняют текущее
  прод-поведение байт-в-байт;
- dedup_hash оставлен как 'ros:dkp:' || id: id уникален в источнике независимо от типа
  документа, а смена формы ключа осиротила бы уже загруженные строки (ровно то, что
  разгребала миграция 077);
- deploy/import-rosreestr.sh: REGION_CODE / DOC_TYPE как env со старыми дефолтами,
  doc_type протащен через staging в deals; шапка про «ЕКБ квартиры» переписана честно —
  city-фильтр снят давно, скоуп = весь регион;
- тесты: test_rosreestr_dedup_key переведён с ассертов на литералы на проверку
  «параметр + дефолт = скоуп 077»; новый test_3051_* проверяет bind-параметры реальным
  вызовом с моком Session, дефолты 66/['ДКП'], doc_type в колонках INSERT и текст 288.
2026-09-08 22:45:55 +03:00
278f8055a4 Merge pull request 'chore(tradein): последние обходы пула прокси — admin cian-login/domclick-debug через фабрику; снят костыль _PoolCurlConfig (#3197, #3386)' (#3409) from chore/backend-pool-wiring-tails into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m42s
Deploy Trade-In / deploy (push) Successful in 1m53s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-09-07 11:04:07 +00:00
f4d174283a chore(#3197): cian-login — без холостой аренды и ложного отказа (/login сайдкара override не берёт); честные докстринги
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
Code-review хвоста #3197: на `cian-login` аренда из пула не доходила до сайдкара.
`BrowserFetcher._post_login` не кладёт `payload["proxy"]` (это делают только
`fetch`/`fetch_json`), а на приёме `login_handler` (browser/server.py:2814-2817)
зовёт `_no_live_proxy(provider, None)` и `_ensure_browser(provider)` без override —
`/login` proxy-override не принимает вовсе. Lease брался в `__aenter__` и
освобождался в `__aexit__` без пользы и без health-вердикта по узлу.

Хуже холостого хода: при пустом пуле в production `_acquire_lease` поднимает
`NoProxyAvailableError` ДО POST, `cian_auto_login` ловит любое `Exception` →
`502 Browser login failed`. То есть единственная ручка ВОССТАНОВЛЕНИЯ cian-сессии
отказывала ровно во время инцидента с пулом. Комментарий в admin.py при этом
утверждал, что фикс закрывает InvalidIP на логине — неправда.

Убран `proxy_provider=` (фабрика остаётся ради endpoint/environment из одного
места). `use_pool` без провайдера фетчер игнорирует сам — `_acquire_lease`:
`use_pool AND provider is not None` — поэтому ни аренды, ни прод-отказа.
`domclick-detail-debug` не тронут: он ходит через `fetch`, где override реально
кладётся в тело и читается сайдкаром — там #3197 остаётся настоящим фиксом.

Тесты для cian обратные по значению и падают на HEAD ветки:
`test_cian_auto_login_does_not_lease_from_pool` (провайдер не передан, `acquire`
не вызван) и `test_cian_auto_login_survives_empty_pool_in_production` (пустой пул
на проде не отдаёт 502). Стаб cian — подкласс настоящего `BrowserFetcher`, чтобы
второе утверждение шло через реальный `_acquire_lease`, а не через заглушку.

Follow-up (отдельной задачей): сайдкар `/login` не принимает proxy override →
логин cian всегда с env-узла (сейчас выключенный узел 9).
2026-09-06 17:48:29 +05:00
8761602e9b chore(tradein/proxy): последние две ручки admin.py — через фабрику фетчера; снят форс pool-режима у cian-history (#3197, #3386)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m5s
Два хвоста одной темы — проводка пула прокси в контейнере backend.

#3197: `cian-login` и `domclick-detail-debug` были последними прямыми
конструкциями `BrowserFetcher(source=, endpoint=)` мимо `build_browser_fetcher`.
Без `proxy_provider`/`use_pool`/`environment` сайдкар брал свой env-узел
`SCRAPER_PROXY_URL` (на проде выключенный узел 9: 407 → camoufox `InvalidIP`), а
прод-отказ «пул пуст» (#2616) на этих путях был мёртв — он смотрит на
`environment`, который до конструктора не доезжал. Соседи по эпику уже переведены
(#3382 cian, #3389 yandex). Прямых конструкций без провайдера вне тестов больше
не осталось: остальные (backfill-задачи, pipeline) пул получают своими kwargs,
а `endpoint=None`-ветки providers — это документированный `config=None` для
офлайн-тестов.

#3386: `_PoolCurlConfig` в `cian_price_history` форсил `use_proxy_pool_curl=True`,
потому что у контейнера `backend` не было переменной. #3387 задал
`USE_PROXY_POOL_CURL: "true"` сервису `backend` в compose — зашитая константа
стала лишней и делала рубильник неотключаемым ровно на этом пути (докстринг при
этом описывал уже неверную причину). Теперь `RealScraperConfig()` напрямую.

Тесты меряют значения, а не наличие kwarg'а: на откате исходников красные
4 параметризации нового `test_3197_admin_debug_browser_pool_wiring`
(`assert None is not None` — провайдер не передан) и
`test_price_history_honours_flag_off` (`assert ['cian'] == []` — пул дёргался при
выключенном флаге).
2026-09-06 17:22:46 +05:00
f52a39e3ad Merge pull request 'fix(tradein/proxy): операции пула прокси уходят с event loop публичного /estimate — acurl_proxy_url через to_thread (#3398 п.1)' (#3407) from fix/3398-pool-ops-off-event-loop into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m48s
Deploy Trade-In / deploy (push) Successful in 1m16s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-09-06 11:55:10 +00:00
7d3c0eab51 fix(#3398): acurl_proxy_url — вторая отмена не бросает lease; тест меряет наш release, а не teardown; потолок бюджета в докстринге
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m11s
2026-09-06 16:49:12 +05:00
e4feadefe6 fix(tradein/proxy): операции пула прокси уходят с event loop публичного /estimate (#3398)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m8s
Вход в `providers/_proxy.py::curl_proxy_url` синхронный и стоял ДО первого await во
всех трёх async-сайтах `/estimate` (avito/imv, yandex/valuation, cian/valuation).
`RealProxyProvider.acquire/mark_health/release` ходят в БД синхронно (своя SessionLocal
на операцию), а публичный backend крутится на ОДНОМ воркере uvicorn (#3083): на
cache-miss это 3 источника x (acquire + mark_health + release) блокирующих вызовов
прямо на loop'е. `_with_budget(asyncio.wait_for)` синхронный вход прервать не может, а
при исчерпанном пуле коннектов (5+10) checkout ждёт до 30 с — весь инстанс молчит.

`acurl_proxy_url` — async-обёртка над тем же синхронным контекстом: вход и выход через
`asyncio.to_thread`, выход тоже (иначе mark_health/release держали бы loop на выходе).
Семантика прежняя: `NoProxyAvailableError` до запроса, BaseException-ветка (#3397:
отмена → health=False), release в finally. `to_thread` копирует contextvars, поэтому
`current_run_id` (#3404) виден в потоке как раньше.

Новое по сравнению с sync-путём: отмена может прийти ВО ВРЕМЯ acquire (раньше это было
невозможно по построению). Вход держится через `asyncio.shield` и добирается в except —
иначе выданная в потоке аренда висела бы до reap_stale_leases.

Переключены только три сайта `/estimate`. Sync-вызывающие и async-сайты под scheduler
(cian/detail.py::fetch_detail, cian/newbuilding.py::resolve_cian_zhk_url_via_search)
остаются на `curl_proxy_url` — там loop не обслуживает публичные запросы.
2026-09-06 16:05:50 +05:00
75b0931fad Merge pull request 'fix(cian): капча видна в отчёте прогона (ban_kinds.platform по типу) и банит узел на curl-пути (#3402 follow-up)' (#3406) from fix/3402-followups-ban-kinds-curl into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m10s
Deploy Trade-In / build-backend (push) Successful in 1m41s
Deploy Trade-In / deploy (push) Successful in 1m50s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-06 10:40:11 +00:00
224f91e8fa fix(cian): капча видна в отчёте прогона и банит узел на curl-пути (#3402)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m11s
Два follow-up из ревью #3403.

1. Капча-волна была невидима в счётчиках. `_note_refusal` ключевался только по
   HTTP-статусу, а капча приходит с 200 (свой детект по <title>) или без статуса
   (сайдкар) → рос один `listings_failed_fetch`, `ban_kinds` оставался пустым, и
   волна отказа площадки читалась как дрейф нашей разметки. Теперь диагноз берётся
   сперва по ТИПУ исключения (`ban_kind_of_exception`), статус — фолбэк. Инвариант
   #3196 сохранён: 'unknown' по типу И None по статусу по-прежнему ничего не пишут.

   `ban_kind_of_exception` расширен с `AvitoBlockedError` до `ProxyBanError` — это
   ровно тот mixin, по которому generic-прокси-слой уже снимает узел с выдачи
   источнику. Для Авито поведение не меняется (AvitoBlockedError его наследует),
   Cian/DomClick перестают приезжать как 'unknown'.

2. curl-путь детектил капчу, но не банил: общий parse-путь лежит ЗА границей
   `with curl_proxy_url(...)`, и `CianBlockedError` поднимался уже после
   `mark_health(ok=True)` — узел, которому Циан показывает капчу, оставался в
   выдаче Циану (дефект #2700, только на HTTP 200). Проверка перенесена ВНУТРЬ
   блока, `finally` хелпера сам делает `mark_banned(source='cian')`.

Тесты по значению (оба красные на main): батч с капчей → ban_kinds == {platform: 1};
curl-путь + HTML капчи → mark_banned == [(1, 'cian')], mark_health(ok=True) нет.
2026-09-06 15:30:12 +05:00
72e3bc9e24 Merge pull request 'feat(tradein/proxy): прогон знает свой узел, а снятый бан перестаёт стирать историю (#3404)' (#3405) from feat/3404-proxy-run-attribution into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m14s
Deploy Trade-In / build-backend (push) Successful in 1m39s
Deploy Trade-In / deploy (push) Successful in 1m53s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-06 10:18:14 +00:00
bot-backend
f4099cb89a Merge remote-tracking branch 'forgejo/main' into feat/3404-proxy-run-attribution
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
2026-09-06 13:05:38 +03:00
bot-backend
b89788ee99 feat(tradein/proxy): прогон знает свой узел, а снятый бан перестаёт стирать историю (#3404)
Выбор оператора мобильного прокси опирался на две ненадёжные опоры.

Первая: `scrape_runs` не знала, через какой узел шёл прогон — колонка `proxy_id`
была только у банов и ротаций. «Какой узел собрал 5 карточек из 21» не выяснялось
ни одним запросом.

Вторая: `clear_source_bans` делала DELETE, а зовётся она после КАЖДОЙ успешной
ротации exit-IP. У #540723 (МегаФон) 23 успешные ротации и ноль строк банов,
у #540722 (Tele2) ротаций почти не было и 7 банов. «7 против 0» читалось как
«Tele2 хуже», хотя в той же мере это «у МегаФона историю стёрли 23 раза».

Теперь:
- `scrape_runs.proxy_id` — последний выданный прогону узел; полная цепочка
  (если узел менялся mid-run) копится в `counters.proxy_ids`. Пишет
  `proxy_pool.attribute_run_proxy` из единственной точки — сразу после выдачи
  лиза в `acquire()`, поэтому curl-путь, браузерный sticky lease и ре-acquire
  при ротации покрыты одинаково. `run_id` доходит до адаптера через ContextVar
  (`scraper_kit.orchestration.run_context`): протокол `ProxyProvider.acquire`
  его не несёт, а `RealProxyProvider` живёт одним объектом на весь планировщик.
  Best-effort: `lock_timeout` 2с и проглоченное исключение — диагностика не
  вправе ронять выдачу прокси или ждать на блокировке строки прогона.
- `clear_source_bans` гасит строку (`banned_until = now()`, `ban_count = 0`,
  `cleared_at`/`cleared_reason`) вместо удаления. Эскалация сохраняется 1:1:
  формула в `mark_banned` берёт ПРЕДЫДУЩИЙ `ban_count` показателем степени, при
  нуле это ровно `SOURCE_BAN_BASE_HOURS` — как после DELETE. Строка доживает до
  штатного purge по `SOURCE_BAN_PURGE_DAYS`.

Для всех читателей `scrape_proxy_source_bans` погашенная строка неотличима от
отсутствующей: acquire, оба guard-подзапроса `mark_banned`, `proxy_egress`
(ранжирование по `ban_count` даёт 0, как у узла без истории), admin `_active_ban` —
все гейтятся по `banned_until > now()`.

Ничего не бэкфиллится: связать прошедшие прогоны с узлами нечем (`leased_by`
исторически = NON_RUN_LEASE_MARKER), врать восстановленным значением нельзя.

Миграция 287. Тесты: 9 новых на обе части (главный — эскалация после гашения даёт
базовые 6ч, а не удвоенные) + 14 существующих переведены с DELETE-семантики на
гашение, включая проверку, что секрет ротации не утекает в новое `cleared_reason`.
Полный прогон бэкенда: 5600 passed, 37 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WHFxVPWoBnSZihkdH1Uou
2026-09-06 13:05:20 +03:00
3d154f4ec0 Merge pull request 'fix(cian): капча/страница ошибки Циана (HTTP 200) — отказ площадки в сайдкаре и kit, ротация аренды внутри батча (#3402)' (#3403) from fix/cian-captcha-is-platform-refusal into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m14s
Deploy Trade-In / test (push) Successful in 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m39s
Deploy Trade-In / deploy (push) Successful in 2m1s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-06 10:01:30 +00:00
957e9e8c01 fix(#3402): капча = «Captcha…» и «Вы не робот?» (бан+ротация); «Ошибка - Циан» — только лог на цикл наблюдения
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m27s
Ревью (⚠️ minor) на #3403: «Ошибка - Циан» может быть транзиентной 5xx-страницей,
отданной с кодом 200, а не отказом конкретному узлу. Цена ошибки несимметрична —
mark_banned эскалирует TTL до часов, поэтому 20-минутный сбой площадки выбил бы из
выдачи весь пул. Один список маркеров этого различить не мог: и капча, и страница
ошибки шли одним путём в BanPageDetectedError.

Проба прода 06.09.2026 09:25 UTC (одна карточка по узлам через сайдкар):
* узел 14, час назад отдававший «Captcha - база объявлений ЦИАН», вернул НАСТОЯЩУЮ
  карточку — капча снимается за 1-2 часа, то есть TTL бана по назначению;
* узел 1 отдал ТРЕТИЙ вариант отказа — `<title>Вы не робот?`, 16 КБ (час назад —
  «Ошибка - Циан», 374 КБ). Прежние маркеры его не знали вовсе: отказ уезжал наверх
  как валидный HTML ровно так же, как до #3402.

Маркеры разделены на два класса, одинаково в обоих слоях (образы backend и browser
деплоятся раздельно и расходятся на часы):
* КАПЧА — «captcha - база объявлений циан» + «вы не робот?»: безусловный отказ
  площадки, прежний путь (сайдкар → BanPageDetectedError → 403 + ban_page; kit →
  report_platform_ban + CianBlockedError). За ней нет контента, и узел, которому её
  показали, будет получать её дальше;
* «ошибка - циан» — ТОЛЬКО ЛОГ: сайдкар отдаёт HTML клиенту как есть и пишет WARNING
  «страница ошибки Циана (title=…, upstream=…) — не бан, только лог (#3402)», kit при
  провале extract_state пишет WARNING и возвращает прежний None. Ни бана, ни рапорта,
  ни исключения — решение принимаем по частоте в логах за цикл наблюдения, а не по
  догадке о природе страницы.

Нормализация заголовка прежняя (регистр/пробелы/тире). `_is_cian_refusal` →
`_is_cian_captcha` + `_log_cian_error_page`; `_refusal_title` → `_page_title` и два
кортежа маркеров рядом.

Фальсификация: «вы не робот?» убран из маркеров обоих слоёв → kit 1 failed
(«DID NOT RAISE CianBlockedError»), сайдкар 3 failed («DID NOT RAISE
BanPageDetectedError», `_is_cian_captcha` → assert False is True). Маркер возвращён,
обе сьюты зелёные: backend 5599 passed / 35 skipped, browser 246 passed.
2026-09-06 14:55:45 +05:00
ff979d4afd fix(tradein/cian): капча Циана с HTTP 200 — отказ площадки в обоих слоях (#3402)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 5m11s
Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`, 44 КБ) и страницу
ошибки (`<title>Ошибка - Циан`, 374 КБ) с кодом 200. Детектор сайдкара их не знал
(_REFUSAL_STATUSES {403,429} + маркеры Авито/Домклика), HTML уезжал клиенту как
успех, extract_state возвращал None и провайдер печатал «defaultState extraction
failed» — отказ ПЛОЩАДКИ читался как дрейф НАШЕЙ разметки. Аренда при этом не
менялась: fetch() уже отрапортовал mark_health(ok=True), fail-streak обнулялся, и
один капча-узел сжигал батч целиком (6200: 0/210; 6123/6091/6052/6032/6010/5981:
0/400 — против 161/162 через здоровый узел на прогоне 13).

Два слоя, потому что образы backend и browser деплоятся раздельно и расходятся
на часы:
* сайдкар (browser/server.py) — детект по <title> на обоих путях (navigate и
  подзапрос) → BanPageDetectedError → прежний путь #3288/#3379: 403 + ban_page +
  ЧЕСТНЫЙ upstream-статус 200;
* kit (providers/cian/detail.py) — при провале extract_state те же маркеры →
  CianBlockedError вместо тихого None, плюс report_platform_ban по живому lease.
  Там же ветка SidecarBanPageError: отказ, опознанный сайдкаром, больше не
  гасится общим `except` в «не смогли разобрать».

Слово `captcha` признаком быть не может: в нормальной карточке оно встречается 11
раз, на капче 17. Детект по <title> с нормализацией тире.

`_report_platform_ban` → `report_platform_ban` (публичный): тем же путём обязан
идти отказ, распознанный не сайдкаром, а провайдером. report_ban один только
пишет бан пары «узел×источник» — сменить сожжённую аренду ВНУТРИ батча позволяет
только fail-streak (_LEASE_ROTATE_AFTER_FAILS).
2026-09-06 14:10:57 +05:00
06ad504505 Merge pull request 'refactor(tradein): одна реализация scrape_runs — kit orchestration/runs.py, app-модуль = алиас; counters везде мержатся (#3390)' (#3400) from refactor/3390-single-runs-module into main
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m9s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-backend (push) Successful in 1m42s
Deploy Trade-In / deploy (push) Successful in 7m48s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-06 08:11:52 +00:00
ae6ad9e440 docs(#3390): убрать ложные «counters ЗАМЕНЯЕТ» из комментариев/докстрингов; гейт test_3168 — честная формулировка
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m51s
2026-09-06 12:34:53 +05:00
bbf38f544f Merge pull request 'fix(tradein/estimator): yandex/cian valuation в /estimate через пул прокси; честный лог при пустом пуле (#3398)' (#3399) from fix/3398-estimator-valuations-proxy-pool into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m14s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m33s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-06 07:06:47 +00:00
ec7838b7a9 fix(#3398): пустой пул в фоновой догрузке — WARNING без трейсбека (общая функция); комментарий про stateless
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Successful in 4m58s
На проде `ESTIMATE_EXTERNAL_SOURCES_BACKGROUND=true` (docker-compose.prod.yml:296),
поэтому синхронный cian-вызов идёт с `fetch_on_miss=False` и возвращает None ДО
прокси-слоя (`providers/cian/valuation.py:171`) — добавленная в этой ветке ветка
WARNING в `estimate_quality` на проде почти не звучит. Настоящий фетч уходит в
`_defer_external_refresh`, где `NoProxyAvailableError` попадал в общий
`except Exception: logger.exception(...)` → ERROR + traceback → событие в GlitchTip
на каждый /estimate по новому адресу: ровно тот шум, который PR и убирает.

Правка в ОБЩЕЙ функции отложенной догрузки, а не в cian-ветке: через неё идут все
источники фонового режима (yandex тоже — у него swallow живёт внутри
`_get_or_fetch_yandex_valuation_cached`, дыры нет, но следующий источник получит
поведение бесплатно). Для прочих исключений всё как было: `logger.exception`.

Тест по значению: background=True + пустой пул в production → фоновая догрузка cian
логирует WARNING «пул прокси пуст», записей ERROR/traceback у логгера эстиматора нет.
Задача дожидается внутри того же loop'а и не снимая патчей (`_DEFERRED_REFRESH_TASKS`
+ `asyncio.gather`) — иначе `anyio.run` закрывает loop раньше старта задачи и тест
был бы зелёным по построению. На HEAD ветки тест красный:
  ERROR app.services.estimator:estimator.py:910 deferred cian_valuation: догрузка не
  удалась (кэш не прогрет) + Traceback … NoProxyAvailableError.

Комментарий у `_c_kwargs`: весь dict переиспользуется замыканием фоновой задачи, то
есть `config`/`proxy_provider` — один инстанс на два возможно-одновременных вызова.
Корректно ровно пока оба stateless (`RealScraperConfig` — read-only снимок настроек,
`RealProxyProvider` без полей, короткая сессия БД на операцию); появится per-вызов
состояние — фоновой задаче нужен свой инстанс.
2026-09-06 11:58:42 +05:00
36f2429fbe refactor(tradein/runs): одна реализация scrape_runs — kit, семантика counters мерж (#3390)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m58s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Две живые копии одного модуля с противоположной семантикой counters: app
`mark_done`/`mark_failed`/`mark_banned`/`update_heartbeat` ЗАМЕНЯЛИ
(`counters = CAST(:counters AS jsonb)`), kit — МЕРЖИЛИ
(`COALESCE(counters,'{}') || …`). Расхождение дважды за сутки дало ложные
выводы на ревью (#3388 «отдать только флаг, остальное домержится» — на
replace это стёрло бы измеренное; #3355). Разошлись и другие места: гейт
статуса, `honors_cancel` у mark_cancelled (был только в app), `mark_skipped`
(только в kit), `mark_backfill_finished`/`distinct_sources` (только в app).

Реализация теперь одна — `scraper_kit.orchestration.runs`; в неё перенесены
app-only функции. `app.services.scrape_runs` — алиас kit-модуля через
sys.modules, а не реэкспорт имён: реэкспорт разводит патч-цели
(`patch("app.services.scrape_runs.sentry_sdk")`, `patch.object(runs_mod,
"mark_done")` правили бы глобаль модуля-обёртки, а тело функции читает
глобаль kit'а) — тест остался бы зелёным, не подменив ничего. С алиасом оба
имени ведут в единственную реализацию, и ни один из ~40 вызывающих и ~30
патч-сайтов в тестах не правится.

Победила семантика мержа: у строки прогона несколько писателей (пульс,
финализатор, дрейн), каждый знает лишь свои ключи, и замена теряла чужие —
чекпоинт done_buckets (#930), метку interrupted (#3391), замер из пульса
(#3384). Обратной зависимости («вызывающий рассчитывает, что финализатор
УДАЛИТ ключ заменой») нет: строка создаётся пустой в create_run, резюм читает
counters ПРЕДЫДУЩЕГО прогона по его id.

Тесты по значению на обоих путях импорта (двойник сессии читает SQL: `||`
против CAST, WHERE-гейт из текста): пульс {a:5} + mark_failed {b:1} → {a,b};
пульс/финализатор по финализированной строке — no-op; mark_cancelled
отказывает источнику, который отмену не опрашивает. На main эти тесты
красные для app-пути.

Комментарии в app/services/scheduler.py и kit/pipeline.py, утверждавшие про
живого «перезаписывающего двойника», приведены в соответствие.
2026-09-06 11:45:56 +05:00
8a5f75d5f4 fix(tradein/estimator): yandex/cian valuation в /estimate через пул прокси, пустой пул не роняет ответ (#3398)
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
Тот же корень, что у IMV (#3386 / PR #3397): оба вызова шли БЕЗ `proxy_provider`
— `YandexValuationScraper(RealScraperConfig(), delay_provider=...)` и
`estimate_via_cian_valuation(**_c_kwargs)`. `providers/_proxy.py::curl_proxy_url`
считает `use_pool = флаг AND provider is not None`, поэтому пул был выключен по
построению, а curl уходил на env-прокси SCRAPER_PROXY_URL (выключенный узел
#2613): на проде 06.09 каждая проба `/estimate` давала `curl_cffi ProxyError:
CONNECT tunnel failed, response 407`, а в логах это читалось как
«yandex_valuation: empty result» — оба источника мертвы с 02.09.

Kit-стороне правки не нужны: `YandexValuationScraper.__init__` и
`estimate_via_cian_valuation` уже принимают kwarg-only `proxy_provider` и сами
зовут `curl_proxy_url` (yandex — lease на сессию __aenter__/__aexit__, cian —
lease на вызов, release в finally). Провайдер берётся из module-level импорта
`RealProxyProvider` (он stateless: короткая сессия на операцию), поэтому один
инстанс в `_c_kwargs` покрывает и основной вызов, и отложенную фоновую догрузку.

Мягкая деградация сохранена и стала честной в логах: `NoProxyAvailableError`
(проверка по цепочке причин `caused_by_no_proxy`) → WARNING «пул прокси пуст —
продолжаем без Yandex/Cian» и прежний None-путь, а не ERROR «fetch failed» /
«lookup failed» (запрос вообще не уходил — GlitchTip-событие тут было бы шумом).
Строка «yandex_valuation: empty result» переписана: None неразличимо «дом не
найден» и «фетч не дошёл», причина — в строке scraper_kit выше.

Тесты (7, по значению; на main все красные): оба call site получают провайдера;
пустой пул в production → оценка без этих источников, без исключения, HTTP не
уходит, в логе «пул прокси пуст»; lease освобождён ровно один раз на успехе и на
ошибке фетча — через настоящий `curl_proxy_url`, а не мок провайдера.
2026-09-06 11:30:28 +05:00
075cec4b57 Merge pull request 'fix(tradein/estimator): IMV-путь /estimate через пул прокси; пустой пул — честная деградация без IMV (#3386 п.в)' (#3397) from fix/3386-estimator-imv-proxy-pool into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m2s
Deploy Trade-In / build-backend (push) Successful in 1m39s
Deploy Trade-In / deploy (push) Successful in 1m14s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
2026-09-06 06:04:07 +00:00
33cad4f82e fix(#3386): curl_proxy_url — health=False и на отмене (BaseException); admin IMV через пул; тест без create=True
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m57s
2026-09-06 10:57:08 +05:00
e1f86e3c1b Merge pull request 'fix(scraper-kit/cian): фаза houses cian_city_sweep через пул прокси + стоп на пустом пуле (#3394)' (#3396) from fix/3394-cian-sweep-houses-proxy-pool into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m6s
Deploy Trade-In / build-backend (push) Successful in 1m48s
Deploy Trade-In / deploy (push) Successful in 3m1s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-06 05:53:37 +00:00
fd4bd65126 Merge pull request 'fix(tradein): прогоны, оборванные деплоем (interrupted=1), невидимы лестницам стриков и honest-status-гейтам (#3393)' (#3395) from fix/3393-interrupted-runs-outside-streaks into main
Some checks failed
Deploy Trade-In / changes (push) Has been cancelled
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / build-browser (push) Has been cancelled
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / deploy-status (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
2026-09-06 05:53:24 +00:00
6f97995140 fix(tradein/estimator): IMV-путь берёт прокси из пула, пустой пул не ломает /estimate (#3386)
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Successful in 4m55s
Оба вызова `evaluate_via_imv` в `_get_or_fetch_imv_cached` шли без
`proxy_provider`, а `providers/_proxy.py::curl_proxy_url` считает
`use_pool = флаг AND provider is not None` — пул был выключен по построению,
curl-сессия уходила на env-прокси SCRAPER_PROXY_URL (мёртвый узел, #2613).

Провайдер берётся из уже существующего module-level импорта
`app.services.scraper_adapters` (в estimator цикла нет, в отличие от
house_imv_backfill — там lazy import вынужденный). Lease — один на вызов IMV,
acquire/release внутри `curl_proxy_url`, release в finally на всех выходах.

Пустой пул в проде (`NoProxyAvailableError`, в т.ч. завёрнутый — проверка по
цепочке причин `caused_by_no_proxy`) остаётся graceful: `_get_or_fetch_imv_cached`
возвращает None, ответ отдаётся без IMV-якоря. Причина в логе теперь честная —
«пул прокси пуст», а не «fetch failed» (запрос не уходил вовсе).
2026-09-06 10:32:42 +05:00
fdae823762 fix(#3394): стоп по пустому пулу — по цепочке причин на уровне якоря; один errors_count; ban_kind=infra под тестом
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m55s
2026-09-06 10:15:43 +05:00
ba4fb03a05 fix(#3393): capped по сканированному окну — interrupted-строка не глушит лестницу навсегда; контроль zero-сторожа
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m55s
2026-09-06 10:07:29 +05:00
e12ece8fe6 fix(tradein/cian): houses-фаза cian_city_sweep идёт через пул прокси + стоп на пустом пуле (#3394)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m59s
`run_cian_city_sweep` звал `fetch_newbuilding(zhk_url, config=config)` без
`proxy_provider`, хотя провайдер лежит в аргументах самого свипа и соседние
фазы (SERP через CianScraper, detail через cian_fetch_detail) его передают.
Внутри это давало `build_browser_fetcher(config, "cian", proxy_provider=None)`
→ `use_pool` эффективно False → POST /fetch без "proxy" → сайдкар брал свой
env-узел SCRAPER_PROXY_URL, на проде выключенный (407 → camoufox InvalidIP →
/fetch 503, факт #3386). Фаза houses давала 0/30 с 02.09 (run 6179: 24 ×
`houses failed ... 503 Service Unavailable`), строк `override=True` в логах
сайдкара по ней не было ни одной. Остальные вызывающие `fetch_newbuilding` /
`resolve_cian_zhk_url_via_search` провайдер уже передают (#2767/#2830/#3382),
этот вызов был последним мимо пула.

Второе: пустой пул поднимается ДО запроса, следующий дом упрётся ровно в то
же самое — общий `except Exception` на дом превращал это в 30 одинаковых
houses_failed и прогон уходил в 'done'. Теперь NoProxyAvailableError рвёт
фазу и свип: `no_proxy_stop=1` в counters, mark_banned с ban_kind='infra' и
сохранённым done_buckets (образец — #3389 yandex-nb-sweep, #3382). Ветка
стоит ДО generic-except, иначе наш отказ инфраструктуры читался бы как
«IP likely blocked» — бан площадки.
2026-09-06 09:45:56 +05:00
5f94bf0eeb fix(tradein/scrape_runs): оборванный деплоем прогон вне лестниц стриков и honest-status (#3393)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m57s
После #3392 SIGTERM-дрейн финализирует in-flight прогоны штатными
mark_done/mark_failed с counters.interrupted=1 — раньше они оставались
'running' → 'zombie' и сторожей не касались. В популяции стриков эти строки
судят частичные счётчики:

  - detail-бэкфилл, убитый на 20% отказов, получал 'failed' с диагнозом
    «сбор деградировал» (_failed_ratio_too_high) — диагноз про площадку,
    которого никто не измерял, — и входил в стрик неудач;
  - cian-бэкфилл (без attempted) получал 'done' и ОБНУЛЯЛ стрик банов —
    ровно вред, задокументированный в orchestration/scheduler.py:99
    («5 банов подряд обнулил один 'cancelled' 09.08»).

Прогон с меткой interrupted теперь считается так, будто его не было: он
стрик ни продлевает, ни обнуляет (обе лестницы, обе копии модуля), а три
honest-status-гейта в mark_done пропускаются — статус остаётся 'done' с
меткой interrupted (конвенция #3319/#3333/#3355), причина в логе.
'cancelled' оставлен как был: там прогон прервал человек.

SELECT сторожа неудач дополнен колонкой counters — по ней и идёт отбор.
2026-09-06 09:42:27 +05:00
aeb1b2b3b9 Merge pull request 'fix(scraper-kit/scheduler): SIGTERM-drain помечает in-flight прогоны interrupted=1 при hard-cancel; честный лог drain'а (#3391)' (#3392) from fix/drain-marks-inflight-app-tasks into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 2m7s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-09-06 04:14:06 +00:00
a7362bc5fa fix(#3391): пульс не пишет по финализированной строке; отмена в теле тика тоже помечает; rollback/try/честный лог
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m56s
Пять замечаний deep-ревью к PR #3392, ровно они.

1. Пульс стирал метку дрейна. `update_heartbeat` обеих копий бил `WHERE id = :run_id`
   без гейта по статусу, а app-копия counters ЗАМЕНЯЕТ (#3390): задача, помеченная
   `interrupted`, но ещё живая (ветка таймаута drain_inflight отдаёт её внешнему
   hard-cancel'у — несколько итераций спустя, пульс на каждый батч —
   app/services/scheduler.py:141), следующим же ударом стирала метку, и оборванный
   прогон снова читался как полный проход. Гейт — `IN ('running', 'cancelled')`, а не
   `= 'running'`: 'cancelled' финализирует строку, но задача встаёт лишь на ближайшей
   границе якоря, и её последний пульс — ЕДИНСТВЕННЫЙ писатель чекпоинта в этот момент
   (pipeline.py:1308/2368/2986/4488, mark_done там уже no-op по своему гейту), а
   'cancelled' входит в _RESUME_STATUSES — сужение до 'running' молча съело бы точку
   возобновления у каждой отмены. Возвращаемое значение update_heartbeat не читает
   никто (обе копии -> None, ни одного присваивания на 130 сайтах вызова), так что
   «0 строк обновлено» ломать нечего; no-op логируется WARNING'ом, как у mark_done.

2. Отмена вне drain_inflight. Hard-cancel приходит по расписанию grace'а
   scheduler_main, а не по нашему, и может застать ТЕЛО тика (reap / stale-digest /
   `_dispatch` с сетевым pre_claim). `except Exception` тика CancelledError не ловит,
   до `await ctx.drain_inflight()` дело не доходит — строки оставались 'running'.
   Тело вынесено в `_tick_loop`, `scheduler_loop` ловит CancelledError, помечает
   in-flight и пробрасывает отмену.

3. rollback в except пометки: отказавший statement оставляет сессию в aborted-tx, и
   первый же непроходимый run_id утаскивал все следующие (образец — defensive rollback
   в mark_failed/mark_banned).

4. session_factory()/db.close() втянуты в try: исключение оттуда ЗАМЕНИЛО бы собой
   CancelledError, а suppress(CancelledError) в scheduler_main его не глушит — процесс
   уходил бы с трейсбеком вместо чистого drain-выхода.

5. WARNING перечисляет marked_ids, а не весь run_ids (там были и пропущенные по
   статусу). В докстринге назван потолок: SELECT синхронный, у движка нет ни connect-,
   ни statement-таймаута (app/core/db.py:8-19) — недоступная БД блокирует луп до
   SIGKILL'а через 20 с docker-grace; данные при этом не хуже прежних (строки остаются
   'running' → boot-reap).

Тесты — по значению, не по факту вызова; на исходниках 30e3bacc краснеют все пять:
'listings_processed' дописан в финализированную строку (kit), KeyError: 'interrupted'
(app), assert 'running' == 'done' (отмена в теле тика), assert 0 == 1 (второй run_id
не помечен после отказа первого), RuntimeError наружу (недоступная БД).
2026-09-06 09:03:56 +05:00
30e3bacc5e fix(tradein/scheduler): SIGTERM-drain снимает с 'running' in-flight app-task'и (#3391)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m55s
Прод 07.09 02:36 UTC, первый настоящий drain после #3363: hard-cancel из
scheduler_main оборвал дрейн, и два бэкфилла (cian_detail_backfill 6167,
cian_history_backfill 6173) остались в scrape_runs со статусом 'running' —
boot-reap следующего контейнера сделал их 'zombie' (boot_reaped=true), метки
interrupted не было. interrupted=1 при дрейне писали только kit-пайплайны и
DKP-импорт: у задач, чьё тело живёт в app, ставить её было некому.

Метка ставится в единственной точке, через которую проходит любая detached
run-задача — SchedulerContext.drain_inflight: и по истечении
_CHILD_DRAIN_TIMEOUT_S, и в обработчике CancelledError (тот самый прод-путь).
run_id берётся из нового реестра {task: run_id}, который заполняет _dispatch
сразу после claim'а; claim-логика не тронута. Статус строки перечитывается
перед записью, поэтому успевший финализироваться сам прогон не
перезаписывается, а counters читаются из строки и дописываются — app-копия
mark_done их ЗАМЕНЯЕТ (#3390), голая {"interrupted": 1} стёрла бы чекпоинт.

scheduler_main: _await_scheduler возвращает признак hard-cancel'а, и строка
«scheduler drained cleanly (SIGTERM)» больше не печатается сразу за WARNING'ом
о превышении grace — на проде эти две строки стояли подряд и противоречили
друг другу.

Запас времени на запись: docker stop_grace_period 120s − _DRAIN_TIMEOUT_S 100s
= 20 с после hard-cancel'а, запись синхронная (несколько statement'ов).
2026-09-06 07:56:35 +05:00
3fe2d310b2 Merge pull request 'fix(tradein/yandex): newbuilding resolve/fetch_jk через пул прокси + стоп на пустом пуле (#3197, точки 2-3)' (#3389) from fix/3197-yandex-newbuilding-proxy-pool into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 7m53s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
2026-09-06 02:24:44 +00:00
6444ff7e37 Merge pull request 'fix(tradein): пустой пул на старте батча пишет no_proxy_stop у cian/domclick/avito detail-бэкфиллов (#3384)' (#3388) from fix/3384-no-proxy-at-batch-start into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-06 02:24:38 +00:00
47dabec678 fix(#3384): cian — в mark_failed уходит только измеренное, нули heartbeat не затираются; стаб пула как прод
All checks were successful
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m0s
Ревью нашло у цианa (в отличие от avito/домклика с живым counters.to_dict()) старый
словарь в общем except: реальные значения присваиваются уже ПОСЛЕ возврата из
backfill_cian_history, а отказ бывает и посреди неё — пул опустел между стадиями, упал
SELECT домов. Тогда поверх измеренного в запись прогона уезжали нули, и SQL-разбор
простоя (#3288/#3367) читал «к площадке не ходили» про прогон, который ходил.

Механизм оказался хуже описанного в ревью: mark_failed мержит counters (`counters ||
:counters`) только в kit-копии, а cian/avito/домклик зовут app.services.scrape_runs, где
UPDATE counters ЗАМЕНЯЕТ (scrape_runs.py:738). Поэтому «отдать только {no_proxy_stop: 1}»
стёрло бы измеренное начисто; вместо этого _heartbeat кладёт свой снимок в те же
counters (nonlocal), и в mark_failed уезжает последнее измеренное + флаг.

Тест по значению: heartbeat записал listings_processed=5, дальше пул пуст → в jsonb-
payload mark_failed должно остаться 5, а не 0 (проверяется сам payload UPDATE'а,
runs_mod настоящий). На HEAD ветки красный: `counters={'listings_processed': 0, ...,
'no_proxy_stop': 1}: нули поверх измеренных 5`.

Стаб пула приведён к проду: RealProxyProvider.acquire при пустом пуле ВОЗВРАЩАЕТ None
(scraper_adapters.py:230), а не поднимает, — исключение из провайдера глотал
`except Exception` в _acquire_lease и приходило к тому же отказу другим путём. Теперь
NoProxyAvailableError рождается там же, где в проде (browser_fetcher.py:712, ветка
`lease is None and use_pool and production`) — проверено прогоном против до-#3384
исходников: все три теста красные, трейс из _acquire_lease.

_prod_pool патчит app.core.config.settings явно + assert, что все три задачи держат тот
же синглтон: раньше патч через chb.settings выглядел настройкой одного циана.
2026-09-06 06:27:45 +05:00
939a8a1646 fix(#3197): no_proxy_stop=1 как у соседей, INFO на финале хендлера, докстринг фабрики
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m57s
2026-09-06 06:27:40 +05:00
cb5714fff0 fix(#3197): yandex newbuilding — обе точки в пул прокси + стоп на пустом пуле
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m56s
`YandexNewbuildingScraper.fetch_jk` и `resolve_yandex_jk_slug` строили
`BrowserFetcher(source="yandex", endpoint=...)` без proxy_provider/use_pool/
environment — сайдкар брал env-узел SCRAPER_PROXY_URL, на проде выключенный
(407 → camoufox InvalidIP → /fetch 503, факт #3386), то есть путь шёл мимо пула
целиком, а прод-отказ «пул пуст» (#2616) был мёртв: он смотрит на environment,
который до конструктора не доезжал. Обе точки собраны через
build_browser_fetcher(config, "yandex", proxy_provider=...), как yandex/serp.py.

Второе: общий `except Exception` в обеих функциях глотал NoProxyAvailableError и
возвращал None — прогон, не ходивший к площадке, перебирал все ЖК и уходил в
'done'. Теперь «пул пуст» пробрасывается наружу (caused_by_no_proxy), sweep
обрывается на первом доме с no_proxy_stop, а прогон финализируется как failed
(образец дефекта — #3382, cian/detail.py).
2026-09-06 05:55:36 +05:00
caa3b8b056 fix(tradein): пустой пул ДО первого объявления теряет диагноз в записи прогона (#3384)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m55s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
Lease берётся один раз в BrowserFetcher.__aenter__, поэтому на проде с пустым пулом
NoProxyAvailableError вылетает из самого `async with` — ДО первой карточки и мимо
стоп-механики внутри цикла (no_proxy_stop = True; break), которая и пишет
counters.no_proxy_stop. Общий `except Exception` ловил его и делал mark_failed с
нулевыми counters без ключа: прогон, который к площадке не ходил вообще, в SQL-разборе
простоя по counters.no_proxy_stop (#3288/#3367) не находится.

Дыра одинаковая у всех трёх бэкфиллов (avito её тоже не обрабатывал: __aenter__
вызывается напрямую строкой 427, отказ уходит в тот же общий except). Правка — в трёх
уже существующих обработчиках, которые и так зовут mark_failed: ключ no_proxy_stop=1
при caused_by_no_proxy(exc). Оборачивать `async with` в try/except пришлось бы с
переносом ~200 строк тела под новый отступ в каждом файле, и покрывало бы только
падение на входе; здесь ловится любой путь мимо цикла.

Тест — через настоящий BrowserFetcher: подделан только провайдер прокси (его acquire
поднимает NoProxyAvailableError), отказ рождается там же, где в проде. Проверяется
failed + no_proxy_stop=1 + attempted=0 (у циана listings_processed=0) и ноль POST'ов
в сайдкар.

Closes #3384
2026-09-06 05:48:32 +05:00
33444b7eab Merge pull request 'fix(tradein): гейт против сдвига разряда (×10) в offer_price_history у всех писателей + миграция 286 (#3376)' (#3383) from fix/3376-price-history-decimal-slips into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Successful in 4m4s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 1m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
2026-09-06 00:19:33 +00:00
1b86ba7e06 fix(#3376): первая точка — только по третьей точке истории; числа под критерием
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m58s
2026-09-06 05:12:30 +05:00
dc63b1830b docs(#3376): ссылка на follow-up #3385 вместо TODO в yandex_price_history
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m52s
2026-09-05 22:57:04 +00:00
721ceb9876 fix(tradein): выборка миграции 286 повторяет гейт 1:1, правило первой точки (#3376)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m56s
Ревью нашло, что миграция и код ловили РАЗНОЕ. Миграция брала базой предыдущую
СЫРУЮ строку (lag), гейт — предыдущую ОСТАВЛЕННУЮ. На 1M→10M→1M→10M (цена 1M)
lag-версия удаляла честную точку, на 1M→10M→1.05M→9.9M — не была идемпотентной
(второй прогон доедал 9.9M). Теперь кандидаты выбирает PL/pgSQL-цикл, пошагово
повторяющий drop_decimal_slips, а правило первой точки — отдельным INSERT..SELECT
уже по ОСТАВШИМСЯ строкам.

Правило первой точки — из прод-разбора: 12 из 20 остатков domklik это серии вида
330 000 → 3 300 000 (текущая цена 3 300 000) и 420 000 → 4 200 000 → 4 500 000,
где дефектная точка ПЕРВАЯ и базы слева у неё нет. Свидетелей по-прежнему два:
×10 ко второй точке И подтверждение второй третьей-или-текущей-ценой. Решение по
первой точке принимается по kept-серии, а не по сырой, — иначе гейт теряет
идемпотентность (перебор ловит 1122 таких прогона).

Идемпотентность доказана НА ГЕЙТЕ: property-тест gate(gate(s)) == gate(s) по всем
сериям длины 2-6 (19 525 серий × 3 текущие цены). Фальсифицирован обеими
поломками — сырая база даёт 136 красных прогонов, сырые соседи первой точки 1122.
Раз SQL зеркалит гейт, свойство переносится на миграцию.

Ещё в 286: третий свидетель ПРОТИВ удаления (цена подтверждена триггерной строкой
того же объявления — значит она реально наблюдалась в listings.price_rub) и
финальный шаг |diff_percent| > 100 → NULL по всем источникам, то же правило, что
validate_diff_percent на записи. Ожидаемое число удалений в шапке — 35 + ~12 из
двухсвидетельского предзамера, а не 76 (то была односвидетельская цифра).
Прогон обеих фаз дважды с ROLLBACK — tradein-mvp/scripts/sql/286_dryrun.sql.

yandex: проводка гейта снята как мёртвая. На том пути серия из двух точек, а
свидетель последней — текущая цена лота, то есть она же сама: ветка по построению
не могла выбросить ничего. Оставлен честный комментарий-потолок и ссылка на
follow-up (отлов требует DELETE на следующем наблюдении).

cian: после выброса точки соседу пересчитывается diff_percent (было только у
domclick). domclick: цена листинга берётся RETURNING'ом у UPDATE вместо отдельного
SELECT по PK, пересчёт вынесен в общий recompute_diff_percent с гейтом на пустую
цену (ручной ingest кладёт price_changes из JSONL без валидации).
2026-09-06 03:53:08 +05:00
6875992746 Merge pull request 'fix(tradein/compose): USE_PROXY_POOL_CURL/BROWSER и сервису backend — не только scraper (#3386)' (#3387) from fix/backend-proxy-pool-flags into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Successful in 36s
Deploy Trade-In / build-frontend (push) Successful in 2m8s
Deploy Trade-In / test (push) Successful in 4m3s
Deploy Trade-In / build-backend (push) Successful in 31s
Deploy Trade-In / deploy (push) Successful in 1m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-05 22:30:40 +00:00
b65f75c1fc fix(tradein/compose): USE_PROXY_POOL_* и для backend, не только scraper
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
`USE_PROXY_POOL_CURL`/`USE_PROXY_POOL_BROWSER` объявлены только у сервиса
scraper (#2126/#2160). В процессе backend оба флага оставались False, из-за
чего `_kit_proxy_provider()` возвращал None, а `curl_proxy_url` /
`BrowserFetcher._acquire_lease` игнорировали переданный proxy_provider и
уходили на env-фолбэк `SCRAPER_PROXY_URL` — на проде это выключенный узел
(407) → camoufox InvalidIP, 83 провала запуска сайдкара за 3 ч 05.09.

Правка — только объявление тех же двух переменных в `environment:` backend.
`environment:` перекрывает `env_file` (backend/.env.runtime), значений
USE_PROXY_POOL_* в runtime-env нет, так что конфликта нет.

Гейты: yaml.safe_load rc=0, scripts/check-compose-ambiguous-hosts.py rc=0.

Refs: issue «tradein-backend без USE_PROXY_POOL_* → браузерные скрейпы на мёртвый env-прокси»

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 03:16:26 +05:00
b21b1a928e Merge pull request 'fix(tradein/cian): cian_history_backfill идёт через пул прокси + стоп на пустом пуле (#3197, точка 1 из 3)' (#3382) from fix/3197-cian-history-backfill-proxy-pool into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m0s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 1m36s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-05 22:13:09 +00:00
0c21e522c0 fix(#3197): пустой пул пробрасывается из cian fetch_detail; тест через настоящий fetch_detail
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m58s
2026-09-06 03:05:32 +05:00
ea4fb88b66 Merge pull request 'fix(scraper-kit/yandex): full-load on_bucket отдаёт список лотов бакета, а не int (#3375)' (#3378) from fix/3375-yandex-fullload-on-bucket-contract into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m28s
Deploy Trade-In / build-backend (push) Successful in 2m19s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / test (push) Successful in 4m10s
Deploy Trade-In / deploy (push) Successful in 2m43s
Deploy Trade-In / deploy-status (push) Successful in 1s
2026-09-05 21:46:35 +00:00
2426b529a8 Merge pull request 'fix(browser-sidecar): бан-страница площадки отдаётся 403, а не 500 (#3288 п.C)' (#3379) from fix/3288-sidecar-ban-status-code into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-05 21:46:28 +00:00
a5f60eaaa8 test(#3288): фикстуры бан-страницы на 403 и 500, фальсифицируемые проверки текста; комментарий про путь текста
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m32s
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
2026-09-06 02:38:29 +05:00
235cc3065e test(#3375): стаб доходит до _degraded и многостраничного листа; комментарий про красноту
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m9s
2026-09-06 02:38:12 +05:00
142967d064 fix(tradein): гейт против сдвига разряда в offer_price_history (#3376)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
В priceHistory источников встречаются точки ровно ×10/÷10 к соседям с
возвратом к базе следующей же точкой — это потерянный разряд у источника,
а не рынок. Прод-замер 06.09.2026: 76 таких строк у 55 объявлений
(domklik 48, cian 21, yandex 6; единственная avito-строка — триггерная).
Читатели колонки — медианный торг лендинга (#3223), админка, /scrapers.

drop_decimal_slips живёт рядом с validate_diff_percent — на той же единой
границе записи, что и гейт #3225, и подключён ко ВСЕМ писателям истории
(domclick/detail.py, cian/detail.py, yandex_price_history.py). Критерий
требует двух свидетелей: скачок ×10 к предыдущей точке И возврат к базе у
следующей; у последней точки серии свидетель — текущая цена объявления,
нет и её → точку не трогаем (без второго свидетеля ×10 может быть честной
сменой цены). У domklik после выброса пересчитывается diff_percent
соседа: парсер считал его от базы, которой больше нет.

Миграция 286 чистит уже собранные строки тем же критерием и только у
загрузчика (change_time <> recorded_at): триггерные строки — это живые
смены listings.price_rub, у них другая база отсчёта (см. 285). Падает,
если кандидатов больше 200.
2026-09-06 02:35:39 +05:00
8129e21b95 fix(tradein/cian): суточный бэкфилл истории — в прокси-пул, а не в env-прокси (#3197)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m51s
BrowserFetcher(source="cian") в cian_history_backfill конструировался без
proxy_provider/use_pool/environment — трёх аргументов, которые кладут "proxy" в тело
POST /fetch. Сайдкар брал свой env-прокси (SCRAPER_PROXY_URL): пул из 4 узлов, его
баны и ротация проходили мимо, а прод-отказ «пул пуст → не ходить на env/direct»
(#2616) на этом пути был мёртв, потому что смотрит на environment. Проводка теперь
как у соседей — domclick_detail_backfill и house_imv_backfill.

Ожившему отказу нужен обработчик: NoProxyAvailableError ловился общим except на
объявление, и батч крутил впустую весь список (пул пуст с первого — значит пуст и на
1000-м). Распознаём по цепочке причин, обрываем прогон, counters.no_proxy_stop=1 и
mark_failed вместо mark_banned — отказ нашей стороны не должен записываться как бан
Циана. Дома и оценки после стопа пропускаем: они идут через тот же пул.

caused_by_no_proxy вынесен в scraper_kit.proxy_errors (у avito #3288 и domclick #3283
живут приватные копии — их схлопывание отдельной правкой).
2026-09-06 02:27:03 +05:00
8a969c06c2 Merge pull request 'fix(deploy-metrics): пересоздавать alloy после выката конфига + гейт по иноду (#3380)' (#3381) from fix/3380-deploy-metrics-alloy-recreate into main
All checks were successful
Deploy Metrics / server (push) Successful in 14s
Deploy Metrics / agent-apps (push) Successful in 20s
Deploy Metrics / agent-infra (push) Successful in 19s
2026-09-05 21:17:52 +00:00
c9a9085df6 fix(deploy-metrics): пересоздавать alloy после выката конфига, гейт по иноду
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Конфиг Alloy смонтирован бинд-маунтом ОДНОГО файла, а `git reset --hard`
в деплое не правит его на месте, а пишет новым инодом. `up -d` сравнивает
описание сервиса, содержимое бинд-маунта в сравнение не входит — контейнер
не пересоздаётся и продолжает читать прежний, уже удалённый инод.

Отказ беззвучный: на диске новый конфиг, деплой зелёный, а фильтрация идёт
по старому. Пойман на проде 06.09: на Beget после success-деплоя контейнер
держал инод от 26.08, и новый фильтр заработал только после ручного
`docker restart gendesign-alloy`.

Тот же приём, что у alertmanager в джобе server. Позиции чтения журнала
лежат в томе alloy_data и пересоздание переживают.

Гейт после подъёма сверяет инод файла на хосте с инодом внутри контейнера:
расхождение = деплой красный, а не «зелёный со старым конфигом».
2026-09-06 02:11:38 +05:00
1200af58f5 fix(tradein): сайдкар отдаёт подтверждённый бан площадки кодом 403, не 500
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m31s
CI Trade-In / backend-tests (pull_request) Successful in 4m58s
Единственный код 500 означал и «площадка забанила», и «сайдкар упал»:
разбор каждого инцидента начинался с ложного следа — в лог провайдера и в
houses.imv_error_reason уезжала httpx-преамбула «Server error '500 Internal
Server Error' for url 'http://tradein-browser:3000/fetch'», то есть текст
ошибки называл гонца, а не виновника.

- browser/server.py: BanPageDetectedError → 403 (доступ ограничен площадкой);
  451 — про юридическую блокировку, это не она. Своих 403 сайдкар не отдаёт
  (400/422/503), код однозначен. classify_browser_probe не задета: у неё любой
  status >= 400 → "sidecar". Тело не меняется — ban_page/status на месте.
- scraper_kit/browser_fetcher.py: текст SidecarBanPageError теперь свой —
  «площадка отдала бан-страницу (upstream 403, ответ сайдкара 403): …».
  Распознавание остаётся по ТЕЛУ и code-agnostic: tradein-browser — отдельный
  образ со своим деплоем, версии штатно расходятся на часы, и гейт по коду в
  этот час уводил бы отказ площадки в инфра-ветку.
- Тесты: 403 → SidecarBanPageError; старый 500 + ban_page → он же; чистый 500
  без ban_page → прежний инфра-диагноз; текст ошибки без «500»/«Server error».

Refs #3288 п.4
2026-09-06 01:58:02 +05:00
e04e315203 fix(scraper-kit): yandex on_bucket отдаёт лоты бакета, а не len(seen)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m3s
`_leaf`/`_degraded` звали on_bucket(bucket_key, len(seen), complete) — int
уезжал в run_yandex_full_load._on_bucket и дальше в save_listings
(`for lot in lots`) → TypeError, ручной full-load Яндекса не сохранял ничего.
Контракт выровнен по cian/avito: провайдер копит лоты бакета (новые в seen)
и отдаёт список. Parity-фикстура подменяла скрапер целиком и слала list по
построению — добавлен прогон run_yandex_full_load через НАСТОЯЩИЙ
YandexRealtyScraper (замокан только gate-JSON транспорт).

Closes #3375
2026-09-06 01:55:45 +05:00
1cff12cc71 Merge pull request 'fix(ops/alloy): второй слой маскировки query-секретов на сборщике логов (apps + infra) — defence-in-depth к #3353' (#3377) from fix/3354-alloy-query-secret-scrub into main
All checks were successful
Deploy Metrics / server (push) Successful in 28s
Deploy Metrics / agent-infra (push) Successful in 20s
Deploy Metrics / agent-apps (push) Successful in 21s
2026-09-05 20:53:42 +00:00
ed71292f03 fix(metrics): маскировать секреты из query-строки в Alloy до отправки в Loki
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Скруббер #3115 знал одну форму — пароль в DSN (scheme://user:pass@host).
Секрет в query-строке (`?secret=<64 hex>` вебхука GlitchTip, #3154) проходил
насквозь и оседал в Loki на 30 суток ретенции.

Приложение чистит это у себя (#3353), но фильтр стоит на логгере ОДНОГО
процесса. Второй слой на сборщике закрывает всё, что придёт мимо: sidecar,
чужой процесс, будущий логгер без фильтра. Множество имён параметров взято
из log_scrub.py дословно, включая суффиксные client_secret/refresh_token.

Группа захвата стоит на значении, а не на имени параметра: Alloy заменяет
содержимое ГРУПП, а не весь совпавший фрагмент. Группа вокруг `?secret=`
затёрла бы имя и оставила сам секрет — то есть ровно наоборот.

Стадия добавлена и в alloy-infra.alloy: на инфра-хосте Forgejo и GlitchTip
с их `?token=` в адресах пишут в тот же Loki, дефект там тот же.

Closes #3354
2026-09-06 01:46:53 +05:00
357c4348ac Merge pull request 'fix(tradein/rbac): аноним на admin-префиксе получает единый 404 — существование admin-ручек снаружи не перебирается' (#3371) from fix/3360-admin-prefix-anon-404 into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 11s
perimeter-smoke-mera / smoke (push) Failing after 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m3s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m59s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-05 20:32:05 +00:00
f129d52cbe Merge pull request 'fix(scraper-kit): yandex _leaf под CAP не пишется в done-леджер как complete — capped_buckets отдельным счётчиком' (#3373) from fix/3368-yandex-leaf-complete-flag into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m59s
Deploy Trade-In / deploy (push) Successful in 2m9s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m39s
Deploy Trade-In / perimeter-smoke (push) Failing after 11s
2026-09-05 20:22:49 +00:00
1c8035934d Merge pull request 'fix(scraper-kit): domclick city sweep — done_buckets во всех семи выходах, а не только в дрейне и одном бане' (#3372) from fix/3369-domclick-cancel-checkpoint into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-09-05 20:19:44 +00:00
6e0a34a683 Merge remote-tracking branch 'origin/main' into HEAD
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m10s
# Conflicts:
#	scripts/smoke-mera-perimeter.sh
2026-09-06 01:16:29 +05:00
9519c89d92 fix(scraper-kit/yandex): выпавшая страница leaf'а делает бакет неполным; capped_buckets переживает дрейн
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m5s
Ревью #3373, два minor:
1. `_leaf` считал полноту только по потолку страниц — упавшая страница
   (`payload=None` → `[]`) молча уходила в чекпоинт как собранная. Теперь
   паритет с cian по-настоящему: `complete = not capped and dropped_pages == 0`.
2. `counters.capped_buckets` присваивался ПОСЛЕ await — cancel/shutdown
   (RuntimeError из `_on_bucket`) уносил управление мимо строки. Перенесено в
   `finally`, счётчик виден в финальном payload дрейна.

Нит: `ceil(total / 20)` → `_GATE_PAGE_SIZE`.

Тесты по значению: бакет с 1 выпавшей страницей из 3 — не в done-леджере
(+ контроль: 3 успешных страницы по-прежнему complete); дрейн после обрезанного
бакета → `capped_buckets == 1` в финальных counters. Фейк `_DrainAtFirstBucket`
(#3355) теперь объявляет `capped_buckets` явно: его catch-all `__getattr__`
отдавал корутину на любое имя и ронял сериализацию counters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:16:14 +05:00
c49d6386c0 Merge pull request 'fix(tradein/domklik): diff_percent — проценты из соседних price_rub вместо рублей источника, гейт отвергает |x|>100, миграция 285 пересчитывает историю' (#3370) from fix/3225-domklik-diff-percent into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m14s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-09-05 20:14:18 +00:00
140caa4ddf fix(domclick): честная мотивировка чекпоинта + один _payload() на все ветки
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m3s
Правка ревью к PR #3372: комментарии и текст коммита обещали восстановление
потери, которой нет. Факт: pipeline.py импортирует scraper_kit.orchestration.runs,
и все четыре его писателя МЕРЖАТ jsonb (`counters = COALESCE(counters,'{}') ||
CAST(:counters AS jsonb)`, runs.py:708/776/817/880), а _pick_resume наследует
done_buckets при claim (scheduler.py:741-748, #3074) — чекпоинт domclick в БД не
терялся. Перезаписывающий двойник app/services/scrape_runs.py этой функцией не
вызывается.

Поэтому done_buckets в каждом payload — единообразие и защита от смены писателя
(если запись пойдёт через app-копию или kit-писатель станет перезаписывающим), а
не спасение данных. Комментарии переписаны под этот факт.

Дрейн-ветка и NoProxyAvailableError строили dict вручную — переведены на
{**_payload(), "interrupted": 1} и _payload(), чтобы «один _payload() на функцию»
было правдой.

Тест: к cancel добавлены ассерты на финальный heartbeat и mark_done — оба
payload'а несут унаследованное ∪ пройденное этим прогоном.

Refs #3355, PR #3363. Closes #3369
2026-09-06 01:13:15 +05:00
afa93f9a1e fix(tradein): миграция 285 не трогает строки триггера (deep-review блокер)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m56s
Утверждение «формула триггера = формула миграции» было ложным по ИСТОЧНИКУ базы:
record_listing_price_change (131:76-86) считает процент от listings.OLD.price_rub,
а не от предыдущей строки offer_price_history. Пересчёт по lag() портил честные
значения: у листинга, чья история начинается с триггерной строки, lag() = NULL →
−0.82 уходил в NULL; у триггерной строки с соседом-строкой загрузчика база чужая.

Теперь пересчитываем и берём как базу ТОЛЬКО строки загрузчика
(change_time <> recorded_at — триггер ставит обе метки одним now()). Это ровно то,
что делает починенный код: процент внутри истории самой карточки. Окно lag()
сужено до листингов с domklik-строками — иначе оконная функция шла по всей
таблице под lock_timeout = 5s и валила деплой.

Признак закреплён тестом на INSERT загрузчика (recorded_at не указан → DEFAULT
NOW()); при добавлении recorded_at в INSERT тест краснеет — проверено.
В шапке миграции отмечена асимметрия: старые cian-строки с |x| > 100 не чиним.
2026-09-06 01:08:09 +05:00
3d6bcb9ee1 Merge pull request 'chore(smoke): периметр-смоук бьёт в реальный роут истории — /api/v1/trade-in/history, а не в несуществующий /api/v1/history' (#3374) from fix/smoke-history-path into main
Some checks failed
perimeter-smoke-mera / smoke (push) Failing after 10s
2026-09-05 20:08:02 +00:00
d5fb675858 test(rbac): сделать db_only-ветку anon-cloak фальсифицируемой
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m1s
Ассерт `/api/v1/me → 401` проходил в обоих мирах: если monkeypatch
auth_mode тихо не подействует, запрос без заголовка уходит в ветку «нет
заголовка» и даёт тот же 401 (и тот же 404 на admin-пути). Теперь
сверяется detail РАВЕНСТВОМ со строкой db_only-ветки; `in` не годится —
'valid session required' является подстрокой dual-текста.

Мутация (monkeypatch режима закомментирован) даёт красный:
assert 'no authenticated user (valid session required)' == 'valid session required'
2026-09-06 01:04:35 +05:00
01367c2a9e fix(smoke): бить в существующий роут /api/v1/trade-in/history
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Проверка «trade-in /api/v1/history — 401 anonymous» ходила по
несуществующему пути: ручка объявлена как @router.get("/history") в
app/api/v1/trade_in.py, а роутер подключён с префиксом /api/v1/trade-in,
то есть внешний путь — /trade-in/api/v1/trade-in/history.

Годы проверка была зелёной случайно: guard отвечал 401 на любой путь. После
#3352 несуществующий путь отдаёт 404, и проверка покраснела честно.
2026-09-06 01:04:13 +05:00
5ad0d1a304 fix(scraper-kit/yandex): обрезанный потолком страниц leaf не идёт в чекпоинт
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m54s
После #3362 degraded-ветка отмечается complete=False, а `_leaf` писал бакет как
полный, даже когда его пагинация упиралась в max_pages_per_bucket/_GATE_MAX_PAGES_CAP.
С containment-гейтом (#3358/#3359) такой ключ покрывает свой интервал целиком, и
резюм больше не заходит в полосу, чей хвост не читали ни разу.

Флаг полноты — как у cian: complete = pages_needed <= max_pages, передаётся в
on_bucket на обоих выходах leaf'а (_mark_bucket кладёт в done только complete).
Переполненный leaf возможен только там, где бисекции дробить нечем (размах <
min_bracket, открытый верхний брекет, потолок глубины) — это честный исход
«бакет неполон по построению», поэтому он ещё и считается отдельно
(scraper.capped_buckets → counters.capped_buckets): лечится не повтором прогона,
а порогами бисекции.

Closes #3368
2026-09-06 00:56:36 +05:00
b796702d9a fix(domclick): чекпоинт done_buckets во всех payload'ах свипа
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m5s
cancel-ветка run_domclick_city_sweep писала голый counters.to_dict(); писатель —
app-level scrape_runs с полной перезаписью (CAST(:counters AS jsonb)), а
'cancelled' входит в _RESUME_STATUSES → отменённый прогон закрывался с пустым
чекпоинтом и резюм пересобирал все корзины. Те же потери были у финального
heartbeat и у mark_banned/mark_failed/mark_done (banned/failed тоже
резюмируемы): они затирали чекпоинт, записанный после SERP-фазы.

Один _payload() на функцию — done_buckets едет в каждой записи counters.

Refs #3355, PR #3363. Closes #3369
2026-09-06 00:51:47 +05:00
990a59e469 fix(rbac): анониму на admin-префиксе отвечать 404, а не 401
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
#3360. Периметр /trade-in/api/v1/admin/* снаружи не срезан (caddy/sites/apps.caddy:
блок `handle /trade-in/api/*` стоит выше `import caddy/users.caddy.snippet`), и
срезать его нельзя: admin-UI кабинета зовёт эти пути ИЗ БРАУЗЕРА (12 файлов
tradein-mvp/frontend/src — scrapers/**, components/scrapers/**, admin-audit-api.ts,
GuardedRoute). Значит внешний аноним доходит до rbac_guard, а после #3324
(несуществующий путь → 404 роутера) 401 на существующей ручке стал оракулом:
перебором имён восстанавливался список admin-API.

Все три ветки «личность не установлена» (нет X-Authenticated-User, auth_mode=db_only,
подделанный заголовок без #2213-секрета) на admin-префиксе теперь отдают ровно то же,
что роутер даёт на несуществующий путь. Аутентифицированные не тронуты: admin — 200,
не-admin — 403 «admin only» (прятать наличие ручки от опознанного человека незачем).

Смоук периметра: пара admin-путей — существующий /admin/proxies и несуществующий
/admin/users — оба обязаны быть 404 снаружи.
2026-09-06 00:51:09 +05:00
769a36098b fix(tradein): считать domklik diff_percent из цен, отвергать не-проценты (#3225)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
offer_price_history.diff_percent у domklik содержал РУБЛИ: загрузчик карточки
клал поле источника priceHistory.diff как есть, а clamp_diff_percent только
зажимал его в ±999999.99 — заведомо неправдоподобное значение проходило молча.
Прод 29.08.2026: 8900 из 11 075 непустых значений с |diff| > 50, p50 = -50 010.

- парсер Домклика считает процент сам из соседних price_rub (сортировка по
  change_time); у самой ранней записи предыдущей цены нет -> NULL, не 0;
- clamp_diff_percent -> validate_diff_percent: |x| > 100 не зажимается, а
  отвергается (NULL + warning с listing_id и сырым значением). Гейт стоит в
  общем хелпере, поэтому закрывает и cian-путь;
- миграция 285 пересчитывает уже собранные domklik-строки оконной lag() по
  (listing_id, change_time); идемпотентна (UPDATE только IS DISTINCT FROM).

Closes #3225
2026-09-06 00:48:08 +05:00
7afaa12d75 Merge pull request 'fix(tradein/avito): брейкер добора считает долю только по отказам площадки — сайдкар/пустой пул не рвут прогон и не красят его banned' (#3367) from fix/3288-breaker-block-kind into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m3s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Failing after 32s
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 1s
2026-09-05 19:38:48 +00:00
83946e5d4f Merge pull request 'fix(scraper-kit): дрейн-метка interrupted=1 у domclick sweep и трёх full-load — резюм после SIGTERM для всех функций с чекпоинтом' (#3363) from fix/3355-drain-mark-fullloads into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m7s
Deploy Trade-In / build-backend (push) Successful in 1m47s
Deploy Trade-In / deploy (push) Successful in 3m12s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Failing after 12s
2026-09-05 19:28:25 +00:00
c82ec7c3af fix(tradein): понижать статус только у прогона, который работу сделал (#3288)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m59s
Прошлая правка понижала banned→failed/done по одному диагнозу infra и ломала
обратный контракт: нулевой прогон с infra (yandex 5xx #3196, финализатор #2764)
получал 'failed' — настоящий бан площадки, опознанный как infra, прятался под
«нашу поломку». Хуже исходного дефекта: 2 красных теста в полном прогоне.

Понижение сужено до случая прогона 5425 — dominant='infra' И produced > 0:
брейкер оборвал по доле, а карточки при этом обогащались → 'done'. Нулевой
прогон остаётся 'banned' (честность несёт ban_kind), пустая перепись — тем
более: dominant='unknown', статус не трогаем.

Тесты: контроль на обратную ошибку (ноль результата → banned+infra) и на
пустой census (→ banned+unknown); основной кейс {'infra': 20} при 10
обогащённых — не banned.
2026-09-06 00:28:24 +05:00
12945d7b2f fix(scraper): дрейн domclick сохраняет чекпоинт, дрейн cian-detail помечается
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m0s
Ревью PR #3363:

1. domclick city sweep: комментарий обещал, что унаследованный при claim
   чекпоинт переживёт дрейн «jsonb-мержем» — мержа нет. domclick пишет
   counters через app-level scrape_runs (update_heartbeat/mark_done делают
   `counters = CAST(:counters AS jsonb)`, полная перезапись), а scheduler
   при claim done_buckets не наследует. Дрейн закрывал прогон с ПУСТЫМ
   чекпоинтом, резюм пересобирал все шесть корзин. Чтение чекпоинта поднято
   выше ранних выходов, дрейн-payload несёт `done_buckets` явно — как уже
   делает ban-ветка (except NoProxyAvailableError) той же функции.

2. cian full-load, detail-фаза: второй ранний выход по shutdown_requested()
   делал `break` и уходил в обычный mark_done — SERP целый, обогащение
   обрезано, а прогон выглядел полным. Теперь тот же sentinel
   RuntimeError("shutdown"), что и в _on_bucket: interrupted=1 + done_buckets.

3. yandex/avito full-load проверены: по одному shutdown-сайту в _on_bucket,
   detail-фазы нет вовсе — аналогичного дефекта нет.

Тесты: чекпоинт дрейна domclick сверяется ПО ЗНАЧЕНИЮ в обеих записях
(heartbeat + финализатор); дрейн cian в detail-фазе даёт interrupted=1.
2026-09-06 00:21:55 +05:00
6ced16618c Merge pull request 'fix(tradein/yandex): недорендеренная карточка — отказ, не успех: структурный маркер encryptedPhones + порог размера, карточка остаётся в очереди' (#3364) from fix/3191-yandex-detail-completeness into main
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m2s
Deploy Trade-In / build-backend (push) Successful in 2m4s
Deploy Trade-In / deploy (push) Has been cancelled
2026-09-05 19:21:06 +00:00
29c0ae006d Merge pull request 'fix(scraper-kit): containment до пробы у cian и yandex — резюм full-load не пере-пробивает готовые полосы' (#3362) from fix/3359-containment-cian-yandex into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-09-05 19:19:31 +00:00
1120f9d430 Merge pull request 'fix(tradein/payments): отказ банка на Init не блокирует покупателя на 30 минут — мёртвая попытка уходит из предиката живого платежа' (#3361) from fix/3323-payment-init-fail-409 into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-09-05 19:19:00 +00:00
78e3da6bc3 fix(tradein): маркер полноты — encryptedPhones ИЛИ redirectPhones (#3191)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m21s
Ревью #3364. Требуя именно encryptedPhones, отбраковывали бы вечно
необмеренный класс карточек «без телефона / только чат»: в очередь они
возвращаются, а ключ не появится. redirectPhones измерен тем же замером
#3192 и присутствует в обеих ветках (с куками и без).

Текст ABORT: consecutive_none смешанный (фетч-ошибка + parse-None +
недогруз) — «N подряд без обогащения», а не «недогруженных».
2026-09-06 00:14:43 +05:00
4184f751bb fix(yandex): degraded-бакет не зачитывается как пройденный
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m11s
Ревью #3362: в _degraded (probe провалился → пагинация до пустоты, обрезаемая
max_pages_per_bucket) yandex звал on_bucket БЕЗ признака полноты, и ключ падал
в done-леджер наравне с честно добранным листом. До containment-гейта это был
точечный skip одного ключа; теперь ключ покрывает ИНТЕРВАЛ и сливается со
смежными — недобранная после отказа полоса больше никогда не переобходится.

Тот же путь, что у cian: третий позиционный аргумент complete, в чекпоинт
пишет только _mark_bucket(..., True); partial_buckets вынесен в счётчики
прогона (виден в heartbeat), лоты и cancel/shutdown-проверки не трогаем.

Плюс комментарий смежности в bisection.py приводил полуоткрытый пример, споря
с «hi ВКЛЮЧИТЕЛЬНА» в том же докстринге.
2026-09-06 00:13:11 +05:00
ec8213c269 fix(payments): обрыв соединения и Init без PaymentURL перестают запирать покупателя
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m11s
Три дыры в одном замке (строка без payment_url невидима для _find_live_payment,
но видима предикату UNIQUE 279 → ложный 409 на 30 минут):

- tbank_client ловил пару (TimeoutException, NetworkError): RemoteProtocolError,
  ProxyError и UnsupportedProtocol летели наружу голым httpx-типом мимо
  `except TBankApiError` в checkout. Ловим родителя — httpx.TransportError.
- Ветка «Success:true без PaymentURL» отвечала 502, не трогая статус, — здесь
  банк заказ ПРИНЯЛ. Тот же терминальный статус, error_code=no_payment_url;
  UPDATE вынесен в общий _mark_init_failed.
- _FakeDb в тестах применял статус по наличию ключа в params, а не по тексту
  SQL: мутант без `SET status = :status` оставался зелёным. Гейт по SQL —
  мутант краснит все три теста про замок.

Комментарий про «возможный холд» на ветке отказа Init поправлен: Init холд не
создаёт, авторизация идёт с оплаты формы, а форму покупателю не выдавали.
2026-09-06 00:11:35 +05:00
4a7e648068 Merge pull request 'fix(scraper-kit): свежесть источника — по последнему прогону, принёсшему данные, а не по статусу done' (#3365) from fix/3172-freshness-by-data into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m8s
Deploy Trade-In / build-backend (push) Successful in 1m59s
Deploy Trade-In / deploy (push) Successful in 2m5s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Failing after 12s
2026-09-05 19:10:13 +00:00
1a693192e1 fix(tradein): вид блока решает обрыв по доле и статус прогона (#3288 часть B)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 5m19s
Прогон 5425 оборвался по доле блоков и получил статус banned на 48 «блоках»,
из которых 41 был отказом нашего сайдкара: record_block() вида не принимал,
поэтому infra падал в числитель скользящего окна #3184 наравне с настоящим
баном, а mark_backfill_finished считал диагноз только ради телеметрии.

- record_block(kind): в числитель идёт только platform; всё остальное — в
  знаменатель (как record_failure), мимо серии и safety-net;
- NoProxyAvailableError у avito — не блок и не отказ площадки: прогон
  завершается no_proxy_stop=1 + mark_failed «пул прокси пуст» (как домклик
  после #3283); опознаётся по цепочке __cause__, не по подстроке (#3272);
- статус banned — только при доминировании platform; при infra прогон
  получает failed (нулевой результат) или done, с честной причиной.
2026-09-06 00:09:37 +05:00
3a0b94e052 fix(scheduler): мерить свежесть источника данными, а не статусом прогона
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m9s
`_STALE_SOURCES_SQL` считала свежесть возрастом последнего прогона со
статусом 'done'. Прогон с блоком честно финализируется как 'banned'
(#2657) и при этом вставляет строки: у domclick_city_sweep 886 строк
25.08 и 128 строк 23.08 — оба 'banned'. Источник, регулярно ловящий блок
и столь же регулярно приносящий данные, числился мёртвым навсегда,
отсюда ложный P1 #3118 «домклик не собирается с 5 августа».

Запрос отдаёт завершённые прогоны, решение «прогон дал данные»
принимает `run_brought_data` ТЕМ ЖЕ результатным словарём, которым уже
судит сторож нулевого результата (runs._RESULT_COUNTER_KEYS, #2703) —
одна мера на оба механизма. Не 'lots_inserted': это новизна, а не
наличие данных (здоровый дедуплицированный sweep вставляет ноль).
Результат не измерен (28 источников без результатного ключа) → судим
прежней мерой, статусом: «не измерено» ≠ «ноль». never_ok считается той
же мерой, иначе соврал бы в другую сторону.

Closes #3172
2026-09-06 00:02:21 +05:00
f6cf948358 fix(scraper): пометить SIGTERM-дрейн interrupted=1 у full-load'ов и domclick-свипа
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m5s
После #3330/#3346 резюм берёт 'done' только с counters.interrupted=1, но четыре
функции с живым чекпоинтом done_buckets финализировали дрейн чистым 'done':
cian/yandex/avito full-load (ветка RuntimeError("shutdown")) и domclick city
sweep (дрейн до SERP). Оборванный обход был неотличим от полного, а собранные
бакеты никто не подхватывал — у avito это ещё и бан-бюджет (#3315).

done_buckets в domclick-payload не добавляем: чекпоинт унаследован при claim
(#3074), jsonb-мерж его сохраняет.

Closes #3355
2026-09-05 23:59:22 +05:00
4b5f8fb54c fix(tradein): недогруженная карточка Яндекса — отказ, а не успех (#3191)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m11s
Страница на 1,8 МБ без блока контактов приходит с HTTP 200 и валидным HTML:
window.INITIAL_STATE на месте, parse отрабатывает — и частичная карточка уезжала
в БД с detail_enriched_at, выбывая из очереди навсегда. Единственная проверка
размера (newbuilding.py, len(html) < 500) отвечала на вопрос «пришло ли хоть
что-то»: 1,8 МБ проходит её в 3600 раз.

Признак полноты структурный + размерный, любой из двух даёт отказ:
encryptedPhones (65 вхождений у полных карточек, 0 у недогруза; отдаётся и
анонимной сессии — см. yandex_session.py) и settings.yandex_detail_min_html_bytes
(1 МБ). Наблюдавшийся недогруз ловит именно структурный: 1,8 МБ порог проходит.

В backfill проверка стоит ДО parse: исход incomplete ⊆ failed, save не
вызывается, значит detail_enriched_at не проставляется и следующий снапшот
(detail_enriched_at IS NULL) возьмёт объявление снова. Серия недогрузов двигает
consecutive_none — тот же брейкер, что у parse→None, поэтому вечно недогружаемая
карточка обрывает прогон, а не молотится (per-listing счётчика попыток в схеме
нет).

Фейковые ответы в тестах-соседях (#3196/#3338) теперь при HTTP 200 выглядят
полной страницей — иначе они молча стали бы кейсами про полноту.
2026-09-05 23:59:15 +05:00
35c7ea8492 fix(cian,yandex): skip fully-covered price ranges before probing on resume
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m1s
Гейт should_skip живёт в общем движке (walk_price_range, #3315), но предикат
из done-леджера строил и передавал только avito. У cian и yandex та же
бисекция и тот же чекпоинт — на резюме дерево деления спускалось ВНУТРЬ
зачтённых полос живыми probe-запросами: ключи чекпоинта суть границы
ДИНАМИЧЕСКОЙ бисекции, при сдвиге рынка новый лист старому не равен даже
внутри собранной территории, поэтому сравнение строк ничего не ловит.

Формат ключей у провайдеров разный, и ключи не трогаем (иначе протухнут
живые чекпоинты): cian пишет room_label:lo:hi / :open — как avito, парсер
подходит без изменений; yandex пишет _combo_label «rooms:lo-hi» с «None»
вместо открытого потолка, поэтому в done_range_skipper параметризованы
range_sep и open_token (дефолты = прежнее поведение avito/cian).

Один леджер на два режима: у yandex incremental-ключи несут префикс
сегмента (secondary/2:…) и отсев по label их не пропускает, а смешение
режимов блокирует _pick_resume (params IS NOT DISTINCT FROM); у cian
incremental-режима нет вовсе. Записано в докстринге предиката.

Тесты по значению на обоих провайдерах: счётчик стоит на горлышке фетча
(_fetch_page_html / _fetch_page_json), резюм готовой комнатности = 0
запросов, частично покрытая полоса по-прежнему пробивается.

Closes #3359
2026-09-05 23:56:46 +05:00
5bd9c586b7 fix(payments): отказ банка на Init больше не запирает покупателя на 30 минут
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m52s
Строка после провального Init оставалась в NEW и без payment_url: для
_find_live_payment (фильтр payment_url IS NOT NULL) её нет, а для предиката
UNIQUE миграции 279 — есть. Следующий checkout ловил конфликт и получал 409
'retry shortly' до истечения _ABANDONED_AFTER_MINUTES — из-за сбоя банка, а
не своего действия.

Переводим такую строку в терминальный DEADLINE_EXPIRED (тот же, которым
checkout уже помечает брошенные попытки) в том же UPDATE, что пишет
error_code/error_message: статус вне предиката 279, пара
(estimate_id, product_code) освобождается сразу. Новый статус в CHECK 233 не
заводим — миграция ради ярлыка не нужна, причина и так в error_*.

Refs #3323
2026-09-05 23:54:22 +05:00
63dbc209b2 Merge pull request 'fix(scraper-kit): бан площадки не гасит здоровье узла для всех источников — report_ban по паре узел×источник вместо mark_health(ok=False)' (#3357) from fix/3288-avito-ban-per-source into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m1s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 1m45s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Failing after 10s
2026-09-05 18:36:29 +00:00
e48b4a47d7 Merge pull request 'fix(tradein/domclick): дом-поля карточки → houses, backfill total_units/material_* из raw_payload (миграция 284)' (#3356) from fix/3253-domclick-house-fields into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
Deploy Trade-In / test (push) Successful in 4m12s
Deploy Trade-In / build-backend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 2m6s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Failing after 12s
2026-09-05 18:26:18 +00:00
c57138f4c7 fix(tradein/proxy): бан площадки рапортует только фетчер — ротация больше не банит свежий узел
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m1s
Дедуп report_ban по _banned_lease_id не достигал цели при ротации. Узел 13 ловит
бан-страницу → фетчер репортит бан 13 и по fail-streak меняет lease на 14 →
провайдерский report_ban в providers/avito/detail.py видит уже сброшенный
_banned_lease_id и банит СВЕЖИЙ узел 14, который к площадке не ходил. При трёх узлах
в пуле одна бан-страница выбивала две трети выдачи на 6 часов с эскалацией ban_count.

Убран провайдерский report_ban на ветках SidecarBanPageError в avito/detail.py и
domclick/detail.py: фетчер репортит сам, раньше и по правильному lease. Детекты не от
сайдкара (firewall / 0 карточек в serp.py, QRATOR-маркеры parse_detail_html) фетчеру
не видны — там report_ban остаётся.

Плюс два смежных: fetch()-ретрай ловил httpx.HTTPError, подклассом которого является
SidecarBanPageError, — каждая бан-страница стоила 2 POST'а и +2 к fail-streak (ротация
вдвое раньше задуманного); и NoProxyAvailableError из ротационного _acquire_lease внутри
_report_platform_ban вылетала ВМЕСТО SidecarBanPageError, подменяя диагноз platform на
infra — теперь ротация там best-effort.

Тесты: рабочий пул теперь РОТИРУЮЩИЙ (13→14) — на неподвижном пуле дефект физически не
проявляется. Два теста, пинившие прежний контракт (провайдер репортит), инвертированы:
у них MagicMock-фетчер, который настоящего рапорта не делает.

Refs #3288
2026-09-05 23:25:19 +05:00
0e4a227a5a Merge pull request 'fix(scraper-kit): пропуск по куке двигает next_run_at на interval_minutes, а не на случайный час завтра' (#3351) from fix/3312-defer-interval-minutes into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m15s
Deploy Trade-In / test (push) Has been cancelled
2026-09-05 18:22:41 +00:00
bab4b4f8ff Merge pull request 'fix(tradein/rbac): 401-оракул закрыт — несуществующий путь под публичным префиксом отвечает так же, как любой другой мусор' (#3352) from fix/3324-rbac-401-oracle into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m28s
Deploy Trade-In / test (push) Has been cancelled
2026-09-05 18:19:27 +00:00
d518efbed1 fix(tradein/domclick): дом-поля едут в словаре ДОМ.РФ, а не сырьём Домклика
All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 16s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m26s
houses.material_walls уже заполнена словарём ДОМ.РФ (капремонт КР1.2, #2013):
кирпич 2662, железобетонная панель 2107, иное 1850, монолит 754. Ветка писала
туда сырую фразу карточки (Монолитный 2656, Кирпичный 2241, Панельный 1671,
Монолитно-кирпичный 773) — колонка стала бы двухсловарной, и `WHERE
material_walls = 'монолит'` перестал бы видеть весь Домклик. Ровно та болезнь,
которую sale_type уже пережил в #2674.

canon_wall_type / canon_floor_type стоят на границе записи в houses (как
canon_sale_type — на границе записи в listings): Кирпичный→кирпич,
Панельный→железобетонная панель, Монолитный/Монолитно-кирпичный→монолит,
Блочный/Деревянный→иное, Железобетонный→Железобетонные (форма, уже лежащая в
колонке). Незнакомое → None + warning раз на процесс: сырьё в колонку не
попадает никогда, а новое значение словаря видно в логах. В raw_payload сырая
фраза площадки остаётся как была.

Миграция 284 получила тот же CASE lower(...) — иначе backfill залил бы задним
числом ровно то, что код перестал писать. CASE без ELSE: незнакомое → NULL.
2026-09-05 23:17:50 +05:00
25d3db2ae7 Merge pull request 'fix(scraper-kit): резюм exhaustive не пере-пробивает готовые полосы — containment по done-леджеру до пробы, 17 911 запросов → 0' (#3358) from fix/3315-exhaustive-containment-skip into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 19s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
2026-09-05 18:17:01 +00:00
7322615868 Merge branch 'main' into fix/3312-defer-interval-minutes
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m34s
2026-09-05 18:15:47 +00:00
4441e6ccbd Merge pull request 'test(tradein/auth): флаки-замер «< 500 мс» заменён проверкой механизма — bcrypt обязан исполняться вне потока event loop' (#3350) from fix/3343-auth-latency-flaky into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
2026-09-05 18:14:38 +00:00
65155e5ccd Merge pull request 'fix(tradein/backfill): save-False у yandex/avito больше не теряет попытку — attempted == сумма исходов у всех трёх бэкфиллов' (#3347) from fix/3338-backfill-save-false into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
Deploy Trade-In / deploy-status (push) Has been cancelled
2026-09-05 18:14:06 +00:00
b8084f6afe Merge pull request 'fix(tradein): секрет вебхука GlitchTip не попадает в access-log — скруббер query-секретов + приём из заголовка' (#3353) from fix/3154-glitchtip-secret-log-leak into main
Some checks are pending
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 24s
2026-09-05 18:12:58 +00:00
80db30c7f7 Merge pull request 'fix(tradein/front): число оценочной модели площадки подписано «рынок из N объявлений — в расчёт не входит» при тонком рынке' (#3349) from fix/3339-thin-market-badge into main
Some checks are pending
Deploy Trade-In / changes (push) Waiting to run
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
2026-09-05 18:12:39 +00:00
2d4f669d4b Merge pull request 'fix(caddy): окно деплоя — 503 «Сервис обновляется» с Retry-After вместо голого 502 на публичных путях МЕРЫ' (#3348) from fix/3274-deploy-window-error-page into main
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 8s
Deploy / changes (push) Successful in 13s
perimeter-smoke-mera / smoke (push) Successful in 21s
Deploy / build-backend (push) Successful in 54s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 52s
Deploy / build-frontend (push) Successful in 48s
Deploy / deploy (push) Successful in 5m31s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 17s
2026-09-05 18:12:35 +00:00
8c4be70193 fix(rbac): честный докстринг про размен оракулов + трейлинг-слэш больше не выдаёт маршрут
All checks were successful
CI Trade-In / changes (pull_request) Successful in 16s
CI / changes (pull_request) Successful in 19s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m51s
Ревью #3352: комментарий у _path_is_routed утверждал «ослабления нет» — неверно.
Раньше 401 был ПРЕФИКСНЫМ оракулом (таблицу маршрутов по нему не перечислить),
теперь 401/404 — оракул СУЩЕСТВОВАНИЯ маршрута, включая имена admin-ручек.
Докстринг переписан честно, с проверенным по caddy/sites/apps.caddy фактом:
блок handle /trade-in/api/* стоит выше import users.caddy.snippet, внешнего
basic_auth у trade-in нет — значит перебор имён выполним и снаружи.

Второй канал того же оракула закрыт: /api/v1/me/ не матчил ни один маршрут,
guard пропускал, а роутер отвечал 307 на существующий путь. FastAPI получил
redirect_slashes=False (проверено: ни одного route с трейлинг-слэшем, ни одного
такого вызова во фронте; deny-правила уже на глоб-форме).

Тесты: PARTIAL-кейс (POST на GET-путь анониму → 401, не 404/405), трейлинг-слэш
на РЕАЛЬНОМ app.main (тест на копии был бы тавтологией), admin-гейт сверяется по
тексту 'admin only' — scope-ветка отвечает тем же 403, но 'forbidden for role'.
2026-09-05 23:12:17 +05:00
3e481b0276 fix(caddy): смонтировать deploy-window сниппет — иначе Caddy не адаптирует конфиг
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 18s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Каталог caddy/ пробрасывается в контейнер ПОФАЙЛОВО (users + metrics-*, плюс
каталоги sites/ и local/). apps.caddy импортирует `../deploy-window.caddy.snippet`,
которого без этой строки в контейнере нет: Caddy падает на 'File to import not
found', уходит в restart-loop и роняет ВСЕ домены хоста — ровно постмортем #3102.
Гейт scripts/check-caddy-snippet-mounts.py на ветке был красный (две строки
apps.caddy + сам сниппет), после этой строки зелёный.

Смоук периметра: 503 на payments/notify перестал что-либо доказывать — тот же
код теперь отдаёт заглушка окна деплоя. check_post получил необязательный
шаблон-дискриминатор: код совпал, но в ответе `Retry-After: 30` или
`service_unavailable` — это Caddy, а не приложение, и это FAIL.

Refs #3274
2026-09-05 23:06:54 +05:00
cd95222d90 test(auth): порог пробы по темпу тиков, диагностика в лог CI
All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m17s
Абсолютное `len(probe_latencies) >= 10` слабело ровно в целевом сценарии:
elapsed под нагрузкой растёт, и заблокированный цикл добирает 10 тиков за
3-5с. Сверяем темп: замер пул 65.5-66.3 тик/с против 1.0 тик/с у bcrypt в
`async def`, порог 8.0 — геометрическая середина, запас x8 в обе стороны.

Тики/медиану/темп печатаем безусловно через capsys.disabled(): у зелёного
теста pytest захваченный вывод не показывает, а запас на общем раннере виден
только когда всё прошло.
2026-09-05 23:06:35 +05:00
c048e8f2c9 fix(avito): skip fully-covered price ranges before probing on resume
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 18s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m48s
Резюм exhaustive-обхода пере-пробивал уже зачтённую территорию: skip
проверялся в листе, ПОСЛЕ probe, поэтому дерево бисекции спускалось в
поддиапазоны done-корзин живыми запросами (прогон 5718: 21 минута внутри
room_studii:4000000:4999999, ноль новых корзин). На пуле из 1-2 нод это
сжигает весь бан-бюджет до первой НОВОЙ работы.

Ключи чекпоинта — границы ДИНАМИЧЕСКОЙ бисекции: при сдвиге рынка новый
лист ключом не равен старому даже внутри покрытого диапазона, поэтому
сравнение строк бесполезно. done_range_skipper парсит ключи room:lo:hi
(hi=open → бесконечность) в отрезки, сливает пересекающиеся и смежные и
отдаёт предикат покрытия; walk_price_range проверяет его на входе в узел,
ДО probe, и обрезает готовые поддеревья без единого запроса.

Closes #3315
2026-09-05 23:06:26 +05:00
659543f004 fix(tradein/backfill): warning save-False называет оба id (avito) и не дублирует listing_id (yandex)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m13s
Ревью #3347, два minor.

1. avito: WHERE в save_detail_enrichment ключуется по source_id из РАЗОБРАННОГО
   HTML (enrichment.item_id), а warning печатал row["id"] из снимка. При
   редиректе/подмене карточки строка с row-id жива, и читатель лога идёт искать
   несуществующую проблему не у той строки («текст ошибки называет гонца»).
   Печатаем оба: listing_id=%s item_id=%s.
2. yandex: listing_id стоял в строке дважды (в начале и как «id=%d» в хвосте) —
   убран дубль.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:06:22 +05:00
d953899d92 fix(mera): подпись тонкого рынка доезжает до карточки оценочной модели
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 17s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 2m3s
Карточка печатает тот же recommended_price, что и hero, но берёт его из
своего endpoint и подписи не имела — рядом с итогом стояла неподписанная
копия величины, исключённой из расчёта (ровно то, что #3339 чинил в hero).

Проп note + проброс thinMarketNote(estimate.avito_imv) со страницы; подпись
тем же классом .bench-chip__sub, ему же дан max-width, чтобы длинная строка
не растягивала чип.

Тест падает при снятии рендера подписи (проверено: 1 failed / 5 passed).
2026-09-05 23:05:12 +05:00
3b8545f609 fix(tradein/proxy): бан площадки снимает узел адресно, а не гасит его здоровье для всех источников
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m10s
Сайдкар на бан-странице Авито отвечает HTTP 500 с ban_page-маркером, клиент
поднимает SidecarBanPageError — но она подкласс httpx.HTTPStatusError, и общий
except Exception в _post_fetch звал mark_health(ok=False). Это ГЛОБАЛЬНОЕ решение
по узлу: три бан-страницы Авито выбивали его из выдачи и Яндексу, и Циану, и
Домклику (прод-замер 31.08-01.09: узлы 9/13/14 на потолке MAX_CONSECUTIVE_FAILS
при banned_for_source=0, живая проба тех же узлов проходила).

Теперь бан-страница ловится отдельной веткой ДО общего except и уходит в
mark_banned(source=...) — приговор паре «узел×источник», которую фильтрует
acquire(source). Здоровье узла не трогаем; транспортный сбой (таймаут, плоская
500) как и раньше идёт в mark_health(ok=False). report_ban дедуплицирован по
lease: одно событие доезжало до него трижды (POST, ретрай fetch(), провайдер), а
каждый вызов растит ban_count и кратно удлиняет отдых пары.

Refs #3288
2026-09-05 23:03:33 +05:00
55a67e5c45 fix(tradein): маскировать секрет и в параметрах с префиксом (#3154)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m38s
Ревью-minor: якорь [?&] вплотную к имени пропускал client_secret=,
refresh_token=, auth_token=, webhook_secret=. Разрешаем префикс [\w.-]*
перед альтернацией — маскируем имена, ОКАНЧИВАЮЩИЕСЯ на чувствительное
слово. Кейс not_a_secret_name заменён на честные отрицательные:
?secretary= и ?tokens_page= (не оканчиваются на secret/token) плюс
/api/v1/token-info в пути.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:03:16 +05:00
4044acccf0 Merge pull request 'fix(scraper-kit): метка дрейна interrupted=1 во всех city-свипах — yandex/cian/newbuilding больше не объявляют недоделанный обход готовым' (#3346) from fix/3333-drain-mark-all-sweeps into main
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m30s
Deploy Trade-In / build-backend (push) Successful in 2m10s
Deploy Trade-In / deploy (push) Has been cancelled
2026-09-05 18:03:04 +00:00
2532bcbe27 fix(tradein/domclick): дом-поля карточки → houses, backfill total_units (#3253)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
Парсер карточки Домклика читал houseInfo.info и складывал блок дома целиком
в listings.raw_payload; в houses не переносил ничего. Замер 29.08: total_units
= 0 у ВСЕХ источников, хотя quarters_count уже лежал в собранных payload'ах.

save_detail_enrichment получает второй оператор — тот же fill-only паттерн, что
у avito (#3036), связь через listings.house_id_fk: quarters_count → total_units,
wall_type → material_walls, floor_type → material_floors. COALESCE в SET и в
WHERE-гейте: непустое значение дома не затирается (у houses есть конкурирующие
писатели — ДОМ.РФ капремонт, Houses Catalog). Серия дома, энергоэффективность и
число подъездов остаются в raw_payload — колонок под них нет, схему не расширяем.

Миграция 284 переливает то же самое задним числом из уже собранных payload'ов,
только в пустые колонки, идемпотентно, под lock_timeout.
2026-09-05 22:57:00 +05:00
ed94a03f73 fix(tradein): не логировать секрет вебхука GlitchTip в access-log (#3154)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m9s
uvicorn печатает в access-log полный путь вместе с query, поэтому секрет
вебхука (`?secret=`, он же TRADEIN_INTERNAL_AUTH_SECRET, второй рубеж rbac)
уезжал в Loki открытым текстом. Скруббер #3115 в Alloy ловит только форму
`user:pass@host` (DSN postgres_exporter) и такую строку не закрывает.

Два слоя:
- хендлер принимает секрет из заголовка X-GlitchTip-Secret; query-параметр
  остаётся fallback'ом, т.к. сам GlitchTip 6.1.6 (`send_webhook()`)
  заголовков не шлёт вовсе — убрать query можно только когда заголовок начнёт
  подставлять кто-то перед нами (Caddy header_up) или сменится отправитель;
- app/core/log_scrub.py: logging-фильтр маскирует значения чувствительных
  query-параметров (secret/token/api_key/…) на uvicorn.access и на
  обработчиках корневого логгера — секрета нет уже в `docker logs`.

Сравнение секрета и было constant-time (`secrets.compare_digest`).
2026-09-05 22:51:57 +05:00
0ef1880e55 fix(rbac): не отвечать 401 за несуществующий путь (#3324)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 18s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m36s
rbac_guard — HTTP-middleware, он отрабатывает до роутинга и потому отвечал
401 с rbac-текстом даже на пути, которых в приложении нет. Аноним получал
бесплатный оракул периметра: мусор под «интересным» префиксом давал 401, а
такой же мусор под публичным префиксом — 404 роутера, то есть выключенная
ручка была отличима от несуществующей.

Guard теперь пропускает запрос дальше, если ни один маршрут роутера не
матчится (Match.NONE у всех) — 404 отдаёт тот же роутер, что и на любой
другой мусор. Существующие маршруты не затронуты: Match.PARTIAL (путь есть,
метод другой) по-прежнему идёт в guard, реальный закрытый маршрут анониму
даёт 401, публичный — работает без идентичности.
2026-09-05 22:51:22 +05:00
63e2210abd fix(scheduler): defer уважает interval_minutes источника
Some checks failed
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 16s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 5m49s
_defer_next_run_at (путь пропуска pre_claim — например протухшие куки Циана)
читал только interval_days, поэтому cian_detail_backfill с каденцией 360 мин
после пропуска получал случайный час завтрашних суток: от ~1 до ~47 часов
вместо шести. Ручное обновление кук не давало эффекта ещё сутки.

Разбор interval_minutes вынесен в _interval_minutes и переиспользован
post_claim-хуком reschedule_after_minutes — два входа в одну каденцию.

Нижний порог _MIN_DEFER_MINUTES = 3 тика планировщика сохраняет #1522:
defer обязан пережить несколько get_due_schedules, иначе pre-check снова
гоняется каждую минуту.

Refs #3312, #1522
2026-09-05 22:50:58 +05:00
87781ad53c Merge pull request 'fix(tradein/estimator): thin-market гейт IMV перестаёт быть витринным — рынок из 1 объявления больше не двигает headline и range_high' (#3336) from fix/3323-imv-thin-market-gate into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m38s
Deploy Trade-In / build-backend (push) Successful in 1m20s
Deploy Trade-In / deploy (push) Successful in 1m40s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 13s
2026-09-05 17:49:29 +00:00
d177b916e3 fix(caddy): страница «сервис обновляется» вместо голого 502 в окне деплоя
Some checks failed
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Failing after 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Каждый мерж в tradein оставляет 30-90 с, в которые контейнера нет и Caddy
набирает несуществующий апстрим: 15 x 502 на meraocenka.ru и 23 на gendsgn.ru
за 4 суток, все на окнах деплоя (duration < 2 мс = мгновенный отказ
соединения, а не перегрузка).

handle_errors 502/503/504 подменяет это на 503 + Retry-After: 30 и читаемое
тело. 502 читается как постоянная поломка — поисковик выкидывает страницу из
индекса, клиенты не ретраят; 503 + Retry-After означает ровно то, что
происходит. Для /trade-in/api/* тело JSON, а не HTML: эти ручки вызывают из JS
и внешних клиентов, HTML у них превращается в ошибку разбора.

На gendsgn.ru перехват гейтится по пути /trade-in* — Site Finder («Птица»)
деплоится отдельным пайплайном и в задачу не входит; вне матчера ошибка
остаётся необработанной и поведение прежнее.

Окно простоя это НЕ убирает — zero-downtime деплой (п.1 issue) требует
решения владельца. Меняется только то, что видно внутри окна.

Refs #3274
2026-09-05 22:48:54 +05:00
ecd6d5c91d test(auth): проверять вынос bcrypt механизмом, а не секундомером
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 19s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m53s
`probe_latencies[-1] < 0.5` мерил на общем раннере не свойство кода, а
свободен ли CPU у соседей: 03.09 деплой Trade-In встал на «худший
сторонний запрос 544мс» на коммите, который часом раньше прошёл на
незанятой машине (#3343). Тест из #2712 проверял правильную вещь
неправильной метрикой.

Вердикт теперь дискретный: сверка пароля обязана исполниться НЕ в потоке
событийного цикла (двойник bcrypt пишет `threading.get_ident()`).
Загрузка раннера этого не подделает. Второй половиной остаётся счётчик
тиков пробы: при bcrypt в `async def` цикл стоит всю секунду флуда и
проба не успевает почти никогда, при выносе в пул успевает ~100 —
порог 10 лежит посередине с запасом ×10 в обе стороны. Абсолютные
латентности сохранены в тексте падения как диагностика, но не гейтят.

Фальсификация: возврат `verify_password` из пула прямо в `async def`
роняет тест на новом утверждении («сверка пароля исполнилась в потоке
событийного цикла», `assert 8387963968 not in {8387963968}`), 5 прогонов
подряд под load average 24 — зелёные.

Closes #3343
2026-09-05 22:48:37 +05:00
5356d564e6 fix(mera): подписать IMV на тонком рынке — число вне расчёта
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m51s
Бэкенд (#3336, thin_market) при market_count ниже порога исключает IMV из
headline, но витрина ставила число рядом с итоговой ценой молча — читалось
как расчётная величина. Подпись называет реальный N из market_count.

Closes #3339
2026-09-05 22:48:07 +05:00
3658434c03 fix(tradein/backfill): save-False у yandex/avito больше не теряет попытку — attempted == сумма исходов
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m41s
Тот же дефект, что #3332 у domclick (#3335), у обоих соседей: `if save(...):
enriched += 1` без else. UPDATE, не задевший строку (объявление удалено между
снимком и записью), оставлял попытку без исхода — attempted переставал сходиться
с суммой исходов, и расхождение читается как потерянный отказ площадки.

Разбор ВСЕХ точек выхода из цикла попыток показал, что у соседей это
единственная дыра: обрыва «пул прокси пуст» у них нет (resolve_proxy_url
бросает ProxyPoolExhaustedError ДО цикла), а budget/SIGTERM-брейки стоят до
`attempted += 1`. Исход — failed с отдельным warning про ненайденную строку.

Формы тождества разные и это не описка: у yandex blocked ⊆ failed (#3196),
у avito blocked/gone/failed — непересекающиеся корзины.

Тесты — по значению (числа, не «не бросило»), плюс контроль на противоположную
ошибку (исход не начисляется дважды). В test_3332 добавлен запрошенный ревью
кейс: транспортный сбой → пустой пул даёт attempted=2 failed=2, а не 3.

Closes #3338
2026-09-05 22:47:08 +05:00
ac11156f7d fix(scraper-kit): метка дрейна interrupted=1 во всех city-свипах (#3333)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m13s
После #3319 резюм подхватывает 'done'-прогоны только с counters.interrupted
(_drained_done в scheduler._resume_decision), но ставил метку ровно один
avito_city_sweep. У yandex, cian и newbuilding SIGTERM-drain финализировался
чистым 'done' с частичными счётчиками: оборванный деплоем обход неотличим от
полного и из резюма выпадал, хотя чекпоинт done_buckets есть у всех трёх
(combo-метки / имена якорей / номера страниц).

done_buckets в дрейн-payload не добавляю: heartbeat мержит jsonb, уже
записанные единицы обхода переживают финализатор, а пустой список у
multi-anchor yandex затёр бы унаследованный при claim чекпоинт.
2026-09-05 22:44:46 +05:00
aaf8119408 Merge remote-tracking branch 'origin/main' into HEAD
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m1s
# Conflicts:
#	tradein-mvp/backend/app/services/estimator.py
2026-09-05 22:40:52 +05:00
99f112db0b Merge pull request 'fix(tradein/auth): единый источник ролей — БД first, YAML только legacy-fallback (закрывает эскалацию до admin и 403 своим)' (#3331) from fix/3316-role-single-source into main
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m47s
Deploy Trade-In / deploy (push) Successful in 2m8s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 13s
2026-09-05 17:37:38 +00:00
126608e1fc Merge pull request 'fix(tradein/domclick): обрыв «пул пуст» и save-False больше не теряют попытку — attempted == сумма исходов' (#3335) from fix/3332-domclick-counter-identity into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-09-05 17:36:54 +00:00
64ec4a1aad Merge pull request 'fix(scraper-kit): чекпоинт avito_city_sweep доживает до финализатора — 0/67 прогонов писали done_buckets' (#3330) from fix/3319-citysweep-checkpoint into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-05 17:36:50 +00:00
8f88841bff Merge pull request 'fix(mera-public): подписи метрик снижения цены и бенчмарк отчёта не называют площадку' (#3345) from fix/mera-public-price-cut-note-imv into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / test (push) Successful in 4m10s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m18s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 2m6s
Deploy Trade-In / deploy-status (push) Successful in 2s
2026-09-04 08:36:46 +00:00
bot-backend
76b24e9f4d fix(mera-public): подписи метрик снижения цены и бенчмарк отчёта не называют площадку
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m12s
CI Trade-In / backend-tests (pull_request) Successful in 5m5s
Замечание юриста (Сайт_МЕРА_v2, п.3): публичный измеримый контент не должен
строиться на названных данных площадок.

- landing_stats.py: note метрик price_cut_share_pct / price_cut_median_pct_per_month
  «Только Домклик (…)» → «Только одна из площадок (…)». Текст персистится в
  landing_stats.note и рендерится на лендинге в блоке «цена ошибки» — после
  деплоя нужен пересчёт (refresh_landing_stats), деплой кода note не перепишет.
- HeroSummary.tsx / IMVBenchmark.tsx: убраны видимые «IMV» и title
  «Information Market Value» / «Индекс Market Value» — бренд-термин оценочной
  модели площадки; подпись — «оценочная модель» + sourcePublicLabel.
- public-copy-no-platform-names.test.ts: «IMV», «Information Market Value»
  добавлены в BANNED (гейт 34/34).
2026-09-04 11:27:32 +03:00
9cb5bf3fa3 Merge pull request 'fix(tradein/pdf): клиентский PDF не называет площадки-источники — «Источник N», как в веб-отчёте (#3341)' (#3344) from fix/3341-pdf-public-source-labels into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / deploy (push) Successful in 7m47s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 13s
2026-09-03 09:04:51 +00:00
bot-backend
f2f9ac941b fix(tradein/pdf): клиентский PDF не называет площадки-источники — «Источник N», как в веб-отчёте (#3341)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
Веб-отчёт и лендинг с #3342 не показывают названия площадок (канон publicLabel в
frontend/src/lib/source-registry.ts), а клиентский PDF по тому же /estimate/{id}
печатал Avito / Циан / «Домклик · Сбер» / Я.Недвижимость / Этажи в пилюлях
источников, «подтверждают Росреестр, ДомКлик…» в советах и «на Циан, Авито,
Я.Недвижимости» в тарифах. Один клиент — два документа с разной нормой, и юр-риск,
ради которого всё делалось, в PDF оставался открытым.

- `_SOURCE_DISPLAY_NAMES` → публичные лейблы 1:1 с реестром фронта: avito → «Источник 1»,
  cian → 2, yandex → 3, domklik → 4, etazhi → 5, rosreestr → «Росреестр»; fallback для
  незнакомого id — «Другой источник», а не `source.title()` (сырой id — та же утечка).
- Алиасы (avito_imv, cian_valuation, yandex_valuation, domclick, etagi) канонизируются
  ДО выбора цвета и лейбла (`_canonical_source`), а списки источников на страницах
  объявлений и сделок дедуплицируются до среза [:5] (`_public_sources`): иначе
  `sources_used` = listing ∪ valuation давал «Источник 1, Источник 1, Источник 2,
  Источник 2, Источник 4» с серой точкой у алиасов и вытеснял yandex.
- Цвета пилюль не тронуты: цвет — опознаватель источника, как на вебе.
- Тексты: «Росреестр, сделки площадок и продажи агентств», «на основных площадках
  объявлений» (двойник offer-rates.ts).
- Гейт `tests/test_pdf_public_source_labels.py`: видимый текст страниц (без тегов и
  атрибутов, href на домены площадок законны) не содержит названий площадок и сырых
  id, по одному кейсу на имя; дедуп алиасов; ветка совета с процентом.

Не тронуто: ссылки на объявления (avito.ru/domclick.ru) — отдельное решение;
`_QUALITY_SOURCE_SLOTS` — только счётчик, имена не рендерит.
2026-09-03 11:58:45 +03:00
34e84ed0ed Merge pull request 'fix(mera-public): публичная копия — сервис не называет себя оценщиком, площадки-источники не названы' (#3342) from fix/mera-public-copy-legal-deplatforming into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m12s
Deploy Trade-In / test (push) Failing after 4m8s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 1s
2026-09-03 06:57:11 +00:00
bot-backend
5541aa9d37 fix(mera-public): публичная копия — сервис не называет себя оценщиком, площадки-источники не названы
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 5m2s
Три юр-правки по документу владельца продукта «Сайт_МЕРА_v2» (31.08.2026), сделаны
локально 31.08, но не были закоммичены — на meraocenka.ru всё оставалось по-старому.

1. «Оценщик» про собственный алгоритм. Витрина сделок (`landing_showcase_deals.py`,
   REJECTION_RULE/NOTE) и сноска статьи «Как оценить квартиру» теперь говорят
   «расчёт МЕРЫ»: самоназвание обесценивало дисклеймер «не официальный отчёт оценщика».
   Комментарии и докстринги не тронуты — посетитель их не видит.
2. «Путь 2»: «стоимость услуг фиксированная и известна заранее» — читалось как
   фиксированная цена квартиры.
3. Названия площадок убраны из видимой копии лендинга и веб-отчёта. Канон —
   `publicLabel` + `sourcePublicLabel()` в `source-registry.ts`: одна площадка = один
   номер «Источник N» и один цвет точки (цвет остаётся опознавателем между блоками),
   Росреестр под своим именем, неизвестный id → «Другой источник» (раньше fallback
   отдавал сырой id). Три параллельные реализации `sourceLabel` сведены к одной;
   `sourceLabel()` с реальными именами живёт для админки. Backend: якорь
   confidence_explanation «по оценке Avito IMV» → «по оценочной модели площадки».
   Подсказки геокодера «Yandex / Nominatim» и «по Яндексу» сняты из клиентских форм.

Гейт `public-copy-no-platform-names.test.ts` сканирует mera-public/** и
components/trade-in/** без комментариев, по Unicode-границе слова; список запретов
регистрозависимый намеренно (строчные id `"avito"` законны), поэтому капс-варианты
и словоформы перечислены явно — «6202 ОБЪЯВЛЕНИЯ ДОМКЛИК» в статье именно так
проходил первую версию гейта.

Не закрыто здесь: клиентский PDF (#3341) и ссылки на объявления на доменах площадок.

Текст витрины хранится в БД (`landing_showcase_runs.rejection_rule`,
`landing_showcase_deals.note`), планировщика у пересчёта нет — после деплоя нужен
ручной пересчёт или UPDATE трёх подстрок на проде.
2026-09-03 09:50:41 +03:00
6883a46ecd Merge pull request 'fix(caddy): периметр МЕРЫ — секрет убран из анонимного пути, чанки закрытого контура не раздаются с meraocenka' (#3340) from fix/3324-mera-perimeter into main
Some checks failed
Deploy Infra Host / sync-infra-host (push) Successful in 7s
Deploy / changes (push) Successful in 11s
perimeter-smoke-mera / smoke (push) Failing after 15s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-frontend (push) Successful in 48s
Deploy / build-backend (push) Successful in 50s
Deploy / build-worker (push) Successful in 51s
Deploy / deploy (push) Successful in 1m17s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 13s
2026-09-02 12:48:51 +00:00
5d9ff13dae fix(caddy): исключить _not-found из фильтра чанков закрытого контура
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 16s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Ревью #3340: `chunks/app/_not-found/` — внутренняя страница Next, а не
маршрут продукта; фильтр её глушил, и клиентская навигация по битому href
падала в error-boundary вместо честного «не найдено». Закрытого кода она
не несёт. Заодно в комментарии матчера явно зафиксировано, что
`static/css/app/<маршрут>/*` оставлен без фильтра сознательно — тот же
класс «имена, а не код», потолок уже задекларирован.
2026-09-02 17:47:28 +05:00
72a17922ee Merge pull request 'fix(mera-public): честный текст покрытия и разные квартиры в раундах игры' (#3337) from fix/3323-coverage-copy-honesty into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m46s
Deploy Trade-In / deploy (push) Successful in 1m12s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-09-02 12:46:05 +00:00
4f4345e27c fix(tradein): завершить thin-market гейт IMV — Guard-1b и GET-путь (#3323)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m23s
MEDIUM-1: imv_anchor_present ставился по anchor_total МИМО гейта — на тонком
рынке якорь отброшен, а Guard-1b (#764) продолжал глушить квартальную поправку
«потому что якорь есть»: headline не получал ни одной поправки, отброшенный
якорь двигал деньги вычитанием. Теперь present = not thin_market.

MEDIUM-2: trade_in.py (GET ?id= — расшаренная ссылка/PDF) — третья точка
сборки карточки: market_count=0 читался как «неизвестно», thin_market не
передавался вовсе → одна оценка показывала thin_market=True в POST и False
при переоткрытии.
2026-09-02 17:44:32 +05:00
ef166eaf21 fix(mera-public): защиты вместо тавтологий в coverage-тексте и выборе раундов
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m9s
Проверка «уверенные + средние + пустые = 100» была верна по определению
средней доли и упасть не могла. Инвариант перенесён на сам замер: уверенные и
пустые обязаны оставлять место третьей доле. Если её всё-таки не останется
(перезамер 85 + 15), текст теперь двухчастный — «Ещё в 0 данных меньше»
отрендериться не может.

`street_name !== null` считал пустую строку названием улицы, и вторая такая
строка вылетала из игры как «повтор». Проверка на пустоту.
2026-09-02 17:43:56 +05:00
177207223b fix(caddy): снять мёртвый internal-секрет с анонимного пути и чанки закрытого контура с публичного домена
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
#3324, п.3: Caddy на gendsgn.ru инжектил X-Internal-Auth-Secret в каждый
анонимный запрос /trade-in/api/* и /trade-in/* (хоп во фронт). Сравнение
недостижимо по построению: backend читает этот заголовок только в
legacy trusted-header ветке rbac.py, и только после непустого
X-Authenticated-User, который тот же самый хоп строкой ниже удаляет.
Второй потребитель секрета (вебхук GlitchTip) берёт его из query-параметра.
Защита от подделки заголовков внутри gendesign_shared — проверка в rbac.py,
она не тронута; инжект работал в обратную сторону, раздавая секрет наружу.

#3324, п.1: meraocenka.ru раздавал анонимам постраничные чанки App Router
чужих маршрутов (chunks/app/{admin,team,scrapers,...}/) — код закрытого
контура с именами внутренних ручек. Матчер отсекает 404-ом «есть подкаталог,
и он не mera-public»; лэндингу нужны только chunks/app/<файл>.js и
chunks/app/mera-public/* (замер на проде). Карта чанков в webpack-*.js
остаётся — имена файлов по-прежнему читаются, полное разделение = split build.

Регресс-тесты в smoke-mera-perimeter.sh отличают 404 Caddy (пустое тело) от
404 Next и отдельно проверяют, что статика лэндинга жива.
2026-09-02 17:41:38 +05:00
c093212eae fix(mera-public): честный текст покрытия и разные квартиры в раундах игры
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m8s
Остаток «100 − уверенные» в подписи города был однородным только на словах:
внутрь него входят пустые пробы, на которые describeCoverage отвечает «рядом
не продаётся ничего похожего» и расчёт не предлагает. Текст обещал расчёт и
на них — две наши же строки спорили друг с другом. Остаток разложен на долю
«посчитаем, но разброс шире» и долю «считать не станем».

Возраст объявления бэкенд считает percentile_cont(0.5) — это медиана, а
«в среднем» под скошенным распределением называет другую величину.

Раунды игры брались первыми тремя строками витрины, а она отсортирована по
полноте и свежести: на проде это давало три студии с одной улицы. Выбор
жадный по комнатности и улице, детерминированный — страница серверная.
2026-09-02 17:37:13 +05:00
148da3e5a7 fix(tradein): thin-market IMV не двигает деньги (#3323)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
avito_imv_thin_market_threshold рождал только warning: IMV с market_count=1
всё равно уходил в blend (w=0.5 при A > median*1.15) и растягивал range_high.
Гейт поставлен в _apply_imv_blend — единственной точке, через которую IMV
влияет на деньги (обе ветки якоря, imv_anchor и imv_eval, сходятся там):
market_count < threshold → no-op, якорь остаётся display-only в карточке.
market_count >= threshold и market_count=None (порог не передан) — поведение
прежнее. market_count=0 больше не читается как «неизвестно».
Warning теперь говорит, что IMV ОТБРОШЕН, а не просто «тонкий рынок».
2026-09-02 17:36:22 +05:00
e2e7e17f95 fix(domclick): каждая попытка добора получает исход (#3332)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m15s
Обрыв «пул прокси пуст» уходил из цикла между attempted++ и записью исхода,
поэтому тождество attempted = enriched + failed + blocked ломалось ровно на 1
(прод: 5 прогонов с diff=1). Исход честно failed, не blocked: к площадке не
ходили, отказала наша инфраструктура — тот же разряд, что у транспортных сбоев
(#3283); причина прогона по-прежнему в no_proxy_stop=1 + mark_failed.

Та же дыра закрыта у save_detail_enrichment(...) is False: карточка разобрана,
но строки уже нет — попытка была, исхода не было.

Closes #3332
2026-09-02 17:35:48 +05:00
d8b7de2cf6 Merge pull request 'fix(deploy): гейт деплоя ПТИЦЫ — мёртвый worker/beat/frontend больше не уезжает зелёным' (#3334) from fix/3324-ptica-deploy-gate into main
All checks were successful
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 42s
Deploy / build-worker (push) Successful in 43s
Deploy Infra Host / sync-infra-host (push) Successful in 6s
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Successful in 41s
Deploy / deploy (push) Successful in 1m12s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
2026-09-02 12:17:32 +00:00
1b1977efa3 fix(deploy): правки ревью гейта ПТИЦЫ — таймаут сессии, crash-loop, множественный id (#3324)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m4s
CI / backend-tests (pull_request) Successful in 17m35s
command_timeout: 30m — дефолт appleboy/ssh-action 10m короче worst-case гейта
(~17 мин), сессию убило бы посреди диагноза и авария читалась бы обрывом связи.

Деградация «нет health-конфига» требовала лишь running дважды: crash-loop с
временем жизни больше паузы проходил как стабильный (обе проверки видят running,
просто это разные жизни контейнера). Теперь сверяется RestartCount до/после окна
15 с; окно учитывается в счётчике ожидания, иначе таймаут 240 с растянулся бы на
~24 мин и упёрся в command_timeout.

cid(): `ps -aq` возвращает несколько id при залежавшемся exited-контейнере →
docker inspect падает → пустой статус → ложный красный. tail -n1.

worker healthcheck: убран `2>&1` (глушил причину, которую деплой печатает из
.State.Health.Log), retries 3→5 — при interval 60s тройка промахов = 3 минуты,
столько длится обычный флап Redis, а из unhealthy контейнер сам не выходит.
2026-09-02 16:58:40 +05:00
13c4420d1f fix(deploy): гейт ПТИЦЫ читает health worker/beat/frontend, а не только curl backend (#3324)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m4s
CI / backend-tests (pull_request) Successful in 17m40s
Деплой ПТИЦЫ проверял ровно один признак — `curl backend /health`. Worker и beat
не имели healthcheck'а в compose вообще, у frontend была TCP-only проба, которую
деплой не читал. Crash-loop воркера, вставший beat и фронт с 500 уезжали зелёным
деплоем: признак «прод жив» отсутствовал в старом состоянии ровно так же, как в
новом.

compose: worker — `celery inspect ping -d celery@$(hostname)` (адресно в ЭТОТ узел,
без -d ответил бы любой воркер на брокере); beat — свежесть shelve-файла
расписания (на inspect ping beat не отвечает; поминутная beat-задача гарантирует
обновление mtime не реже ~3 мин при sync_every=180 с, порог 10 мин = 3× запас).
Фиктивной `true`-пробы нет: она повторяла бы State.Running.

deploy.yml: после подъёма — ожидание healthy для backend/worker/beat/frontend
через docker inspect, HTTP-статус фронта (TCP мало), сверка running-образа с
локально скачанным $IMAGE_TAG (приём #2679 из deploy-tradein). Каждая проверка
при провале печатает контейнер, статус, healthcheck-лог и хвост логов; итог —
явный rc в логе и exit им же. Порядок «миграции до подъёма кода» не тронут,
`up -d --wait` не используется намеренно (подъём разбит на несколько up).
2026-09-02 16:48:50 +05:00
18e0f1d999 test(tradein): изолировать test_rbac.py от состояния реестра (#3316)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
CI-красное на голове ветки: 4 теста в tests/test_rbac.py ждали YAML-роль
(kopylov=pilot, user1=pilot), а в CI-базе реестр засеян миграцией 193
(kopylov=manager, user*=employee) — DB-first резолвер честно отдавал роль из БД.
Локально те же тесты были зелёными ровно потому, что БД нет и работал
YAML-fallback: результат файла зависел от ОКРУЖЕНИЯ, а такой тест не проверяет
ничего.

Чинится не подгонкой чисел в ассертах, а изоляцией: файл проверяет ИМЕННО
legacy-путь roles.yaml (разбор файла, globs, guard и /me на trusted-header), и
теперь заявляет это явно — autouse-фикстура `_legacy_yaml_only` глушит реестр
(`_registry_role` → None). Ассерты на YAML-роли после этого законны в любом
окружении. Приоритет реестра, эквивалентность scope employee↔pilot и
конфигурация kopylov (DB manager + YAML pilot) покрыты отдельно —
tests/test_role_single_source.py.

Проверено обоими способами: полный `pytest tests` без сида и он же с
плагином-имитацией засеянного реестра (подменяется тот же шов, что и в проде,
`identity_store.identity_session`) — 5290 passed, 35 skipped в обоих.
2026-09-02 16:46:44 +05:00
67c1ba043d Merge pull request 'fix(site_finder): Россети — стабильный ключ вместо сессионного fid, дедуп power_supply_centers ×10 → ~481' (#3329) from fix/3322-rosseti-stable-key into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 4m53s
Deploy / build-backend (push) Successful in 5m57s
Deploy / deploy (push) Successful in 1m42s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 11s
2026-09-02 10:10:29 +00:00
5cdf2bd74c Merge pull request 'fix(tradein-v2): пустая выборка сделок больше не рисуется нулями — «0,00 млн · −100%» зелёным исчезает' (#3328) from fix/3320-v2-honest-zeros into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m20s
Deploy Trade-In / deploy (push) Successful in 1m1s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-09-02 10:00:48 +00:00
cde95aa9f6 test(tradein): прод-конфигурация ролей в приёмке #3316 + компромисс фолбэка
Some checks failed
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 5m8s
Review PR #3331: приёмка «роли не изменились» гонялась с ПУСТЫМ реестром, а в
проде строка в БД есть у 12 из 13 юзеров и DB-роль ИНАЯ (kopylov: manager при
YAML pilot, user1: employee при YAML pilot). Добавлены два кейса именно этой
конфигурации:

  * YAML pilot + реестр employee → employee, и scope не поехал: allow/deny
    DB_ROLE_PATHS['employee'] сверяются со списками роли pilot из roles.yaml
    целиком — дрейф ЛЮБОГО из двух списков теперь красный тест, а не тихо
    потерянный/выданный раздел в проде;
  * YAML pilot + реестр manager → manager, и лишних путей на tradein-периметре
    нет: manager отличается от employee ровно префиксом /api/v1/team/** (вне
    /trade-in/**), deny-списки совпадают.

Докстринг `_registry_role`: зафиксирован компромисс — при недоступном реестре
фолбэк временно возвращает авторитетность roles.yaml, то есть состояние, которое
фикс и лечит. Сегодня безопасно (прод-коллизий имён нет, новые закрыты
409-гвардом create_employee); появится коллизия — ветку менять на fail-closed.
2026-09-02 14:59:14 +05:00
41f21c4969 docs(scraper-kit): обоснование #3319 — что именно теряло чекпоинт
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m13s
Ревью опровергло формулировку «точку терял каждый финализатор»: все четыре
писателя в runs.py мержат jsonb (`counters || :counters`), записанный ключ
переживал mark_done/mark_banned/mark_failed. Правка закрывает выходы РАНЬШЕ
первого end-of-anchor heartbeat (cancel/дрейн на первом якоре, ранний done
#1950 на якоре №1) — комментарий и докстринг переписаны на это.

Замер «0 из 67 за 60 дней» назван тем, чем он является: запись появилась
26.08.2026 (#3074) при такте avito 7 суток, выборка почти вся из эры без
механизма. Плюс тест на прогон без ключей (эра до #3074) — метка дрейна не
меняет вердикт «нечего подхватывать».
2026-09-02 14:53:02 +05:00
6a2d873c70 fix(rosseti): считать координату ключа в SQL тем же double, что в питоне
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m24s
CI / backend-tests (pull_request) Successful in 17m41s
Ревью PR #3329. round(ST_X(geom)::numeric * 100000) округляет по кратчайшему
десятичному представлению float8, а питон — по двоичному double: расхождение на
0.19% реальных координат (761 из 400000), напр. 64.423605 → питон 6442360
(6442360.499999999), numeric-путь 6442361. Каждое расхождение = вечный дубль ЦП,
который сам не зарастёт — миграция применяется один раз (_schema_migrations).
Теперь в SQL sign/floor/abs над float8 без каста в numeric: IEEE754 бит в бит
как math.floor в питоне.

test_coord_e5 брал 60.123455, где двоичное и десятичное округление совпадают —
защита, которая не защищает. Добавлено расходящееся значение 64.423605.

RAISE WARNING при rows_after > 700 заменён на RAISE EXCEPTION: warning не
останавливает прогон, файл помечался бы applied навсегда вместе с дублями.

Refs #3322
2026-09-02 14:51:47 +05:00
35db6608ee fix(tradein-v2): футер 04, падеж сделок и цвет дельты по знаку
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m31s
Ревью PR #3328:
- HistoryView: «Показано N из M сделок» брало M из street-deals, а строки —
  из sales-vs-listings; после гашения пустого коридора выходило «из —».
  Знаменатель убран, как в 05 РЫНОК;
- обе подписи склоняются через pluralRu («3 фактические сделки», «2 сделки»);
- цвет дельты медианы сделок считается по знаку (mapSources.deltaColor):
  зелёным красились и минус, и прочерк — это второй причинный узел жалобы
  «−100% зелёным»;
- подпись полосы совпала с карточкой 1 буквально («ПО СДЕЛКАМ РОСРЕЕСТРА»);
- тест: убран тавтологичный assert про EmptyTableNote (её рисует пустой
  dealRows, а не guard), добавлены кейсы на футер 04 и на цвет дельты.
2026-09-02 14:50:47 +05:00
4feb61c006 fix(tradein): резолвить роль из реестра, roles.yaml — только fallback (#3316)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 5m27s
Роль жила в двух местах сразу: люди заводятся в БД (`tradein_users.role`),
а `get_role` читал ТОЛЬКО `auth/roles.yaml` — и никто эти два источника не
сверял. Дефект двусторонний:

  * вверх: менеджер заводил сотрудника с именем, которое уже числится в
    roles.yaml админом (проверялись лишь regex и уникальность в БД) — на
    входе тот получал admin из YAML, то есть чтение ЛЮБОЙ чужой оценки
    (admin проходит мимо ownership-check в trade_in.py) и безлимитную квоту;
  * вниз: сотрудник, которого в roles.yaml нет, ловил KeyError → 403 на
    СОБСТВЕННУЮ оценку.

Источник теперь один и лечится один раз — в `app.core.auth.get_role`:
реестр (`tradein_users.role` / `auth.users.role`) спрашивается первым,
roles.yaml остаётся fallback для legacy-юзеров, у которых строки в реестре
нет. Реестр недоступен → тоже fallback: падение БД не выключает legacy-вход.
Вызывающие (rbac, trade_in, team, account_quota) не менялись.

Сопутствующее, чтобы поведение существующих аккаунтов не поехало:
  * rbac_guard выбирает матчер путей по РОДУ роли (роль реестра → DB_ROLE_PATHS),
    иначе employee/manager на legacy-пути получил бы 403 на всё;
  * get_user_scope отдаёт scope роли реестра из того же DB_ROLE_PATHS;
  * право на персональный `unlimited` осталось за roles.yaml (account_quota +
    _batch_quota_status) — фикс убирает эскалацию, а не раздаёт новую;
  * `_batch_quota_status` берёт роли из уже прочитанных строк — иначе список
    «Команды» снова стал бы N+1.

Defense-in-depth: create_employee отдаёт 409 на username, за которым в
roles.yaml числится не-employee роль.
2026-09-02 14:49:27 +05:00
24f88988f7 Merge pull request 'fix(mera-public): этаж и состояние переживают переход на /estimate' (#3327) from fix/3321-draft-fields-survive into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m15s
Deploy Trade-In / deploy (push) Successful in 1m5s
Deploy Trade-In / perimeter-smoke (push) Successful in 18s
Deploy Trade-In / deploy-status (push) Successful in 2s
2026-09-02 09:48:51 +00:00
390a6f05b1 fix(mera-public): сверка адреса у хвоста черновика — в коде, не в комментарии
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m23s
readDraftExtras требует адрес, под который спрашивают, и на несовпадении
отдаёт null. Гарантия «потребитель обязан сверить» жила только в комментарии,
а takeDraft выходит раньше записи хвоста на пустом и на битом черновике — то
есть хвост от предыдущей квартиры доживал до следующего захода в той же
вкладке и дождался бы потребителя, который сверить забыл.

Плюс два непокрытых кейса: черновик без floor/condition убирает прошлый хвост;
хвост не отдаётся чужому адресу.
2026-09-02 14:46:10 +05:00
0ed934ea09 fix(scraper-kit): чекпоинт avito_city_sweep доживает до финализатора (#3319)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m30s
0 из 67 прогонов за 60 дней имели done_buckets в counters: точку писала одна
строка внутри цикла якорей, а каждый выход (mark_done — включая ранний #1950
«SERP собран, detail заблокирован», — mark_banned, mark_failed) отдавал голый
counters.to_dict(). Точка держалась только на jsonb-мерже в runs.py, то есть на
свойстве чужого модуля, которого этот файл не проверяет.

- payload любого выхода собирается одной функцией _ckpt() — done_buckets несут
  все 14 записей, а не одна;
- якорь, умерший по таймауту, больше не считается пройденным (тот же инвариант,
  что у generic-except): SERP мог успеть, detail нет, и резюм пропускал такой
  якорь навсегда при штатно завершившемся прогоне;
- SIGTERM-дрейн помечается counters.interrupted=1 и участвует в резюме. Статус
  остаётся 'done' — ни один читатель статуса не меняется; метка та же, что у
  rosreestr_dkp-дрейна. 'done' в _RESUME_STATUSES НЕ добавлен: чистый полный
  обход резюмить нечего. Дрейн перед IMV-фазой помечен отдельно
  (imv_phase_drained) — якоря там пройдены все, подхват собрал бы ноль.
2026-09-02 14:44:38 +05:00
b35f444d50 fix(rosseti): стабильный external_id ЦП вместо сессионного fid GeoServer
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 2m22s
CI / backend-tests (pull_request) Successful in 17m46s
feature['id'] в WFS-ответе — сессионный fid, новый на каждый GetFeature.
ON CONFLICT (source, external_id) не срабатывал ни разу, каждый weekly-прогон
дописывал полный комплект ~488 фич: 4880 строк на 481 ЦП. Задуманный sha1-фолбэк
по атрибутам был мёртв — fid присутствует всегда.

Ключ теперь считается ТОЛЬКО по стабильным атрибутам (нормализованное имя, класс
напряжения, координаты в 1e-5 градуса), fid игнорируется. Координата квантуется
в целое, а не форматируется как float: ключ обязан совпадать байт-в-байт с
бэкфиллом в SQL. sha256 вместо sha1 — встроен в PG16, pgcrypto не нужен.

Починка разбора старые строки не убирает (ключи не совпадут, ON CONFLICT ничего
не перезапишет) → data/sql/99c_power_supply_centers_dedup.sql: пересчёт ключа
существующих строк + схлопывание копий (победитель — свежайший snapshot,
NULLS LAST явно), с печатью чисел до/после и идемпотентностью.

Refs #3322
2026-09-02 14:43:58 +05:00
08cabe8d98 fix(tradein-v2): пустая выборка сделок больше не рисуется нулями
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m0s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Бэкенд по контракту не умеет отдать null в street-deals: «сделок на улице
нет» приезжает как count=0 с нулевыми ценами. Витрина сохраняла этот ноль
через ?? и показывала его как данные — «0,00 млн ₽ · −100% к цене
объявления» зелёным и диапазон «0,00 – 0,00».

- usableStreetDeals() схлопывает пустую оболочку в null на границе мапперов
  (mapSources / mapSummary / mapHistory / mapResultPanel) — дальше null уже
  везде означает «нет данных», отдельного состояния заводить не нужно;
- футер таблицы сделок брал N и M из РАЗНЫХ выборок (строки — actual_deals
  оценки, знаменатель — коридор по улице), выходило «Показано 10 из 3»;
  тотала у той же выборки в ответе нет, поэтому знаменатель убран;
- в deals-only ветке (n_analogs=0) полоса диапазона строилась из ДКП-цен,
  но была подписана «В ОБЪЯВЛЕНИЯХ» — подпись согласована с карточкой 1.

Closes #3320
2026-09-02 14:42:49 +05:00
7b61ecb122 fix(mera-public): этаж и состояние переживают переход на /estimate
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 58s
Карточка лэндинга требовала этаж и состояние обязательными и клала их в
черновик. Целевая страница на монтировании звала takeDraft, а та стирала
черновик целиком, прочитав только адрес, комнаты, площадь и город: оба
обязательных поля уничтожались непрочитанными. Человек заполнял два поля,
единственным эффектом которых было их же удаление.

takeDraft по-прежнему забирает черновик «на вынос» (адрес не должен
подставляться на следующей неделе в той же вкладке), но перекладывает
непрочитанные floor/condition в отдельный ключ вместе с адресом, к которому
они относятся, — чтобы платный шаг (#2896) не приклеил этаж одной квартиры к
другой. Потребителя у полей пока нет: платного шага нет.

Closes #3321
2026-09-02 14:40:36 +05:00
1c533c8a98 Merge pull request 'v2: бейдж «Мои отчёты» показывает «использовано / доступно» — остаток квоты был не виден нигде' (#3326) from fix/v2-quota-remaining-visible into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Successful in 2m5s
Deploy Trade-In / deploy (push) Successful in 57s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
2026-09-02 07:28:24 +00:00
ee17935d39 Merge pull request 'auth: тестовый аккаунт buyer1 (pilot) для потенциального покупателя' (#3325) from feat/buyer1-trial-account into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / deploy-status (push) Successful in 3s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy / build-backend (push) Has been skipped
Deploy / deploy (push) Successful in 1m7s
2026-09-02 07:25:29 +00:00
012d98205f v2: бейдж «Мои отчёты» показывает квоту «использовано / доступно»
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 59s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Владелец выдал покупателю 50 оценок и не смог найти, где виден остаток.
Ответ был: нигде. Бэкенд отдаёт limit/used/remaining/unlimited целиком, а
фронт рендерил голое used с фолбэком на длину истории — «0» у свежего
аккаунта читался как «отчётов нет», а был месячным счётчиком (#3320 п.4:
одна цифра с двумя смыслами).

Теперь: «3 / 50» + title с расшифровкой. Фолбэк на history.length убран —
lifetime-число с потолком 50 строк смыслово другое; нет квоты (unlimited
или не загрузилась) — нет бейджа: лучше ничего, чем не то.

Тест по значению, с открытием меню; фальсификация: возврат голого used
красит «Unable to find 3 / 50». 204 теста (v2 + mera-public) зелёные.
2026-09-02 12:25:14 +05:00
ab3cb4fcb3 auth: тестовый аккаунт buyer1 (pilot) для потенциального покупателя
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 2m32s
CI Trade-In / backend-tests (pull_request) Successful in 5m13s
CI / backend-tests (pull_request) Successful in 17m21s
По просьбе владельца 02.09.2026. YAML-роль pilot (доступ только /trade-in/**),
DB-роль manager — по образцу praktika/kopylov: самостоятельный внешний аккаунт.
Квота 50 оценок/мес выдаётся через account_quota_overrides.monthly_limit
(дефолт 15), НЕ unlimited.

Строка в roles.yaml обязательна не только для RouteGuard: без неё session-юзер
получает 403 на чтение СВОЕЙ оценки (get_role → KeyError, см. #3316).
2026-09-02 12:05:50 +05:00
223 changed files with 23962 additions and 2101 deletions

View file

@ -419,11 +419,22 @@ jobs:
docker compose -p gendesign-metrics-agent \
-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
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 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:
runs-on: ubuntu-latest
needs: server
@ -478,7 +489,18 @@ jobs:
docker compose -p gendesign-metrics-agent \
-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
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 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; }

View file

@ -596,6 +596,11 @@ jobs:
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
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
script: |
set -euo pipefail
@ -1037,6 +1042,171 @@ jobs:
fi
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:`
# молча (result=skipped), когда build падает (например, битый blob в
# buildcache роняет `docker/build-push-action` — до ретрая выше, #2841).

View 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())

View file

@ -133,6 +133,11 @@ users:
# продукта; ранее expired с 2026-06-27). Безлимитная квота оценок
# выдана через account_quota_overrides.unlimited (migration 191),
# не через код — см. 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
pilottest: pilot # temp QA 2026-05-26
analysttest: analyst # temp QA 2026-06-07 (#962)

View file

@ -19,6 +19,7 @@ per-row SAVEPOINT при UPSERT (битая фича не валит weekly-sync
import hashlib
import json
import logging
import math
import re
import httpx
@ -101,20 +102,47 @@ def parse_voltage_class(name: str | None) -> str | None:
return m.group(1).replace(".", "/")
def _stable_external_id(feature: dict, props: dict) -> str:
"""Стабильный external_id фичи: feature['id'] или хэш ключевых полей.
def _coord_e5(value: float | None) -> str:
"""Координата → целое в единицах 1e-5 градуса (~1 м), полукруглением от нуля.
WFS обычно отдаёт стабильный ``feature['id']``; если его нет детерминированный
sha1 по (sc_name, координаты) чтобы UPSERT оставался идемпотентным.
Целое, а не форматированный float: ключ обязан совпадать байт-в-байт с SQL-
бэкфиллом (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 fid:
return str(fid)
geom = feature.get("geometry") or {}
coords = geom.get("coordinates")
seed = f"{props.get('sc_name', '')}|{coords}"
# sha1 здесь — стабильный дедуп-id фичи, не криптография.
return "h:" + hashlib.sha1(seed.encode("utf-8")).hexdigest()[:16]
if value is None:
return ""
n = math.floor(abs(value) * 100000 + 0.5)
return str(-n if value < 0 else n)
def _stable_external_id(feature: dict, props: dict) -> str:
"""Стабильный 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:

View file

@ -18,6 +18,7 @@ Pure / mock-based — без реальной сети и БД. Покрывае
from __future__ import annotations
import hashlib
import io
from contextlib import contextmanager
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
# ── _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 (кВА-санитайз) ───────────────────────────────────

View file

@ -0,0 +1,54 @@
# ═══════════════════════════════════════════════════════════════════════════
# caddy/deploy-window.caddy.snippet — ответ на окно деплоя (#3274)
#
# Импортируется ВНУТРЬ `handle_errors 502 503 504 { ... }` (см. apps.caddy):
# сам по себе снипет ничего не перехватывает, он только решает, ЧТО отдать,
# когда апстрим не отвечает.
#
# ЧТО ЭТО ЛЕЧИТ, А ЧТО НЕТ. Каждый деплой tradein-frontend/tradein-backend
# оставляет окно 3090 с, в котором контейнера просто нет: 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
}

View file

@ -175,10 +175,28 @@ gendsgn.ru {
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
# delete) — корректное поведение не должно зависеть от того, как именно
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
# basic_auth), это единственное, что теперь отсекает подделку заголовков
# изнутри gendesign_shared network для legacy dual-mode пути.
# X-Internal-Auth-Secret ЗДЕСЬ БОЛЬШЕ НЕ ПОДСТАВЛЯЕТСЯ (#3324). До этой
# правки Caddy инжектил его в КАЖДЫЙ запрос этого хопа — включая
# анонимный, до всякого логина trade-in.
#
# ПОЧЕМУ ОН БЫЛ МЁРТВЫМ. Единственное место, где 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_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
@ -186,7 +204,6 @@ gendsgn.ru {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
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
reverse_proxy tradein-frontend:3000 {
# См. комментарий над /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-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@ -246,6 +267,32 @@ gendsgn.ru {
}
}
}
# Окно деплоя (#3274) — ТОЛЬКО для /trade-in. Каждый мерж в tradein
# оставляет 3090 с, в которые контейнера нет и посетитель видит голый 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 {
@ -463,7 +510,40 @@ meraocenka.ru {
# `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок,
# CPU-нагрузка по запросу), который на лэндинге не используется вообще:
# 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/* {
@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 {
header_up -X-Authenticated-User
}
@ -499,9 +579,12 @@ meraocenka.ru {
# strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не
# часть маршрута FastAPI.
#
# X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130):
# публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с
# анонимного домена — расширять доверие без нужды.
# X-Internal-Auth-Secret здесь НЕ подставляется: публичные ручки его не
# проверяют, а инжектить внутренний секрет в хоп с анонимного домена —
# расширять доверие без нужды. С #3324 это уже не отличие от B2B-блока
# gendsgn.ru, а общее правило: инжекта не осталось нигде в Caddyfile —
# заголовок нужен только внутрисетевому dual-mode трафику, который ходит
# мимо прокси и несёт его сам.
handle /trade-in/api/public/* {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
@ -514,6 +597,19 @@ meraocenka.ru {
handle {
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.

View 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;

View file

@ -313,6 +313,30 @@ services:
# /data/anton-sqlite/analysis.db).
- /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"]
# #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,
# которому нужен прямой TCP-доступ к tradein-postgres через gendesign_shared.
# default — обязательно явно, иначе сервис выпадет из дефолтной сети.
@ -336,6 +360,25 @@ services:
# хранит только last_run_at для periodic tasks — потеря на restart OK,
# beat перестроит из `celery_app.conf.beat_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) ────────
# Переезд продукта Beget (46.173.16.127) → Selectel Poincare (188.124.37.140),
@ -735,6 +778,9 @@ services:
# роняя ВСЕ сайты хоста, а не только metrics.gendsgn.ru.
- ./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
# То же самое для страницы окна деплоя (#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). Каталог
# держится в git через caddy/local/.gitignore — иначе docker создал бы
# отсутствующий bind-source сам, root-owned пустышкой.

View file

@ -172,6 +172,26 @@ loki.process "scrub_credentials" {
expression = "://[^:@/ ]+:([^@ ]+)@"
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" {

View file

@ -131,6 +131,18 @@ loki.process "scrub_credentials" {
expression = "://[^:@/ ]+:([^@ ]+)@"
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" {

View file

@ -52,15 +52,22 @@ check() {
}
check_post() {
local desc="$1" url="$2" body="$3" expected="$4"
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
local desc="$1" url="$2" body="$3" expected="$4" reject="${5:-}"
local out code head_and_body
# -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)
if [ "$code" = "$expected" ]; then
echo "PASS: $desc ($url -> $code)"
else
code=${out##*$'\n'}
head_and_body=${out%$'\n'*}
if [ "$code" != "$expected" ]; then
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
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
}
@ -119,6 +126,51 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
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/*
# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене
# по-прежнему 404.
@ -156,8 +208,23 @@ check_post "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/ap
# Гейт данных переехал на API — там и проверяем, иначе тест зелёный при
# открытом наружу бэкенде.
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
check "trade-in /api/v1/admin/* — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/admin/users" 401
# Путь именно `/api/v1/trade-in/history`: роутер trade_in подключён в main.py с
# префиксом `/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 стоит ДО роутинга
# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается
@ -206,8 +273,16 @@ check "meraocenka.ru payments/checkout — must 404 (Caddy не проксиру
# маршрут существует (не 404 — старый образ) И что приём платежей выключен
# (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув
# этот смоук. 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 (маршрут есть, приём выключен)" \
"$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)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405
check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \

View file

@ -40,7 +40,6 @@ from pydantic import BaseModel, Field, field_validator
# убрал legacy app.services.scheduler.scheduler_loop fallback) — тот же orchestrator,
# что и debug-роуты этого файла.
from scraper_kit.base import save_listings
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.orchestration.pipeline import (
DEFAULT_REGION_CODE,
run_avito_city_sweep,
@ -49,6 +48,7 @@ from scraper_kit.orchestration.pipeline import (
run_yandex_city_sweep,
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.houses import fetch_house_catalog, save_house_catalog_enrichment
from scraper_kit.providers.avito.imv import (
@ -268,6 +268,7 @@ async def geocode_missing(
db: Annotated[Session, Depends(get_db)],
limit: int = 100,
target: Literal["listings", "deals"] = "listings",
region_code: int = 66,
) -> dict:
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
@ -279,6 +280,10 @@ async def geocode_missing(
geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed-
адреса не выбираются повторно 7 дней cron-loop завершается, не зацикливается.
geom обновляется автоматически триггером.
region_code (дефолт 66, #3051) — фильтрует обе таблицы (`listings`/`deals`
несут колонку) и прокидывается в `known_city_hint`/`geocode`. Дефолт 66
прежнее поведение без изменений (cron не меняется, follow-up ниже).
"""
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
@ -291,13 +296,14 @@ async def geocode_missing(
WHERE lat IS NULL
AND COALESCE(address, '') != ''
{extra_filter}
AND region_code = CAST(:region_code AS int)
AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - interval '7 days')
ORDER BY geocode_tried_at NULLS FIRST
LIMIT :limit
"""
),
{"limit": limit},
{"limit": limit, "region_code": region_code},
)
.mappings()
.all()
@ -332,8 +338,8 @@ async def geocode_missing(
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
city = known_city_hint(row.get("city"))
result = await geocode(clean, db, city_hint=city)
city = known_city_hint(row.get("city"), region_code)
result = await geocode(clean, db, city_hint=city, region_code=region_code)
if result is None:
# Помечаем что пробовали — иначе ретрай на каждом cron.
db.execute(
@ -362,10 +368,12 @@ async def geocode_missing(
WHERE lat IS NULL
AND COALESCE(address, '') != ''
{extra_filter}
AND region_code = CAST(:region_code AS int)
AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - interval '7 days')
"""
)
),
{"region_code": region_code},
).scalar()
return {
@ -508,9 +516,16 @@ async def cian_auto_login(
)
try:
async with BrowserFetcher(
source="cian", endpoint=settings.browser_http_endpoint
) as fetcher:
# #3197 (хвост): через фабрику (endpoint/environment из одного места), но
# НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает
# (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(
url=settings.cian_login_url,
email=email,
@ -699,7 +714,12 @@ async def debug_domclick_detail_fetch(
# построен и verified live именно 2026-07-04 (815КБ __SSR_STATE__ через свежий IP).
# Заменяет прежний source="cian" (docstring domclick/detail.py — устаревшая
# рекомендация от 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:
enrichment = await domclick_fetch_detail(
body.card_url, browser_fetcher=bf, cookies=cookies
@ -1106,6 +1126,10 @@ async def scrape_avito_imv(
has_balcony=has_balcony,
has_loggia=has_loggia,
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:
# Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning
@ -1944,7 +1968,10 @@ async def scrape_yandex_newbuilding(
"""
# fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен.
# 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)
if result is None:
raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")

View file

@ -13,9 +13,11 @@ _send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ
``{"text": str, "attachments": [{"title","title_link","text","color",
"fields",...}]}`` просто у uptime пустые/отсутствующие ``fields``/``color``;
2) единственный канал для аутентификации сам URL (как и Slack-вебхуки).
Секрет ОБЯЗАН ехать query-параметром, HTTP-заголовок здесь поставить
нечем (GlitchTip-сторона его не добавляет).
2) единственный канал для аутентификации от САМОГО GlitchTip сам URL (как и
у Slack-вебхуков): заголовок GlitchTip-сторона не добавляет. Поэтому
хендлер принимает секрет и из заголовка ``X-GlitchTip-Secret``
(предпочтительно не течёт в access-log, #3154), и из query-параметра
``?secret=`` как fallback для текущего отправителя.
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета тот же
@ -47,7 +49,7 @@ import secrets
from datetime import UTC, datetime
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 app.core.config import settings
@ -175,8 +177,9 @@ def _alerts_configured() -> bool:
def _verify_secret(provided: str) -> None:
expected = settings.tradein_internal_auth_secret
# constant-time: длина/префикс секрета не утекают через время ответа.
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")
@ -184,18 +187,27 @@ def _verify_secret(provided: str) -> None:
async def glitchtip_webhook(
request: Request,
secret: Annotated[str, Query()] = "",
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
) -> dict[str, str]:
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
Путь публичный в ``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():
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()
received_at = datetime.now(UTC)

View file

@ -187,12 +187,52 @@ _ABANDONABLE_STATUSES = frozenset({"NEW", "FORM_SHOWED"})
# списания требуют, чтобы человек намеренно оплатил обе формы.
_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-"
# 32 байта энтропии (43 символа base64url) — перебор capability-ссылки
# неосуществим, а сама ссылка остаётся кликабельной в мессенджере.
_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:
"""Kill-switch контура. 503, а не 404: путь существует, приём оплаты выключен."""
if not settings.payments_enabled:
@ -389,21 +429,17 @@ def checkout(
)
)
except TBankApiError as exc:
# Запись остаётся в БД со статусом NEW и текстом ошибки — иначе факт
# попытки (и возможного холда, если обрыв случился после приёма запроса
# банком) не остался бы нигде. Слепой повтор Init по тому же order_id
# Запись остаётся в БД с текстом ошибки — иначе факт попытки (и заказа,
# который банк мог принять до обрыва) не остался бы нигде. Холда здесь
# быть не может: Init только заводит заказ и отдаёт ссылку на форму, а
# авторизация суммы происходит, когда покупатель платит по форме — её
# ему не выдавали. Слепой повтор Init по тому же order_id всё равно
# запрещён (см. докстринг init_payment) — это работа реконсиляции.
db.execute(
text(
"""
UPDATE payments
SET error_code = :code, error_message = :message, updated_at = NOW()
WHERE order_id = :order_id
"""
),
{"code": exc.error_code[:64], "message": str(exc)[:500], "order_id": order_id},
)
db.commit()
#
# Статус — терминальный (см. `_mark_init_failed`); если банк всё же
# пришлёт по этой строке нотификацию, статус-машина notify доведёт её до
# конца (терминальный статус не блокирует CONFIRMED).
_mark_init_failed(db, order_id, code=exc.error_code, message=str(exc))
logger.warning("checkout: Init отклонён банком, order_id=%s: %s", order_id, exc)
raise HTTPException(status_code=502, detail="payment provider error") from exc
@ -411,7 +447,15 @@ def checkout(
tbank_payment_id = init.get("PaymentId")
if not isinstance(payment_url, str) or not payment_url:
# 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)
raise HTTPException(status_code=502, detail="payment provider returned no payment url")

View file

@ -78,7 +78,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
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.db import get_db
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-запроса
вместо 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]] = {}
for username in usernames:
override = override_by_username.get(username)
try:
role = get_role(username)
except KeyError:
role = None
# #3316: get_role ходит в реестр, а вызывающий уже прочитал роли этих
# же строк — иначе батч снова стал бы N+1 (ловит
# test_list_employees_query_count_is_not_n_plus_1). Роль реестра —
# ровно то, что вернул бы 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":
unlimited = True
elif role is not None:
elif yaml_role(username) is not None:
unlimited = bool(override is not None and override["unlimited"])
else:
# username не в roles.yaml — is_unlimited() короткое замыкание на
@ -432,6 +442,20 @@ async def create_employee(
`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()
existing = identity_db.execute(
text(f"SELECT id FROM {schema.users_table} WHERE username = :u"),
@ -762,7 +786,11 @@ async def list_employees(
.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]

View file

@ -50,6 +50,7 @@ from app.schemas.trade_in import (
TradeInEstimateInput,
)
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.image_sanitizer import ImageSanitizationError, sanitize_image
from app.services.user_events import schedule_event
@ -763,8 +764,22 @@ def load_estimate(
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
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(
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
@ -776,7 +791,16 @@ def load_estimate(
recommended_price=int(imv_raw["recommended_price"]),
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,
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")
else None

View file

@ -8,6 +8,11 @@ from repo root. We deliberately do NOT share code between repos via
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`)
и пропускает в backend заголовок `X-Authenticated-User: <username>` через
`header_up X-Authenticated-User {http.auth.user.id}` в каждом reverse_proxy.
@ -25,13 +30,15 @@ import logging
import re
from functools import lru_cache
from pathlib import Path
from typing import Literal, TypedDict
from typing import Literal, TypedDict, cast
import yaml
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):
@ -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:
"""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()
users: dict[str, Role] = config["users"]
if username not in users:
@ -217,13 +290,22 @@ def get_user_scope(username: str) -> UserScope:
"""
config = _load_roles_config()
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)
return UserScope(
username=username,
role=role,
allowed_paths=list(role_def.get("paths", []) or []),
deny_paths=list(role_def.get("deny", []) or []),
allowed_paths=allowed_paths,
deny_paths=deny_paths,
brand=get_brand_for_user(username),
display_name=display_name,
org=org,

View file

@ -1007,6 +1007,14 @@ class Settings(BaseSettings):
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
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
def yandex_proxy_url(self) -> str | None:
"""Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
@ -1179,7 +1187,8 @@ class Settings(BaseSettings):
# #3283g: ротация exit-IP НА САМ БАН площадки, а не только по счётчику попыток.
# Бан привязан к IP (замерено вживую: rotate_proxy() лечит забаненный узел за
# секунды, clear_source_bans снимает запись из scrape_proxy_source_bans), но
# секунды, clear_source_bans гасит бан в scrape_proxy_source_bans — строка живёт
# до purge, #3404), но
# #3251/#3212 запрещают сбрасывать browser-context на КАЖДЫЙ блок -- сброс без
# смены IP выбрасывает пройденный QRATOR-PoW и запускает самоподдерживающийся
# каскад блоков на том же адресе. rotate_on_ban МЕНЯЕТ IP вместе со сбросом,

View 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())

View file

@ -35,10 +35,11 @@ from typing import Any
from fastapi import Request
from fastapi.responses import JSONResponse, Response
from starlette.routing import Match
from app.core.auth import get_role, is_path_allowed
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
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)
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:
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
только добавляя при отсутствии, чтобы ``RateLimitMiddleware``/
@ -239,6 +324,11 @@ async def rbac_guard(
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PATH_PREFIXES):
return await call_next(request)
# #3324: путь, которого нет в роутере, отвечает как любой несуществующий
# путь (404 роутера) — иначе 401 работает оракулом существования ручки.
if not _path_is_routed(request):
return await call_next(request)
username: str | None = None
role: str | None = None
from_session = False
@ -278,18 +368,12 @@ async def rbac_guard(
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
# отключён, даже если валидный X-Authenticated-User присутствует.
if settings.auth_mode != "dual":
return JSONResponse(
status_code=401,
content={"detail": "valid session required"},
)
return _unauthenticated(path, "valid session required")
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
username = request.headers.get("X-Authenticated-User")
if not username:
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (valid session required)"},
)
return _unauthenticated(path, "no authenticated user (valid session required)")
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
@ -305,10 +389,7 @@ async def rbac_guard(
username,
path,
)
return JSONResponse(
status_code=401,
content={"detail": "invalid or missing internal auth secret"},
)
return _unauthenticated(path, "invalid or missing internal auth secret")
try:
role = get_role(username)
@ -334,7 +415,11 @@ async def rbac_guard(
# scope-narrowed юзер не смог бы получить свою роль вовсе.
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
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)
else:
try:

View file

@ -44,6 +44,7 @@ from app.core.config import settings
from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping
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.rbac import rbac_guard
from app.core.request_audit import RequestAuditMiddleware
@ -64,6 +65,11 @@ logging.basicConfig(
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
logging.getLogger("httpx").setLevel(logging.WARNING)
# #3154: uvicorn access-log печатает полный путь С QUERY, а лог уезжает в Loki —
# так секрет вебхука GlitchTip (`?secret=…`) оказался в хранилище открытым.
# Маскируем значения чувствительных query-параметров ДО записи строки.
install_query_secret_filter()
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
@ -218,6 +224,16 @@ app = FastAPI(
description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", # noqa: E501
version="0.1.0",
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,
)

View file

@ -101,15 +101,23 @@ def _should_run() -> bool:
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'ом.
Возвращает True, если задачу пришлось хард-кансельнуть (grace истёк), False если
она вышла сама. Вызывающий обязан различать эти исходы в логах (#3391): строка
«drained cleanly» после hard-cancel'а — ложь, а именно она печаталась на проде
07.09 сразу за WARNING'ом о превышении grace.
- Нет shutdown: scheduler_loop бесконечен задача никогда не завершается, ждём её
как есть (процесс просто работает).
- shutdown запрошен, задача ещё бежит: bounded `wait_for(_DRAIN_TIMEOUT_S)`
кооперативная задача докоммитит текущий unit на ближайшем checkpoint'е и выйдет
сама. Если превысила grace hard-cancel + suppress CancelledError, чтобы
некооперативная задача не подвесила процесс за пределами 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'а
# должен стартовать от МОМЕНТА запроса drain'а, а не от старта процесса.
@ -127,7 +135,7 @@ async def _await_scheduler(task: asyncio.Task[None]) -> None:
# упал (сохраняем прежнюю loud-crash семантику `await task`), а не глушит его.
task.result()
logger.info("scheduler_main: scheduler task exited cleanly")
return
return False
logger.info(
"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:
await asyncio.wait_for(task, timeout=_DRAIN_TIMEOUT_S)
logger.info("scheduler_main: scheduler drained and exited cleanly")
return False
except TimeoutError:
logger.warning(
"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()
with suppress(asyncio.CancelledError):
await task
return True
async def _run_kit_scheduler() -> None:
@ -224,8 +234,12 @@ async def _run() -> None:
# Windows dev: signal handlers через loop не поддерживаются
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():
logger.info("scheduler_main: scheduler drained cleanly (SIGTERM)")
else:

View file

@ -46,7 +46,7 @@ from fastapi import HTTPException
from sqlalchemy import text
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
logger = logging.getLogger(__name__)
@ -61,8 +61,7 @@ def limit_exhausted_message(limit: int) -> str:
отличаться от глобального MONTHLY_LIMIT для персонального override ИЛИ
anon default_limit, см. #b2c-antiabuse-2)."""
return (
f"Лимит из {limit} оценок в этом месяце исчерпан. "
"За полной версией обращайтесь к Копылову."
f"Лимит из {limit} оценок в этом месяце исчерпан. За полной версией обращайтесь к Копылову."
)
@ -97,6 +96,14 @@ def is_unlimited(db: Session, username: str) -> bool:
return False
if role == "admin":
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(
text(
"""

View file

@ -56,6 +56,8 @@ from __future__ import annotations
from collections import Counter, deque
from dataclasses import dataclass, field
from scraper_kit.orchestration.runs import BAN_KIND_PLATFORM
@dataclass
class BlockRatioBreaker:
@ -88,7 +90,26 @@ class BlockRatioBreaker:
"""Знаменатель ratio-критерия: сколько попыток уже влезло в окно."""
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._window.append(True)

View 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

View file

@ -34,25 +34,6 @@ from app.services.scraper_settings import get_scraper_delay
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
class CianPriceHistoryResult:
checked: int = 0
@ -84,7 +65,13 @@ async def backfill_cian_price_history(
# Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает
# `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт
# (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()
if listing_id is not None:

View 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),
)

View 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

View file

@ -51,6 +51,7 @@ from scraper_kit.providers.yandex.valuation import (
YandexValuationResult,
YandexValuationScraper,
)
from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -65,6 +66,7 @@ from app.schemas.trade_in import (
PriceTrendPoint,
TradeInEstimateInput,
)
from app.services import regions as regions_mod
from app.services.dadata import DadataAddressResult
# 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.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.tasks.asking_to_sold_ratio import area_bucket
@ -343,22 +345,24 @@ DEAL_MAX_FLOOR = 60 # выше реального максимума ЕКБ →
# глобальный пол 50k ₽/м² для дешёвых городов области НЕ anti-outlier guard, а
# cut-off легитимного рынка (Североуральск median ≈21.7k, Новоуральск ≈36k,
# Асбест ≈41k — все ниже 50k → 46.6% не-ЕКБ сделок молча дропались).
# deal_city_price_bands (миграция 178) — per-city [ppm2_min, ppm2_max] band из
# перцентилей p1/p99 реальных rosreestr-сделок города (hard floor/ceiling
# 8000/800000 всё равно режут доли/опечатки). Екатеринбург НАМЕРЕННО исключён
# из таблицы — .get(city, ...) fallback на глобальные DEAL_MIN_PPM2/MAX_PPM2
# ниже гарантирует byte-identical ЕКБ-поведение.
# deal_city_price_bands (миграция 178, ключ (region_code, city) — миграция 298
# #3051) — per-(region, city) [ppm2_min, ppm2_max] band из перцентилей p1/p99
# реальных rosreestr-сделок города (hard floor/ceiling 8000/800000 всё равно
# режут доли/опечатки). Екатеринбург (region_code=66) НАМЕРЕННО исключён из
# таблицы — .get((region_code, city), ...) fallback на глобальные
# DEAL_MIN_PPM2/MAX_PPM2 ниже гарантирует byte-identical ЕКБ-поведение.
_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]]:
"""#2478: {city: (ppm2_min, ppm2_max)} из deal_city_price_bands (миграция 178).
def _load_city_price_bands(db: Session) -> dict[tuple[int, str], tuple[int, int]]:
"""#2478 + #3051 (298): {(region_code, city): (ppm2_min, ppm2_max)} из deal_city_price_bands.
Кэш в процессе (TTL _CITY_PRICE_BANDS_CACHE_TTL_S) таблица рефрешится
периодическим ре-запуском миграции, не на каждый estimate. Таблицы нет /
любая ошибка {} (graceful) вызывающий код fallback'ит на глобальные
DEAL_MIN_PPM2/DEAL_MAX_PPM2, т.е. поведение как до #2478.
периодическим ре-запуском derivation (298 / deal_city_price_bands_refresh),
не на каждый estimate. Таблицы нет / любая ошибка {} (graceful)
вызывающий код fallback'ит на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2, т.е.
поведение как до #2478.
"""
global _city_price_bands_cache
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
try:
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()
.all()
)
except Exception as exc:
logger.warning("deal_city_price_bands lookup failed (graceful): %s", exc)
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())
return bands
@ -794,6 +802,11 @@ async def _get_or_fetch_imv_cached(
has_balcony=has_balcony,
has_loggia=has_loggia,
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)
logger.info(
@ -827,6 +840,7 @@ async def _get_or_fetch_imv_cached(
has_balcony=has_balcony,
has_loggia=has_loggia,
config=RealScraperConfig(),
proxy_provider=RealProxyProvider(), # #3386, см. первый вызов выше
)
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
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)
return None
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
@ -892,8 +913,18 @@ def _defer_external_refresh(label: str, work: Callable[[Session], Awaitable[obje
db = SessionLocal()
try:
await work(db)
except Exception:
logger.exception("deferred %s: догрузка не удалась (кэш не прогрет)", label)
except Exception as exc:
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:
db.close()
@ -992,14 +1023,27 @@ async def _get_or_fetch_yandex_valuation_cached(
# Fresh fetch
try:
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:
result = await scraper.fetch_house_history(
address=address,
offer_category=offer_category,
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
# (main.py/scheduler_main.py) слушает event_level=logging.ERROR. WARNING,
# даже с exc_info=True, остаётся ниже порога и НЕ создаёт событие в GlitchTip
@ -1009,7 +1053,15 @@ async def _get_or_fetch_yandex_valuation_cached(
return 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
# Save to cache (UPSERT on (source, cache_key))
@ -1320,6 +1372,8 @@ def _apply_imv_blend(
anchor_higher: int | None,
weight: float,
threshold: float,
market_count: int | None = None,
thin_market_threshold: int = 0,
) -> tuple[int, int, float, bool, int | None]:
"""Чистая (testable без БД) blend-трансформация для #651.
@ -1330,12 +1384,22 @@ def _apply_imv_blend(
Если A ниже медианы медиану НЕ трогаем, но диапазон можем расширить, чтобы
включить 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,
anchor_used_total).
"""
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
if market_count is not None and market_count < thin_market_threshold:
return median_price, range_high, median_ppm2, False, None
blended = False
new_median = median_price
new_ppm2 = median_ppm2
@ -1775,6 +1839,7 @@ def _fetch_dkp_corridor(
rooms: int | None,
area: float | None,
city: str | None = None,
region_code: int = regions_mod.DEFAULT_REGION_CODE,
period_months: int = DEALS_PERIOD_MONTHS,
area_tolerance: float = AREA_TOLERANCE,
) -> dict[str, Any] | None:
@ -1794,6 +1859,16 @@ def _fetch_dkp_corridor(
Тагиле/Лесном/Краснотурьинске/Серове/ЕКБ/Первоуральске) коридор превращался
в кросс-областной мусор для не-ЕКБ таргетов. None (город не определился)
фильтр не применяется, прежнее (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:
return None
@ -1816,8 +1891,10 @@ def _fetch_dkp_corridor(
f"""
SELECT d.price_per_m2, d.deal_date
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'
AND d.region_code = CAST(:region_code AS int)
AND d.address ILIKE :street_pattern
AND d.address ~* :street_regex
AND d.rooms = CAST(:rooms AS integer)
@ -1826,11 +1903,12 @@ def _fetch_dkp_corridor(
- (CAST(:period_months AS integer) || ' months')::interval
AND d.price_per_m2 > 0
{city_filter_sql}
-- #699 + #2478: режем нерыночные ppm²-выбросы из коридора
-- expected_sold. Per-city band (deal_city_price_bands, миграция
-- 178) когда для города сделки есть строка (не-ЕКБ область);
-- иначе (в т.ч. Екатеринбург, НАМЕРЕННО не в таблице) fallback
-- на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 byte-identical.
-- #699 + #2478 + #3051: режем нерыночные ppm²-выбросы из коридора
-- expected_sold. Per-(region, city) band (deal_city_price_bands,
-- миграция 178, ключ (region_code, city) миграция 298) когда для
-- (региона, города) сделки есть строка; иначе (в т.ч. Екатеринбург
-- 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 COALESCE(b.ppm2_max, CAST(:ppm_max AS int))
"""
@ -1845,6 +1923,7 @@ def _fetch_dkp_corridor(
"ppm_min": DEAL_MIN_PPM2,
"ppm_max": DEAL_MAX_PPM2,
"target_city": city.lower() if city else None,
"region_code": region_code,
},
)
.mappings()
@ -1897,8 +1976,10 @@ def _fetch_dkp_corridor(
"""
SELECT d.price_per_m2, d.deal_date
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'
AND d.region_code = CAST(:region_code AS int)
AND d.city IS NOT NULL
AND LOWER(d.city) = CAST(:target_city AS text)
AND d.rooms = CAST(:rooms AS integer)
@ -1918,6 +1999,7 @@ def _fetch_dkp_corridor(
"period_months": period_months,
"ppm_min": DEAL_MIN_PPM2,
"ppm_max": DEAL_MAX_PPM2,
"region_code": region_code,
},
)
.mappings()
@ -3347,8 +3429,16 @@ def _price_from_inputs(
anchor_higher = (
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
)
anchor_label = "оценке Avito IMV"
_imv_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
# Публичная копия: клиент видит эту строку в confidence_explanation
# (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(
recommended_price=anchor_total,
lower_price=(
@ -3379,7 +3469,10 @@ def _price_from_inputs(
elif imv_eval is not None and 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_label = "оценке Avito IMV"
# Публичная копия: клиент видит эту строку в confidence_explanation
# (HeroSummary), название площадки туда не должно утекать —
# та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
anchor_label = "оценочной модели площадки"
avito_imv_summary = AvitoImvSummary(
recommended_price=anchor_total,
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:
logger.warning(
"avito_imv thin_market #audit-5b: market_count=%s"
" (< avito_imv_thin_market_threshold=%d) — IMV reliability low",
"avito_imv thin_market #3323: market_count=%s"
" (< avito_imv_thin_market_threshold=%d) — IMV ОТБРОШЕН из денежного"
" пути (ни blend медианы, ни расширение range_high); остаётся"
" display-only в карточке avito_imv",
avito_imv_summary.market_count,
settings.avito_imv_thin_market_threshold,
)
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(
median_price=median_price,
range_high=range_high,
@ -3411,6 +3514,10 @@ def _price_from_inputs(
anchor_higher=anchor_higher,
weight=settings.estimate_imv_blend_weight,
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:
logger.info(
@ -3442,7 +3549,11 @@ def _price_from_inputs(
# median/expected_sold/ranges блок не трогает.
if avito_imv_summary is None:
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(
recommended_price=int(imv_anchor["recommended_price"]),
lower_price=(
@ -4469,6 +4580,15 @@ async def estimate_quality(
target_city = _resolve_target_city(payload.address) or _resolve_target_city(
(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(
_fetch_dkp_corridor,
db,
@ -4476,6 +4596,7 @@ async def estimate_quality(
rooms=payload.rooms,
area=payload.area_m2,
city=target_city,
region_code=target_region_code,
)
# ── Stage 3: Avito IMV evaluation as 5-th source (on-demand cached) ──
@ -4569,6 +4690,21 @@ async def estimate_quality(
"deal_type": "sale",
"use_cache": True,
"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:
cian_val = await _with_budget(
@ -4588,13 +4724,19 @@ async def estimate_quality(
cian_val.sale_accuracy,
cian_val.external_house_id,
)
except Exception:
# 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)")
except Exception as e:
if caused_by_no_proxy(e):
# #3398: пустой пул поднимает NoProxyAvailableError ДО HTTP (curl_proxy_url),
# то есть это не сбой Циана. WARNING, не ERROR: в GlitchTip такое событие —
# шум. Оценка отдаётся без cian-источника, /estimate не 5xx.
logger.warning("cian_valuation: пул прокси пуст — продолжаем без Cian: %s", e)
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 ─────────────────────────────────
# Guard mirrors the original in-block guard exactly; when false → ([], None).
@ -6419,25 +6561,29 @@ def _is_plausible_deal(
area_m2: float | None = None,
price_rub: float | 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:
"""#699 + Mera-audit fix-2 + #2478: True если ДКП-сделка правдоподобна (не выброс).
"""#699 + Mera-audit fix-2 + #2478 + #3051 (298): True если ДКП-сделка правдоподобна.
Абсолютные guard-bands (см. DEAL_* константы). None-поля не судим (keep
нечем сравнивать). Проверки:
- 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
(deal_city_price_bands, миграция 178) для не-ЕКБ городов области, где
глобальный пол 50k /м² режет легитимно дешёвый рынок. city не в bands
(в т.ч. Екатеринбург НАМЕРЕННО не в таблице) или bands=None
fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 (byte-identical
сегодняшнему поведению).
bands.get((region_code, city), (DEAL_MIN_PPM2, DEAL_MAX_PPM2)) #2478:
per-(region, city) band (deal_city_price_bands, миграция 178, ключ
(region_code, city) миграция 298) для не-ЕКБ городов области, где
глобальный пол 50k /м² режет легитимно дешёвый рынок. region_code
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 > total_floors физически невозможен drop
- area_m2 задана и <= 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):
return False
if floor is not None:
@ -6462,7 +6608,7 @@ def _fetch_deals(
SELECT
source, address, lat, lon,
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,
cadastral_number,
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²
# / нулевая площадь / нулевая цена) до выдачи в actual_deals и expected_sold.
# #2478: per-city ppm² band (deal_city_price_bands) вместо глобального
# DEAL_MIN_PPM2/MAX_PPM2 — грузим один раз на вызов, graceful {} при ошибке.
# #2478 + #3051 (298): per-(region, city) ppm² band (deal_city_price_bands)
# вместо глобального 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)
deals = [dict(r) for r in rows]
clean = [
@ -6507,6 +6656,7 @@ def _fetch_deals(
d.get("price_rub"),
city=d.get("city"),
bands=bands,
region_code=d.get("region_code") or regions_mod.DEFAULT_REGION_CODE,
)
]
if len(clean) < len(deals):

View file

@ -290,8 +290,9 @@ matplotlib.rcParams["font.family"] = "Manrope"
# ── Source pseudo-logos (текстовые pill-badges с брендовыми цветами источников) ─
# Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится
# через безопасный fallback в _source_pill: серый фон + source.title() — код на
# исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
# через безопасный fallback в _source_pill: серый фон + «Другой источник» (#3341 —
# сырой id тоже мог бы утечь именем площадки, source.title() больше не используется)
# — код на исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
# сторонних площадок (Avito/Циан/...), НЕ часть внутренней design-системы —
# намеренно не конвертируются в наши OKLCH-токены.
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
@ -303,15 +304,59 @@ _SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
"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] = {
"avito": "Avito",
"cian": "Циан",
"domklik": "Домклик · Сбер",
"yandex": "Я.Недвижимость",
"avito": "Источник 1",
"cian": "Источник 2",
"yandex": "Источник 3",
"domklik": "Источник 4",
"etazhi": "Источник 5",
"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:
"""Source pill — мягкий HUD-чип (.source-pill, ObjectSummary.tsx dot-идиома):
@ -319,10 +364,12 @@ def _source_logo_pill(source: str) -> str:
непрозрачный цветной чип; теперь единая с остальным документом мягкая палитра
(Mera v2), бренд-цвет остаётся только акцентной точкой. Название из
_SOURCE_DISPLAY_NAMES (короткий bounded набор, max ~18 символов)
overflow-wrap на всякий случай, если source незнаком и попадёт .title()
произвольной длины (см. fallback ниже)."""
dot = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))[0]
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
overflow-wrap на всякий случай, если source незнаком (см. fallback ниже).
Канонизирует алиасы (avito_imv avito и т.п.) ДО lookup, чтобы цвет/лейбл
совпадали с базовой площадкой (#3341 review)."""
canon = _canonical_source(source)
dot = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff"))[0]
name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник")
return (
"<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;"
@ -333,11 +380,13 @@ def _source_logo_pill(source: str) -> str:
def _source_badge_inline(source: str | None) -> str:
"""Маленький source badge для table cells (без фона)."""
"""Маленький source badge для table cells (без фона). Канонизирует алиасы
ДО lookup см. _source_logo_pill."""
if not source:
return f"<span style='color:{_MUTED};'>—</span>"
bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
canon = _canonical_source(source)
bg, fg = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник")
return (
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>"
@ -1167,12 +1216,12 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
advice_discount_text = (
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
f"ДомКлик и продажи агентств недвижимости"
f"сделки площадок и продажи агентств недвижимости"
)
else:
advice_discount_text = (
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
"Росреестр, ДомКлик и продажи агентств недвижимости"
"Росреестр, сделки площадок и продажи агентств недвижимости"
)
disclaimer_html = ""
@ -1357,7 +1406,11 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
# count «с учётом ремонта» не существует, второе число было идентично n_total.
# 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])
# Params правой колонки — параметры поиска (НЕ конкретной квартиры)
@ -1598,10 +1651,13 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
# #3341 review: канонизация+дедуп (_public_sources) ДО фильтра/среза [:5] — те же
# причины, что и на странице листингов (см. коммент там).
_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:
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])
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;">
<div class="bold">Расходы на рекламу</div>
<div style="font-size:{_FS_XS};color:{_MUTED};">Ежемесячное базовое продвижение объекта
на Циан, Авито, Я.Недвижимости</div>
на основных площадках объявлений</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
бесплатно</td>

View 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)

View 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
}

View 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

View file

@ -28,9 +28,16 @@ from tenacity import retry, stop_after_attempt, wait_exponential
from app.core.config import settings
from app.services import dadata
from app.services.regions import REGIONS as _ALL_REGIONS
from app.services.regions import Region, is_within_bbox
_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__)
# ── Общий ограничитель темпа обращений к Nominatim (#2953) ──────────────────
@ -170,8 +177,15 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
SVERDLOVSK_OBLAST_CITIES = _REGION_66.cities # #3051: список — в реестре регионов
def known_city_hint(value: str | None) -> str | None:
"""`value` как city_hint, если это узнаваемое имя города региона 66, иначе None.
def known_city_hint(value: str | None, region_code: int = 66) -> str | 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()`
(#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте
@ -200,7 +214,14 @@ def known_city_hint(value: str | None) -> str | None:
"""
if not value:
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": ...}]`.
@ -231,20 +252,41 @@ _OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р", "р-он", "район", "жк"})
def _has_oblast_marker(text_lower: str) -> bool:
"""True если текст уже содержит упоминание области/города региона 66.
# region_code → скомпилированный regex городов региона (word-boundary), кэш по
# коду. 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())
if _OBLAST_MARKER_RE.search(normalized):
if region_code == 66 and _OBLAST_MARKER_RE.search(normalized):
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()
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
@ -252,12 +294,14 @@ def _has_oblast_marker(text_lower: str) -> bool:
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), когда сам текст адреса города не называет.
Приоритет:
1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`)
1. Адрес уже содержит маркер города/области `region_code` (`_has_oblast_marker`)
город уже указан пользователем в тексте адреса, ничего подставлять не
нужно. Возвращает (None, True).
2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный
@ -266,15 +310,18 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str
для жителей других городов области это давало уверенно неверную цену
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
обе улицы называются одинаково). Теперь НЕ подставляем никакой город
провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без
привязки к конкретному городу. Возвращает
провайдер ищет по region-viewbox/bbox (см. `OBLAST66_VIEWBOX`,
`_region_viewbox`), без привязки к конкретному городу. Возвращает
(None, False) второй элемент False сигнализирует, что город
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
`region_code` (дефолт 66, #3051) — byte-identical прежнему поведению для
всех вызовов без аргумента.
Returns:
(city_or_none, city_specified_by_user).
"""
if _has_oblast_marker(address.lower()):
if _has_oblast_marker(address.lower(), region_code):
return None, True
hint = (city_hint or "").strip()
if hint:
@ -678,13 +725,15 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
# ── 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).
True/False если state однозначно про/не про Свердловскую область. None если
поле отсутствует/не строка тогда accept-логика падает обратно на bbox.
True/False если state однозначно про/не про регион `region_code`. None если
поле отсутствует/не строка, ИЛИ регион не имеет записи в `_REGION_STATE_MARKERS`
тогда accept-логика падает обратно на bbox.
Ловит Тюмень/Шадринск/Кунгур/Снежинск они внутри генерального OBLAST66_BBOX
(специально щедрого), но их state явно другой регион.
(специально щедрого), но их state явно другой регион. `region_code=66`
(дефолт) byte-identical прежнему поведению (`"свердловск" in state.lower()`).
"""
addr = item.get("address")
if not isinstance(addr, dict):
@ -692,22 +741,41 @@ def _nominatim_region_ok(item: dict) -> bool | None:
state = addr.get("state")
if not isinstance(state, str) or not state:
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.
ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при
опечатках не возвращать Пермский край / Челябинск но не резать легитимные
Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области).
ВАЖНО: фильтруем результаты по bbox региона `region_code` прямо тут, чтобы
при опечатках не возвращать Пермский край / Челябинск но не резать
легитимные Нижний Тагил / Серов и т.д. (генеральный bbox всего региона).
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его
не первым) иначе первый кандидат внутри OBLAST66. Плюс region cross-check
(`address.state`) отсекает кандидатов ЯВНО из другого региона (Тюмень и
т.п.), даже если координаты попали в генеральный bbox.
bbox региона (byte-identical для region_code=66, даже если Nominatim
ранжировал его не первым) иначе первый кандидат внутри генерального bbox
региона. Плюс region cross-check (`address.state`) отсекает кандидатов
ЯВНО из другого региона (Тюмень и т.п.), даже если координаты попали в
генеральный 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()
response = await client.get(
"https://nominatim.openstreetmap.org/search",
@ -717,8 +785,8 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
"limit": "3",
"countrycodes": "ru",
"addressdetails": "1",
"viewbox": OBLAST66_VIEWBOX["viewbox"],
"bounded": "1", # строго в пределах области (region 66)
"viewbox": viewbox,
"bounded": "1", # строго в пределах региона
},
)
response.raise_for_status()
@ -730,11 +798,11 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
lon_f = float(item["lon"])
except Exception:
continue
if _nominatim_region_ok(item) is False:
continue # регион явно не Свердловская область — не рассматриваем
if is_within_ekb_bbox(lat_f, lon_f):
return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
if _nominatim_region_ok(item, region_code) is False:
continue # регион явно не тот, что запрошен — не рассматриваем
if is_within_bbox(lat_f, lon_f, region.bbox_tight):
return item # tight-bbox приоритетнее — тот же результат, что и раньше
if oblast_fallback is None and is_within_bbox(lat_f, lon_f, region.bbox_region):
oblast_fallback = item
return oblast_fallback
@ -756,21 +824,25 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
# (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)
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.
Бан-policy: User-Agent с email обязателен.
Tier 1: bounded область (region 66) на оригинальный адрес.
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга Цвиллинга).
Tier 1: bounded регион `region_code` на оригинальный адрес.
Tier 2: bounded регион `region_code` на typo-варианты (Цвилинга Цвиллинга).
#2580 (C): city_hint, если известен, подставляется в текст запроса — без
него `_nominatim_query` полагается ТОЛЬКО на oblast66-bbox фильтр + tie-break
(предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона
(напр. "Ленина" и в Екатеринбурге, и в с. Свердловское) не различает город.
Эмпирически подтверждено: "Ленина 1" без города случайное село внутри
области; "Нижний Тагил, Ленина 1" корректно резолвится. Nominatim
единственный живой внешний провайдер (#2593: Yandex Geocoder удалён) —
city_hint должен реально влиять на его результат, не только на кэш-ключ.
него `_nominatim_query` полагается ТОЛЬКО на region-bbox фильтр + tie-break
(предпочитает tight bbox региона), который для одноимённых улиц ВНУТРИ
региона (напр. "Ленина" и в Екатеринбурге, и в с. Свердловское) не
различает город. Эмпирически подтверждено: "Ленина 1" без города
случайное село внутри области; "Нижний Тагил, Ленина 1" корректно
резолвится. Nominatim единственный живой внешний провайдер (#2593:
Yandex Geocoder удалён) city_hint должен реально влиять на его результат,
не только на кэш-ключ. `region_code=66` (дефолт, #3051) — byte-identical
прежнему поведению для всех вызовов без аргумента.
"""
headers = {
"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",
"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
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
# Tier 1: оригинал
item = await _nominatim_query(client, query)
item = await _nominatim_query(client, query, region_code)
# Tier 2: typo-variants
if item is None:
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
item = await _nominatim_query(client, variant_query)
item = await _nominatim_query(client, variant_query, region_code)
if item is not None:
logger.info("nominatim typo-fixed: %s%s", address, variant)
break
@ -1753,7 +1825,9 @@ async def suggest(
# ── 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).
Тонкая обёртка над `_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):
return result
logger.warning(
@ -1788,7 +1875,7 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
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:
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
@ -1802,6 +1889,10 @@ async def _geocode_resolve(
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
разных городов по одному и тому же тексту адреса не перезатирали
друг друга.
region_code: регион покрытия (дефолт 66, #3051). ЕКБ-only локальные
тиры (geoportal/cad_buildings/houses) применяются ТОЛЬКО при 66
это ЕКБ-специфичные реестры, у других регионов данных в них нет.
Прокидывается в Nominatim-тир (bbox/viewbox/city-словарь).
Returns:
GeocodeResult или None если ни один провайдер не отвечает.
@ -1812,7 +1903,7 @@ async def _geocode_resolve(
if not address or len(address.strip()) < 3:
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
addr_norm = _cache_key(normalize_address(address), city_hint)
@ -1838,7 +1929,14 @@ async def _geocode_resolve(
# Раньше решение по тексту адреса принималось от противного (список из 37
# городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион
# РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#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%).
if use_local_ekb and parsed is not None:
@ -1914,7 +2012,7 @@ async def _geocode_resolve(
# 3. Nominatim fallback
try:
result = await _nominatim_lookup(address, city_hint)
result = await _nominatim_lookup(address, city_hint, region_code)
if result is not None:
result = replace(result, city_ambiguous=city_ambiguous)
await asyncio.to_thread(_cache_put, db, addr_norm, result)

View file

@ -126,7 +126,14 @@ class TBankClient:
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
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:
logger.error(
"tbank client: %s — network error после %d попыток: %s",

View file

@ -367,7 +367,27 @@ async def _job_yandex_newbuilding_sweep(
# исход — назвать прогон неуспешным, а не дотянуть succeeded до ненуля.
processed = int(counters.get("processed") 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(
"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 ─────────
async def _job_house_imv_backfill(
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], {})
# ── 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-ФЗ) ───────────────
async def _job_purge_expired_trade_in_data(
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),
),
"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"),
"asking_to_sold_ratio_refresh": Handler(
_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"),
"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"),
"dtp_stat_refresh": Handler(_job_dtp_stat_refresh, "dtp_stat_refresh"),
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
"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(
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
),

View file

@ -54,7 +54,10 @@ Self-healing (#2600):
WARNING (пул надо пополнять, #2638).
- Ручное снятие `clear_source_bans` (ложный бан детектора капчи, #2642) плюс
автоматическое после успешной ротации 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):
- scrape_proxies.disabled_reason (миграция 209) различает ДВЕ разные причины
@ -143,6 +146,7 @@ __all__ = [
"STALE_LEASE_MINUTES",
"ProxyLease",
"acquire",
"attribute_run_proxy",
"clear_source_bans",
"mark_banned",
"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},
)
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:
logger.warning(
"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:
"""Освободить прокси (leased_by/leased_at → NULL). Идемпотентно (0-row если уже свободен)."""
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
SET ban_count = scrape_proxy_source_bans.ban_count + 1,
banned_until = now() + make_interval(hours => CAST(
SET ban_count = scrape_proxy_source_bans.ban_count + 1,
banned_until = now() + make_interval(hours => CAST(
LEAST(
CAST(:base_hours AS integer)
* power(2, LEAST(scrape_proxy_source_bans.ban_count, 16)),
CAST(:max_hours AS integer)
) AS integer)),
reason = CAST(:reason AS text),
updated_at = now()
reason = CAST(:reason AS text),
-- #3404: строка могла быть погашена clear_source_bans (banned_until
-- в прошлом попадает в WHERE ниже) новый бан затирает её метки
-- гашения, иначе на СНОВА забаненной паре висели бы cleared_at/
-- cleared_reason от предыдущего, уже неактуального гашения.
cleared_at = NULL,
cleared_reason = NULL,
updated_at = now()
-- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет),
-- свою же (обычная эскалация) или перебиваем боевым сбором он сильнее
-- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803).
@ -1058,12 +1145,25 @@ def clear_source_bans(
банила IP, а строка бана привязана к proxy_id и пережила бы смену адреса,
держа узел вне выдачи уже без причины.
source=None снять все баны узла; конкретный source только его. DELETE, а не
`banned_until = now()`: строка живёт ещё и ради `ban_count` (память об эскалации),
а здесь мы как раз объявляем историю недействительной новый бан начнётся с базовых
SOURCE_BAN_BASE_HOURS.
source=None снять все баны узла; конкретный source только его. Гасим строку
(`banned_until = now()`, `ban_count = 0`, `cleared_at`/`cleared_reason`), а НЕ
удаляем (#3404, было DELETE): строка доживает до штатного purge'а
(`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, т.е. «снимать только строки, которые
написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое
@ -1075,14 +1175,22 @@ def clear_source_bans(
rows = db.execute(
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)
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))
-- Уже погашенная строка (гейт покоя, см. докстринг) не трогаем: без
-- него повторный вызов сдвигал бы banned_until вперёд и отодвигал purge.
AND NOT (cleared_at IS NOT NULL AND ban_count = 0 AND banned_until <= now())
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()
db.commit()
if rows:
@ -1465,6 +1573,11 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
# Снесём раньше — узел, который площадка банит каждые сутки, каждый раз начинал бы с
# 6 часов и никогда не доходил до длинных пауз. Отложенный purge и есть механизм сброса:
# неделя без нового бана = пара считается чистой, эскалация с нуля. НЕ «оптимизировать».
# #3404: под этот же порог теперь попадают и ПОГАШЕННЫЕ clear_source_bans строки —
# для них banned_until == момент гашения (== cleared_at), т.е. таймер до purge
# отсчитывается от гашения, а не от исходного истечения бана. ban_count у них уже
# 0 к моменту гашения, так что покидающий purge их не «сбрасывает» повторно —
# он просто убирает уже неактуальный след из таблицы.
purged = len(
db.execute(
text(

View file

@ -47,6 +47,15 @@ class Region:
Регион без тира должен деградировать ЯВНО (потребитель
спрашивает 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
@ -58,6 +67,7 @@ class Region:
city_token: str
cities: frozenset[str]
enrichment_tiers: frozenset[str]
canonical_city: str | None = None
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
@ -144,6 +154,10 @@ REGIONS: dict[int, Region] = {
# sber_index покрывают регион 66. Пустое множество здесь — не заглушка,
# а ФАКТ, который потребители обязаны озвучивать (см. класс-докстринг).
enrichment_tiers=frozenset(),
# #3051: Росреестр по Москве отдаёт в city муниципальный округ/поселение
# ("муниципальный округ Раменки", "поселение Сосенское"), не сам город —
# import_rosreestr_dkp подставляет каноничное имя вместо city источника.
canonical_city="Москва",
),
}

View file

@ -25,13 +25,16 @@ Zombie-reap, advisory-lock claim и tick-loop теперь целиком в
from __future__ import annotations
import json
import logging
from typing import Any
# kit_runs.update_heartbeat (в отличие от локального runs_mod.update_heartbeat) мержит
# counters (`counters || :counters`) вместо замены — нужен для чекпоинта курсора
# import_rosreestr_dkp (issue #3168), чтобы resume-вердикт, записанный на старте, не
# затирался последующими per-batch heartbeat'ами того же прогона.
# kit_runs — ТОТ ЖЕ модуль, что и runs_mod ниже: с #3390 `app.services.scrape_runs`
# его алиас, реализация одна и counters везде МЕРЖАТСЯ (`counters || :counters`). До
# #3390 копии было две, и app-копия counters ЗАМЕНЯЛА — тогда чекпоинт курсора
# import_rosreestr_dkp (#3168) обязан был писаться именно kit-именем, иначе resume-вердикт
# со старта затирался первым же per-batch пульсом. Имя оставлено как есть: теперь это
# один объект, и переименование в runs_mod ничего не чинит и ничего не ломает.
from scraper_kit.orchestration import runs as kit_runs
# compute_next_run_at жил здесь ВТОРОЙ, побайтово одинаковой копией kit-версии (#2674).
@ -42,11 +45,13 @@ from scraper_kit.orchestration import runs as kit_runs
# такта снова разъедется по одному из них. Re-export (а не правка импорта у вызывающих)
# сохраняет `from app.services.scheduler import compute_next_run_at` в admin.py и тестах.
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.orm import Session
from app.core.shutdown import shutdown_requested
from app.services import scrape_runs as runs_mod
from app.services.regions import REGIONS
__all__ = ["compute_next_run_at", "has_running_run"]
@ -126,8 +131,16 @@ async def _execute_cian_backfill(
"""Сигнал живости из середины батча. Best-effort: сбой heartbeat не должен
ронять уже идущую работу прогон в худшем случае вернётся к прежнему
поведению (пометка 'zombie' на 6-м часу)."""
nonlocal counters
# #3384: снимок измеренного едет не только в БД, но и в `counters` — этот словарь
# уезжает в mark_failed из общего except ниже. Мерж (#3390) спасает лишь ключи,
# которых в payload нет; одноимённые он ПЕРЕЗАПИСЫВАЕТ, поэтому предынициализированные
# нули без этого присваивания легли бы поверх измеренного, и SQL-разбор простоя
# (#3288/#3367) прочитал бы «к площадке не ходили» про прогон, который ходил.
# Присваивание ДО записи в БД: сбой heartbeat'а не должен стирать сам факт замера.
counters = _counters(progress)
try:
runs_mod.update_heartbeat(db, run_id, _counters(progress))
runs_mod.update_heartbeat(db, run_id, counters)
except Exception:
logger.warning(
"scheduler: cian_history_backfill run_id=%d heartbeat failed (ignored)",
@ -135,6 +148,8 @@ async def _execute_cian_backfill(
exc_info=True,
)
# Стартовые нули: прогон виден в админке до первого прогресса. Держатся здесь ровно
# до первого `_heartbeat` — дальше в `counters` лежит измеренное (см. выше).
counters: dict[str, int] = {
"listings_processed": 0,
"listings_succeeded": 0,
@ -161,6 +176,28 @@ async def _execute_cian_backfill(
# #3196: отказ detail-фетча теперь несёт диагноз (HTTP-статус последнего ответа
# сайдкара). В 'banned' переводим ТОЛЬКО прогон, который отказы видел и не
# обогатил НИЧЕГО, — частичный успех остаётся '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:
counters["blocked"] = result.listings_blocked
# Полная перепись диагнозов, а не только доминирующий вид (#3196) — иначе
@ -186,12 +223,41 @@ async def _execute_cian_backfill(
)
except Exception as exc:
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)
raise
_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 прошлого прогона не подхватываем, прогон
# стартует с 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 раньше жил только в памяти процесса (init на 0 при каждом запуске): heartbeat
@ -225,7 +293,14 @@ def _resume_dkp_cursor(db: Session, run_id: int) -> tuple[int, dict[str, Any]]:
обратно. Обрыв (деплой/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, локальная копия ладдера контракт
другой: нет params/interval_days, курсор числовой, а не bucket-set):
- '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, не замена), чтобы решение было видно в
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}
if row is None:
@ -276,48 +351,92 @@ def import_rosreestr_dkp(
) -> None:
"""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.
USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader).
Область покрытия: вся Свердловская область (region_code=66), не только Екатеринбург
прежний ILIKE-фильтр по подстроке города (ограничивавший импорт одним Екатеринбургом)
снят (Mera trade-in расширяется на весь регион, unlocks +47183 сделок вне ЕКБ уже
сидящих в source foreign table). address и deals.city строятся из реального city
источника (не хардкод "Екатеринбург"), deals.region_code заполняется из строки
источника (= 66 при текущем фильтре).
Область покрытия задаётся параметром, а не литералом (#3051 п.6): region_code
приходит из params, дефолт 66 = вся Свердловская область (не только Екатеринбург
прежний ILIKE-фильтр по подстроке города снят, unlocks +47183 сделок вне ЕКБ уже
сидящих в source foreign table). Неизвестный код региона (нет в REGIONS) ValueError,
прогон падает явно, а не молча импортирует мусор с чужим region_code.
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):
- region_code = 66 (вся Свердловская область, все города)
- city IS NOT NULL AND trim(city) != '' (непустой город корректный address)
- region_code = :region_code (параметризовано, было хардкод 66)
- city IS NOT NULL AND trim(city) != '' ТОЛЬКО если у региона нет canonical_city
- realestate_type_code = '002001003000' (квартира)
- area BETWEEN 18 AND 200
- deal_price BETWEEN 1000000 AND 100000000
- 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')
dedup_hash: 'ros:dkp:' || id плоский натуральный ключ (инъективный, без коллизий,
human-readable). До #576 здесь был md5('ros:dkp:' || id); миграция 077 конвертировала
существующие строки. source_id хранит исходный rosreestr id (дедуп переустанавливаем).
Префикс ':dkp:' НАМЕРЕННО оставлен неизменным после параметризации doc_types: id
уникален в источнике сам по себе, независимо от типа документа, поэтому ключ и без
того не коллизирует; а вот смена формы ключа осиротила бы все уже загруженные строки
(их пришлось бы конвертировать ещё одной миграцией ровно то, что делала 077).
Rooms: выводятся из площади (Росреестр не отдаёт кол-во комнат).
Batch-процессинг: читаем из FDW батчами по batch_size через cursor-based пагинацию
(WHERE id > last_id ORDER BY id). Heartbeat обновляется каждый батч (= checkpoint),
мержем (kit_runs.update_heartbeat), а не заменой. На старте _resume_dkp_cursor решает
продолжить с 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 не откатывает батч.
Координаты: NULL после импорта геокодинг остаётся follow-up (geocode-deals).
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"))
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] = {
"rows_fetched": 0,
@ -354,7 +473,7 @@ def import_rosreestr_dkp(
)
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
kit_runs.update_heartbeat(db, run_id, resume_verdict)
logger.info(
@ -404,9 +523,20 @@ def import_rosreestr_dkp(
id,
id AS source_id_src,
'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,
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
WHEN area < 30 THEN 0
WHEN area < 44 THEN 1
@ -426,21 +556,45 @@ def import_rosreestr_dkp(
year_build AS year_built,
round(deal_price)::bigint AS price_rub,
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
WHERE region_code = 66
AND city IS NOT NULL AND trim(city) <> ''
WHERE region_code = CAST(:region_code AS int)
AND (
CAST(:canonical_city AS text) IS NOT NULL
OR (city IS NOT NULL AND trim(city) <> '')
)
AND realestate_type_code = '002001003000'
AND area BETWEEN 18 AND 200
AND deal_price BETWEEN 1000000 AND 100000000
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 id > CAST(:last_id AS bigint)
ORDER BY id
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()
.all()
@ -478,7 +632,7 @@ def import_rosreestr_dkp(
INSERT INTO deals (
source, dedup_hash, source_id, address, region_code, city,
rooms, area_m2, floor, year_built, price_rub, price_per_m2,
deal_date
deal_date, doc_type, raw_payload
)
VALUES (
'rosreestr',
@ -493,7 +647,9 @@ def import_rosreestr_dkp(
CAST(:year_built AS int),
CAST(:price_rub AS bigint),
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
address = EXCLUDED.address,
@ -505,7 +661,9 @@ def import_rosreestr_dkp(
year_built = EXCLUDED.year_built,
price_rub = EXCLUDED.price_rub,
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
OR deals.region_code IS DISTINCT FROM EXCLUDED.region_code
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_per_m2 IS DISTINCT FROM EXCLUDED.price_per_m2
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
"""),
{
@ -531,6 +691,12 @@ def import_rosreestr_dkp(
"price_rub": row["price_rub"],
"price_per_m2": row["price_per_m2"],
"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()
if result is None:
@ -564,8 +730,9 @@ def import_rosreestr_dkp(
counters["last_id"] = last_id # type: ignore[assignment]
# Heartbeat = checkpoint: allows zombie detection + resume visibility.
# kit_runs (merge, не замена) — иначе этот REPLACE стёр бы resume_verdict,
# записанный _resume_dkp_cursor'ом перед циклом (issue #3168).
# Пульс МЕРЖИТ counters (`counters || :counters`), поэтому resume_verdict,
# записанный _resume_dkp_cursor'ом перед циклом, переживает per-batch запись
# (issue #3168; с #3390 мерж — единственная семантика, см. scrape_runs).
kit_runs.update_heartbeat(db, run_id, counters)
logger.info(
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d "

File diff suppressed because it is too large Load diff

View file

@ -221,10 +221,17 @@ class RealProxyProvider:
def acquire(self, provider: str) -> ProxyLease | None:
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()
try:
lease = _proxy_pool.acquire(db, provider)
lease = _proxy_pool.acquire(db, provider, run_id=run_id)
finally:
db.close()
if lease is None:

View file

@ -140,6 +140,16 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
{"listing_id": listing_id},
).fetchone()
# ПОТОЛОК: гейта сдвига разряда (#3376) здесь НЕТ, и он бы тут не
# сработал. drop_decimal_slips требует двух свидетелей — скачка ×10
# к предыдущей точке и подтверждения у следующей. На этом пути серия
# максимум из двух точек (последняя лежащая в БД + текущая), у
# последней точки свидетель — текущая цена лота, а она и ЕСТЬ эта
# точка: свидетель совпадает с подозреваемым, отношение всегда 1.0.
# То есть проводка была бы декорацией: ветка, которая по построению
# не может выбросить ни одной точки. Отлов ×10 у yandex требует
# другого механизма — сравнения со СЛЕДУЮЩИМ наблюдением, а значит
# DELETE уже вставленной строки. Отдельная задача: #3385.
if latest is None:
# История пуста — seed (+ опционально previous-точка).
prev = lot.price_previous_rub

View file

@ -80,6 +80,7 @@ from scraper_kit.providers.avito.detail import (
save_detail_enrichment,
)
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 sqlalchemy import text
from sqlalchemy.orm import Session
@ -173,6 +174,34 @@ def _top_failure(census: Counter[str]) -> str | None:
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" (секунды на переподключение
# канала после ротации) в RotationResult.reconnect_delay_s -- см. app.services.
# proxy_rotation docstring. Дефолт нужен ТОЛЬКО если провайдер его не прислал
@ -529,6 +558,8 @@ async def run_avito_detail_backfill(
consecutive_failures = 0
aborted_by_blocks = False
abort_reason: str | None = None
# #3288: прогон оборван пустым пулом прокси — «нечем ходить», а не бан.
no_proxy_stop = False
do_sleep = False
items_since_warm = 0
# Счётчик попыток (успех ИЛИ отказ — оба тратят бюджет IP одинаково, см.
@ -684,6 +715,28 @@ async def run_avito_detail_backfill(
)
if save_detail_enrichment(db, enrichment):
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:
items_since_warm += 1
breaker.record_success()
@ -743,10 +796,33 @@ async def run_avito_detail_backfill(
)
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
failure_census[_failure_signature(e)] += 1
block_ban_kinds[ban_kind_of_exception(e)] += 1
block_ban_kinds[ban_kind] += 1
do_sleep = False
# #3251/#3283g: на настоящий бан ПЛОЩАДКОЙ (AvitoBlockedError и подтипы:
# AvitoContentBlockedError, AvitoWarmupCookiesMissingError; НЕ на
@ -960,6 +1036,29 @@ async def run_avito_detail_backfill(
# и без этого ключа "banned" опять не отличить по причине (#3178).
if abort_reason is not None:
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(
db,
run_id,
@ -989,7 +1088,16 @@ async def run_avito_detail_backfill(
run_id,
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
finally:

View 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()

View file

@ -38,8 +38,11 @@ from collections.abc import Callable
from dataclasses import dataclass, field
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.valuation import estimate_via_cian_valuation
from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -77,6 +80,10 @@ class CianBackfillResult:
# у него не проставлялся вовсе.
listings_blocked: int = 0
ban_kinds: Counter[str] = field(default_factory=Counter)
# #3197: прогон оборван, потому что пул прокси пуст — к площадке не ходили вовсе.
# Не блок и не отказ Циана: caller (scheduler) обязан пометить прогон failed, а не
# banned, иначе запись утверждает про площадку то, чего не было.
no_proxy_stop: bool = False
@property
def ban_kind(self) -> str:
@ -91,18 +98,32 @@ class CianBackfillResult:
return _dominant_ban_kind(self.ban_kinds)
def _note_refusal(result: CianBackfillResult, status: int | None) -> str | None:
"""Записать отказ detail-фетча, если его природа диагностируема по HTTP-статусу (#3196).
def _note_refusal(
result: CianBackfillResult, status: int | None, exc: BaseException | None = None
) -> str | None:
"""Записать отказ detail-фетча, если его природа установлена — типом или статусом.
Инвариант: непустой `ban_kinds` мы видели ответ с 403/429/5xx. `ban_kind_from_status`
отвечает диагнозом только на такие статусы; на всё прочее (в т.ч. статуса нет сайдкар
не дошёл до навигации, или это HTTP 200 с промахом нашего парсера дрейф разметки, а
не отказ площадки) возвращает None, и тогда эта функция НЕ инкрементит ни
`listings_blocked`, ни `ban_kinds` только возвращает None вызывающему. Раньше
недиагностируемые случаи писались как 'unknown' и это в scrape_runs.mark_banned
(scheduler.py) превращало наши собственные сбои в фиктивный бан площадки.
Инвариант (#3196) прежний: непустой `ban_kinds` ⟺ отказ был ПОКАЗАН, а не назначен.
Установить его можно двумя способами, и статуса одного мало:
* ТИП исключения (#3402 follow-up) — `CianBlockedError` (капча Циана, отказ
сайдкара `SidecarBanPageError`, WAF-403) наследует `ProxyBanError`, то есть по
построению означает «площадка себя показала»: `ban_kind_of_exception` даёт
'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:
return None
result.listings_blocked += 1
@ -203,7 +224,20 @@ async def backfill_cian_history(
else:
# One BrowserFetcher instance shared across all listings in this batch.
# 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:
listing_id: int = row["id"]
source_url: str = row["source_url"]
@ -215,7 +249,26 @@ async def backfill_cian_history(
try:
enrichment = await fetch_detail(source_url, browser_fetcher=bf)
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(
"cian_detail fetch failed for listing_id=%s url=%s: %s "
"(http=%s ban_kind=%s)",
@ -272,7 +325,9 @@ async def backfill_cian_history(
await asyncio.sleep(delay)
# ── 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
# RealScraperConfig() at the call site below so BrowserFetcher gets a real
# endpoint instead of degrading to endpoint=None (#2397 Part D2).
@ -363,7 +418,7 @@ async def backfill_cian_history(
await asyncio.sleep(delay)
# ── 3. Cian listings без external_valuations (price prediction backfill) ──
if do_valuations:
if do_valuations and not result.no_proxy_stop: # #3197: пул пуст — см. блок домов
rows = (
db.execute(
text("""

View file

@ -15,17 +15,29 @@ asking_to_sold_ratio_refresh (06:00-07:00 UTC), чтобы бэнды счита
свежему срезу deals, что и ratio-таблица того же дня.
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, см.
комментарий в 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
паттерна) множество городов монотонно растёт (rosreestr_dkp_import только
INSERT/ON CONFLICT DO UPDATE, никогда не удаляет сделки), поэтому merge-по-city
(ON CONFLICT DO UPDATE) достаточен: город, перешедший в другой tier, просто
перезаписывается на следующем refresh. Екатеринбург НЕ включён (WHERE city <>
'Екатеринбург') estimator.py fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2
для ЕКБ остаётся byte-identical (invariant из 178/194 сохранён).
паттерна) множество (region_code, city) монотонно растёт
(rosreestr_dkp_import только INSERT/ON CONFLICT DO UPDATE, никогда не удаляет
сделки), поэтому merge-по-ключу (ON CONFLICT DO UPDATE) достаточен: город,
перешедший в другой tier, просто перезаписывается на следующем refresh.
Екатеринбург НЕ включён для региона 66 (WHERE NOT (region_code = 66 AND
city = 'Екатеринбург')) estimator.py fallback на глобальные
DEAL_MIN_PPM2/DEAL_MAX_PPM2 для ЕКБ остаётся byte-identical (invariant из
178/194/298 сохранён).
"""
from __future__ import annotations
@ -39,22 +51,28 @@ from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 194) ─────────────────────────────────
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 298, region-aware) ──────────────────
_REDERIVE_SQL = text(
"""
WITH region_stats AS (
SELECT GREATEST(
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
8000
) AS region_ppm2_min
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 city <> 'Екатеринбург'
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,
@ -63,13 +81,15 @@ _REDERIVE_SQL = text(
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 city <> 'Екатеринбург'
GROUP BY city
AND region_code IS NOT NULL
AND NOT (region_code = 66 AND city = 'Екатеринбург')
GROUP BY region_code, city
),
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
FROM city_stats
WHERE n_deals >= 30
@ -77,23 +97,24 @@ _REDERIVE_SQL = text(
UNION ALL
SELECT city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals,
'rough'::text AS tier
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.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals,
'region_fallback'::text AS tier
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
CROSS JOIN region_stats r
JOIN region_stats r ON r.region_code = c.region_code
WHERE c.n_deals < 10
)
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
SELECT city, ppm2_min, ppm2_max, n_deals, tier, now()
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 (city) DO UPDATE
ON CONFLICT (region_code, city) DO UPDATE
SET ppm2_min = EXCLUDED.ppm2_min,
ppm2_max = EXCLUDED.ppm2_max,
n_deals = EXCLUDED.n_deals,
@ -103,13 +124,18 @@ _REDERIVE_SQL = text(
)
# ── 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(
"""
SELECT
COUNT(*) AS rows_written,
COUNT(*) FILTER (WHERE tier = 'full') AS full_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
"""
)
@ -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.
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] = {
"rows_written": 0,
"full_rows": 0,
"rough_rows": 0,
"region_fallback_rows": 0,
"regions": 0,
}
try:
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["rough_rows"] = int(row["rough_rows"] or 0)
counters["region_fallback_rows"] = int(row["region_fallback_rows"] or 0)
counters["regions"] = int(row["regions"] or 0)
db.commit()
runs_mod.mark_done(db, run_id, counters)
logger.info(
"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,
counters["rows_written"],
counters["full_rows"],
counters["rough_rows"],
counters["region_fallback_rows"],
counters["regions"],
)
return counters
except Exception as exc:

View file

@ -447,6 +447,19 @@ async def run_domclick_detail_backfill(
enrichment = await fetch_detail(source_url, browser_fetcher=bf, cookies=cookies)
if save_detail_enrichment(db, listing_id, enrichment):
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_soft = 0
@ -471,6 +484,15 @@ async def run_domclick_detail_backfill(
# Прогон 5399 умер именно так: три «блока» подряд, из них два
# 500 от сайдкара и один пустой пул, отказов площадки — ноль.
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(
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d",
@ -620,5 +642,14 @@ async def run_domclick_detail_backfill(
run_id,
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

View 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()

View 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()

View 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()

View file

@ -110,10 +110,10 @@ logger = logging.getLogger(__name__)
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
# значило бы завести проверку, которая в проде не срабатывает никогда.
REJECTION_RULE = (
"Строка не попадает на витрину, только если данных нет: оценщик не дал "
"Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал "
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
"витрина показывала бы лучший хвост, а не работу оценщика. Санитарный "
"витрина показывала бы лучший хвост, а не работу расчёта. Санитарный "
"диапазон цены сделки (30 000600 000 ₽/м² для Екатеринбурга) применён "
"к выборке до расчёта, по цене самой сделки."
)
@ -123,7 +123,7 @@ NOTE = (
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): "
"она бывает занижена сторонами, и тогда "
"строка выглядит как промах оценщика, хотя врёт документ. "
"строка выглядит как промах расчёта, хотя врёт документ. "
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
"есть у 2.7% строк, поэтому дом не показан и показан быть не может."
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "

View file

@ -305,9 +305,12 @@ def collect_landing_metrics(db: Session) -> list[dict[str, Any]]:
).first()
if row is not None and row.n:
n = int(row.n)
# Публичная подпись метрики: площадка не называется (норма publicLabel,
# решение 31.08.2026); фактически это Домклик — единственный источник,
# где триггер пишет стартовую цену. Текст персистится в landing_stats.note.
base_note = (
f"Только Домклик (единственный источник, где триггер пишет стартовую цену), "
f"наблюдение от {_PRICE_SPAN_DAYS} дней, изменения свыше "
"Только одна из площадок (единственный источник, где фиксируется "
f"стартовая цена), наблюдение от {_PRICE_SPAN_DAYS} дней, изменения свыше "
f"{_PRICE_MAX_ABS_PCT}% отброшены как смена объекта"
)
metrics.append(

View file

@ -44,6 +44,14 @@ Why curl_cffi and not YandexDetailScraper.fetch_detail:
non-200 ответ считается блоком, а его диагноз берётся из HTTP-статуса
(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
@ -57,10 +65,15 @@ from dataclasses import dataclass, field
from curl_cffi.requests import AsyncSession
from scraper_kit.browser_fetcher import ban_kind_from_status
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.orm import Session
from app.core.config import settings
from app.services import scrape_runs as runs_mod
from app.services.proxy_egress import resolve_proxy_url
from app.services.scrape_runs import BAN_KIND_UNKNOWN
@ -136,6 +149,11 @@ class YandexDetailBackfillResult:
# обогащения») не переписываем. Без этого счётчика ветка перевода прогона в
# 'banned' у Яндекса недостижима по построению (#3196).
blocked: int = 0
# Недогруженные страницы среди попыток (#3191): HTTP 200, валидный HTML, но без
# блока контактов / заметно меньше нормы. Тоже подмножество failed — попытка была,
# обогащения не случилось; detail_enriched_at не проставляется, объявление остаётся
# в очереди (снапшот берётся по detail_enriched_at IS NULL).
incomplete: int = 0
failed: int = 0
# Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
# source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
@ -150,6 +168,7 @@ class YandexDetailBackfillResult:
"attempted": self.attempted,
"enriched": self.enriched,
"blocked": self.blocked,
"incomplete": self.incomplete,
"failed": self.failed,
"url_from_offer_id": self.url_from_offer_id,
"unenrichable_pending": self.unenrichable_pending,
@ -419,6 +438,47 @@ async def run_yandex_detail_backfill(
break
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)
if enrichment is None:
@ -458,6 +518,21 @@ async def run_yandex_detail_backfill(
consecutive_blocks = 0
if save_detail_enrichment(db, listing_id, enrichment):
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:
counters.failed += 1
@ -487,11 +562,12 @@ async def run_yandex_detail_backfill(
)
logger.info(
"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,
counters.attempted,
counters.enriched,
counters.blocked,
counters.incomplete,
counters.failed,
counters.duration_sec,
)

View file

@ -43,10 +43,11 @@ import random
import time
from dataclasses import dataclass, field, fields
from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text
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
logger = logging.getLogger(__name__)
@ -78,10 +79,21 @@ class YandexNewbuildingSweepResult:
failed_fetch: int = 0 # fetch_jk вернул None / упал
rows_inserted: int = 0 # строк в market.yandex_jk_enrichment (новых/обновлённых)
# Пул прокси пуст → прогон оборван, к площадке не ходили (#3197).
no_proxy_stop: bool = False
duration_sec: float = field(default=0.0)
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 ────────────────────────────────────────────────────────────────────────
@ -331,9 +343,25 @@ async def enrich_yandex_newbuilding_sweep(
try:
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:
# #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(
"resolve_yandex_jk_slug house_id=%s ext_id=%s raised: %s",
house_id,
@ -383,9 +411,22 @@ async def enrich_yandex_newbuilding_sweep(
# ── Fetch через BrowserFetcher ────────────────────────────────────
info = None
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)
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(
"fetch_jk failed house_id=%s jk_slug=%s ext_id=%s: %s",
house_id,

View 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;

View file

@ -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;

View file

@ -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;

View 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;

View 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;

View file

@ -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;

View 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;

View file

@ -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;

View 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;

View file

@ -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:0008: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;

View 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;

View file

@ -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;

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -31,6 +31,9 @@ dependencies = [
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
"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) — общие утилиты скрапперов;
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
# Docker-context = tradein-mvp root, uv sync ставит editable.

View file

@ -1233,14 +1233,19 @@ _CANDIDATES_SQL = text(
)
# (oblast D) Per-city PPM2 sanity band — same table the estimator's
# _fetch_dkp_corridor COALESCEs against (migration 178). Looked up only when
# --city is set (see _resolve_city_ppm2_band); the default (city=None) path
# never issues this query.
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
# _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(
"""
SELECT ppm2_min, ppm2_max
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]:
"""Per-city PPM2 sanity band (oblast D) — falls back to the module globals.
def _resolve_city_ppm2_band(
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,
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
materially different sane / range than the EKB-tuned 30k..600k globals
(e.g. Нижний Тагил: 16 955..108 175). No row for the city (e.g.
Екатеринбург intentionally excluded from the table, mirrors
estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB error →
the globals; read-only best-effort, never raises.
(region_code, city)'s own ``[ppm2_min, ppm2_max]`` — a region-66 town can
have a materially different sane / range than the EKB-tuned 30k..600k
globals (e.g. Нижний Тагил: 16 955..108 175). No row for the (region, city)
(e.g. Екатеринбург region_code=66 intentionally excluded from the table,
mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB
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:
return float(PPM2_MIN), float(PPM2_MAX)
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
logger.warning("city PPM2 band lookup failed for %r (fallback to global): %s", city, exc)
return float(PPM2_MIN), float(PPM2_MAX)

View file

@ -80,6 +80,12 @@ Flags:
plus the geocode hit/miss split. No DB writes.
--batch LABEL log label (default `deals_nominatim_YYYY-MM-DD`).
--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
@ -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.
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 "
" AND address IS NOT NULL "
" AND length(trim(address)) >= 3 "
" AND region_code = CAST(:region_code AS int) "
" AND (geocode_tried_at IS NULL "
" OR geocode_tried_at < NOW() "
" - 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) "
"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()
.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.
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 "
" AND address IS NOT NULL "
" AND length(trim(address)) >= 3 "
" AND region_code = CAST(:region_code AS int) "
" AND (geocode_tried_at IS NULL "
" OR geocode_tried_at < NOW() "
" - make_interval(days => CAST(:stale_days AS int)))"
),
{"stale_days": stale_days},
{"stale_days": stale_days, "region_code": region_code},
).first()
if row is None:
return (0, 0)
@ -274,9 +284,15 @@ def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
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:
"""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
(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
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.
"""
result = db.execute(
@ -300,14 +322,15 @@ def _update_deals_geocoded(
" geocode_tried_at = NOW() "
" WHERE address = CAST(:addr AS text) "
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
" AND region_code = CAST(:region_code AS int) "
" 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
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).
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
can't be downgraded.
Scoped to the (address, city) pair for the same reason as the coords write
a miss in one city must not defer the other city's retry (#2603).
Scoped to the (address, city, region_code) pair for the same reason as the
coords write a miss in one city/region must not defer another
region's retry (#2603, #3051).
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() "
" WHERE address = CAST(:addr AS text) "
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
" AND region_code = CAST(:region_code AS int) "
" AND lat IS NULL"
),
{"addr": address, "city": city},
{"addr": address, "city": city, "region_code": region_code},
)
return result.rowcount
@ -345,6 +370,7 @@ async def _run_backfill(
*,
batch: str,
dry_run: bool,
region_code: int = 66,
) -> Stats:
"""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
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).
`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()
for i, group in enumerate(groups, start=1):
address = group.address
city = group.city
# Only a recognised oblast-66 city is fed to the geocoder; junk Rosreestr
# values degrade to None (geocoder.known_city_hint — shared with the
# other DB-column callers). The raw `city` is still used for the UPDATE
# scope — it identifies the group either way.
hint = known_city_hint(city)
# Only a recognised city of `region_code` is fed to the geocoder; junk
# Rosreestr values degrade to None (geocoder.known_city_hint — shared
# with the other DB-column callers). The raw `city` is still used for
# the UPDATE scope — it identifies the group either way.
hint = known_city_hint(city, region_code)
# The geocoder itself rejects <3 chars, but skip here too so the dry-run
# report and counters stay honest (no phantom "processed" address).
@ -377,7 +407,7 @@ async def _run_backfill(
result: GeocodeResult | None = None
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
stats.geocode_failed += 1
stats.processed += 1
@ -407,7 +437,7 @@ async def _run_backfill(
else:
try:
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()
except Exception as exc: # defensive — isolate one bad UPDATE
db.rollback()
@ -441,7 +471,12 @@ async def _run_backfill(
try:
with db.begin_nested():
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.
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."
),
)
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)
@ -575,34 +620,44 @@ async def main(argv: list[str] | None = None) -> int:
"""
args = _parse_args(argv)
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.limit,
args.stale_days,
args.region_code,
args.dry_run,
)
db = SessionLocal()
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)
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),
total_rows,
args.region_code,
)
if not groups:
logger.info(
"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.region_code,
)
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:
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(
stats,
total_streets=total_streets,
@ -611,9 +666,10 @@ async def main(argv: list[str] | None = None) -> int:
)
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)",
args.batch,
args.region_code,
stats.processed,
stats.geocoded,
stats.geocode_failed,

View file

@ -32,7 +32,9 @@ importers) — `scraper_kit.domclick_exceptions` остаётся единств
from __future__ import annotations
import itertools
import json
import logging
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
@ -40,7 +42,11 @@ import httpx
import pytest
from scraper_kit.browser_fetcher import SidecarBanPageError
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 (
DomClickDetailEnrichment,
_extract_ssr_state,
@ -86,6 +92,7 @@ _SSR_LITERAL = """{
"priceInfo": {
"priceHistory": [
{"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": "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:
e = parse_detail_html(_HTML, _CARD_URL)
# entry1 ок; entry2 bad-date skip; entry3 price=null skip → 1 запись
assert len(e.price_changes) == 1
change = e.price_changes[0]
assert change["price_rub"] == 13400000
assert change["diff_percent"] == -400000
assert isinstance(change["change_time"], datetime)
# 2 валидные записи; bad-date skip; price=null skip. Порядок — по change_time.
assert len(e.price_changes) == 2
first, second = e.price_changes
assert [c["price_rub"] for c in e.price_changes] == [13800000, 13400000]
# Процент считается из соседних price_rub, поле diff площадки (рубли) игнорируется
# (#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.
assert change["change_time"].tzinfo is not None
assert change["change_time"].utcoffset() == timedelta(hours=3)
assert second["change_time"].tzinfo is not None
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:
@ -463,13 +489,18 @@ def _sidecar_ban_page_error(upstream_status: int | None = 401) -> SidecarBanPage
@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.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
with pytest.raises(DomClickBlockedError):
await fetch_detail(_CARD_URL, browser_fetcher=bf)
bf.report_ban.assert_called_once()
assert _CARD_URL in bf.report_ban.call_args.args[0]
bf.report_ban.assert_not_called()
@pytest.mark.asyncio
@ -552,6 +583,37 @@ def test_save_detail_enrichment_writes_columns() -> None:
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:
db = MagicMock()
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"]
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.execute.return_value.rowcount = 1
ct = datetime(2026, 4, 1, tzinfo=UTC)
e = DomClickDetailEnrichment(
item_id="x",
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)
assert _insert_diff_param(db) == 999999.99
with caplog.at_level(logging.WARNING):
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.execute.return_value.rowcount = 1
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}],
)
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:
@ -635,31 +700,288 @@ def test_save_detail_enrichment_none_diff_unchanged() -> 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 перед фиксом) —
# прямые unit-тесты хелпера живут здесь, рядом с save_detail_enrichment-тестами,
# которые его же и используют.
def test_clamp_diff_percent_extreme_positive() -> None:
assert clamp_diff_percent(5000000) == 999999.99
def test_validate_diff_percent_rejects_rubles(caplog: pytest.LogCaptureFixture) -> None:
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:
assert clamp_diff_percent(-5000000) == -999999.99
def test_validate_diff_percent_rejects_extreme_negative() -> None:
assert validate_diff_percent(-5000000) is None
def test_clamp_diff_percent_in_range_unchanged() -> None:
assert clamp_diff_percent(-2.5) == -2.5
def test_validate_diff_percent_boundary_kept() -> None:
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:
assert clamp_diff_percent(None) is None
def test_validate_diff_percent_in_range_unchanged() -> 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:
assert clamp_diff_percent(True) is None
assert clamp_diff_percent(False) is None
def test_validate_diff_percent_none() -> None:
assert validate_diff_percent(None) 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) ───────────────────────────────────────────────────

View file

@ -337,6 +337,7 @@ def test_update_deals_geocoded_sets_lat_lon_tried_at_not_geom():
"city": "Екатеринбург",
"lat": 56.1,
"lon": 60.2,
"region_code": 66,
}
assert n == 7
@ -492,7 +493,7 @@ async def test_run_backfill_passes_known_city_as_hint():
) as mock_geo:
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"] == "Нижний Тагил"
@ -507,7 +508,7 @@ async def test_run_backfill_junk_city_geocodes_without_hint_but_scopes_update():
) as mock_geo:
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"] == "Бессонова"
@ -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)
assert coord_updates == []
assert tried_updates == [{"addr": "Победы, 30", "city": "Нижний Тагил"}]
assert tried_updates == [{"addr": "Победы, 30", "city": "Нижний Тагил", "region_code": 66}]
# ---------------------------------------------------------------------------

View file

@ -438,21 +438,38 @@ class FakeSession:
[{"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:
# clear_source_bans: снять баны узла (все либо один source), #2600 п.2.
# Фильтр по reason (#2800) гейтим по подстроке боевого SQL — как ban-фильтры
# в acquire-ветке: иначе мок «чинил» бы код, который фильтра не содержит, и
# тест на «успешная проба не гасит чужой бан» остался бы зелёным на сломанном.
if "UPDATE scrape_proxy_source_bans" in sql and "cleared_reason" in sql:
# clear_source_bans (#3404): гасим строку (banned_until=now(), ban_count=0,
# cleared_at/cleared_reason) вместо DELETE — трассируемость снятия бана, строка
# доживает до штатного purge. Фильтр по reason (#2800) гейтим по подстроке
# боевого SQL — как ban-фильтры в acquire-ветке: иначе мок «чинил» бы код,
# который фильтра не содержит, и тест на «успешная проба не гасит чужой бан»
# остался бы зелёным на сломанном.
filters_reason = "reason = CAST(:only_reason AS text)" in sql
only_reason = p.get("only_reason") if filters_reason else None
cleared = [
b
for b in self.bans
if b["proxy_id"] == p["proxy_id"]
and (p["source"] is None or b["source"] == p["source"])
and (only_reason is None or b.get("reason") == only_reason)
]
self.bans = [b for b in self.bans if b not in cleared]
now = datetime.now(UTC)
cleared: list[dict[str, Any]] = []
for b in self.bans:
if b["proxy_id"] != p["proxy_id"]:
continue
if p["source"] is not None and b["source"] != p["source"]:
continue
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])
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). Теперь бан
# в отдельной таблице и истекает только по таймеру (до 72ч при эскалации) — без этой
# ручки ложное срабатывание детектора капчи (#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]:
@ -1428,17 +1449,37 @@ def _active_ban(pid: int, source: str, ban_count: int = 1) -> dict[str, Any]:
"ban_count": ban_count,
"banned_until": datetime.now(UTC) + timedelta(hours=SOURCE_BAN_MAX_HOURS),
"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(
[_proxy(1, affinity="any"), _proxy(2, affinity="any")],
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]
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]
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"
)
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:
@ -1462,8 +1512,8 @@ def test_clear_source_bans_noop_when_nothing_to_clear() -> None:
def test_clear_source_bans_resets_escalation() -> None:
"""DELETE, а не banned_until=now(): снятие обнуляет и ban_count — следующий бан
начинается с базовых SOURCE_BAN_BASE_HOURS, а не продолжает эскалацию."""
"""Гашение (banned_until=now(), ban_count=0), а не удаление строки: следующий бан той
же пары начинается с базовых SOURCE_BAN_BASE_HOURS, а не продолжает эскалацию (#3404)."""
assert mark_banned is not None
db = FakeSession(
[_proxy(1, affinity="any"), _proxy(2, affinity="any")],

View file

@ -105,9 +105,19 @@ class FakeSession:
)
return _FakeResult([])
if "DELETE FROM scrape_proxy_source_bans" in sql: # clear_source_bans (#2600 п.2)
cleared = [b for b in self.source_bans if b["proxy_id"] == p["proxy_id"]]
self.source_bans = [b for b in self.source_bans if b not in cleared]
if "UPDATE scrape_proxy_source_bans" in sql and "cleared_reason" in sql:
# clear_source_bans (#3404): гасит строку (cleared_reason=reason), а НЕ
# удаляет — строка остаётся для трассируемости. Фейк мутирует найденные
# записи в месте, не убирая их из 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])
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]
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:
@ -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)
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]
assert SECRET_TOKEN not in (result.reason 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:
@ -574,7 +593,10 @@ async def test_token_never_appears_in_log_messages_or_sentry_text(
else:
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):
caplog.clear()
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(), (
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 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]
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(
@ -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)
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):
caplog.clear()
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(), (
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(

View file

@ -806,7 +806,7 @@ async def test_admin_geocode_missing_passes_city_hint(target: str) -> None:
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["skipped"] == 0
# 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]
)
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

View file

@ -78,7 +78,14 @@ def _mock_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."""
resp = MagicMock()
resp.status_code = status

View file

@ -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)
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:
sentry = MagicMock()
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
sentry.capture_message.assert_called_once()
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 не мешает сводке — она меряет календарь, а не серию."""
sentry = MagicMock()
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"}
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()
fresh = [_row("avito_city_sweep", None, 1.1), _row("sber_index_pull", 7, 4.1)]
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()
def test_digest_is_daily_not_per_tick() -> None:
"""Планировщик тикает раз в минуту; сводка обязана выходить раз в сутки."""
sentry = MagicMock()
db = _db(PROD_ROWS)
db = _db(PROD_RUN_ROWS)
with patch.object(sched, "sentry_sdk", sentry):
sched.emit_stale_digest(db, now=NOW)
sched.emit_stale_digest(db, now=NOW + timedelta(minutes=1))

View file

@ -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")
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:

View file

@ -302,7 +302,14 @@ async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) ->
avito = db._ban(1, "avito")
assert avito is not None, "чужой бан проба снимать не имеет права"
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

View file

@ -14,9 +14,12 @@
kwarg'а в вызове. Красные на старом коде:
* (1) `fetch_detail` вызывался без `proxy_provider` lease не брался, 403 никому не
сообщался: `mark_banned_calls == []`. Плюс ловушка «правка без эффекта»:
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper`, а ручка живёт в `backend`,
где флага нет один `proxy_provider=` пул бы не включил (см. `_PoolCurlConfig`).
сообщался: `mark_banned_calls == []`. Ловушка «правка без эффекта» была в том, что
`USE_PROXY_POOL_CURL` задавался только контейнеру `scraper`, а ручка живёт в
`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` и не имел
параметра `db` вызов из теста падал бы на сигнатуре, а исход «пул исчерпан»
выражения не имел вообще.
@ -123,7 +126,10 @@ def _price_history_db(n_listings: int) -> MagicMock:
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.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)
),
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, "_SessionLocal", MagicMock()),
):
@ -139,23 +146,33 @@ async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) ->
@pytest.mark.asyncio
async def test_price_history_takes_pool_node_despite_flag_off() -> None:
"""Узел берётся из пула даже при выключенном USE_PROXY_POOL_CURL (контейнер backend).
async def test_price_history_takes_pool_node() -> None:
"""Узел берётся из пула, вердикт возвращается, lease не течёт.
Красный на старом коде дважды: не было ни `proxy_provider=`, ни принудительного
pool-режима `curl_proxy_url` уходил на статичный env-узел и `acquire` не звал.
Красный на коде до #2830: не было `proxy_provider=` — `curl_proxy_url` уходил на
статичный env-узел и `acquire` не звал.
"""
from app.core.config import settings
assert settings.use_proxy_pool_curl is False, (
"тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL"
)
pool = _SpyPool()
await _run_price_history(pool, status_code=200)
assert pool.acquire_calls == ["cian"]
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
async def test_price_history_403_bans_the_node_for_cian() -> None:
"""403 от Циана снимает узел с выдачи ИМЕННО Циану. Красный: было `mark_banned` = []."""

View file

@ -27,18 +27,21 @@ import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.services.product_handlers import _job_yandex_newbuilding_sweep
from app.tasks.yandex_newbuilding_sweep import YandexNewbuildingSweepResult
def _ctx() -> SimpleNamespace:
return SimpleNamespace(runs=MagicMock())
def _result(processed: int, succeeded: int) -> MagicMock:
r = MagicMock()
r.to_dict = MagicMock(
return_value={"processed": processed, "succeeded": succeeded, "total": 425}
)
return r
def _result(processed: int, succeeded: int) -> YandexNewbuildingSweepResult:
"""Настоящий результат задачи, а не MagicMock.
У MagicMock любой не заданный атрибут (`no_proxy_stop` из #3197) — truthy, то есть
добавление хендлеру новой ветки по полю результата уводило бы сюда ВСЕ три случая,
и тест краснел бы по причине, не имеющей отношения к #2860.
"""
return YandexNewbuildingSweepResult(processed=processed, succeeded=succeeded, total=425)
@pytest.mark.asyncio

View file

@ -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

View 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 разъехался с кодом"

View file

@ -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 == ""

View file

@ -127,6 +127,24 @@ def test_region_by_city_folds_and_resolves() -> 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:
"""У Москвы нет ни одного тира обогащения — и это ЯВНЫЙ факт с готовой
формулировкой, а не молчаливое «посчитаем без источника»."""

View 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

View file

@ -23,10 +23,10 @@ import_rosreestr_dkp (source='rosreestr_dkp_import'), шестой backfill, н
штатным поведением).
- Потолок возраста чекпоинта 24ч (_DKP_CHECKPOINT_STALE_HOURS): старше курсор
считается негодным, прогон стартует с last_id=0, причина 'checkpoint_stale'.
- Вердикт пишется в scrape_runs.counters через kit_runs.update_heartbeat
(`counters || :counters` merge), а не локальный runs_mod.update_heartbeat
(`CAST(:counters AS jsonb)` полная замена): иначе первый же per-batch heartbeat
после старта стирает resume-вердикт.
- Вердикт пишется в scrape_runs.counters через update_heartbeat, а тот counters
МЕРЖИТ (`counters || :counters`) иначе первый же per-batch heartbeat после старта
стёр бы resume-вердикт. На момент #3168 мерж был только у kit-копии, поэтому запись
шла именно kit-именем; с #3390 копия одна и семантика мержа — единственная.
Обратимость (см. PR summary): временный откат last_id на литерал 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
# ── 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. Курсор старше потолка не подхватывается — прогон стартует заново ─────────────
@ -163,20 +209,15 @@ def test_checkpoint_just_under_ceiling_is_still_accepted() -> None:
# ── 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:
"""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:
def test_update_heartbeat_merges_into_existing_counters() -> None:
"""Сама примитива слияния: второй write добавляет rows_fetched/last_id, НЕ стирая
resume_from/resume_reason, записанные первым write'ом.

View file

@ -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

View file

@ -87,10 +87,16 @@ def _mock_db(n: int) -> MagicMock:
return db
# HTTP 200 обязан выглядеть ПОЛНОЙ карточкой (#3191): недогруз теперь отсеивается до
# parse, и страница-заглушка превратила бы эти кейсы в тесты про полноту, а не про
# природу блока. Маркер контактов + размер выше порога — оба признака полноты.
_FULL_PAGE_HTML = "<html>" + "x" * 1_100_000 + '"encryptedPhones":["a"]</html>'
def _resp(status: int) -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.text = "<html>отказ</html>"
resp.text = _FULL_PAGE_HTML if status == 200 else "<html>отказ</html>"
return resp

View file

@ -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

View file

@ -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

View file

@ -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)

View 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

View file

@ -1,7 +1,8 @@
"""#3283: бан-страница Авито («доступ ограничен: проблема с IP») приходит от
сайдкара как SidecarBanPageError (HTTP 500 + ban_page-маркер в теле, #3239), но
у Авито её никто не читал она заворачивалась в AvitoSidecarUnavailableError и
уезжала в транспортные сбои ('infra'), а не в отказ площадки ('platform').
сайдкара как SidecarBanPageError (ban_page-маркер в теле, #3239; код ответа — 403
с #3288 п.4, 500 у сайдкара старой сборки), но у Авито её никто не читал — она
заворачивалась в AvitoSidecarUnavailableError и уезжала в транспортные сбои
('infra'), а не в отказ площадки ('platform').
Прод-замер (прогон 5606): 64 бан-страницы, ban_kinds={"infra":26,"platform":7}, при
этом ни ОДНОЙ записи для source=avito в scrape_proxy_source_bans узел не банился и
@ -26,7 +27,7 @@ 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 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.detail import fetch_detail
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"
def _sidecar_ban_page_error(upstream_status: int | None = 500) -> SidecarBanPageError:
"""Ровно то, что сайдкар шлёт на подтверждённую бан-страницу («доступ ограничен:
проблема с IP») зеркало _sidecar_ban_page_error из test_domclick_detail.py."""
request = httpx.Request("POST", "http://tradein-browser:3000/fetch")
response = httpx.Response(500, request=request)
return SidecarBanPageError(
"Server error '500' | tradein-browser: BanPageDetectedError: доступ ограничен",
request=request,
response=response,
upstream_status=upstream_status,
_SIDECAR_BAN_CODES = [403, 500]
def _sidecar_ban_page_error(
sidecar_status: int = 403, *, upstream_status: int | None = 403
) -> SidecarBanPageError:
"""Ровно то, что клиент поднимает на подтверждённой бан-странице («доступ
ограничен: проблема с IP») зеркало _sidecar_ban_page_error из test_domclick_detail.py.
Исключение строим НАСТОЯЩИМ `_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
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, не
AvitoSidecarUnavailableError. Фальсификация: до фикса SidecarBanPageError падала
в `except Exception` (она подкласс httpx.HTTPStatusError) и уезжала сюда."""
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:
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
async def test_fetch_detail_sidecar_ban_page_reports_ban() -> None:
"""Подтверждённый маркер-бан обязан попасть в scrape_proxy_source_bans через
report_ban иначе узел не ротируется и продолжает выдаваться в аренду."""
@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES)
async def test_fetch_detail_sidecar_ban_page_does_not_report_ban_again(
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.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(sidecar_status))
with pytest.raises(AvitoBlockedError):
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
bf.report_ban.assert_called_once()
assert _ITEM_URL in bf.report_ban.call_args.args[0]
bf.report_ban.assert_not_called()
@pytest.mark.asyncio
@ -99,7 +136,10 @@ async def test_fetch_detail_transport_failure_stays_infra_no_report_ban() -> Non
@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, БЕЗ ретраев.
Детект маркерный (SidecarBanPageError.__doc__), переспрашивать его нечем, а
@ -109,7 +149,7 @@ async def test_serp_sidecar_ban_page_raises_platform_block_without_retrying() ->
"""
scraper = AvitoScraper(RealScraperConfig())
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)
sleep_mock = AsyncMock()

View file

@ -0,0 +1,387 @@
"""#3288: бан площадки снимает узел АДРЕСНО (узел×источник), а не глобально по здоровью.
Прод-замер 31.0801.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}"

View 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