fix(tradein/tasks): городской гейт в ночном бэкфилле координат (#2583) #2588
3 changed files with 122 additions and 7 deletions
|
|
@ -10,6 +10,20 @@
|
|||
Парсинг адреса — _parse_street_house из app.services.geocoder (готовый парсер),
|
||||
работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28».
|
||||
|
||||
Городской гейт (#2583, находка H3): в `listings` НЕТ отдельной колонки города — город
|
||||
известен только из текста адреса. `ekb_geoportal_buildings` — EKB-only реестр: улица+дом
|
||||
могут буквально совпасть между Екатеринбургом и другим городом области (например,
|
||||
«проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки города такой листинг
|
||||
получает екатеринбургские координаты, хотя находится в другом городе. Перед вызовом
|
||||
_geoportal_house_match каждый адрес проверяется через _names_non_ekb_city (та же функция,
|
||||
что гейтит EKB-only тиры внутри geocoder.geocode()) — адрес, явно называющий другой город
|
||||
региона, пропускается (counted как skipped_non_ekb) и остаётся lat IS NULL для
|
||||
geocode_missing_listings (oblast-aware Nominatim/Yandex, окно 06:00-09:00 UTC).
|
||||
Прямой вызов _geoportal_house_match (а не полноценный geocode()) оставлен намеренно —
|
||||
это pure local-DB матч без единого внешнего HTTP-запроса; полноценный geocode() на каждый
|
||||
non-EKB адрес добавил бы Nominatim/Yandex вызов на весь backlog (сотни-тысячи строк за
|
||||
ночь) — лишняя нагрузка на и так ограниченный Nominatim (Yandex сейчас 403, #2585).
|
||||
|
||||
Запуск:
|
||||
python -m app.tasks.backfill_listings_coords_geoportal
|
||||
python -m app.tasks.backfill_listings_coords_geoportal --limit 5000 --batch-size 200
|
||||
|
|
@ -19,7 +33,17 @@ migration 171) — run_geoportal_coords_backfill(). Local exact match, ника
|
|||
HTTP/rate-limit, поэтому окно ставится ПЕРЕД geocode_missing_listings (Nominatim/Yandex,
|
||||
coarse city-centroid fallback): точный house-level матч должен получить шанс первым,
|
||||
иначе Nominatim успевает проставить грубые coords и адрес выпадает из WHERE lat IS NULL
|
||||
(#1967 — было единичным manual-прогоном #1841, здесь становится recurring).
|
||||
(#1967 — было единичным manual-прогоном #1841, здесь становится recurring). С городским
|
||||
гейтом (#2583) порядок окон остаётся корректным: не-ЕКБ адреса больше не матчатся здесь
|
||||
вообще, поэтому «победа в гонке» больше не портит их координаты — они просто ждут
|
||||
geocode_missing_listings в следующем окне, как и раньше для адресов без geoportal-матча.
|
||||
|
||||
geo_precision: этот тир всегда даёт house-level точный матч (не city-centroid), поэтому
|
||||
_update_listing_coords НЕ проставляет geo_precision — он остаётся NULL, что в текущей
|
||||
конвенции (089_listings_geo_precision.sql, geocode_missing.py) означает «не coarse»
|
||||
(тот же смысл, что и geo_precision=None для precise-адресов в geocode_missing_listings).
|
||||
Downstream-фильтры (`geo_precision IS DISTINCT FROM 'city'`) корректно НЕ исключают такие
|
||||
строки — исключать нужно только 'city'-fallback, а не «пока не размечено».
|
||||
|
||||
Идемпотентность: UPDATE применяется только к строкам с lat IS NULL (WHERE id=:id AND lat IS NULL).
|
||||
Повторный прогон не затирает уже проставленные координаты.
|
||||
|
|
@ -37,7 +61,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.geocoder import _geoportal_house_match, _parse_street_house
|
||||
from app.services.geocoder import _geoportal_house_match, _names_non_ekb_city, _parse_street_house
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -55,6 +79,7 @@ class BackfillCoordsResult:
|
|||
updated: int = 0 # реально обновлено (UPDATE rowcount)
|
||||
no_address: int = 0 # listing.address IS NULL / не распарсился
|
||||
no_match: int = 0 # адрес распарсился, но в реестре здания нет
|
||||
skipped_non_ekb: int = 0 # адрес явно называет другой город области (#2583 гейт)
|
||||
errors: int = 0 # исключения при обработке отдельной записи
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
|
|
@ -65,6 +90,7 @@ class BackfillCoordsResult:
|
|||
"updated": self.updated,
|
||||
"no_address": self.no_address,
|
||||
"no_match": self.no_match,
|
||||
"skipped_non_ekb": self.skipped_non_ekb,
|
||||
"errors": self.errors,
|
||||
"duration_sec": int(self.duration_sec),
|
||||
}
|
||||
|
|
@ -184,6 +210,14 @@ def backfill_coords_from_geoportal(
|
|||
res.no_address += 1
|
||||
continue
|
||||
|
||||
# Городской гейт (#2583, H3) — ekb_geoportal_buildings EKB-only,
|
||||
# улица+дом могут совпасть с другим городом области. Адрес, явно
|
||||
# называющий другой город региона, пропускаем — остаётся
|
||||
# lat IS NULL для oblast-aware geocode_missing_listings.
|
||||
if _names_non_ekb_city(address):
|
||||
res.skipped_non_ekb += 1
|
||||
continue
|
||||
|
||||
# Парсинг адреса — переиспользуем парсер geocoder'а
|
||||
parsed = _parse_street_house(address)
|
||||
if parsed is None:
|
||||
|
|
@ -259,12 +293,13 @@ def backfill_coords_from_geoportal(
|
|||
|
||||
logger.info(
|
||||
"backfill_coords: DONE — candidates=%d matched=%d updated=%d "
|
||||
"no_address=%d no_match=%d errors=%d duration=%.1fs",
|
||||
"no_address=%d no_match=%d skipped_non_ekb=%d errors=%d duration=%.1fs",
|
||||
res.candidates,
|
||||
res.matched,
|
||||
res.updated,
|
||||
res.no_address,
|
||||
res.no_match,
|
||||
res.skipped_non_ekb,
|
||||
res.errors,
|
||||
res.duration_sec,
|
||||
)
|
||||
|
|
@ -314,13 +349,14 @@ def run_geoportal_coords_backfill(
|
|||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"run_geoportal_coords_backfill: run_id=%d DONE candidates=%d matched=%d "
|
||||
"updated=%d no_address=%d no_match=%d errors=%d duration=%.1fs",
|
||||
"updated=%d no_address=%d no_match=%d skipped_non_ekb=%d errors=%d duration=%.1fs",
|
||||
run_id,
|
||||
res.candidates,
|
||||
res.matched,
|
||||
res.updated,
|
||||
res.no_address,
|
||||
res.no_match,
|
||||
res.skipped_non_ekb,
|
||||
res.errors,
|
||||
res.duration_sec,
|
||||
)
|
||||
|
|
@ -376,12 +412,13 @@ def main() -> None:
|
|||
|
||||
logger.info(
|
||||
"Готово: кандидатов=%d сматчено=%d обновлено=%d "
|
||||
"без_адреса=%d без_матча=%d ошибок=%d время=%.1fs",
|
||||
"без_адреса=%d без_матча=%d не_ЕКБ=%d ошибок=%d время=%.1fs",
|
||||
result.candidates,
|
||||
result.matched,
|
||||
result.updated,
|
||||
result.no_address,
|
||||
result.no_match,
|
||||
result.skipped_non_ekb,
|
||||
result.errors,
|
||||
result.duration_sec,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,15 @@
|
|||
-- precision БЕЗ единого внешнего HTTP-запроса (в отличие от Nominatim) — но был ТОЛЬКО
|
||||
-- manual script (`python -m app.tasks.backfill_listings_coords_geoportal`), ни разу не
|
||||
-- запускавшийся на recurring основе. Один прошлый ручной прогон (#1841): 17241
|
||||
-- кандидатов → 1008 проставлено (не-ЕКБ адреса не матчатся — корректно, EKB-only реестр).
|
||||
-- кандидатов → 1008 проставлено.
|
||||
--
|
||||
-- ИСПРАВЛЕНО #2583 (находка H3): до фикса не-ЕКБ адреса region 66 (Нижний Тагил, Серов
|
||||
-- и т.д.) НЕ отсекались — street+house парсились без учёта города и слепо матчились
|
||||
-- против EKB-only реестра. Улица+дом могут буквально совпасть с ЕКБ ("проспект Ленина 1"
|
||||
-- есть и в ЕКБ, и в Нижнем Тагиле) — такой листинг получал координаты Екатеринбурга.
|
||||
-- Фикс: городской гейт _names_non_ekb_city перед вызовом _geoportal_house_match (тот же
|
||||
-- гейт, что и в geocoder.geocode()). Не "корректно, EKB-only реестр", как было написано
|
||||
-- здесь раньше — это была реальная утечка не-ЕКБ адресов в ЕКБ-координаты.
|
||||
--
|
||||
-- Решение: wire в in-app scheduler (source='geoportal_coords_backfill') по паттерну
|
||||
-- cadastral_geo_match (migration 125) — pure internal DB op, SAFE to enable=true.
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
|||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
|
||||
from app.services.geocoder import GeocodeSuggestion # noqa: E402
|
||||
from app.services.geocoder import GeocodeSuggestion, _names_non_ekb_city # noqa: E402
|
||||
from app.tasks import backfill_listings_coords_geoportal as bcg # noqa: E402
|
||||
from app.tasks.backfill_listings_coords_geoportal import ( # noqa: E402
|
||||
BackfillCoordsResult,
|
||||
|
|
@ -203,6 +203,76 @@ def test_unparseable_address_counted_as_no_address() -> None:
|
|||
mock_geo.assert_not_called()
|
||||
|
||||
|
||||
# ── городской гейт (#2583, H3) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_non_ekb_address_skipped_not_matched_against_ekb_registry() -> None:
|
||||
"""Адрес другого города области (Нижний Тагил) НЕ должен матчиться против
|
||||
EKB-only ekb_geoportal_buildings, даже если street+house буквально совпадают
|
||||
с ЕКБ-домом («проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле, #2583 H3).
|
||||
|
||||
_names_non_ekb_city — РЕАЛЬНАЯ функция (не мок), чтобы проверить настоящий гейт,
|
||||
а не заглушку. _geoportal_house_match мокнут так, будто он ложно нашёл ЕКБ-дом —
|
||||
тест доказывает, что до этого вызова дело не доходит вообще.
|
||||
"""
|
||||
rows = [{"id": 100, "address": "г. Нижний Тагил, проспект Ленина, 1"}]
|
||||
db = _make_db([rows, []])
|
||||
|
||||
# Sanity: реальная функция действительно считает этот адрес не-ЕКБ.
|
||||
assert _names_non_ekb_city("г. Нижний Тагил, проспект Ленина, 1") is True
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
|
||||
return_value=_HIT, # ложное совпадение по street+house, если бы гейт не сработал
|
||||
) as mock_geo,
|
||||
patch("app.tasks.backfill_listings_coords_geoportal._parse_street_house") as mock_parse,
|
||||
):
|
||||
res = backfill_coords_from_geoportal(db, batch_size=500)
|
||||
|
||||
assert res.candidates == 1
|
||||
assert res.skipped_non_ekb == 1
|
||||
assert res.matched == 0
|
||||
assert res.updated == 0
|
||||
assert res.no_match == 0
|
||||
assert res.no_address == 0
|
||||
# Ни парсер, ни geoportal-матчер не должны были вызываться — гейт стоит раньше.
|
||||
mock_parse.assert_not_called()
|
||||
mock_geo.assert_not_called()
|
||||
update_calls = [c for c in db.execute.call_args_list if "UPDATE" in str(c.args[0])]
|
||||
assert len(update_calls) == 0
|
||||
|
||||
|
||||
def test_ekb_address_still_matched_with_real_gate() -> None:
|
||||
"""Явно ЕКБ-адрес по-прежнему проходит гейт и матчится как раньше (не деградация).
|
||||
|
||||
_names_non_ekb_city — реальная функция: "Екатеринбург" в адресе имеет приоритет
|
||||
над любым омонимом другого города области (см. docstring _names_non_ekb_city).
|
||||
"""
|
||||
rows = [{"id": 101, "address": "г. Екатеринбург, проспект Ленина, 1"}]
|
||||
db = _make_db([rows, []])
|
||||
|
||||
assert _names_non_ekb_city("г. Екатеринбург, проспект Ленина, 1") is False
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
|
||||
return_value=_HIT,
|
||||
) as mock_geo,
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._parse_street_house",
|
||||
return_value=("проспект ленина", "1"),
|
||||
),
|
||||
):
|
||||
res = backfill_coords_from_geoportal(db, batch_size=500)
|
||||
|
||||
assert res.candidates == 1
|
||||
assert res.skipped_non_ekb == 0
|
||||
assert res.matched == 1
|
||||
assert res.updated == 1
|
||||
mock_geo.assert_called_once_with(db, "проспект ленина", "1")
|
||||
|
||||
|
||||
# ── idempotency ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue