Compare commits
No commits in common. "4a579e74e01c8e3299fd6af9ba1357567ee4916a" and "b93d26990b60e93567b893068bebf64647f5c7ca" have entirely different histories.
4a579e74e0
...
b93d26990b
11 changed files with 18 additions and 653 deletions
|
|
@ -167,26 +167,9 @@ async def clean_address(address: str) -> DadataAddressResult | None:
|
||||||
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
|
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
|
||||||
return None
|
return None
|
||||||
if status in (401, 403):
|
if status in (401, 403):
|
||||||
body_preview = (response.text or "")[:200]
|
|
||||||
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
|
|
||||||
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
|
|
||||||
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
|
|
||||||
# (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена.
|
|
||||||
if status == 403 and (
|
|
||||||
"disabled" in body_preview.lower() or "feature" in body_preview.lower()
|
|
||||||
):
|
|
||||||
logger.error(
|
logger.error(
|
||||||
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
|
"dadata: HTTP %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.",
|
||||||
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
|
|
||||||
"полагайся на suggest-fallback (enrich_address). Ответ: %r",
|
|
||||||
body_preview,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
"dadata: HTTP %d — auth/secret rejected. "
|
|
||||||
"Проверь DADATA_API_TOKEN/SECRET. Ответ: %r",
|
|
||||||
status,
|
status,
|
||||||
body_preview,
|
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
if status >= 500:
|
if status >= 500:
|
||||||
|
|
@ -263,12 +246,6 @@ class DadataSuggestion:
|
||||||
street: str | None
|
street: str | None
|
||||||
city: str | None
|
city: str | None
|
||||||
kind: str
|
kind: str
|
||||||
# #dadata-403 — доп. поля для suggest-fallback в enrich_address (когда CLEAN
|
|
||||||
# выключен на аккаунте). house_fias_id — ФИАС ДОМА (для house-level == fias_id);
|
|
||||||
# kladr_id / qc_geo дублируют то, что отдаёт /clean, чтобы деградация была равноценной.
|
|
||||||
house_fias_id: str | None = None
|
|
||||||
kladr_id: str | None = None
|
|
||||||
qc_geo: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_kind(fias_level: Any) -> str:
|
def _classify_kind(fias_level: Any) -> str:
|
||||||
|
|
@ -318,9 +295,6 @@ def _parse_suggestion(item: dict[str, Any]) -> DadataSuggestion | None:
|
||||||
street=data.get("street"),
|
street=data.get("street"),
|
||||||
city=data.get("city"),
|
city=data.get("city"),
|
||||||
kind=_classify_kind(data.get("fias_level")),
|
kind=_classify_kind(data.get("fias_level")),
|
||||||
house_fias_id=data.get("house_fias_id"),
|
|
||||||
kladr_id=data.get("kladr_id"),
|
|
||||||
qc_geo=_coerce_int(data.get("qc_geo")),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -427,55 +401,3 @@ async def suggest_addresses(
|
||||||
|
|
||||||
logger.info("dadata suggest: %r → %d вариантов", query[:60], len(out))
|
logger.info("dadata suggest: %r → %d вариантов", query[:60], len(out))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ── Enrichment orchestrator (clean → suggest fallback) ───────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
async def enrich_address(address: str) -> DadataAddressResult | None:
|
|
||||||
"""Обогащение целевого адреса с graceful-деградацией CLEAN → SUGGEST.
|
|
||||||
|
|
||||||
Порядок:
|
|
||||||
1. /clean/address (token+secret) — canonical + house_cadnum + house_fias_id +
|
|
||||||
qc_geo + metro. Если вернул результат — используем его.
|
|
||||||
2. Fallback: /suggest/address (token-only, доступен на free-tier 10k/день) —
|
|
||||||
берём top house-level кандидата. Даёт house_fias_id + гео + kladr_id + qc_geo,
|
|
||||||
но НЕ canonical_address / house_cadnum (их умеет только CLEAN) → они остаются
|
|
||||||
None. Честная деградация: недостающие поля не выдумываем.
|
|
||||||
|
|
||||||
Мотивация (#dadata-403, vault Mera_Hard_Audit_0712): на проде у аккаунта услуга
|
|
||||||
CLEAN выключена (HTTP 403 «Feature CLEAN disabled»), из-за чего house_fias_id —
|
|
||||||
ключ join с houses/ДОМ.РФ (year_built и пр.) — терялся, хотя /suggest его отдаёт
|
|
||||||
бесплатно. Город НЕ ограничиваем (city="") — enrichment работает по всей обл. (66).
|
|
||||||
"""
|
|
||||||
cleaned = await clean_address(address)
|
|
||||||
if cleaned is not None:
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
suggestions = await suggest_addresses(address, limit=5, city="")
|
|
||||||
for s in suggestions:
|
|
||||||
fias = s.house_fias_id or s.fias_id
|
|
||||||
if s.kind == "house" and fias:
|
|
||||||
# house-level ФИАС → гео дома точное; qc_geo из ответа либо 0 (exact) по умолчанию.
|
|
||||||
qc_geo = s.qc_geo if s.qc_geo is not None else 0
|
|
||||||
logger.info(
|
|
||||||
"dadata: CLEAN недоступен → suggest-fallback %r → house_fias_id=%s "
|
|
||||||
"(canonical/cadnum недоступны без CLEAN)",
|
|
||||||
address[:60],
|
|
||||||
fias,
|
|
||||||
)
|
|
||||||
return DadataAddressResult(
|
|
||||||
canonical_address=None,
|
|
||||||
house_cadnum=None,
|
|
||||||
house_fias_id=fias,
|
|
||||||
lat=s.lat,
|
|
||||||
lon=s.lon,
|
|
||||||
qc_geo=qc_geo,
|
|
||||||
qc_house=None,
|
|
||||||
kladr_id=s.kladr_id,
|
|
||||||
okato=None,
|
|
||||||
oktmo=None,
|
|
||||||
metro=[],
|
|
||||||
raw={"_source": "suggest_fallback", "value": s.value},
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,7 @@ from app.schemas.trade_in import (
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
)
|
)
|
||||||
from app.services.dadata import DadataAddressResult
|
from app.services.dadata import DadataAddressResult
|
||||||
|
from app.services.dadata import clean_address as dadata_clean_address
|
||||||
# enrich_address = /clean с graceful fallback на /suggest (#dadata-403: на проде услуга
|
|
||||||
# CLEAN выключена → house_fias_id/гео берём из token-only /suggest). Локальный алиас
|
|
||||||
# оставлен как dadata_clean_address — это точка патча в 11 тестах (без churn).
|
|
||||||
from app.services.dadata import enrich_address as dadata_clean_address
|
|
||||||
from app.services.geocoder import (
|
from app.services.geocoder import (
|
||||||
SVERDLOVSK_OBLAST_CITIES,
|
SVERDLOVSK_OBLAST_CITIES,
|
||||||
GeocodeResult,
|
GeocodeResult,
|
||||||
|
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
-- 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;
|
|
||||||
|
|
@ -720,168 +720,3 @@ async def test_suggest_addresses_clamps_limit() -> None:
|
||||||
body = captured["body"]
|
body = captured["body"]
|
||||||
assert isinstance(body, dict)
|
assert isinstance(body, dict)
|
||||||
assert body["count"] == 20 # clamped to max
|
assert body["count"] == 20 # clamped to max
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# #dadata-403 — clean feature-disabled log + enrich_address (clean → suggest fallback)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
import logging as _logging # noqa: E402
|
|
||||||
|
|
||||||
# Real prod 403 body when услуга CLEAN не подключена (не отклонённый токен).
|
|
||||||
CLEAN_FEATURE_DISABLED_BODY = {
|
|
||||||
"timestamp": "2026-07-12T16:37:54.585+00:00",
|
|
||||||
"status": 403,
|
|
||||||
"error": "Forbidden",
|
|
||||||
"message": (
|
|
||||||
"Feature 'CLEAN' disabled for token 'xxx'. "
|
|
||||||
"See https://dadata.userecho.com/topics/7784 for help."
|
|
||||||
),
|
|
||||||
"path": "/api/v1/clean/address",
|
|
||||||
}
|
|
||||||
|
|
||||||
# House-level suggest candidate carrying house_fias_id/kladr_id/qc_geo (что нужно fallback'у).
|
|
||||||
SUGGEST_HOUSE_PAYLOAD = {
|
|
||||||
"suggestions": [
|
|
||||||
{
|
|
||||||
"value": "г Екатеринбург, ул Малышева, д 4",
|
|
||||||
"unrestricted_value": "620014, Свердловская обл, г Екатеринбург, ул Малышева, д 4",
|
|
||||||
"data": {
|
|
||||||
"geo_lat": "56.831463",
|
|
||||||
"geo_lon": "60.580943",
|
|
||||||
"fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
|
|
||||||
"house_fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
|
|
||||||
"kladr_id": "6600000100006480017",
|
|
||||||
"qc_geo": "0",
|
|
||||||
"fias_level": "8",
|
|
||||||
"house": "4",
|
|
||||||
"street": "Малышева",
|
|
||||||
"city": "Екатеринбург",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_enrich_transport(
|
|
||||||
clean_status: int, clean_body: object, suggest_body: object
|
|
||||||
) -> httpx.MockTransport:
|
|
||||||
"""Routes /clean vs /suggest by URL — enrich_address зовёт оба через один AsyncClient."""
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
url = str(request.url)
|
|
||||||
if "cleaner.dadata.ru" in url:
|
|
||||||
return httpx.Response(clean_status, json=clean_body)
|
|
||||||
if "suggestions.dadata.ru" in url:
|
|
||||||
return httpx.Response(200, json=suggest_body)
|
|
||||||
return httpx.Response(404, json={"error": "unexpected url"})
|
|
||||||
|
|
||||||
return httpx.MockTransport(handler)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
|
|
||||||
"""403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен)."""
|
|
||||||
from app.services import dadata
|
|
||||||
|
|
||||||
transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
|
|
||||||
with _patch_settings(), _patch_async_client(transport):
|
|
||||||
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
|
|
||||||
result = await dadata.clean_address("Екатеринбург, Малышева 4")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
text = caplog.text
|
|
||||||
assert "Стандартизация" in text or "выключена" in text
|
|
||||||
# Не должны обвинять токен при feature-disabled.
|
|
||||||
assert "auth/secret rejected" not in text
|
|
||||||
|
|
||||||
|
|
||||||
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
|
|
||||||
"""401 (или 403 без 'disabled') → сообщение про креды."""
|
|
||||||
from app.services import dadata
|
|
||||||
|
|
||||||
transport = _mock_transport_returning(401, {"message": "Unauthorized"})
|
|
||||||
with _patch_settings(), _patch_async_client(transport):
|
|
||||||
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
|
|
||||||
result = await dadata.clean_address("Екатеринбург, Малышева 4")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
assert "auth/secret rejected" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
async def test_suggest_addresses_captures_house_fias_kladr_qc() -> None:
|
|
||||||
"""_parse_suggestion теперь тянет house_fias_id / kladr_id / qc_geo из data."""
|
|
||||||
from app.services import dadata
|
|
||||||
|
|
||||||
transport = _mock_suggest_transport(200, SUGGEST_HOUSE_PAYLOAD)
|
|
||||||
with _patch_settings(), _patch_async_client(transport):
|
|
||||||
results = await dadata.suggest_addresses("Малышева 4")
|
|
||||||
|
|
||||||
assert len(results) == 1
|
|
||||||
assert results[0].house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
|
|
||||||
assert results[0].kladr_id == "6600000100006480017"
|
|
||||||
assert results[0].qc_geo == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_enrich_address_uses_clean_when_available() -> None:
|
|
||||||
"""CLEAN 200 → берём его результат (canonical/cadnum), suggest не подменяет."""
|
|
||||||
from app.services import dadata
|
|
||||||
|
|
||||||
transport = _mock_enrich_transport(200, SAMPLE_OK_PAYLOAD, {"suggestions": []})
|
|
||||||
with _patch_settings(), _patch_async_client(transport):
|
|
||||||
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 125")
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result.canonical_address == "г Екатеринбург, ул Малышева, д 125"
|
|
||||||
assert result.house_cadnum == "66:41:0704045:350"
|
|
||||||
assert result.raw.get("_source") != "suggest_fallback"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_enrich_address_falls_back_to_suggest_when_clean_unavailable() -> None:
|
|
||||||
"""CLEAN 403 disabled → suggest-fallback даёт house_fias_id/гео; canonical/cadnum=None."""
|
|
||||||
from app.services import dadata
|
|
||||||
|
|
||||||
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, SUGGEST_HOUSE_PAYLOAD)
|
|
||||||
with _patch_settings(), _patch_async_client(transport):
|
|
||||||
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 4")
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result.house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
|
|
||||||
assert result.kladr_id == "6600000100006480017"
|
|
||||||
assert result.qc_geo == 0
|
|
||||||
assert result.lat is not None and abs(result.lat - 56.831463) < 1e-6
|
|
||||||
# Честная деградация: без CLEAN нет canonical/cadnum — не выдумываем.
|
|
||||||
assert result.canonical_address is None
|
|
||||||
assert result.house_cadnum is None
|
|
||||||
assert result.raw.get("_source") == "suggest_fallback"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_enrich_address_ignores_non_house_suggestions() -> None:
|
|
||||||
"""Если suggest вернул только street/city (не house) → fallback не даёт house_fias_id → None."""
|
|
||||||
from app.services import dadata
|
|
||||||
|
|
||||||
street_only = {
|
|
||||||
"suggestions": [
|
|
||||||
{
|
|
||||||
"value": "г Екатеринбург, ул Малышева",
|
|
||||||
"unrestricted_value": "Свердловская обл, г Екатеринбург, ул Малышева",
|
|
||||||
"data": {"geo_lat": "56.83", "geo_lon": "60.58", "fias_id": "s", "fias_level": "7"},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, street_only)
|
|
||||||
with _patch_settings(), _patch_async_client(transport):
|
|
||||||
result = await dadata.enrich_address("Екатеринбург, ул. Малышева")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_enrich_address_returns_none_when_clean_and_suggest_both_fail() -> None:
|
|
||||||
"""CLEAN недоступен + suggest пустой → None (graceful)."""
|
|
||||||
from app.services import dadata
|
|
||||||
|
|
||||||
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, {"suggestions": []})
|
|
||||||
with _patch_settings(), _patch_async_client(transport):
|
|
||||||
result = await dadata.enrich_address("Малышева 4")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
|
||||||
|
|
@ -28,15 +28,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
from scraper_kit.orchestration import scheduler as kit_sched
|
from scraper_kit.orchestration import scheduler as kit_sched
|
||||||
from scraper_kit.orchestration.pipeline import CITY_ANCHORS
|
|
||||||
from scraper_kit.orchestration.scheduler import (
|
from scraper_kit.orchestration.scheduler import (
|
||||||
Handler,
|
Handler,
|
||||||
SchedulerContext,
|
SchedulerContext,
|
||||||
_claim_run,
|
_claim_run,
|
||||||
_dispatch,
|
_dispatch,
|
||||||
_job_avito_city_sweep,
|
|
||||||
_job_cian_city_sweep,
|
|
||||||
_job_yandex_city_sweep,
|
|
||||||
build_registry,
|
build_registry,
|
||||||
reap_zombies,
|
reap_zombies,
|
||||||
reschedule_after_minutes,
|
reschedule_after_minutes,
|
||||||
|
|
@ -78,20 +74,14 @@ _PRODUCT_SOURCES: set[str] = {
|
||||||
"proxy_healthcheck",
|
"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 = {
|
_KIT_NATIVE_SOURCES = {
|
||||||
"avito_city_sweep",
|
"avito_city_sweep",
|
||||||
"avito_city_sweep_*",
|
|
||||||
"avito_full_load",
|
"avito_full_load",
|
||||||
"avito_full_load_exhaustive",
|
"avito_full_load_exhaustive",
|
||||||
"avito_newbuilding_sweep",
|
"avito_newbuilding_sweep",
|
||||||
"yandex_city_sweep",
|
"yandex_city_sweep",
|
||||||
"yandex_city_sweep_*",
|
|
||||||
"cian_city_sweep",
|
"cian_city_sweep",
|
||||||
"cian_city_sweep_*",
|
|
||||||
"cian_full_load",
|
"cian_full_load",
|
||||||
"domclick_city_sweep",
|
"domclick_city_sweep",
|
||||||
}
|
}
|
||||||
|
|
@ -166,10 +156,7 @@ def test_real_build_product_handlers_covers_all_scheduled_sources() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_kit_native_handler_set() -> None:
|
def test_kit_native_handler_set() -> None:
|
||||||
"""Ровно 11 kit-native sweep-обработчиков зарегистрированы встроенно (8 + 3 oblast
|
"""Ровно 8 kit-native sweep-обработчиков зарегистрированы встроенно."""
|
||||||
|
|
||||||
"*_city_sweep_*" wildcard, #179).
|
|
||||||
"""
|
|
||||||
registry = build_registry()
|
registry = build_registry()
|
||||||
assert set(registry) == _KIT_NATIVE_SOURCES
|
assert set(registry) == _KIT_NATIVE_SOURCES
|
||||||
for src in _KIT_NATIVE_SOURCES:
|
for src in _KIT_NATIVE_SOURCES:
|
||||||
|
|
@ -447,87 +434,3 @@ async def test_dispatch_post_claim_hook_runs() -> None:
|
||||||
await asyncio.gather(*list(ctx._inflight_tasks))
|
await asyncio.gather(*list(ctx._inflight_tasks))
|
||||||
post.assert_called_once()
|
post.assert_called_once()
|
||||||
assert post.call_args[0][1] == 300 # run_id
|
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_*"]
|
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,6 @@ from __future__ import annotations
|
||||||
EKB_BBOX_TIGHT = (56.65, 56.95, 60.40, 60.85)
|
EKB_BBOX_TIGHT = (56.65, 56.95, 60.40, 60.85)
|
||||||
EKB_BBOX_WIDE = (56.6, 57.1, 60.3, 60.9)
|
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(
|
def is_within_ekb_bbox(
|
||||||
lat: float, lon: float, bbox: tuple[float, float, float, float] = EKB_BBOX_TIGHT
|
lat: float, lon: float, bbox: tuple[float, float, float, float] = EKB_BBOX_TIGHT
|
||||||
|
|
@ -34,18 +28,6 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
|
||||||
|
|
||||||
Используется для валидации координат из avito detail-страниц перед записью в БД.
|
Используется для валидации координат из avito detail-страниц перед записью в БД.
|
||||||
Шире geocoder-tight, поэтому не режет легитимное приграничье, но отсекает не-ЕКБ
|
Шире geocoder-tight, поэтому не режет легитимное приграничье, но отсекает не-ЕКБ
|
||||||
(Питер/Тюмень/Уфа). EKB-only fast-path — для oblast-wide sweep см.
|
(Питер/Тюмень/Уфа).
|
||||||
`is_within_oblast66_bbox`.
|
|
||||||
"""
|
"""
|
||||||
return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE)
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -283,33 +283,6 @@ EKB_ANCHORS: list[tuple[float, float, str]] = [
|
||||||
(56.8650, 60.6200, "Пионерский"),
|
(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 = {
|
_CHROME_HEADERS = {
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
"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",
|
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,6 @@ from sqlalchemy import text
|
||||||
|
|
||||||
from scraper_kit.orchestration import runs as _kit_runs
|
from scraper_kit.orchestration import runs as _kit_runs
|
||||||
from scraper_kit.orchestration.pipeline import (
|
from scraper_kit.orchestration.pipeline import (
|
||||||
get_city_anchors,
|
|
||||||
run_avito_city_sweep,
|
run_avito_city_sweep,
|
||||||
run_avito_full_load,
|
run_avito_full_load,
|
||||||
run_avito_newbuilding_sweep,
|
run_avito_newbuilding_sweep,
|
||||||
|
|
@ -441,11 +440,6 @@ def resolve_handler(source: str, registry: Mapping[str, Handler]) -> Handler | N
|
||||||
async def _job_avito_city_sweep(
|
async def _job_avito_city_sweep(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
) -> None:
|
) -> 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(
|
await run_avito_city_sweep(
|
||||||
db,
|
db,
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
|
|
@ -454,7 +448,6 @@ async def _job_avito_city_sweep(
|
||||||
enrichment=ctx.enrichment,
|
enrichment=ctx.enrichment,
|
||||||
shutdown_requested=ctx.shutdown_requested,
|
shutdown_requested=ctx.shutdown_requested,
|
||||||
proxy_provider=ctx.proxy_provider,
|
proxy_provider=ctx.proxy_provider,
|
||||||
anchors=anchors,
|
|
||||||
pages_per_anchor=int(params.get("pages_per_anchor", 3)),
|
pages_per_anchor=int(params.get("pages_per_anchor", 3)),
|
||||||
detail_top_n=int(params.get("detail_top_n", 20)),
|
detail_top_n=int(params.get("detail_top_n", 20)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
||||||
|
|
@ -520,12 +513,6 @@ async def _job_avito_full_load_exhaustive(
|
||||||
async def _job_yandex_city_sweep(
|
async def _job_yandex_city_sweep(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
) -> None:
|
) -> 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(
|
await run_yandex_city_sweep(
|
||||||
db,
|
db,
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
|
|
@ -534,7 +521,6 @@ async def _job_yandex_city_sweep(
|
||||||
enrichment=ctx.enrichment,
|
enrichment=ctx.enrichment,
|
||||||
shutdown_requested=ctx.shutdown_requested,
|
shutdown_requested=ctx.shutdown_requested,
|
||||||
proxy_provider=ctx.proxy_provider,
|
proxy_provider=ctx.proxy_provider,
|
||||||
anchors=anchors,
|
|
||||||
pages_per_anchor=int(params.get("pages_per_anchor", 2)),
|
pages_per_anchor=int(params.get("pages_per_anchor", 2)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 9.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 9.0)),
|
||||||
radius_m=int(params.get("radius_m", 1500)),
|
radius_m=int(params.get("radius_m", 1500)),
|
||||||
|
|
@ -546,9 +532,6 @@ async def _job_yandex_city_sweep(
|
||||||
async def _job_cian_city_sweep(
|
async def _job_cian_city_sweep(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
) -> None:
|
) -> 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(
|
await run_cian_city_sweep(
|
||||||
db,
|
db,
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
|
|
@ -556,7 +539,6 @@ async def _job_cian_city_sweep(
|
||||||
matcher=ctx.matcher,
|
matcher=ctx.matcher,
|
||||||
shutdown_requested=ctx.shutdown_requested,
|
shutdown_requested=ctx.shutdown_requested,
|
||||||
proxy_provider=ctx.proxy_provider,
|
proxy_provider=ctx.proxy_provider,
|
||||||
anchors=anchors,
|
|
||||||
pages_per_anchor=int(params.get("pages_per_anchor", 3)),
|
pages_per_anchor=int(params.get("pages_per_anchor", 3)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 5.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 5.0)),
|
||||||
radius_m=int(params.get("radius_m", 1500)),
|
radius_m=int(params.get("radius_m", 1500)),
|
||||||
|
|
@ -602,29 +584,16 @@ async def _job_domclick_city_sweep(
|
||||||
|
|
||||||
|
|
||||||
def _default_kit_handlers() -> dict[str, Handler]:
|
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 {
|
return {
|
||||||
"avito_city_sweep": Handler(_job_avito_city_sweep, "avito_city_sweep"),
|
"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_newbuilding_sweep": Handler(_job_avito_newbuilding_sweep, "avito_newbuilding_sweep"),
|
||||||
"avito_full_load": Handler(_job_avito_full_load, "avito_full_load"),
|
"avito_full_load": Handler(_job_avito_full_load, "avito_full_load"),
|
||||||
"avito_full_load_exhaustive": Handler(
|
"avito_full_load_exhaustive": Handler(
|
||||||
_job_avito_full_load_exhaustive, "avito_full_load_exhaustive"
|
_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"),
|
||||||
"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_city_sweep_*": Handler(_job_cian_city_sweep, "cian_city_sweep_*"),
|
|
||||||
"cian_full_load": Handler(_job_cian_full_load, "cian_full_load"),
|
"cian_full_load": Handler(_job_cian_full_load, "cian_full_load"),
|
||||||
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
|
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,18 +24,6 @@ _EKB_SECONDARY: list[tuple[int, int | None]] = [
|
||||||
|
|
||||||
CITY_PRICE_SEED_BRACKETS: dict[str, list[tuple[int, int | None]]] = {
|
CITY_PRICE_SEED_BRACKETS: dict[str, list[tuple[int, int | None]]] = {
|
||||||
"ekaterinburg": _EKB_SECONDARY,
|
"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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
Развязка от `app.*`:
|
Развязка от `app.*`:
|
||||||
- `app.core.config.settings` → инжектируемый `ScraperConfig` (scraper_kit.contracts)
|
- `app.core.config.settings` → инжектируемый `ScraperConfig` (scraper_kit.contracts)
|
||||||
- `app.services.scraper_settings.get_scraper_delay` → инжектируемый `delay_provider`
|
- `app.services.scraper_settings.get_scraper_delay` → инжектируемый `delay_provider`
|
||||||
- `app.services.geocoder.is_within_oblast66_bbox` → `scraper_kit.geo` (было is_within_ekb_bbox_wide
|
- `app.services.geocoder.is_within_ekb_bbox_wide` → `scraper_kit.geo`
|
||||||
до B1 oblast rollout — детекция координат теперь oblast-wide, не только ЕКБ)
|
|
||||||
- `app.services.matching.houses.match_or_create_house` → инжектируемый `HouseMatcher`
|
- `app.services.matching.houses.match_or_create_house` → инжектируемый `HouseMatcher`
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ from scraper_kit.avito_exceptions import (
|
||||||
AvitoListingGoneError,
|
AvitoListingGoneError,
|
||||||
AvitoRateLimitedError,
|
AvitoRateLimitedError,
|
||||||
)
|
)
|
||||||
from scraper_kit.geo import is_within_oblast66_bbox
|
from scraper_kit.geo import is_within_ekb_bbox_wide
|
||||||
from scraper_kit.providers._base import build_document_session
|
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.serp import _clean_address, _is_firewall_page
|
||||||
from scraper_kit.providers.avito.shared import RUS_MONTHS
|
from scraper_kit.providers.avito.shared import RUS_MONTHS
|
||||||
|
|
@ -562,17 +562,14 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
|
||||||
lon = _try_float(map_el.attributes.get("data-map-lon"))
|
lon = _try_float(map_el.attributes.get("data-map-lon"))
|
||||||
avito_location_id = _try_int(map_el.attributes.get("data-location-id"))
|
avito_location_id = _try_int(map_el.attributes.get("data-location-id"))
|
||||||
|
|
||||||
# bbox-guard (#1871, расширен до oblast66 — B1 rollout): detail-страница иногда
|
# bbox-guard (#1871): detail-страница иногда отдаёт координаты вне ЕКБ
|
||||||
# отдаёт координаты вне Свердловской области (Питер/Тюмень/Уфа — кросс-региональные
|
# (Питер/Тюмень/Уфа — кросс-региональные дубли объявлений). Эти координаты
|
||||||
# дубли объявлений). Эти координаты попадают в UPDATE COALESCE без bbox-валидации
|
# попадают в UPDATE COALESCE без bbox-валидации (в отличие от geocoder.py,
|
||||||
# (в отличие от geocoder.py, где bbox-фильтр везде). Сбрасываем в None → листинг
|
# где bbox-фильтр везде). Сбрасываем в None → листинг уходит в geocode-cron
|
||||||
# уходит в geocode-cron путь, который сам отфильтрует по адресу. SERP-ingest
|
# путь, который сам отфильтрует по адресу. SERP-ingest координат не даёт вовсе.
|
||||||
# координат не даёт вовсе. Guard расширен с EKB-only (is_within_ekb_bbox_wide) до
|
if lat is not None and lon is not None and not is_within_ekb_bbox_wide(lat, lon):
|
||||||
# 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(
|
logger.warning(
|
||||||
"avito_detail #1871: dropped non-oblast66 coords item=%s lat=%s lon=%s",
|
"avito_detail #1871: dropped non-EKB coords item=%s lat=%s lon=%s",
|
||||||
item_id,
|
item_id,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue