feat(tradein): вся Свердловская область — 40 городов в city-sweep (#2879)
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 3m33s
Deploy Trade-In / build-backend (push) Successful in 1m41s
Deploy Trade-In / deploy (push) Successful in 1m38s
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 3m33s
Deploy Trade-In / build-backend (push) Successful in 1m41s
Deploy Trade-In / deploy (push) Successful in 1m38s
This commit is contained in:
parent
a4d6cbba25
commit
01b5e73ea4
6 changed files with 1681 additions and 10 deletions
|
|
@ -109,9 +109,16 @@ __all__ = [
|
|||
# матчит неэкранированный '%/nizhniy_tagil/%' (LIKE default '_'=wildcard) и НЕ
|
||||
# матчит экранированный '%/nizhniy\_tagil/%' (LIKE '\_' = литерал '_'); точный
|
||||
# слаг 'nizhniy_tagil' матчит оба варианта -- позитивный кейс не сломан.
|
||||
#
|
||||
# #262 wave 2: avito_slug — Optional в CityLocation (не у каждого oblast-города
|
||||
# подтверждён). Города без avito_slug пропускаем целиком — у них НЕТ avito_city_
|
||||
# sweep schedule (262_), значит НЕТ и avito-листингов с их URL; паттерн для них
|
||||
# был бы либо мёртвым, либо (что хуже) построен из city_slug вместо реального
|
||||
# avito URL-сегмента и создал бы ложный LIKE-матч.
|
||||
_OBLAST_AVITO_URL_PATTERNS = tuple(
|
||||
"%/" + loc.avito_slug.replace("\\", "\\\\").replace("_", "\\_").replace("%", "\\%") + "/%"
|
||||
for loc in CITY_LOCATIONS.values()
|
||||
if loc.avito_slug is not None
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,951 @@
|
|||
-- 262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql
|
||||
-- Seed rows для oblast-wide city-sweep (Свердловская область, region 66) — WAVE 2:
|
||||
-- avito/cian/yandex city-sweep за пределами Екатеринбурга для оставшихся 40 городов
|
||||
-- области (wave 1 — 179_scrape_schedules_seed_oblast_city_sweeps.sql, 5 городов:
|
||||
-- nizhniy_tagil/kamensk_uralskiy/pervouralsk/verkhnyaya_pyshma/serov). Объявления
|
||||
-- по области сейчас 3229 против 20111 по ЕКБ — wave 2 заводит оставшийся охват
|
||||
-- Свердловской обл. Domclick (BFF, city_id-based) — отдельный rollout, сюда НЕ входит.
|
||||
--
|
||||
-- Координаты городов (lat/lon/название) — проверены на проде (геокодер + независимая
|
||||
-- сверка медианой координат сделок Росреестра по городу, exclusion в радиусе 12км от
|
||||
-- ЕКБ). CITY_ANCHORS-записи для всех 40 slug'ов — тот же PR,
|
||||
-- packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py.
|
||||
--
|
||||
-- БИСЕРТЬ ИСКЛЮЧЕНА ЦЕЛИКОМ (не 41, а 40 городов): у Циана её нет вообще — поиск на
|
||||
-- любой запрос ("Бисерть", "пгт Бисерть") отдаёт Сысерть id=176028. Это пгт, а не
|
||||
-- город области — не заводится ни в CITY_ANCHORS, ни здесь.
|
||||
--
|
||||
-- ═══ ГЛАВНОЕ ОТЛИЧИЕ ОТ ПЕРВОЙ ВЕРСИИ ЭТОГО ФАЙЛА ═══
|
||||
-- Первая версия (до ревью) заводила 41 город × 3 источника = 123 строки для ВСЕХ
|
||||
-- источников сразу, планируя добыть provider-идентификаторы (avito_slug/cian_region_id/
|
||||
-- yandex_rgid) ПОСЛЕ. Это оказалось бы РОВНО тем самым багом, о котором предупреждала её
|
||||
-- же шапка: без подтверждённого идентификатора run_avito_city_sweep/run_yandex_city_sweep
|
||||
-- падают на ЕКБ-дефолт (region_id/rgid Екатеринбурга) — развёртка "включена", но реально
|
||||
-- собирает ЕКБ под меткой чужого города, порча данных под видом покрытия.
|
||||
--
|
||||
-- Идентификаторы теперь ДОБЫТЫ И ВАЛИДИРОВАНЫ (см. CITY_LOCATIONS-коммент в pipeline.py:
|
||||
-- cian_id — api.cian.ru/geo-suggest/v1/suggest; yandex_rgid — realty.yandex.ru/gate/
|
||||
-- region_suggest/suggest; avito_slug — живой GET avito.ru/<slug>/kvartiry; все три метода
|
||||
-- валидированы 5/5 на wave-1 городах с уже известными значениями). Но НЕ у каждого города
|
||||
-- подтверждены ВСЕ ТРИ идентификатора. Правило этой миграции: **строка заводится ТОЛЬКО
|
||||
-- там, где идентификатор подтверждён**. Развёртка, которая молча соберёт Екатеринбург,
|
||||
-- хуже отсутствующей — недостающие источники НЕ заводим вовсе (а не заводим с заглушкой/
|
||||
-- fallback).
|
||||
--
|
||||
-- Дополнительный defensive guard в коде (тот же PR, pipeline.py): если КОГДА-ЛИБО
|
||||
-- run_avito_city_sweep/run_yandex_city_sweep будет вызван с city_slug, у которого в
|
||||
-- CITY_LOCATIONS известный город, но конкретный provider-идентификатор всё ещё None —
|
||||
-- функция явно падает `ValueError` (НЕ молчаливый ЕКБ-дефолт). При штатной эксплуатации
|
||||
-- этой миграции (schedule заводится только при подтверждённом идентификаторе) этот
|
||||
-- ValueError сработать не должен — он ловит будущий рассинхрон данных, не текущий.
|
||||
--
|
||||
-- ИТОГО 102 строки (не 123):
|
||||
-- cian_city_sweep_* — 40 строк (cian_region_id подтверждён у ВСЕХ 40 городов).
|
||||
-- yandex_city_sweep_* — 39 строк (ВСЕ, КРОМЕ mikhaylovsk — Михайловск Нижнесергинского
|
||||
-- р-на ОТСУТСТВУЕТ в гео-базе Яндекс.Недвижимости вообще: единственный "Михайловск"
|
||||
-- там — ставропольский, rgid 586221, подставлять чужой регион нельзя. Это
|
||||
-- подтверждённое ОТСУТСТВИЕ данных у источника, не "не проверили" — довести
|
||||
-- нечем, ждать нечего).
|
||||
-- avito_city_sweep_* — 23 строки. avito_slug НЕ подтверждён для 17 городов:
|
||||
-- revda, polevskoy, berezovskiy, zarechny, kachkanar, sredneuralsk, degtyarsk,
|
||||
-- artemovskiy, kamyshlov, sukhoy_log, kushva, karpinsk, nizhnyaya_tura,
|
||||
-- nizhnie_sergi, lesnoy, verkhoturye, mikhaylovsk.
|
||||
-- Причина по каждому — либо чистый 404 на опробованных вариантах slug'а (omonym-
|
||||
-- коллизия с городом в другом регионе — нужна avito-специфичная дизамбигуация,
|
||||
-- которой в проверке не делали), либо 403/429 из-за исчерпания пула прокси во
|
||||
-- время проверки (кандидат НЕ опровергнут, просто НЕ подтверждён — это единственная
|
||||
-- категория из трёх, которую стоит ПЕРЕПРОВЕРИТЬ на свежем пуле и добрать отдельной
|
||||
-- миграцией; остальные — city_rgid mikhaylovsk и omonym-404 avito — подтверждённое
|
||||
-- отсутствие/коллизия, довести нечем).
|
||||
--
|
||||
-- !!! DORMANT BY DESIGN !!! Все 102 строки ship enabled = false. Оператор включает
|
||||
-- ВРУЧНУЮ по одному городу за раз (как в wave 1), волнами после деплоя:
|
||||
-- UPDATE scrape_schedules SET enabled = true WHERE source = 'cian_city_sweep_revda';
|
||||
-- Capability уже полностью wired — тот же механизм, что и wave 1 (pipeline.CITY_ANCHORS/
|
||||
-- get_city_anchors, scheduler._job_{avito,cian,yandex}_city_sweep читают
|
||||
-- default_params->>'city', wildcard-registry "*_city_sweep_*" в
|
||||
-- scraper_kit.orchestration.scheduler._default_kit_handlers) — код скраперов/хендлеров
|
||||
-- НЕ меняется (кроме defensive-guard в pipeline.py выше, не меняющего штатный путь).
|
||||
--
|
||||
-- default_params — за основу взяты прод-дефолты enabled-городов wave 1 (см. 179_ +
|
||||
-- 206_), с тремя отличиями:
|
||||
-- 1. radius_m = 3000 у avito/cian (было 1500 в 179_) — один anchor на город должен
|
||||
-- покрыть город целиком; сама 179_ предупреждала, что 1500м мало для городов
|
||||
-- крупнее одного круга. yandex — 25000 как есть (gate-API город скоупит city_rgid,
|
||||
-- lat/lon/radius_m игнорирует целиком, см. run_yandex_city_sweep docstring —
|
||||
-- radius_m там мёртвый default).
|
||||
-- 2. detail_top_n = 0 у avito (было 20 в 179_) — Avito detail-страницы сейчас отдают
|
||||
-- HTTP 439 firewall независимо от IP (issue #2827). Обречённые detail-запросы на
|
||||
-- 23 подтверждённых города только приблизят бан общего прокси-пула зря — не тратим
|
||||
-- их, пока #2827 не починен. cian detail_top_n = 10 — оставлен как в 179_.
|
||||
-- 3. interval_days = 3 у всех трёх источников — тот же такт, на который migration 206_
|
||||
-- перевела wave-1 15 job'ов после замера (daily избыточен, независимая проверка по
|
||||
-- listings_snapshots показала ~0.02-0.15%/сутки волатильности цены).
|
||||
--
|
||||
-- window_start_hour/window_end_hour (UTC, 1-часовые окна): 24 часа в сутках, 102 новые
|
||||
-- строки — полная уникальность окна на строку математически невозможна для cian/yandex
|
||||
-- (40 и 39 > 16-18 свободных часов), возможна для avito (23 <= 18). Тот же round-robin
|
||||
-- scheme, что в первой версии файла (координаты НЕ пересчитывались — просто отфильтрован
|
||||
-- набор строк по подтверждённым идентификаторам, часы у оставшихся ГОРОДОВ не менялись):
|
||||
-- окна исключают ПОЛНОСТЬЮ (а) EKB-окна (avito 6-7, cian 2-5, yandex 16-17) и (б) окна
|
||||
-- wave-1 179_ (avito {0,1,5,7,8}, cian {9,10,11,12,13}, yandex {14,15,17,18,19}); внутри
|
||||
-- оставшихся свободных часов round-robin по городам в исходном 41-городском TSV-порядке
|
||||
-- (novouralsk..bisert, bisert выброшен целиком), затем строка эмитится, только если
|
||||
-- источник подтверждён для этого города. Итоговый максимум коллизий ОДНОГО источника в
|
||||
-- одном часе: avito <= 2, cian <= 3, yandex <= 3 (ниже, чем было бы при полных 41 —
|
||||
-- меньше строк на источник). Разные провайдеры МОГУТ делить час — не ограничивалось (см.
|
||||
-- 179_/206_ — proxy-pool уже не единственный узел).
|
||||
--
|
||||
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 179_ (wave 1,
|
||||
-- CITY_ANCHORS-механизм и wildcard resolve_handler — не переопределяются здесь).
|
||||
-- Idempotent: ON CONFLICT (source) DO NOTHING — каждый source в этой миграции уникален
|
||||
-- по построению (40 городов × подтверждённые источники, ни один не пересекается с
|
||||
-- wave-1 5 городами).
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO scrape_schedules (
|
||||
source,
|
||||
enabled,
|
||||
window_start_hour,
|
||||
window_end_hour,
|
||||
next_run_at,
|
||||
default_params
|
||||
)
|
||||
VALUES
|
||||
-- ── avito_city_sweep_<city> — ТОЛЬКО 23 города с подтверждённым avito_slug
|
||||
-- (radius_m 3000, detail_top_n 0 — issue #2827, enrich_houses true,
|
||||
-- pages_per_anchor 3, request_delay_sec 7, interval_days 3) ──────────────
|
||||
(
|
||||
'avito_city_sweep_novouralsk',
|
||||
false,
|
||||
2,
|
||||
3,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "novouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_asbest',
|
||||
false,
|
||||
9,
|
||||
10,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "asbest"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_bogdanovich',
|
||||
false,
|
||||
10,
|
||||
11,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "bogdanovich"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_irbit',
|
||||
false,
|
||||
11,
|
||||
12,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "irbit"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_krasnoufimsk',
|
||||
false,
|
||||
12,
|
||||
13,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_krasnoturinsk',
|
||||
false,
|
||||
16,
|
||||
17,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_severouralsk',
|
||||
false,
|
||||
17,
|
||||
18,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "severouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_ivdel',
|
||||
false,
|
||||
18,
|
||||
19,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "ivdel"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_tavda',
|
||||
false,
|
||||
19,
|
||||
20,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "tavda"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_turinsk',
|
||||
false,
|
||||
20,
|
||||
21,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "turinsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_sysert',
|
||||
false,
|
||||
21,
|
||||
22,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "sysert"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_verkhnyaya_salda',
|
||||
false,
|
||||
2,
|
||||
3,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_nizhnyaya_salda',
|
||||
false,
|
||||
3,
|
||||
4,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_nevyansk',
|
||||
false,
|
||||
4,
|
||||
5,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "nevyansk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_alapaevsk',
|
||||
false,
|
||||
11,
|
||||
12,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "alapaevsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_krasnouralsk',
|
||||
false,
|
||||
14,
|
||||
15,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_verkhniy_tagil',
|
||||
false,
|
||||
17,
|
||||
18,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_rezh',
|
||||
false,
|
||||
20,
|
||||
21,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "rezh"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_aramil',
|
||||
false,
|
||||
21,
|
||||
22,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "aramil"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_volchansk',
|
||||
false,
|
||||
22,
|
||||
23,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "volchansk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_verkhnyaya_tura',
|
||||
false,
|
||||
23,
|
||||
0,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_talitsa',
|
||||
false,
|
||||
4,
|
||||
5,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "talitsa"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_novaya_lyalya',
|
||||
false,
|
||||
9,
|
||||
10,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb
|
||||
),
|
||||
-- ── cian_city_sweep_<city> — ВСЕ 40 городов (cian_id подтверждён у всех)
|
||||
-- (radius_m 3000, detail_top_n 10, enrich_houses true, pages_per_anchor 3,
|
||||
-- request_delay_sec 5, interval_days 3) ─────────────────────────────────
|
||||
(
|
||||
'cian_city_sweep_novouralsk',
|
||||
false,
|
||||
0,
|
||||
1,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "novouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_revda',
|
||||
false,
|
||||
1,
|
||||
2,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "revda"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_polevskoy',
|
||||
false,
|
||||
5,
|
||||
6,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "polevskoy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_asbest',
|
||||
false,
|
||||
6,
|
||||
7,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "asbest"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_bogdanovich',
|
||||
false,
|
||||
7,
|
||||
8,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "bogdanovich"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_irbit',
|
||||
false,
|
||||
8,
|
||||
9,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "irbit"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_krasnoufimsk',
|
||||
false,
|
||||
14,
|
||||
15,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_berezovskiy',
|
||||
false,
|
||||
15,
|
||||
16,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "berezovskiy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_zarechny',
|
||||
false,
|
||||
16,
|
||||
17,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "zarechny"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_kachkanar',
|
||||
false,
|
||||
17,
|
||||
18,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kachkanar"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_krasnoturinsk',
|
||||
false,
|
||||
18,
|
||||
19,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_severouralsk',
|
||||
false,
|
||||
19,
|
||||
20,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "severouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_ivdel',
|
||||
false,
|
||||
20,
|
||||
21,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "ivdel"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_tavda',
|
||||
false,
|
||||
21,
|
||||
22,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "tavda"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_turinsk',
|
||||
false,
|
||||
22,
|
||||
23,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "turinsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_sysert',
|
||||
false,
|
||||
23,
|
||||
0,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sysert"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_sredneuralsk',
|
||||
false,
|
||||
0,
|
||||
1,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sredneuralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_degtyarsk',
|
||||
false,
|
||||
1,
|
||||
2,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "degtyarsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_verkhnyaya_salda',
|
||||
false,
|
||||
5,
|
||||
6,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_nizhnyaya_salda',
|
||||
false,
|
||||
6,
|
||||
7,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_nevyansk',
|
||||
false,
|
||||
7,
|
||||
8,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nevyansk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_artemovskiy',
|
||||
false,
|
||||
8,
|
||||
9,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "artemovskiy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_kamyshlov',
|
||||
false,
|
||||
14,
|
||||
15,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kamyshlov"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_alapaevsk',
|
||||
false,
|
||||
15,
|
||||
16,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "alapaevsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_sukhoy_log',
|
||||
false,
|
||||
16,
|
||||
17,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sukhoy_log"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_kushva',
|
||||
false,
|
||||
17,
|
||||
18,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kushva"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_krasnouralsk',
|
||||
false,
|
||||
18,
|
||||
19,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_karpinsk',
|
||||
false,
|
||||
19,
|
||||
20,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "karpinsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_nizhnyaya_tura',
|
||||
false,
|
||||
20,
|
||||
21,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnyaya_tura"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_verkhniy_tagil',
|
||||
false,
|
||||
21,
|
||||
22,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_nizhnie_sergi',
|
||||
false,
|
||||
22,
|
||||
23,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnie_sergi"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_lesnoy',
|
||||
false,
|
||||
23,
|
||||
0,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "lesnoy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_rezh',
|
||||
false,
|
||||
0,
|
||||
1,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "rezh"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_aramil',
|
||||
false,
|
||||
1,
|
||||
2,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "aramil"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_volchansk',
|
||||
false,
|
||||
5,
|
||||
6,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "volchansk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_verkhnyaya_tura',
|
||||
false,
|
||||
6,
|
||||
7,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_mikhaylovsk',
|
||||
false,
|
||||
7,
|
||||
8,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "mikhaylovsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_verkhoturye',
|
||||
false,
|
||||
8,
|
||||
9,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhoturye"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_talitsa',
|
||||
false,
|
||||
14,
|
||||
15,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "talitsa"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_novaya_lyalya',
|
||||
false,
|
||||
15,
|
||||
16,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb
|
||||
),
|
||||
-- ── yandex_city_sweep_<city> — 39 городов (ВСЕ, КРОМЕ mikhaylovsk — города
|
||||
-- нет в гео-базе Яндекса вообще) (radius_m 25000, pages_per_anchor 3,
|
||||
-- request_delay_sec 9, interval_days 3) ───────────────────────────────
|
||||
(
|
||||
'yandex_city_sweep_novouralsk',
|
||||
false,
|
||||
0,
|
||||
1,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "novouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_revda',
|
||||
false,
|
||||
1,
|
||||
2,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "revda"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_polevskoy',
|
||||
false,
|
||||
2,
|
||||
3,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "polevskoy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_asbest',
|
||||
false,
|
||||
3,
|
||||
4,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "asbest"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_bogdanovich',
|
||||
false,
|
||||
4,
|
||||
5,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "bogdanovich"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_irbit',
|
||||
false,
|
||||
5,
|
||||
6,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "irbit"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_krasnoufimsk',
|
||||
false,
|
||||
6,
|
||||
7,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_berezovskiy',
|
||||
false,
|
||||
7,
|
||||
8,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "berezovskiy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_zarechny',
|
||||
false,
|
||||
8,
|
||||
9,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "zarechny"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_kachkanar',
|
||||
false,
|
||||
9,
|
||||
10,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kachkanar"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_krasnoturinsk',
|
||||
false,
|
||||
10,
|
||||
11,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_severouralsk',
|
||||
false,
|
||||
11,
|
||||
12,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "severouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_ivdel',
|
||||
false,
|
||||
12,
|
||||
13,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "ivdel"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_tavda',
|
||||
false,
|
||||
13,
|
||||
14,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "tavda"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_turinsk',
|
||||
false,
|
||||
20,
|
||||
21,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "turinsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_sysert',
|
||||
false,
|
||||
21,
|
||||
22,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sysert"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_sredneuralsk',
|
||||
false,
|
||||
22,
|
||||
23,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sredneuralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_degtyarsk',
|
||||
false,
|
||||
23,
|
||||
0,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "degtyarsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_verkhnyaya_salda',
|
||||
false,
|
||||
0,
|
||||
1,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_nizhnyaya_salda',
|
||||
false,
|
||||
1,
|
||||
2,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_nevyansk',
|
||||
false,
|
||||
2,
|
||||
3,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nevyansk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_artemovskiy',
|
||||
false,
|
||||
3,
|
||||
4,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "artemovskiy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_kamyshlov',
|
||||
false,
|
||||
4,
|
||||
5,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kamyshlov"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_alapaevsk',
|
||||
false,
|
||||
5,
|
||||
6,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "alapaevsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_sukhoy_log',
|
||||
false,
|
||||
6,
|
||||
7,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sukhoy_log"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_kushva',
|
||||
false,
|
||||
7,
|
||||
8,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kushva"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_krasnouralsk',
|
||||
false,
|
||||
8,
|
||||
9,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_karpinsk',
|
||||
false,
|
||||
9,
|
||||
10,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "karpinsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_nizhnyaya_tura',
|
||||
false,
|
||||
10,
|
||||
11,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnyaya_tura"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_verkhniy_tagil',
|
||||
false,
|
||||
11,
|
||||
12,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_nizhnie_sergi',
|
||||
false,
|
||||
12,
|
||||
13,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnie_sergi"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_lesnoy',
|
||||
false,
|
||||
13,
|
||||
14,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "lesnoy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_rezh',
|
||||
false,
|
||||
20,
|
||||
21,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "rezh"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_aramil',
|
||||
false,
|
||||
21,
|
||||
22,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "aramil"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_volchansk',
|
||||
false,
|
||||
22,
|
||||
23,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "volchansk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_verkhnyaya_tura',
|
||||
false,
|
||||
23,
|
||||
0,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_verkhoturye',
|
||||
false,
|
||||
1,
|
||||
2,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhoturye"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_talitsa',
|
||||
false,
|
||||
2,
|
||||
3,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "talitsa"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_novaya_lyalya',
|
||||
false,
|
||||
3,
|
||||
4,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb
|
||||
)
|
||||
ON CONFLICT (source) DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE scrape_schedules IS
|
||||
'In-app scheduler config (заменяет cron-script setup). Источники перечислены в '
|
||||
'tests/test_scraper_kit_scheduler_parity.py::_PRODUCT_SOURCES и в сид-миграциях '
|
||||
'data/sql/*scrape_schedules*seed*.sql. Последний добавленный: 102 wave-2 oblast '
|
||||
'city-sweep source''ы (40 городов, только подтверждённые provider-id: '
|
||||
'cian x40 / yandex x39 (без mikhaylovsk) / avito x23, #262 — все enabled=false, '
|
||||
'defensive ValueError guard в pipeline.py против молчаливого ЕКБ-fallback).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -250,3 +250,4 @@
|
|||
259_data_quality_drop_pct_cadastr.sql
|
||||
260_houses_drop_has_panorama.sql
|
||||
261_listings_search_mv_drop_placeholder_columns.sql
|
||||
262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql
|
||||
|
|
|
|||
|
|
@ -31,20 +31,41 @@ def test_ekb_anchors_count() -> None:
|
|||
|
||||
|
||||
def test_resolve_city_name_known_oblast_slugs() -> None:
|
||||
"""Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя."""
|
||||
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, resolve_city_name
|
||||
"""Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя.
|
||||
|
||||
expected = {
|
||||
#262 wave 2: CITY_LOCATIONS выросла с 5 (wave 1) до 45 (wave 1 + 40 wave-2
|
||||
городов) — вместо хардкода полного списка (дублировал бы CITY_DISPLAY_NAMES и
|
||||
ломался при каждом новом городе) проверяем структурный инвариант: CITY_DISPLAY_
|
||||
NAMES обязан покрывать РОВНО те же slug'и, что CITY_LOCATIONS (иначе oblast-город
|
||||
бы тихо получил ЕКБ-дефолт вместо своего имени) + spot-check wave-1 (не тронуты
|
||||
этим PR) и по одному wave-2 city из каждой tier-группы (все три id / только
|
||||
cian+yandex / только cian).
|
||||
"""
|
||||
from scraper_kit.orchestration.pipeline import (
|
||||
CITY_DISPLAY_NAMES,
|
||||
CITY_LOCATIONS,
|
||||
resolve_city_name,
|
||||
)
|
||||
|
||||
wave1_expected = {
|
||||
"nizhniy_tagil": "Нижний Тагил",
|
||||
"kamensk_uralskiy": "Каменск-Уральский",
|
||||
"pervouralsk": "Первоуральск",
|
||||
"verkhnyaya_pyshma": "Верхняя Пышма",
|
||||
"serov": "Серов",
|
||||
}
|
||||
# CITY_DISPLAY_NAMES обязан покрывать ровно те же slug'и, что CITY_LOCATIONS
|
||||
# (иначе oblast-город бы тихо получил ЕКБ-дефолт вместо своего имени).
|
||||
assert set(expected) == set(CITY_LOCATIONS)
|
||||
for slug, name in expected.items():
|
||||
assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS)
|
||||
assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items()
|
||||
for slug, name in wave1_expected.items():
|
||||
assert resolve_city_name(slug) == name
|
||||
# wave-2 spot-check: novouralsk (avito+cian+yandex), revda (cian+yandex, avito
|
||||
# НЕ подтверждён), mikhaylovsk (только cian, yandex отсутствует у источника).
|
||||
for slug, name in {
|
||||
"novouralsk": "Новоуральск",
|
||||
"revda": "Ревда",
|
||||
"mikhaylovsk": "Михайловск",
|
||||
}.items():
|
||||
assert slug in CITY_LOCATIONS
|
||||
assert resolve_city_name(slug) == name
|
||||
|
||||
|
||||
|
|
@ -62,6 +83,56 @@ def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None:
|
|||
assert resolve_city_name("nonexistent_city") == "Екатеринбург"
|
||||
|
||||
|
||||
# ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный
|
||||
# ЕКБ-fallback) ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_run_avito_city_sweep_raises_on_known_city_without_avito_slug() -> None:
|
||||
"""revda — известный CITY_LOCATIONS город, но avito_slug=None (не подтверждён).
|
||||
|
||||
run_avito_city_sweep обязан упасть ДО любого сетевого/DB похода — иначе
|
||||
`_city_seg()` молча взяла бы 'ekaterinburg' и sweep собрал бы ЕКБ под меткой
|
||||
'revda'. Raise происходит в самом начале функции (до await) — MagicMock() для
|
||||
config/matcher/enrichment безопасен, до них не доходит."""
|
||||
from scraper_kit.orchestration.pipeline import run_avito_city_sweep
|
||||
|
||||
with pytest.raises(ValueError, match="revda"):
|
||||
await run_avito_city_sweep(
|
||||
MagicMock(),
|
||||
run_id=1,
|
||||
config=MagicMock(),
|
||||
matcher=MagicMock(),
|
||||
enrichment=MagicMock(),
|
||||
city_slug="revda",
|
||||
)
|
||||
|
||||
|
||||
async def test_run_yandex_city_sweep_raises_on_known_city_without_yandex_rgid() -> None:
|
||||
"""mikhaylovsk — известный CITY_LOCATIONS город, но yandex_rgid=None (город
|
||||
отсутствует в гео-базе Яндекса вообще). run_yandex_city_sweep обязан упасть ДО
|
||||
любого сетевого/DB похода — иначе YandexRealtyScraper(city_rgid=None) молча
|
||||
взял бы ЕКБ rgid."""
|
||||
from scraper_kit.orchestration.pipeline import run_yandex_city_sweep
|
||||
|
||||
with pytest.raises(ValueError, match="mikhaylovsk"):
|
||||
await run_yandex_city_sweep(
|
||||
MagicMock(),
|
||||
run_id=1,
|
||||
config=MagicMock(),
|
||||
matcher=MagicMock(),
|
||||
enrichment=MagicMock(),
|
||||
city_slug="mikhaylovsk",
|
||||
)
|
||||
|
||||
|
||||
async def test_run_avito_city_sweep_does_not_raise_for_ekb_none_city_slug() -> None:
|
||||
"""city_slug=None (ЕКБ-путь, back-compat) НЕ должен затронуть новый guard — guard
|
||||
условие `_loc is not None`, а get_city_location(None) возвращает None."""
|
||||
from scraper_kit.orchestration.pipeline import get_city_location
|
||||
|
||||
assert get_city_location(None) is None
|
||||
|
||||
|
||||
# ── CitySweepCounters ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,452 @@
|
|||
"""Static guards for migration 262 (wave 2 — оставшиеся 40 городов Свердловской обл.).
|
||||
|
||||
Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует живой
|
||||
БД; здесь фиксируем структурные инварианты миграции (транзакционность, отсутствие DDL,
|
||||
отсутствие psycopg CAST-ловушки, все enabled=false) и, ГЛАВНОЕ, parity-guard между
|
||||
data/sql (что реально сеется в scrape_schedules) и `scraper_kit.orchestration.pipeline`
|
||||
(`CITY_ANCHORS`/`CITY_LOCATIONS`, откуда sweep берёт координаты и provider-id по
|
||||
`default_params->>'city'`):
|
||||
|
||||
- каждый slug из миграций 179_/262_ ОБЯЗАН существовать в CITY_ANCHORS — иначе
|
||||
`get_city_anchors(slug)` вернёт None и `_job_*_city_sweep` молча упадёт на
|
||||
EKB_ANCHORS (город "включат", а сборка физически уйдёт в Екатеринбург);
|
||||
- и наоборот: КАЖДЫЙ ключ CITY_ANCHORS (кроме None-пути ЕКБ) обязан иметь schedule-
|
||||
строки где-то (179_ ИЛИ 262_);
|
||||
- #262 ревью (после первой версии файла): строка для (provider, city) существует
|
||||
ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий provider-идентификатор в CITY_LOCATIONS
|
||||
подтверждён (не None) — НЕ "каждый город получает все 3 источника". Первая версия
|
||||
заводила все 123 (41×3) строки, планируя добыть идентификаторы ПОСЛЕ — это был бы
|
||||
ровно тот силентный ЕКБ-fallback баг, о котором она сама предупреждала. Этот файл
|
||||
проверяет обратное соответствие в обе стороны для avito_slug/yandex_rgid/
|
||||
cian_region_id.
|
||||
|
||||
Это ловит ИМЕННО тот класс регрессии, которого просил избежать заказчик: опечатка в
|
||||
slug/рассинхрон идентификатора при заведении миграции, из-за которой sweep молча
|
||||
резолвится на EKB_ANCHORS или ЕКБ region_id/rgid.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from scraper_kit.orchestration.pipeline import CITY_ANCHORS, CITY_LOCATIONS
|
||||
|
||||
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||||
_MIGRATION_179 = _SQL_DIR / "179_scrape_schedules_seed_oblast_city_sweeps.sql"
|
||||
_MIGRATION_262 = _SQL_DIR / "262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql"
|
||||
|
||||
_PROVIDERS = ("avito", "cian", "yandex")
|
||||
|
||||
# source-строка внутри VALUES (), напр. " 'avito_city_sweep_nizhniy_tagil',".
|
||||
# Ограничено 4-пробельным отступом + запятой в конце — не матчит примеры в header-
|
||||
# комментариях (см. "UPDATE scrape_schedules SET enabled = true WHERE source = '...';").
|
||||
_ROW_SOURCE_RE = re.compile(r"^ '(avito|cian|yandex)_city_sweep_([a-z_]+)',$", re.MULTILINE)
|
||||
|
||||
_WAVE1_SLUGS = {
|
||||
"nizhniy_tagil",
|
||||
"kamensk_uralskiy",
|
||||
"pervouralsk",
|
||||
"verkhnyaya_pyshma",
|
||||
"serov",
|
||||
}
|
||||
_WAVE2_SLUGS = {
|
||||
"novouralsk",
|
||||
"revda",
|
||||
"polevskoy",
|
||||
"asbest",
|
||||
"bogdanovich",
|
||||
"irbit",
|
||||
"krasnoufimsk",
|
||||
"berezovskiy",
|
||||
"zarechny",
|
||||
"kachkanar",
|
||||
"krasnoturinsk",
|
||||
"severouralsk",
|
||||
"ivdel",
|
||||
"tavda",
|
||||
"turinsk",
|
||||
"sysert",
|
||||
"sredneuralsk",
|
||||
"degtyarsk",
|
||||
"verkhnyaya_salda",
|
||||
"nizhnyaya_salda",
|
||||
"nevyansk",
|
||||
"artemovskiy",
|
||||
"kamyshlov",
|
||||
"alapaevsk",
|
||||
"sukhoy_log",
|
||||
"kushva",
|
||||
"krasnouralsk",
|
||||
"karpinsk",
|
||||
"nizhnyaya_tura",
|
||||
"verkhniy_tagil",
|
||||
"nizhnie_sergi",
|
||||
"lesnoy",
|
||||
"rezh",
|
||||
"aramil",
|
||||
"volchansk",
|
||||
"verkhnyaya_tura",
|
||||
"mikhaylovsk",
|
||||
"verkhoturye",
|
||||
"talitsa",
|
||||
"novaya_lyalya",
|
||||
}
|
||||
assert len(_WAVE2_SLUGS) == 40
|
||||
assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана)
|
||||
|
||||
|
||||
def _sql(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _executable_sql(path: Path) -> str:
|
||||
"""SQL без построчных `--`-комментариев — только исполняемый код."""
|
||||
lines = []
|
||||
for raw in _sql(path).splitlines():
|
||||
code = raw.split("--", 1)[0]
|
||||
if code.strip():
|
||||
lines.append(code)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _row_sources(path: Path) -> list[tuple[str, str]]:
|
||||
"""(provider, slug) пар из исполняемых INSERT-строк VALUES (не из header-комментов)."""
|
||||
return _ROW_SOURCE_RE.findall(_executable_sql(path))
|
||||
|
||||
|
||||
def _flat(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text).strip().lower()
|
||||
|
||||
|
||||
# ── existence / basic shape ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_migration_262_exists() -> None:
|
||||
assert _MIGRATION_262.exists(), f"missing migration: {_MIGRATION_262}"
|
||||
|
||||
|
||||
def test_migration_262_is_transactional() -> None:
|
||||
sql = _sql(_MIGRATION_262)
|
||||
assert "BEGIN;" in sql
|
||||
assert "COMMIT;" in sql
|
||||
|
||||
|
||||
def test_migration_262_no_ddl() -> None:
|
||||
flat = _flat(_executable_sql(_MIGRATION_262))
|
||||
assert "alter table" not in flat
|
||||
assert "create table" not in flat
|
||||
assert "drop table" not in flat
|
||||
assert "truncate" not in flat
|
||||
|
||||
|
||||
def test_migration_262_no_psycopg_cast_trap() -> None:
|
||||
assert not re.search(r":\w+::", _sql(_MIGRATION_262))
|
||||
|
||||
|
||||
def test_migration_262_idempotent_on_conflict_do_nothing() -> None:
|
||||
flat = _flat(_executable_sql(_MIGRATION_262))
|
||||
assert "on conflict (source) do nothing" in flat
|
||||
# Одна INSERT-инструкция на весь файл (не 102 отдельных INSERT) — ON CONFLICT
|
||||
# покрывает весь батч разом; повторный прогон всего файла — no-op.
|
||||
assert flat.count("insert into scrape_schedules") == 1
|
||||
|
||||
|
||||
def test_migration_262_excludes_bisert_entirely() -> None:
|
||||
"""Бисерть — пгт, не город; у Циана её нет вообще (любой запрос отдаёт Сысерть).
|
||||
|
||||
Regression-guard: ни одной строки source LIKE '%bisert%' в исполняемом SQL."""
|
||||
flat = _flat(_executable_sql(_MIGRATION_262))
|
||||
assert "bisert" not in flat
|
||||
|
||||
|
||||
# ── row counts (102 = 23 avito + 40 cian + 39 yandex) ───────────────────────
|
||||
|
||||
|
||||
def test_migration_262_has_exactly_102_rows() -> None:
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
assert len(rows) == 102, f"expected 23 avito + 40 cian + 39 yandex = 102 rows, got {len(rows)}"
|
||||
|
||||
|
||||
def test_migration_262_row_counts_per_provider() -> None:
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS}
|
||||
for provider, slug in rows:
|
||||
by_provider[provider].add(slug)
|
||||
assert len(by_provider["avito"]) == 23
|
||||
assert len(by_provider["cian"]) == 40
|
||||
assert len(by_provider["yandex"]) == 39
|
||||
# cian — ровно все 40 wave-2 slug'ов (cian_region_id подтверждён у всех).
|
||||
assert by_provider["cian"] == _WAVE2_SLUGS
|
||||
# yandex — все, КРОМЕ mikhaylovsk.
|
||||
assert by_provider["yandex"] == _WAVE2_SLUGS - {"mikhaylovsk"}
|
||||
# avito — подмножество (только подтверждённые avito_slug), не пересекается с
|
||||
# городами, у которых avito_slug=None в CITY_LOCATIONS.
|
||||
assert by_provider["avito"] <= _WAVE2_SLUGS
|
||||
|
||||
|
||||
def test_migration_262_no_duplicate_sources() -> None:
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
sources = [f"{p}_city_sweep_{s}" for p, s in rows]
|
||||
assert len(sources) == len(set(sources)), "duplicate source в 262_"
|
||||
|
||||
|
||||
def test_migration_262_does_not_touch_wave1_cities() -> None:
|
||||
"""Regression-guard: 262_ не должен переопределять/дублировать wave-1 5 городов —
|
||||
они уже сидированы 179_ и живут (enabled управляется отдельно от них)."""
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
slugs = {slug for _provider, slug in rows}
|
||||
overlap = slugs & _WAVE1_SLUGS
|
||||
assert not overlap, f"262_ пересекается с wave-1 городами: {overlap}"
|
||||
|
||||
|
||||
def test_migration_262_all_rows_enabled_false() -> None:
|
||||
"""Все 102 строки должны быть enabled=false (dormant by design — оператор включает
|
||||
волнами вручную). Считаем по числу `false,` сразу после source-литерала."""
|
||||
executable = _executable_sql(_MIGRATION_262)
|
||||
tuples = re.findall(
|
||||
r"'(?:avito|cian|yandex)_city_sweep_[a-z_]+',\s*\n\s*(true|false),",
|
||||
executable,
|
||||
)
|
||||
assert len(tuples) == 102
|
||||
assert set(tuples) == {"false"}, "найдена строка с enabled=true — нарушение dormant-инварианта"
|
||||
|
||||
|
||||
# ── CITY_LOCATIONS parity (ГЛАВНЫЙ regression-guard после ревью) ───────────
|
||||
|
||||
|
||||
def test_migration_262_row_exists_iff_identifier_confirmed() -> None:
|
||||
"""Строка (provider, city) есть в 262_ ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий
|
||||
provider-идентификатор в CITY_LOCATIONS подтверждён (не None).
|
||||
|
||||
Это единственный источник правды после ревью: НЕ "каждый wave-2 город получает 3
|
||||
строки" (так было в отклонённой первой версии — риск silent EKB-fallback), а
|
||||
"строка существует ровно там, где есть подтверждённый id".
|
||||
"""
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS}
|
||||
for provider, slug in rows:
|
||||
by_provider[provider].add(slug)
|
||||
|
||||
for slug in _WAVE2_SLUGS:
|
||||
loc = CITY_LOCATIONS[slug]
|
||||
|
||||
has_avito_row = slug in by_provider["avito"]
|
||||
has_avito_id = loc.avito_slug is not None
|
||||
assert has_avito_row == has_avito_id, (
|
||||
f"{slug}: avito_slug={loc.avito_slug!r} (confirmed={has_avito_id}) но "
|
||||
f"avito-row-exists={has_avito_row} — рассинхрон CITY_LOCATIONS vs 262_"
|
||||
)
|
||||
|
||||
has_cian_row = slug in by_provider["cian"]
|
||||
has_cian_id = loc.cian_region_id is not None
|
||||
assert has_cian_row == has_cian_id, (
|
||||
f"{slug}: cian_region_id={loc.cian_region_id!r} (confirmed={has_cian_id}) но "
|
||||
f"cian-row-exists={has_cian_row} — рассинхрон CITY_LOCATIONS vs 262_"
|
||||
)
|
||||
|
||||
has_yandex_row = slug in by_provider["yandex"]
|
||||
has_yandex_id = loc.yandex_rgid is not None
|
||||
assert has_yandex_row == has_yandex_id, (
|
||||
f"{slug}: yandex_rgid={loc.yandex_rgid!r} (confirmed={has_yandex_id}) но "
|
||||
f"yandex-row-exists={has_yandex_row} — рассинхрон CITY_LOCATIONS vs 262_"
|
||||
)
|
||||
|
||||
|
||||
def test_every_confirmed_avito_slug_has_a_schedule_row_and_vice_versa() -> None:
|
||||
"""Каждый slug из CITY_LOCATIONS с непустым avito_slug имеет avito_city_sweep_<slug>
|
||||
строку в 262_ (wave-2) или 179_ (wave-1) — и наоборот, каждая avito-строка
|
||||
принадлежит городу с непустым avito_slug."""
|
||||
rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "avito"}
|
||||
rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "avito"}
|
||||
scheduled_avito = rows_262 | rows_179
|
||||
|
||||
confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None}
|
||||
assert scheduled_avito == confirmed_avito, (
|
||||
f"missing schedule rows: {confirmed_avito - scheduled_avito}; "
|
||||
f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_avito}"
|
||||
)
|
||||
|
||||
|
||||
def test_every_confirmed_yandex_rgid_has_a_schedule_row_and_vice_versa() -> None:
|
||||
rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "yandex"}
|
||||
rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "yandex"}
|
||||
scheduled_yandex = rows_262 | rows_179
|
||||
|
||||
confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None}
|
||||
assert scheduled_yandex == confirmed_yandex, (
|
||||
f"missing schedule rows: {confirmed_yandex - scheduled_yandex}; "
|
||||
f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_yandex}"
|
||||
)
|
||||
|
||||
|
||||
def test_every_confirmed_cian_region_id_has_a_schedule_row_and_vice_versa() -> None:
|
||||
rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "cian"}
|
||||
rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "cian"}
|
||||
scheduled_cian = rows_262 | rows_179
|
||||
|
||||
confirmed_cian = {
|
||||
slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None
|
||||
}
|
||||
assert scheduled_cian == confirmed_cian, (
|
||||
f"missing schedule rows: {confirmed_cian - scheduled_cian}; "
|
||||
f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_cian}"
|
||||
)
|
||||
|
||||
|
||||
def test_mikhaylovsk_has_only_cian_row() -> None:
|
||||
"""Regression-guard для самого узкого случая: mikhaylovsk — единственный город без
|
||||
yandex_rgid (город отсутствует в гео-базе Яндекса) и без avito_slug — должен иметь
|
||||
РОВНО одну строку (cian), НЕ три."""
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
mikhaylovsk_providers = {p for p, s in rows if s == "mikhaylovsk"}
|
||||
assert mikhaylovsk_providers == {
|
||||
"cian"
|
||||
}, f"mikhaylovsk должен иметь только cian-строку, получено: {mikhaylovsk_providers}"
|
||||
|
||||
|
||||
# ── CITY_ANCHORS parity ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_migration_262_slugs_all_exist_in_city_anchors() -> None:
|
||||
"""Каждый slug из 262_ ОБЯЗАН быть в CITY_ANCHORS — иначе get_city_anchors(slug)
|
||||
вернёт None и sweep молча резолвится на EKB_ANCHORS (собирает ЕКБ вместо города)."""
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
slugs = {slug for _provider, slug in rows}
|
||||
missing = slugs - set(CITY_ANCHORS)
|
||||
assert not missing, f"slugs из 262_ отсутствуют в CITY_ANCHORS: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_migration_179_slugs_all_exist_in_city_anchors() -> None:
|
||||
"""Тот же guard для wave-1 (179_) — sanity, что baseline не сломан этим PR."""
|
||||
rows = _row_sources(_MIGRATION_179)
|
||||
slugs = {slug for _provider, slug in rows}
|
||||
missing = slugs - set(CITY_ANCHORS)
|
||||
assert not missing, f"slugs из 179_ отсутствуют в CITY_ANCHORS: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_city_anchors_has_no_slug_without_schedule_rows() -> None:
|
||||
"""Обратное направление: каждый ключ CITY_ANCHORS обязан иметь schedule-строки в
|
||||
179_ ИЛИ 262_ — мёртвая запись без сидов сигнализирует рассинхрон/опечатку."""
|
||||
seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | {
|
||||
slug for _p, slug in _row_sources(_MIGRATION_262)
|
||||
}
|
||||
orphaned = set(CITY_ANCHORS) - seeded_slugs
|
||||
assert (
|
||||
not orphaned
|
||||
), f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}"
|
||||
|
||||
|
||||
def test_city_anchors_wave2_count_and_content() -> None:
|
||||
"""CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 —
|
||||
ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи)."""
|
||||
assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS
|
||||
assert len(CITY_ANCHORS) == 45
|
||||
|
||||
|
||||
def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None:
|
||||
"""Каждая wave-2 запись — ровно 1 anchor (город компактнее ЕКБ, см. коммент в
|
||||
pipeline.py) с непустой человекочитаемой подписью, заканчивающейся на 'центр'."""
|
||||
for slug in _WAVE2_SLUGS:
|
||||
anchors = CITY_ANCHORS[slug]
|
||||
assert len(anchors) == 1, f"{slug}: ожидался 1 anchor, получено {len(anchors)}"
|
||||
lat, lon, label = anchors[0]
|
||||
assert 55.0 < lat < 62.0, f"{slug}: lat={lat} вне разумного диапазона Свердловской обл."
|
||||
assert 56.0 < lon < 67.0, f"{slug}: lon={lon} вне разумного диапазона Свердловской обл."
|
||||
assert label.strip().lower().endswith("центр"), f"{slug}: подпись '{label}' без 'центр'"
|
||||
|
||||
|
||||
def test_city_locations_wave2_count_and_content() -> None:
|
||||
"""CITY_LOCATIONS покрывает ровно те же 45 slug'ов, что CITY_ANCHORS (иначе
|
||||
get_city_location(slug) вернёт None для известного anchor'ами города и сборка
|
||||
молча уйдёт на ЕКБ region_id/rgid)."""
|
||||
assert set(CITY_LOCATIONS) == set(CITY_ANCHORS)
|
||||
|
||||
|
||||
def test_city_locations_cian_region_id_never_none() -> None:
|
||||
"""cian_region_id подтверждён у ВСЕХ 45 городов (wave 1 + wave 2) — единственное
|
||||
поле CityLocation, у которого нет Optional-пути."""
|
||||
for slug, loc in CITY_LOCATIONS.items():
|
||||
assert loc.cian_region_id is not None, f"{slug}: cian_region_id не должен быть None"
|
||||
|
||||
|
||||
# ── window scheduling ───────────────────────────────────────────────────────
|
||||
|
||||
_EKB_OCCUPIED = {"avito": {6}, "cian": {2, 3, 4}, "yandex": {16}}
|
||||
_WAVE1_OCCUPIED = {
|
||||
"avito": {0, 1, 5, 7, 8},
|
||||
"cian": {9, 10, 11, 12, 13},
|
||||
"yandex": {14, 15, 17, 18, 19},
|
||||
}
|
||||
# Математический минимум коллизий одного источника в одном часе (round-robin по
|
||||
# 23/40/39 городам на 18/16/18 свободных часов соответственно).
|
||||
_MAX_COLLISIONS = {"avito": 2, "cian": 3, "yandex": 3}
|
||||
|
||||
|
||||
def _row_windows(path: Path) -> list[tuple[str, str, int, int]]:
|
||||
"""(provider, slug, window_start_hour, window_end_hour) для каждой VALUES-строки."""
|
||||
executable = _executable_sql(path)
|
||||
pattern = re.compile(
|
||||
r"'(avito|cian|yandex)_city_sweep_([a-z_]+)',\s*\n\s*(?:true|false),\s*\n\s*(\d+),\s*\n\s*(\d+),",
|
||||
)
|
||||
return [(p, s, int(a), int(b)) for p, s, a, b in pattern.findall(executable)]
|
||||
|
||||
|
||||
def test_migration_262_windows_are_one_hour() -> None:
|
||||
"""Окно ровно на час. Час 23 заворачивается в 0 (cross-midnight) — scheduler
|
||||
это поддерживает явно (`window_end_hour <= window_start_hour` → окно через
|
||||
полночь, см. scraper_kit.orchestration.scheduler)."""
|
||||
for provider, slug, start, end in _row_windows(_MIGRATION_262):
|
||||
expected = (start + 1) % 24
|
||||
assert (
|
||||
end == expected
|
||||
), f"{provider}_{slug}: window [{start},{end}) не 1-часовое (ожидали end={expected})"
|
||||
|
||||
|
||||
def test_migration_262_window_hours_satisfy_db_check_constraint() -> None:
|
||||
"""Оба часа обязаны попадать в 0..23 — иначе миграция не применится вовсе.
|
||||
|
||||
Регресс: первая версия давала start=23, end=24 у пяти строк. Проверка
|
||||
«end == start + 1» это пропускала, а прод-констрейнт `window_end_range`
|
||||
(052_scrape_schedules.sql: CHECK (window_end_hour BETWEEN 0 AND 23)) —
|
||||
нет, и весь INSERT падал в CI на реальном postgres.
|
||||
"""
|
||||
for provider, slug, start, end in _row_windows(_MIGRATION_262):
|
||||
assert 0 <= start <= 23, f"{provider}_{slug}: window_start_hour={start} вне 0..23"
|
||||
assert 0 <= end <= 23, f"{provider}_{slug}: window_end_hour={end} вне 0..23"
|
||||
|
||||
|
||||
def test_migration_262_windows_avoid_ekb_and_wave1_occupied_hours() -> None:
|
||||
"""Ни одна wave-2 строка не наезжает на занятые ЕКБ-окна (avito 6-7, cian 2-5,
|
||||
yandex 16-17) или на окна wave-1 (179_) — тот же provider."""
|
||||
for provider, slug, start, _end in _row_windows(_MIGRATION_262):
|
||||
occupied = _EKB_OCCUPIED[provider] | _WAVE1_OCCUPIED[provider]
|
||||
assert start not in occupied, (
|
||||
f"{provider}_city_sweep_{slug}: окно start={start} пересекает занятый "
|
||||
f"ЕКБ/wave-1 час ({sorted(occupied)})"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_262_same_provider_collisions_within_math_minimum() -> None:
|
||||
"""Коллизии ОДНОГО источника в одном окне не превышают математический минимум
|
||||
(round-robin по подтверждённым городам на свободные часы), и распределены
|
||||
равномерно (не сконцентрированы в 1-2 часах)."""
|
||||
from collections import Counter
|
||||
|
||||
rows = _row_windows(_MIGRATION_262)
|
||||
for provider in _PROVIDERS:
|
||||
hours = [start for p, _slug, start, _end in rows if p == provider]
|
||||
counts = Counter(hours)
|
||||
assert max(counts.values()) <= _MAX_COLLISIONS[provider], (
|
||||
f"{provider}: час {counts.most_common(1)} превышает математический минимум "
|
||||
f"коллизий ({_MAX_COLLISIONS[provider]})"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_262_cross_provider_overlap_allowed() -> None:
|
||||
"""Разные провайдеры МОГУТ делить окно в рамках этой миграции (не ограничивается) —
|
||||
документируем это явно, чтобы будущий рефактор не поломал предположение по ошибке."""
|
||||
rows = _row_windows(_MIGRATION_262)
|
||||
avito_hours = {start for p, _s, start, _e in rows if p == "avito"}
|
||||
cian_hours = {start for p, _s, start, _e in rows if p == "cian"}
|
||||
assert avito_hours and cian_hours
|
||||
|
|
@ -272,12 +272,65 @@ EKB_ANCHORS: list[tuple[float, float, str]] = [
|
|||
# default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек
|
||||
# на большой город), эти города компактнее — одного anchor'а достаточно.
|
||||
# Domclick (BFF, city_id-based, НЕ anchors-based) сюда не входит — отдельный B2 rollout.
|
||||
#
|
||||
# Wave 1 (5 городов, ниже) — enabled на проде. Wave 2 (40 городов, оставшаяся часть
|
||||
# Свердловской обл. КРОМЕ Бисерти — пгт, не город, Циан её не резолвит вообще, см.
|
||||
# CITY_LOCATIONS-коммент ниже; data/sql/262_scrape_schedules_seed_oblast_city_sweeps_
|
||||
# wave2.sql) — координаты проверены оператором (прод-геокодер + независимая сверка
|
||||
# медианой координат сделок Росреестра по городу), все schedule-строки enabled=false
|
||||
# (оператор включает волнами вручную). CITY_LOCATIONS заведён для всех 40 (см. ниже) —
|
||||
# но НЕ у каждого города подтверждены все 3 provider-идентификатора: run_avito_city_
|
||||
# sweep/run_yandex_city_sweep явно падают (ValueError), если для известного city_slug
|
||||
# соответствующий идентификатор не подтверждён (None), вместо молчаливого ЕКБ-дефолта.
|
||||
# 262_ заводит schedule-строку ТОЛЬКО там, где идентификатор источника подтверждён —
|
||||
# поэтому этот ValueError в проде не должен срабатывать при штатной эксплуатации.
|
||||
CITY_ANCHORS: dict[str, list[tuple[float, float, str]]] = {
|
||||
"nizhniy_tagil": [(57.910, 59.980, "Н.Тагил центр")],
|
||||
"kamensk_uralskiy": [(56.414, 61.918, "Каменск центр")],
|
||||
"pervouralsk": [(56.908, 59.943, "Первоуральск центр")],
|
||||
"verkhnyaya_pyshma": [(56.976, 60.578, "В.Пышма центр")],
|
||||
"serov": [(59.604, 60.578, "Серов центр")],
|
||||
# ── wave 2 (40 городов, dormant — см. коммент выше; Бисерть исключена целиком) ──
|
||||
"novouralsk": [(57.2470, 60.0958, "Новоуральск центр")],
|
||||
"revda": [(56.8001, 59.9087, "Ревда центр")],
|
||||
"polevskoy": [(56.4958, 60.2370, "Полевской центр")],
|
||||
"asbest": [(57.0141, 61.4659, "Асбест центр")],
|
||||
"bogdanovich": [(56.7683, 62.0521, "Богданович центр")],
|
||||
"irbit": [(57.6810, 63.0585, "Ирбит центр")],
|
||||
"krasnoufimsk": [(56.6181, 57.7713, "Красноуфимск центр")],
|
||||
"berezovskiy": [(56.9098, 60.8120, "Берёзовский центр")],
|
||||
"zarechny": [(56.8023, 61.3202, "Заречный центр")],
|
||||
"kachkanar": [(58.6995, 59.4811, "Качканар центр")],
|
||||
"krasnoturinsk": [(59.7646, 60.1936, "Краснотурьинск центр")],
|
||||
"severouralsk": [(60.1502, 59.9605, "Североуральск центр")],
|
||||
"ivdel": [(60.6973, 60.4173, "Ивдель центр")],
|
||||
"tavda": [(58.0414, 65.2720, "Тавда центр")],
|
||||
"turinsk": [(58.0525, 63.6909, "Туринск центр")],
|
||||
"sysert": [(56.5054, 60.8097, "Сысерть центр")],
|
||||
"sredneuralsk": [(56.9916, 60.4689, "Среднеуральск центр")],
|
||||
"degtyarsk": [(56.6983, 60.0868, "Дегтярск центр")],
|
||||
"verkhnyaya_salda": [(58.0494, 60.5473, "В.Салда центр")],
|
||||
"nizhnyaya_salda": [(58.0714, 60.7147, "Н.Салда центр")],
|
||||
"nevyansk": [(57.4924, 60.2105, "Невьянск центр")],
|
||||
"artemovskiy": [(57.3386, 61.8941, "Артёмовский центр")],
|
||||
"kamyshlov": [(56.8462, 62.7122, "Камышлов центр")],
|
||||
"alapaevsk": [(57.8508, 61.6950, "Алапаевск центр")],
|
||||
"sukhoy_log": [(56.9047, 62.0350, "Сухой Лог центр")],
|
||||
"kushva": [(58.2817, 59.7625, "Кушва центр")],
|
||||
"krasnouralsk": [(58.3571, 60.0374, "Красноуральск центр")],
|
||||
"karpinsk": [(59.7668, 60.0009, "Карпинск центр")],
|
||||
"nizhnyaya_tura": [(58.6208, 59.8473, "Н.Тура центр")],
|
||||
"verkhniy_tagil": [(57.3745, 59.9321, "В.Тагил центр")],
|
||||
"nizhnie_sergi": [(56.6619, 59.3000, "Н.Серги центр")],
|
||||
"lesnoy": [(58.6364, 59.7897, "Лесной центр")],
|
||||
"rezh": [(57.3706, 61.4043, "Реж центр")],
|
||||
"aramil": [(56.6963, 60.8331, "Арамиль центр")],
|
||||
"volchansk": [(59.9347, 60.0780, "Волчанск центр")],
|
||||
"verkhnyaya_tura": [(58.3605, 59.8182, "В.Тура центр")],
|
||||
"mikhaylovsk": [(56.4445, 59.1242, "Михайловск центр")],
|
||||
"verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")],
|
||||
"talitsa": [(57.0148, 63.7302, "Талица центр")],
|
||||
"novaya_lyalya": [(59.0527, 60.5960, "Н.Ляля центр")],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -325,23 +378,86 @@ class CityLocation:
|
|||
|
||||
avito_slug — city-slug в пути avito URL (ОТЛИЧАЕТСЯ от нашего slug у некоторых
|
||||
городов: kamensk-uralskiy через дефис, verhnyaya_pyshma без kh).
|
||||
Optional (#262 wave 2): у части городов НЕ подтверждён (403/429 —
|
||||
пул исчерпан на проверке, либо omonym-коллизия). None здесь —
|
||||
ЖЁСТКИЙ инвариант "нет достоверного значения", НЕ "бери ЕКБ" —
|
||||
run_avito_city_sweep явно падает (ValueError), если city_slug
|
||||
известен, а avito_slug для него None (см. её тело).
|
||||
cian_region_id — числовой region= в ekb.cian.ru/cat.php (ГОРОД, не область).
|
||||
yandex_rgid — rgid в gate-API Яндекс.Недвижимости (город).
|
||||
Подтверждён у ВСЕХ городов wave 1+2 — required, не Optional.
|
||||
yandex_rgid — rgid в gate-API Яндекс.Недвижимости (город). Optional (#262 wave
|
||||
2): mikhaylovsk отсутствует в гео-базе Яндекса вообще (единственный
|
||||
"Михайловск" там — ставропольский, чужой rgid подставлять нельзя).
|
||||
Тот же жёсткий инвариант, что у avito_slug — run_yandex_city_sweep
|
||||
явно падает (ValueError), если city_slug известен, а yandex_rgid
|
||||
для него None.
|
||||
"""
|
||||
|
||||
avito_slug: str
|
||||
avito_slug: str | None
|
||||
cian_region_id: int
|
||||
yandex_rgid: int
|
||||
yandex_rgid: int | None
|
||||
|
||||
|
||||
# Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах,
|
||||
# в карте держать НЕ обязательно (None-путь = ЕКБ). Города Свердловской обл. (region 66):
|
||||
#
|
||||
# Wave 2 (#262, 40 городов ниже wave-1 пятёрки) — идентификаторы добыты и валидированы
|
||||
# отдельно по каждому provider (метод + валидация 5/5 на wave-1 городах — см. шапку
|
||||
# data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql). Бисерть ИСКЛЮЧЕНА
|
||||
# целиком (не город, пгт — у Циана её нет, любой запрос отдаёт Сысерть id=176028).
|
||||
# avito_slug=None у 17 городов (не подтверждён — пул исчерпан на 429, ПЕРЕПРОВЕРИТЬ
|
||||
# позже, не опровергнут); yandex_rgid=None только у mikhaylovsk (город отсутствует в
|
||||
# гео-базе Яндекса — это НЕ "не проверили", это подтверждённое отсутствие данных).
|
||||
CITY_LOCATIONS: dict[str, CityLocation] = {
|
||||
"nizhniy_tagil": CityLocation("nizhniy_tagil", 4886, 328286),
|
||||
"kamensk_uralskiy": CityLocation("kamensk-uralskiy", 4781, 328468),
|
||||
"pervouralsk": CityLocation("pervouralsk", 4925, 328379),
|
||||
"verkhnyaya_pyshma": CityLocation("verhnyaya_pyshma", 4697, 166366),
|
||||
"serov": CityLocation("serov", 4982, 328238),
|
||||
# ── wave 2: avito+cian+yandex все подтверждены (23 города) ──────────────────
|
||||
"novouralsk": CityLocation("novouralsk", 4900, 327473),
|
||||
"asbest": CityLocation("asbest", 4659, 328344),
|
||||
"bogdanovich": CityLocation("bogdanovich", 4684, 327697),
|
||||
"irbit": CityLocation("irbit", 4773, 327182),
|
||||
"krasnoufimsk": CityLocation("krasnoufimsk", 4826, 326938),
|
||||
"krasnoturinsk": CityLocation("krasnoturinsk", 4824, 328232),
|
||||
"severouralsk": CityLocation("severouralsk", 4979, 328576),
|
||||
"ivdel": CityLocation("ivdel", 4769, 328471),
|
||||
"tavda": CityLocation("tavda", 5007, 328612),
|
||||
"turinsk": CityLocation("turinsk", 176151, 327028),
|
||||
"sysert": CityLocation("sysert", 176028, 327787),
|
||||
"verkhnyaya_salda": CityLocation("verhnyaya_salda", 4698, 328268),
|
||||
"nizhnyaya_salda": CityLocation("nizhnyaya_salda", 4887, 328267),
|
||||
"nevyansk": CityLocation("nevyansk", 4879, 327237),
|
||||
"alapaevsk": CityLocation("alapaevsk", 4642, 328292),
|
||||
"krasnouralsk": CityLocation("krasnouralsk", 4825, 328611),
|
||||
"verkhniy_tagil": CityLocation("verhniy_tagil", 4695, 328510),
|
||||
"rezh": CityLocation("rezh", 4957, 328306),
|
||||
"aramil": CityLocation("aramil", 174206, 327631),
|
||||
"volchansk": CityLocation("volchansk", 4710, 328231),
|
||||
"verkhnyaya_tura": CityLocation("verhnyaya_tura", 4699, 328489),
|
||||
"talitsa": CityLocation("talitsa", 176053, 328369),
|
||||
"novaya_lyalya": CityLocation("novaya_lyalya", 175466, 328244),
|
||||
# ── wave 2: avito НЕ подтверждён (429/403 — пул исчерпан, не опровергнут; либо
|
||||
# omonym без avito-специфичной дизамбигуации) — только cian+yandex (16 городов) ──
|
||||
"revda": CityLocation(None, 4956, 328540),
|
||||
"polevskoy": CityLocation(None, 4937, 328436),
|
||||
"berezovskiy": CityLocation(None, 4679, 559131),
|
||||
"zarechny": CityLocation(None, 4754, 328386),
|
||||
"kachkanar": CityLocation(None, 4792, 327958),
|
||||
"sredneuralsk": CityLocation(None, 5000, 327474),
|
||||
"degtyarsk": CityLocation(None, 4730, 328381),
|
||||
"artemovskiy": CityLocation(None, 4657, 156418),
|
||||
"kamyshlov": CityLocation(None, 4784, 328411),
|
||||
"sukhoy_log": CityLocation(None, 5004, 328395),
|
||||
"kushva": CityLocation(None, 4836, 328492),
|
||||
"karpinsk": CityLocation(None, 4790, 328477),
|
||||
"nizhnyaya_tura": CityLocation(None, 4888, 328487),
|
||||
"nizhnie_sergi": CityLocation(None, 175439, 328114),
|
||||
"lesnoy": CityLocation(None, 4844, 327968),
|
||||
"verkhoturye": CityLocation(None, 174499, 328251),
|
||||
# ── wave 2: ни avito, ни yandex — только cian (1 город) ─────────────────────
|
||||
"mikhaylovsk": CityLocation(None, 175332, None),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -367,6 +483,50 @@ CITY_DISPLAY_NAMES: dict[str, str] = {
|
|||
"pervouralsk": "Первоуральск",
|
||||
"verkhnyaya_pyshma": "Верхняя Пышма",
|
||||
"serov": "Серов",
|
||||
# ── wave 2 (40 городов, #262) — заведены для ВСЕХ, независимо от того, какие
|
||||
# provider-идентификаторы подтверждены в CITY_LOCATIONS: без записи здесь
|
||||
# resolve_city_name() тихо вернула бы "Екатеринбург" для листингов города,
|
||||
# у которого хотя бы один sweep-источник реально enabled.
|
||||
"novouralsk": "Новоуральск",
|
||||
"revda": "Ревда",
|
||||
"polevskoy": "Полевской",
|
||||
"asbest": "Асбест",
|
||||
"bogdanovich": "Богданович",
|
||||
"irbit": "Ирбит",
|
||||
"krasnoufimsk": "Красноуфимск",
|
||||
"berezovskiy": "Берёзовский",
|
||||
"zarechny": "Заречный",
|
||||
"kachkanar": "Качканар",
|
||||
"krasnoturinsk": "Краснотурьинск",
|
||||
"severouralsk": "Североуральск",
|
||||
"ivdel": "Ивдель",
|
||||
"tavda": "Тавда",
|
||||
"turinsk": "Туринск",
|
||||
"sysert": "Сысерть",
|
||||
"sredneuralsk": "Среднеуральск",
|
||||
"degtyarsk": "Дегтярск",
|
||||
"verkhnyaya_salda": "Верхняя Салда",
|
||||
"nizhnyaya_salda": "Нижняя Салда",
|
||||
"nevyansk": "Невьянск",
|
||||
"artemovskiy": "Артёмовский",
|
||||
"kamyshlov": "Камышлов",
|
||||
"alapaevsk": "Алапаевск",
|
||||
"sukhoy_log": "Сухой Лог",
|
||||
"kushva": "Кушва",
|
||||
"krasnouralsk": "Красноуральск",
|
||||
"karpinsk": "Карпинск",
|
||||
"nizhnyaya_tura": "Нижняя Тура",
|
||||
"verkhniy_tagil": "Верхний Тагил",
|
||||
"nizhnie_sergi": "Нижние Серги",
|
||||
"lesnoy": "Лесной",
|
||||
"rezh": "Реж",
|
||||
"aramil": "Арамиль",
|
||||
"volchansk": "Волчанск",
|
||||
"verkhnyaya_tura": "Верхняя Тура",
|
||||
"mikhaylovsk": "Михайловск",
|
||||
"verkhoturye": "Верхотурье",
|
||||
"talitsa": "Талица",
|
||||
"novaya_lyalya": "Новая Ляля",
|
||||
}
|
||||
EKATERINBURG_CITY_NAME = "Екатеринбург"
|
||||
|
||||
|
|
@ -994,6 +1154,21 @@ async def run_avito_city_sweep(
|
|||
# None → ЕКБ-дефолт (совпадает с anchors=EKB_ANCHORS fallback ниже).
|
||||
_anchors = anchors if anchors is not None else EKB_ANCHORS
|
||||
_loc = get_city_location(city_slug)
|
||||
# #262 wave 2: avito_slug у CityLocation Optional — не у каждого известного города
|
||||
# он подтверждён (403/429 на исчерпанном пуле при проверке, либо omonym-коллизия).
|
||||
# Известный city_slug БЕЗ подтверждённого avito_slug — explicit fail, а НЕ молчаливый
|
||||
# fallback на ЕКБ: `_city_seg()` в AvitoScraper делает `target_city_slug or
|
||||
# "ekaterinburg"`, поэтому city_slug=None ниже незаметно собрал бы Екатеринбург под
|
||||
# меткой города-цели. scrape_schedules-сиды (262_) заводят avito_city_sweep_<city>
|
||||
# ТОЛЬКО там, где avito_slug подтверждён — этот раздел кода не должен исполняться в
|
||||
# штатной эксплуатации; сработавший ValueError сигнализирует рассинхрон данных.
|
||||
if _loc is not None and _loc.avito_slug is None:
|
||||
raise ValueError(
|
||||
f"run_avito_city_sweep: city_slug={city_slug!r} есть в CITY_LOCATIONS, но "
|
||||
"avito_slug не подтверждён (None) — отказ от молчаливого fallback на "
|
||||
"ekaterinburg. Подтверди avito_slug для этого города, прежде чем заводить "
|
||||
"avito_city_sweep_<city> schedule."
|
||||
)
|
||||
# kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug —
|
||||
# вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor.
|
||||
_avito_slug = _loc.avito_slug if _loc else city_slug
|
||||
|
|
@ -1932,6 +2107,20 @@ async def run_yandex_city_sweep(
|
|||
# city_slug (#12): rgid города-цели → YandexRealtyScraper.city_rgid скоупит SERP
|
||||
# на город вместо дефолтного ЕКБ. None/неизвестный slug → ЕКБ-дефолт в конструкторе.
|
||||
_loc = get_city_location(city_slug)
|
||||
# #262 wave 2: yandex_rgid у CityLocation Optional — mikhaylovsk (Нижнесергинский
|
||||
# р-н) отсутствует в гео-базе Яндекс.Недвижимости вообще (единственный там —
|
||||
# ставропольский Михайловск, rgid 586221, подставлять чужой rgid нельзя). Известный
|
||||
# city_slug БЕЗ подтверждённого yandex_rgid — explicit fail, а НЕ молчаливый fallback
|
||||
# на ЕКБ rgid (YandexRealtyScraper(city_rgid=None) конструктор молча берёт ЕКБ).
|
||||
# scrape_schedules-сиды (262_) НЕ заводят yandex_city_sweep_<city> для городов без
|
||||
# rgid — этот раздел не должен исполняться в штатной эксплуатации.
|
||||
if _loc is not None and _loc.yandex_rgid is None:
|
||||
raise ValueError(
|
||||
f"run_yandex_city_sweep: city_slug={city_slug!r} есть в CITY_LOCATIONS, но "
|
||||
"yandex_rgid не подтверждён (None) — отказ от молчаливого fallback на ЕКБ "
|
||||
"rgid. Город отсутствует в гео-базе Яндекса — не заводи yandex_city_sweep_"
|
||||
"<city> schedule для него вовсе."
|
||||
)
|
||||
# #2594: город для save_listings(..., city=...) — один на весь sweep.
|
||||
_city_name = resolve_city_name(city_slug)
|
||||
# Гео-guard (соседний-город-в-развёртке): anchor=None у ЕКБ (city_slug=None) — guard
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue