Compare commits

...

2 commits

Author SHA1 Message Date
90a6e4bfe9 Merge pull request 'feat(tradein/scraper): per-city sweep capability для 5 городов области (schedules disabled)' (#2487) from fix/tradein-oblast-b1-city-anchors 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) Has been skipped
Deploy Trade-In / test (push) Successful in 49s
Deploy Trade-In / build-backend (push) Successful in 2m4s
Deploy Trade-In / deploy (push) Has been cancelled
2026-07-12 17:10:22 +00:00
bot-backend
cf91ac6b24 feat(tradein/scraper): per-city sweep capability для 5 городов области (schedules disabled)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
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 52s
B1: avito/cian/yandex city-sweep по городам Свердловской обл. (Н.Тагил/Каменск/Первоуральск/В.Пышма/Серов). Schedules засеяны DISABLED — rollout вручную по одному городу (proxy = 1 живой прокси).

- pipeline.py: CITY_ANCHORS (5) + get_city_anchors(slug) (None→EKB fallback).
- scheduler.py: sweep-jobs читают params.city → анкоры; wildcard-хендлеры *_city_sweep_* (source UNIQUE → per-city source, как deactivate_stale_*).
- geo.py: OBLAST66_BBOX + is_within_oblast66_bbox; avito/detail.py ingest-guard EKB→oblast66.
- price_brackets.py: 5 city-seed (reuse EKB). migration 179: 15 rows enabled=false, staggered, idempotent.
- +6 тестов. domclick (B2) не тронут.
2026-07-12 20:06:46 +03:00
8 changed files with 401 additions and 13 deletions

View file

@ -0,0 +1,199 @@
-- 179_scrape_schedules_seed_oblast_city_sweeps.sql
-- Seed rows для oblast-wide city-sweep (Свердловская область, region 66) — B1 rollout:
-- avito/cian/yandex city-sweep за пределами Екатеринбурга для 5 городов области.
-- Domclick (BFF, city_id-based) — отдельный B2 rollout, сюда НЕ входит.
--
-- !!! DORMANT BY DESIGN !!! Все 15 строк ship enabled = false.
-- Причина: в проксях-пуле сейчас только ОДИН здоровый прокси (см. scrape_proxies,
-- 157/173) — параллельный/резкий rollout на 5 новых городов рискует забанить его.
-- Capability полностью wired (pipeline.CITY_ANCHORS/get_city_anchors,
-- scheduler._job_{avito,cian,yandex}_city_sweep читают default_params->>'city' и
-- резолвят anchors города, wildcard-registry "*_city_sweep_*" в
-- scraper_kit.orchestration.scheduler._default_kit_handlers), но schedule'ы
-- намеренно ВЫКЛЮЧЕНЫ. Оператор включает ВРУЧНУЮ по одному городу за раз:
-- UPDATE scrape_schedules SET enabled = true WHERE source = 'avito_city_sweep_nizhniy_tagil';
--
-- WHY отдельный source на город (а не один source + params.city со списком городов):
-- scrape_schedules.source — UNIQUE (052_scrape_schedules.sql). Несколько строк с
-- одним source='avito_city_sweep' невозможны (второй INSERT/UPDATE тем же source
-- либо конфликтует, либо перезаписывает первую строку — по факту только один город
-- был бы шедулирован). Поэтому каждый (source_type, город) — своя строка с source =
-- '<source_type>_<city_slug>' (например 'cian_city_sweep_kamensk_uralskiy'), а
-- `resolve_handler` в scraper_kit.orchestration.scheduler резолвит её на тот же
-- handler, что и EKB source, через wildcard-запись "avito_city_sweep_*" (и т.п.,
-- тот же механизм, что уже используется для "deactivate_stale_*"). Job читает
-- default_params->>'city' и подставляет anchors города вместо EKB_ANCHORS.
--
-- default_params: source's текущие прод-дефолты (см. SELECT ниже) + {"city": "<slug>"}.
-- Никакие другие поля (radius_m/pages_per_anchor/...) НЕ меняются в этой миграции —
-- один anchor на город (pipeline.CITY_ANCHORS) может не покрывать город целиком на
-- текущем radius_m (1500м у avito/cian) — ОПЕРАТОР должен пересмотреть radius_m перед
-- enable (город крупнее одного 1500м-круга — напр. Н.Тагил/Каменск-Уральский).
--
-- window_start_hour/window_end_hour (UTC, 1-часовые окна) — расставлены НЕ пересекаясь
-- ни друг с другом, ни с уже enabled EKB-окнами (avito 6-7, cian 2-5, yandex 16-17) —
-- на случай если оператор включит несколько строк сразу (proxy-pool = 1, конкурентные
-- sweep'ы разных источников делили бы тот же прокси).
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)).
-- Idempotent: ON CONFLICT (source) DO NOTHING — каждый source в этой миграции уникален
-- по построению (SELECT ниже подтверждает текущие прод-дефолты трёх EKB source'ов,
-- НЕ вставляется повторно и не конфликтует с ними — city_sweep_<slug> != city_sweep).
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
-- ── avito_city_sweep_<city> (база: radius_m 1500, detail_top_n 20, enrich_houses true,
-- pages_per_anchor 3, request_delay_sec 7 — прод-дефолты avito_city_sweep) ──────────
(
'avito_city_sweep_nizhniy_tagil',
false,
0,
1,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "nizhniy_tagil"}'::jsonb
),
(
'avito_city_sweep_kamensk_uralskiy',
false,
1,
2,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "kamensk_uralskiy"}'::jsonb
),
(
'avito_city_sweep_pervouralsk',
false,
5,
6,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "pervouralsk"}'::jsonb
),
(
'avito_city_sweep_verkhnyaya_pyshma',
false,
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "verkhnyaya_pyshma"}'::jsonb
),
(
'avito_city_sweep_serov',
false,
8,
9,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "serov"}'::jsonb
),
-- ── cian_city_sweep_<city> (база: radius_m 1500, detail_top_n 10, enrich_houses true,
-- pages_per_anchor 3, request_delay_sec 5 — прод-дефолты cian_city_sweep) ─────────
(
'cian_city_sweep_nizhniy_tagil',
false,
9,
10,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "nizhniy_tagil"}'::jsonb
),
(
'cian_city_sweep_kamensk_uralskiy',
false,
10,
11,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "kamensk_uralskiy"}'::jsonb
),
(
'cian_city_sweep_pervouralsk',
false,
11,
12,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "pervouralsk"}'::jsonb
),
(
'cian_city_sweep_verkhnyaya_pyshma',
false,
12,
13,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "verkhnyaya_pyshma"}'::jsonb
),
(
'cian_city_sweep_serov',
false,
13,
14,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "serov"}'::jsonb
),
-- ── yandex_city_sweep_<city> (база: radius_m 25000, pages_per_anchor 3,
-- request_delay_sec 9 — прод-дефолты yandex_city_sweep; radius_m большой ok —
-- per 119_yandex_city_sweep_center_combos.sql это лишь fallback-фильтр, не
-- query-ограничение, лишний охват не увеличивает load) ────────────────────────
(
'yandex_city_sweep_nizhniy_tagil',
false,
14,
15,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "nizhniy_tagil"}'::jsonb
),
(
'yandex_city_sweep_kamensk_uralskiy',
false,
15,
16,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "kamensk_uralskiy"}'::jsonb
),
(
'yandex_city_sweep_pervouralsk',
false,
17,
18,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "pervouralsk"}'::jsonb
),
(
'yandex_city_sweep_verkhnyaya_pyshma',
false,
18,
19,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "verkhnyaya_pyshma"}'::jsonb
),
(
'yandex_city_sweep_serov',
false,
19,
20,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "serov"}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMENT ON TABLE scrape_schedules IS
'In-app scheduler config (заменяет cron-script setup). '
'Sources: avito_city_sweep, yandex_city_sweep, cian_city_sweep (EKB, enabled), '
'avito_full_load, avito_full_load_exhaustive, cian_full_load, domclick_city_sweep, '
'avito_city_sweep_<city>/cian_city_sweep_<city>/yandex_city_sweep_<city> '
'(oblast B1 rollout — nizhniy_tagil/kamensk_uralskiy/pervouralsk/'
'verkhnyaya_pyshma/serov, dormant, #179 — resolve_handler wildcard "*_city_sweep_*"), '
'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot, '
'asking_to_sold_ratio_refresh, refresh_search_matview, yandex_address_backfill, '
'sber_index_pull, rosreestr_quarter_poll, deals_freshness_monitor, newbuilding_enrich, '
'yandex_newbuilding_sweep, geoportal_coords_backfill, geocode_missing_listings, '
'avito_detail_backfill, yandex_detail_backfill, cadastral_geo_match, '
'osm_poi_ekb_refresh, house_imv_backfill, house_dedup_merge, proxy_healthcheck, '
'deactivate_stale_avito/yandex/cian.';
COMMIT;

View file

@ -28,11 +28,15 @@ from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.orchestration import scheduler as kit_sched
from scraper_kit.orchestration.pipeline import CITY_ANCHORS
from scraper_kit.orchestration.scheduler import (
Handler,
SchedulerContext,
_claim_run,
_dispatch,
_job_avito_city_sweep,
_job_cian_city_sweep,
_job_yandex_city_sweep,
build_registry,
reap_zombies,
reschedule_after_minutes,
@ -74,14 +78,20 @@ _PRODUCT_SOURCES: set[str] = {
"proxy_healthcheck",
}
# kit-native (тело в scraper_kit.orchestration.pipeline) — регистрируются встроенно
# kit-native (тело в scraper_kit.orchestration.pipeline) — регистрируются встроенно.
# "*_city_sweep_*" — wildcard-семья oblast B1 rollout (region 66, dormant, #179):
# per-city source (avito_city_sweep_nizhniy_tagil и т.п.) резолвится на тот же
# handler, что EKB source, через resolve_handler-префикс (см. _default_kit_handlers).
_KIT_NATIVE_SOURCES = {
"avito_city_sweep",
"avito_city_sweep_*",
"avito_full_load",
"avito_full_load_exhaustive",
"avito_newbuilding_sweep",
"yandex_city_sweep",
"yandex_city_sweep_*",
"cian_city_sweep",
"cian_city_sweep_*",
"cian_full_load",
"domclick_city_sweep",
}
@ -156,7 +166,10 @@ def test_real_build_product_handlers_covers_all_scheduled_sources() -> None:
def test_kit_native_handler_set() -> None:
"""Ровно 8 kit-native sweep-обработчиков зарегистрированы встроенно."""
"""Ровно 11 kit-native sweep-обработчиков зарегистрированы встроенно (8 + 3 oblast
"*_city_sweep_*" wildcard, #179).
"""
registry = build_registry()
assert set(registry) == _KIT_NATIVE_SOURCES
for src in _KIT_NATIVE_SOURCES:
@ -434,3 +447,87 @@ async def test_dispatch_post_claim_hook_runs() -> None:
await asyncio.gather(*list(ctx._inflight_tasks))
post.assert_called_once()
assert post.call_args[0][1] == 300 # run_id
# ── 6. B1 oblast rollout — city param → city anchors, wildcard dispatch ──────────
def _oblast_ctx() -> SchedulerContext:
return SchedulerContext(
config=MagicMock(),
matcher=MagicMock(),
enrichment=MagicMock(),
session_factory=MagicMock(),
runs=MagicMock(),
)
async def test_job_avito_city_sweep_with_city_param_uses_city_anchors() -> None:
"""params={"city": "nizhniy_tagil"} → Н.Тагил anchors переданы в run_avito_city_sweep."""
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nizhniy_tagil"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] == CITY_ANCHORS["nizhniy_tagil"]
assert kwargs["anchors"] == [(57.910, 59.980, "Н.Тагил центр")]
async def test_job_cian_city_sweep_with_city_param_uses_city_anchors() -> None:
"""params={"city": "kamensk_uralskiy"} → anchors города переданы в run_cian_city_sweep."""
with patch.object(kit_sched, "run_cian_city_sweep", AsyncMock()) as mock_run:
await _job_cian_city_sweep(MagicMock(), 1, {"city": "kamensk_uralskiy"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] == CITY_ANCHORS["kamensk_uralskiy"]
async def test_job_yandex_city_sweep_with_city_param_uses_city_anchors() -> None:
"""params={"city": "serov"} → anchors города переданы в run_yandex_city_sweep."""
with patch.object(kit_sched, "run_yandex_city_sweep", AsyncMock()) as mock_run:
await _job_yandex_city_sweep(MagicMock(), 1, {"city": "serov"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] == CITY_ANCHORS["serov"]
async def test_job_avito_city_sweep_without_city_param_falls_back_to_none() -> None:
"""Без city (EKB-schedule, back-compat) — anchors=None передан (run_* сам берёт EKB_ANCHORS)."""
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
await _job_avito_city_sweep(MagicMock(), 1, {}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] is None
async def test_job_avito_city_sweep_unknown_city_falls_back_to_none() -> None:
"""Неизвестный city slug (typo/будущий город без CITY_ANCHORS-записи) → anchors=None,
НЕ падает с KeyError.
"""
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] is None
def test_wildcard_resolves_oblast_city_sweep_sources() -> None:
"""resolve_handler матчит per-city oblast source на wildcard "*_city_sweep_*"."""
registry = build_registry()
assert (
resolve_handler("avito_city_sweep_nizhniy_tagil", registry)
is registry["avito_city_sweep_*"]
)
assert resolve_handler("cian_city_sweep_serov", registry) is registry["cian_city_sweep_*"]
assert (
resolve_handler("yandex_city_sweep_pervouralsk", registry)
is registry["yandex_city_sweep_*"]
)
# EKB source (no city) — по-прежнему резолвится на exact non-wildcard entry.
assert resolve_handler("avito_city_sweep", registry) is registry["avito_city_sweep"]
assert registry["avito_city_sweep"] is not registry["avito_city_sweep_*"]

View file

@ -14,6 +14,12 @@ from __future__ import annotations
EKB_BBOX_TIGHT = (56.65, 56.95, 60.40, 60.85)
EKB_BBOX_WIDE = (56.6, 57.1, 60.3, 60.9)
# Свердловская область целиком (region 66) — щедрый bbox под oblast-wide city-sweep
# (Н.Тагил/Каменск-Уральский/Первоуральск/В.Пышма/Серов, B1 rollout). Зеркалит
# `app.services.geocoder.OBLAST66_BBOX` дословно (та же (lat_min, lat_max, lon_min,
# lon_max)) — держим EKB_BBOX_TIGHT/WIDE отдельно для EKB-only fast-path (не заменяем).
OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6)
def is_within_ekb_bbox(
lat: float, lon: float, bbox: tuple[float, float, float, float] = EKB_BBOX_TIGHT
@ -28,6 +34,18 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
Используется для валидации координат из avito detail-страниц перед записью в БД.
Шире geocoder-tight, поэтому не режет легитимное приграничье, но отсекает не-ЕКБ
(Питер/Тюмень/Уфа).
(Питер/Тюмень/Уфа). EKB-only fast-path для oblast-wide sweep см.
`is_within_oblast66_bbox`.
"""
return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE)
def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
"""Ingest-guard: True если координаты внутри bbox всей Свердловской области (region 66).
Oblast-wide аналог `is_within_ekb_bbox_wide` (B1 rollout city-sweep за пределами ЕКБ:
Н.Тагил/Каменск-Уральский/Первоуральск/В.Пышма/Серов). Мирроит
`app.services.geocoder.is_within_oblast66_bbox` щедрый bbox, всё ещё отсекает
кросс-региональные дубли (Тюмень/Курган и т.п. вне OBLAST66_BBOX).
"""
return is_within_ekb_bbox(lat, lon, OBLAST66_BBOX)

View file

@ -283,6 +283,33 @@ EKB_ANCHORS: list[tuple[float, float, str]] = [
(56.8650, 60.6200, "Пионерский"),
]
# Anchors для oblast-городов вне ЕКБ (B1 rollout — Свердловская область, region 66).
# По 1 anchor на город (центр) — radius_m (передаётся отдельно, в scrape_schedules.
# default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек
# на большой город), эти города компактнее — одного anchor'а достаточно.
# Domclick (BFF, city_id-based, НЕ anchors-based) сюда не входит — отдельный B2 rollout.
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, "Серов центр")],
}
def get_city_anchors(city_slug: str | None) -> list[tuple[float, float, str]] | None:
"""Anchors для oblast-города по slug (CITY_ANCHORS). Неизвестный slug/None → None.
None сигнал caller'у (scheduler.py _job_*_city_sweep) падать обратно на EKB_ANCHORS
(run_avito_city_sweep/run_cian_city_sweep) либо на единственный центральный anchor ЕКБ
(run_yandex_city_sweep combos-mode) см. `anchors if anchors is not None else
EKB_ANCHORS` в соответствующих run_*_city_sweep.
"""
if city_slug is None:
return None
return CITY_ANCHORS.get(city_slug)
_CHROME_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",

View file

@ -38,6 +38,7 @@ from sqlalchemy import text
from scraper_kit.orchestration import runs as _kit_runs
from scraper_kit.orchestration.pipeline import (
get_city_anchors,
run_avito_city_sweep,
run_avito_full_load,
run_avito_newbuilding_sweep,
@ -440,6 +441,11 @@ def resolve_handler(source: str, registry: Mapping[str, Handler]) -> Handler | N
async def _job_avito_city_sweep(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
# #B1 oblast rollout: default_params["city"] (slug, e.g. "nizhniy_tagil") → anchors
# города вместо EKB_ANCHORS. Отсутствует/неизвестен → get_city_anchors вернёт None →
# run_avito_city_sweep сам падает на EKB_ANCHORS (прежнее поведение без city).
city = params.get("city")
anchors = get_city_anchors(city) if city else None
await run_avito_city_sweep(
db,
run_id=run_id,
@ -448,6 +454,7 @@ async def _job_avito_city_sweep(
enrichment=ctx.enrichment,
shutdown_requested=ctx.shutdown_requested,
proxy_provider=ctx.proxy_provider,
anchors=anchors,
pages_per_anchor=int(params.get("pages_per_anchor", 3)),
detail_top_n=int(params.get("detail_top_n", 20)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
@ -513,6 +520,12 @@ async def _job_avito_full_load_exhaustive(
async def _job_yandex_city_sweep(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
# #B1 oblast rollout — см. _job_avito_city_sweep. Для yandex city None-anchors
# означает "единственный центральный ЕКБ anchor combos-mode" (run_yandex_city_sweep
# само подставляет (56.8400, 60.6050, "Центр (combos)") при anchors=None); заданный
# city заменяет его на anchors города (та же итерация anchors×combos, 1 anchor).
city = params.get("city")
anchors = get_city_anchors(city) if city else None
await run_yandex_city_sweep(
db,
run_id=run_id,
@ -521,6 +534,7 @@ async def _job_yandex_city_sweep(
enrichment=ctx.enrichment,
shutdown_requested=ctx.shutdown_requested,
proxy_provider=ctx.proxy_provider,
anchors=anchors,
pages_per_anchor=int(params.get("pages_per_anchor", 2)),
request_delay_sec=float(params.get("request_delay_sec", 9.0)),
radius_m=int(params.get("radius_m", 1500)),
@ -532,6 +546,9 @@ async def _job_yandex_city_sweep(
async def _job_cian_city_sweep(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
# #B1 oblast rollout — см. _job_avito_city_sweep.
city = params.get("city")
anchors = get_city_anchors(city) if city else None
await run_cian_city_sweep(
db,
run_id=run_id,
@ -539,6 +556,7 @@ async def _job_cian_city_sweep(
matcher=ctx.matcher,
shutdown_requested=ctx.shutdown_requested,
proxy_provider=ctx.proxy_provider,
anchors=anchors,
pages_per_anchor=int(params.get("pages_per_anchor", 3)),
request_delay_sec=float(params.get("request_delay_sec", 5.0)),
radius_m=int(params.get("radius_m", 1500)),
@ -584,16 +602,29 @@ async def _job_domclick_city_sweep(
def _default_kit_handlers() -> dict[str, Handler]:
"""Встроенные kit-native sweep-обработчики (тело в scraper_kit.orchestration.pipeline)."""
"""Встроенные kit-native sweep-обработчики (тело в scraper_kit.orchestration.pipeline).
"*_city_sweep_*" wildcard-семья per-city schedule'ов вне ЕКБ (B1 oblast rollout,
Свердловская область region 66). `scrape_schedules.source` UNIQUE не даёт нескольких
строк с одним и тем же source ("avito_city_sweep") на разные города поэтому каждый
oblast-город получает СВОЙ source ("avito_city_sweep_nizhniy_tagil" и т.п.,
data/sql/179_scrape_schedules_seed_oblast_city_sweeps.sql), а `resolve_handler`
резолвит их через wildcard-префикс (тот же механизм, что "deactivate_stale_*") на
ТОТ ЖЕ handler, что и ЕКБ-source job читает default_params["city"] и подставляет
anchors города (см. _job_avito_city_sweep/_job_cian_city_sweep/_job_yandex_city_sweep).
"""
return {
"avito_city_sweep": Handler(_job_avito_city_sweep, "avito_city_sweep"),
"avito_city_sweep_*": Handler(_job_avito_city_sweep, "avito_city_sweep_*"),
"avito_newbuilding_sweep": Handler(_job_avito_newbuilding_sweep, "avito_newbuilding_sweep"),
"avito_full_load": Handler(_job_avito_full_load, "avito_full_load"),
"avito_full_load_exhaustive": Handler(
_job_avito_full_load_exhaustive, "avito_full_load_exhaustive"
),
"yandex_city_sweep": Handler(_job_yandex_city_sweep, "yandex_city_sweep"),
"yandex_city_sweep_*": Handler(_job_yandex_city_sweep, "yandex_city_sweep_*"),
"cian_city_sweep": Handler(_job_cian_city_sweep, "cian_city_sweep"),
"cian_city_sweep_*": Handler(_job_cian_city_sweep, "cian_city_sweep_*"),
"cian_full_load": Handler(_job_cian_full_load, "cian_full_load"),
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
}

View file

@ -24,6 +24,18 @@ _EKB_SECONDARY: list[tuple[int, int | None]] = [
CITY_PRICE_SEED_BRACKETS: dict[str, list[tuple[int, int | None]]] = {
"ekaterinburg": _EKB_SECONDARY,
# B1 oblast rollout (Свердловская область, region 66) — Н.Тагил/Каменск-Уральский/
# Первоуральск/В.Пышма/Серов вторичка дешевле ЕКБ (меньше 17-25М+ сегмента), но
# отдельного per-city анализа прод-распределения пока не делали (сами schedule'ы
# ship disabled — данных для брекетов ещё нет). Временно реюзаем EKB-брекеты как
# безопасный upper-bound-охват (get_price_seed_brackets и так фоллбэчит на
# ekaterinburg для незнакомых городов — эти entries делают выбор ЯВНЫМ, не implicit
# fallback, чтобы легче было найти и заменить, когда появится реальная статистика).
"nizhniy_tagil": _EKB_SECONDARY,
"kamensk_uralskiy": _EKB_SECONDARY,
"pervouralsk": _EKB_SECONDARY,
"verkhnyaya_pyshma": _EKB_SECONDARY,
"serov": _EKB_SECONDARY,
}

View file

@ -10,6 +10,7 @@
Развязка от `app.*`:
- `app.core.config.settings` инжектируемый `ScraperConfig` (scraper_kit.contracts)
- `app.services.scraper_settings.get_scraper_delay` инжектируемый `delay_provider`
- `app.services.geocoder.is_within_ekb_bbox_wide` `scraper_kit.geo`
- `app.services.geocoder.is_within_oblast66_bbox` `scraper_kit.geo` (было is_within_ekb_bbox_wide
до B1 oblast rollout детекция координат теперь oblast-wide, не только ЕКБ)
- `app.services.matching.houses.match_or_create_house` инжектируемый `HouseMatcher`
"""

View file

@ -39,7 +39,7 @@ from scraper_kit.avito_exceptions import (
AvitoListingGoneError,
AvitoRateLimitedError,
)
from scraper_kit.geo import is_within_ekb_bbox_wide
from scraper_kit.geo import is_within_oblast66_bbox
from scraper_kit.providers._base import build_document_session
from scraper_kit.providers.avito.serp import _clean_address, _is_firewall_page
from scraper_kit.providers.avito.shared import RUS_MONTHS
@ -562,14 +562,17 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
lon = _try_float(map_el.attributes.get("data-map-lon"))
avito_location_id = _try_int(map_el.attributes.get("data-location-id"))
# bbox-guard (#1871): detail-страница иногда отдаёт координаты вне ЕКБ
# (Питер/Тюмень/Уфа — кросс-региональные дубли объявлений). Эти координаты
# попадают в UPDATE COALESCE без bbox-валидации (в отличие от geocoder.py,
# где bbox-фильтр везде). Сбрасываем в None → листинг уходит в geocode-cron
# путь, который сам отфильтрует по адресу. SERP-ingest координат не даёт вовсе.
if lat is not None and lon is not None and not is_within_ekb_bbox_wide(lat, lon):
# bbox-guard (#1871, расширен до oblast66 — B1 rollout): detail-страница иногда
# отдаёт координаты вне Свердловской области (Питер/Тюмень/Уфа — кросс-региональные
# дубли объявлений). Эти координаты попадают в UPDATE COALESCE без bbox-валидации
# (в отличие от geocoder.py, где bbox-фильтр везде). Сбрасываем в None → листинг
# уходит в geocode-cron путь, который сам отфильтрует по адресу. SERP-ingest
# координат не даёт вовсе. Guard расширен с EKB-only (is_within_ekb_bbox_wide) до
# oblast-wide (is_within_oblast66_bbox), чтобы не резать легитимные Н.Тагил/Каменск/
# Первоуральск/В.Пышма/Серов координаты (city-sweep за пределами ЕКБ).
if lat is not None and lon is not None and not is_within_oblast66_bbox(lat, lon):
logger.warning(
"avito_detail #1871: dropped non-EKB coords item=%s lat=%s lon=%s",
"avito_detail #1871: dropped non-oblast66 coords item=%s lat=%s lon=%s",
item_id,
lat,
lon,