Merge pull request 'feat(tradein/geocoder): регион-параметризация геокодера — region_code в geocode()/known_city_hint, --region-code у скрипта сделок, region_code у geocode-missing (#3051)' (#3432) from feat/3051-geocoder-region-param into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 2m11s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 2m11s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
This commit is contained in:
commit
6de69c4d05
6 changed files with 256 additions and 93 deletions
|
|
@ -268,6 +268,7 @@ async def geocode_missing(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
target: Literal["listings", "deals"] = "listings",
|
target: Literal["listings", "deals"] = "listings",
|
||||||
|
region_code: int = 66,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
|
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
|
||||||
|
|
||||||
|
|
@ -279,6 +280,10 @@ async def geocode_missing(
|
||||||
geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed-
|
geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed-
|
||||||
адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается.
|
адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается.
|
||||||
geom обновляется автоматически триггером.
|
geom обновляется автоматически триггером.
|
||||||
|
|
||||||
|
region_code (дефолт 66, #3051) — фильтрует обе таблицы (`listings`/`deals`
|
||||||
|
несут колонку) и прокидывается в `known_city_hint`/`geocode`. Дефолт 66 —
|
||||||
|
прежнее поведение без изменений (cron не меняется, follow-up ниже).
|
||||||
"""
|
"""
|
||||||
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
|
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
|
||||||
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
|
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
|
||||||
|
|
@ -291,13 +296,14 @@ async def geocode_missing(
|
||||||
WHERE lat IS NULL
|
WHERE lat IS NULL
|
||||||
AND COALESCE(address, '') != ''
|
AND COALESCE(address, '') != ''
|
||||||
{extra_filter}
|
{extra_filter}
|
||||||
|
AND region_code = CAST(:region_code AS int)
|
||||||
AND (geocode_tried_at IS NULL
|
AND (geocode_tried_at IS NULL
|
||||||
OR geocode_tried_at < NOW() - interval '7 days')
|
OR geocode_tried_at < NOW() - interval '7 days')
|
||||||
ORDER BY geocode_tried_at NULLS FIRST
|
ORDER BY geocode_tried_at NULLS FIRST
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"limit": limit},
|
{"limit": limit, "region_code": region_code},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
|
|
@ -332,8 +338,8 @@ async def geocode_missing(
|
||||||
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
|
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
|
||||||
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
|
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
|
||||||
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
|
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
|
||||||
city = known_city_hint(row.get("city"))
|
city = known_city_hint(row.get("city"), region_code)
|
||||||
result = await geocode(clean, db, city_hint=city)
|
result = await geocode(clean, db, city_hint=city, region_code=region_code)
|
||||||
if result is None:
|
if result is None:
|
||||||
# Помечаем что пробовали — иначе ретрай на каждом cron.
|
# Помечаем что пробовали — иначе ретрай на каждом cron.
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -362,10 +368,12 @@ async def geocode_missing(
|
||||||
WHERE lat IS NULL
|
WHERE lat IS NULL
|
||||||
AND COALESCE(address, '') != ''
|
AND COALESCE(address, '') != ''
|
||||||
{extra_filter}
|
{extra_filter}
|
||||||
|
AND region_code = CAST(:region_code AS int)
|
||||||
AND (geocode_tried_at IS NULL
|
AND (geocode_tried_at IS NULL
|
||||||
OR geocode_tried_at < NOW() - interval '7 days')
|
OR geocode_tried_at < NOW() - interval '7 days')
|
||||||
"""
|
"""
|
||||||
)
|
),
|
||||||
|
{"region_code": region_code},
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,16 @@ from tenacity import retry, stop_after_attempt, wait_exponential
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services import dadata
|
from app.services import dadata
|
||||||
from app.services.regions import REGIONS as _ALL_REGIONS
|
from app.services.regions import REGIONS as _ALL_REGIONS
|
||||||
|
from app.services.regions import Region, is_within_bbox
|
||||||
|
|
||||||
_REGION_66 = _ALL_REGIONS[66]
|
_REGION_66 = _ALL_REGIONS[66]
|
||||||
|
|
||||||
|
# #3051: маркер `address.state` Nominatim по региону, для region cross-check
|
||||||
|
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
|
||||||
|
# без записи здесь получают `marker=None` → cross-check пропускается
|
||||||
|
# (fallback на bbox-only, прежнее поведение).
|
||||||
|
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"}
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Общий ограничитель темпа обращений к Nominatim (#2953) ──────────────────
|
# ── Общий ограничитель темпа обращений к Nominatim (#2953) ──────────────────
|
||||||
|
|
@ -170,8 +177,15 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
|
||||||
SVERDLOVSK_OBLAST_CITIES = _REGION_66.cities # #3051: список — в реестре регионов
|
SVERDLOVSK_OBLAST_CITIES = _REGION_66.cities # #3051: список — в реестре регионов
|
||||||
|
|
||||||
|
|
||||||
def known_city_hint(value: str | None) -> str | None:
|
def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
|
||||||
"""`value` как city_hint, если это узнаваемое имя города региона 66, иначе None.
|
"""`value` как city_hint, если это узнаваемое имя города `REGIONS[region_code]`, иначе None.
|
||||||
|
|
||||||
|
#3051: `region_code` (дефолт 66) — параметризация под трек «Москва»: словарь
|
||||||
|
городов берётся из `REGIONS[region_code].cities`, а не жёстко из
|
||||||
|
`SVERDLOVSK_OBLAST_CITIES`. Для `region_code=66` (дефолт, все существующие
|
||||||
|
вызовы без аргумента) — byte-identical прежнему поведению: `REGIONS[66].cities
|
||||||
|
is SVERDLOVSK_OBLAST_CITIES` (тот же frozenset-объект, см. модульный уровень).
|
||||||
|
Неизвестный `region_code` → ValueError (явная ошибка, не молчаливый None).
|
||||||
|
|
||||||
Для callers, которые берут город из КОЛОНКИ БД и передают его в `geocode()`
|
Для callers, которые берут город из КОЛОНКИ БД и передают его в `geocode()`
|
||||||
(#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте
|
(#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте
|
||||||
|
|
@ -200,7 +214,14 @@ def known_city_hint(value: str | None) -> str | None:
|
||||||
"""
|
"""
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
return value if " ".join(value.lower().split()) in SVERDLOVSK_OBLAST_CITIES else None
|
if region_code == 66:
|
||||||
|
cities = SVERDLOVSK_OBLAST_CITIES
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
cities = _ALL_REGIONS[region_code].cities
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"unknown region_code={region_code!r}") from exc
|
||||||
|
return value if " ".join(value.lower().split()) in cities else None
|
||||||
|
|
||||||
|
|
||||||
# Значение для DaData-констрейнта `locations: [{"region": ...}]`.
|
# Значение для DaData-констрейнта `locations: [{"region": ...}]`.
|
||||||
|
|
@ -231,20 +252,41 @@ _OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
|
||||||
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р-н", "р-он", "район", "жк"})
|
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р-н", "р-он", "район", "жк"})
|
||||||
|
|
||||||
|
|
||||||
def _has_oblast_marker(text_lower: str) -> bool:
|
# region_code → скомпилированный regex городов региона (word-boundary), кэш по
|
||||||
"""True если текст уже содержит упоминание области/города региона 66.
|
# коду. 66 — literal reuse `_OBLAST_CITY_RE` (тот же объект, byte-identical),
|
||||||
|
# остальные регионы строятся из `REGIONS[region_code].cities` при первом
|
||||||
|
# обращении (#3051).
|
||||||
|
_REGION_CITY_RE: dict[int, re.Pattern[str]] = {66: _OBLAST_CITY_RE}
|
||||||
|
|
||||||
Используется чтобы НЕ навязывать "Екатеринбург, " в запрос, когда адрес
|
|
||||||
уже привязан к другому городу/области — иначе получим двойной город
|
|
||||||
("Екатеринбург, Нижний Тагил, Ленина 10") и провайдер вернёт мусор/пусто.
|
|
||||||
|
|
||||||
Матчинг — по границе слова/фразы (см. `_OBLAST_CITY_RE`), НЕ substring —
|
def _region_city_re(region_code: int) -> re.Pattern[str]:
|
||||||
и с исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
|
cached = _REGION_CITY_RE.get(region_code)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
cities = _ALL_REGIONS[region_code].cities
|
||||||
|
compiled = re.compile(r"\b(?:" + "|".join(re.escape(c) for c in cities) + r")\b")
|
||||||
|
_REGION_CITY_RE[region_code] = compiled
|
||||||
|
return compiled
|
||||||
|
|
||||||
|
|
||||||
|
def _has_oblast_marker(text_lower: str, region_code: int = 66) -> bool:
|
||||||
|
"""True если текст уже содержит упоминание области/города `region_code`.
|
||||||
|
|
||||||
|
Используется чтобы НЕ навязывать "Екатеринбург, "/"Москва, " в запрос,
|
||||||
|
когда адрес уже привязан к другому городу/области — иначе получим двойной
|
||||||
|
город ("Екатеринбург, Нижний Тагил, Ленина 10" / "Москва, Москва, Тверская
|
||||||
|
1", #3051 п. б) и провайдер вернёт мусор/пусто.
|
||||||
|
|
||||||
|
Матчинг — по границе слова/фразы (`_region_city_re`), НЕ substring — и с
|
||||||
|
исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
|
||||||
|
`region_code=66` (дефолт) дополнительно матчит "свердловск*" — областной
|
||||||
|
маркер без города; у прочих регионов такого обобщённого маркера нет,
|
||||||
|
город региона уже покрывает случай (для 77 — "москва" в `region.cities`).
|
||||||
"""
|
"""
|
||||||
normalized = " ".join(text_lower.split())
|
normalized = " ".join(text_lower.split())
|
||||||
if _OBLAST_MARKER_RE.search(normalized):
|
if region_code == 66 and _OBLAST_MARKER_RE.search(normalized):
|
||||||
return True
|
return True
|
||||||
for m in _OBLAST_CITY_RE.finditer(normalized):
|
for m in _region_city_re(region_code).finditer(normalized):
|
||||||
prefix_words = normalized[: m.start()].split()
|
prefix_words = normalized[: m.start()].split()
|
||||||
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
|
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
|
||||||
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
|
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
|
||||||
|
|
@ -252,12 +294,14 @@ def _has_oblast_marker(text_lower: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]:
|
def _resolve_city_for_geocode(
|
||||||
|
address: str, city_hint: str | None, region_code: int = 66
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
"""Определяет, какой город подставлять в запрос внешнему провайдеру
|
"""Определяет, какой город подставлять в запрос внешнему провайдеру
|
||||||
(Nominatim), когда сам текст адреса города не называет.
|
(Nominatim), когда сам текст адреса города не называет.
|
||||||
|
|
||||||
Приоритет:
|
Приоритет:
|
||||||
1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`)
|
1. Адрес уже содержит маркер города/области `region_code` (`_has_oblast_marker`)
|
||||||
→ город уже указан пользователем в тексте адреса, ничего подставлять не
|
→ город уже указан пользователем в тексте адреса, ничего подставлять не
|
||||||
нужно. Возвращает (None, True).
|
нужно. Возвращает (None, True).
|
||||||
2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный
|
2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный
|
||||||
|
|
@ -266,15 +310,18 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str
|
||||||
— для жителей других городов области это давало уверенно неверную цену
|
— для жителей других городов области это давало уверенно неверную цену
|
||||||
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
|
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
|
||||||
обе улицы называются одинаково). Теперь НЕ подставляем никакой город —
|
обе улицы называются одинаково). Теперь НЕ подставляем никакой город —
|
||||||
провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без
|
провайдер ищет по region-viewbox/bbox (см. `OBLAST66_VIEWBOX`,
|
||||||
привязки к конкретному городу. Возвращает
|
`_region_viewbox`), без привязки к конкретному городу. Возвращает
|
||||||
(None, False) — второй элемент False сигнализирует, что город
|
(None, False) — второй элемент False сигнализирует, что город
|
||||||
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
|
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
|
||||||
|
|
||||||
|
`region_code` (дефолт 66, #3051) — byte-identical прежнему поведению для
|
||||||
|
всех вызовов без аргумента.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(city_or_none, city_specified_by_user).
|
(city_or_none, city_specified_by_user).
|
||||||
"""
|
"""
|
||||||
if _has_oblast_marker(address.lower()):
|
if _has_oblast_marker(address.lower(), region_code):
|
||||||
return None, True
|
return None, True
|
||||||
hint = (city_hint or "").strip()
|
hint = (city_hint or "").strip()
|
||||||
if hint:
|
if hint:
|
||||||
|
|
@ -678,13 +725,15 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
|
||||||
|
|
||||||
|
|
||||||
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
|
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
|
||||||
def _nominatim_region_ok(item: dict) -> bool | None:
|
def _nominatim_region_ok(item: dict, region_code: int = 66) -> bool | None:
|
||||||
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
|
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
|
||||||
|
|
||||||
True/False если state однозначно про/не про Свердловскую область. None если
|
True/False если state однозначно про/не про регион `region_code`. None если
|
||||||
поле отсутствует/не строка — тогда accept-логика падает обратно на bbox.
|
поле отсутствует/не строка, ИЛИ регион не имеет записи в `_REGION_STATE_MARKERS`
|
||||||
|
— тогда accept-логика падает обратно на bbox.
|
||||||
Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри генерального OBLAST66_BBOX
|
Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри генерального OBLAST66_BBOX
|
||||||
(специально щедрого), но их state явно другой регион.
|
(специально щедрого), но их state явно другой регион. `region_code=66`
|
||||||
|
(дефолт) — byte-identical прежнему поведению (`"свердловск" in state.lower()`).
|
||||||
"""
|
"""
|
||||||
addr = item.get("address")
|
addr = item.get("address")
|
||||||
if not isinstance(addr, dict):
|
if not isinstance(addr, dict):
|
||||||
|
|
@ -692,22 +741,41 @@ def _nominatim_region_ok(item: dict) -> bool | None:
|
||||||
state = addr.get("state")
|
state = addr.get("state")
|
||||||
if not isinstance(state, str) or not state:
|
if not isinstance(state, str) or not state:
|
||||||
return None
|
return None
|
||||||
return "свердловск" in state.lower()
|
marker = _REGION_STATE_MARKERS.get(region_code)
|
||||||
|
if marker is None:
|
||||||
|
return None
|
||||||
|
return marker in state.lower()
|
||||||
|
|
||||||
|
|
||||||
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
|
def _region_viewbox(region: Region) -> str:
|
||||||
|
"""Nominatim `viewbox` (lon_min,lat_max,lon_max,lat_min) из `region.bbox_region`.
|
||||||
|
|
||||||
|
Для region 66 см. `OBLAST66_VIEWBOX["viewbox"]` — литеральная константа
|
||||||
|
(byte-identical), эта функция для 66 не вызывается.
|
||||||
|
"""
|
||||||
|
lat_min, lat_max, lon_min, lon_max = region.bbox_region
|
||||||
|
return f"{lon_min},{lat_max},{lon_max},{lat_min}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _nominatim_query(
|
||||||
|
client: httpx.AsyncClient, address: str, region_code: int = 66
|
||||||
|
) -> dict | None:
|
||||||
"""Single Nominatim search. Возвращает лучший item или None.
|
"""Single Nominatim search. Возвращает лучший item или None.
|
||||||
|
|
||||||
ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при
|
ВАЖНО: фильтруем результаты по bbox региона `region_code` прямо тут, чтобы
|
||||||
опечатках не возвращать Пермский край / Челябинск — но не резать легитимные
|
при опечатках не возвращать Пермский край / Челябинск — но не резать
|
||||||
Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области).
|
легитимные Нижний Тагил / Серов и т.д. (генеральный bbox всего региона).
|
||||||
|
|
||||||
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
|
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
|
||||||
ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его
|
bbox региона (byte-identical для region_code=66, даже если Nominatim
|
||||||
не первым) — иначе первый кандидат внутри OBLAST66. Плюс region cross-check
|
ранжировал его не первым) — иначе первый кандидат внутри генерального bbox
|
||||||
(`address.state`) — отсекает кандидатов ЯВНО из другого региона (Тюмень и
|
региона. Плюс region cross-check (`address.state`) — отсекает кандидатов
|
||||||
т.п.), даже если координаты попали в генеральный bbox.
|
ЯВНО из другого региона (Тюмень и т.п.), даже если координаты попали в
|
||||||
|
генеральный bbox. `region_code=66` (дефолт) — byte-identical прежнему
|
||||||
|
поведению (те же bbox-значения и та же viewbox-строка).
|
||||||
"""
|
"""
|
||||||
|
region = _ALL_REGIONS[region_code]
|
||||||
|
viewbox = OBLAST66_VIEWBOX["viewbox"] if region_code == 66 else _region_viewbox(region)
|
||||||
await _nominatim_throttle()
|
await _nominatim_throttle()
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"https://nominatim.openstreetmap.org/search",
|
"https://nominatim.openstreetmap.org/search",
|
||||||
|
|
@ -717,8 +785,8 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
||||||
"limit": "3",
|
"limit": "3",
|
||||||
"countrycodes": "ru",
|
"countrycodes": "ru",
|
||||||
"addressdetails": "1",
|
"addressdetails": "1",
|
||||||
"viewbox": OBLAST66_VIEWBOX["viewbox"],
|
"viewbox": viewbox,
|
||||||
"bounded": "1", # строго в пределах области (region 66)
|
"bounded": "1", # строго в пределах региона
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
@ -730,11 +798,11 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
||||||
lon_f = float(item["lon"])
|
lon_f = float(item["lon"])
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if _nominatim_region_ok(item) is False:
|
if _nominatim_region_ok(item, region_code) is False:
|
||||||
continue # регион явно не Свердловская область — не рассматриваем
|
continue # регион явно не тот, что запрошен — не рассматриваем
|
||||||
if is_within_ekb_bbox(lat_f, lon_f):
|
if is_within_bbox(lat_f, lon_f, region.bbox_tight):
|
||||||
return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше
|
return item # tight-bbox приоритетнее — тот же результат, что и раньше
|
||||||
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
|
if oblast_fallback is None and is_within_bbox(lat_f, lon_f, region.bbox_region):
|
||||||
oblast_fallback = item
|
oblast_fallback = item
|
||||||
return oblast_fallback
|
return oblast_fallback
|
||||||
|
|
||||||
|
|
@ -756,21 +824,25 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
||||||
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
|
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
|
||||||
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit).
|
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit).
|
||||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
|
||||||
async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None:
|
async def _nominatim_lookup(
|
||||||
|
address: str, city_hint: str | None = None, region_code: int = 66
|
||||||
|
) -> GeocodeResult | None:
|
||||||
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
||||||
|
|
||||||
Бан-policy: User-Agent с email обязателен.
|
Бан-policy: User-Agent с email обязателен.
|
||||||
Tier 1: bounded область (region 66) на оригинальный адрес.
|
Tier 1: bounded регион `region_code` на оригинальный адрес.
|
||||||
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга → Цвиллинга).
|
Tier 2: bounded регион `region_code` на typo-варианты (Цвилинга → Цвиллинга).
|
||||||
|
|
||||||
#2580 (C): city_hint, если известен, подставляется в текст запроса — без
|
#2580 (C): city_hint, если известен, подставляется в текст запроса — без
|
||||||
него `_nominatim_query` полагается ТОЛЬКО на oblast66-bbox фильтр + tie-break
|
него `_nominatim_query` полагается ТОЛЬКО на region-bbox фильтр + tie-break
|
||||||
(предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона
|
(предпочитает tight bbox региона), который для одноимённых улиц ВНУТРИ
|
||||||
(напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не различает город.
|
региона (напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не
|
||||||
Эмпирически подтверждено: "Ленина 1" без города → случайное село внутри
|
различает город. Эмпирически подтверждено: "Ленина 1" без города →
|
||||||
области; "Нижний Тагил, Ленина 1" → корректно резолвится. Nominatim —
|
случайное село внутри области; "Нижний Тагил, Ленина 1" → корректно
|
||||||
единственный живой внешний провайдер (#2593: Yandex Geocoder удалён) —
|
резолвится. Nominatim — единственный живой внешний провайдер (#2593:
|
||||||
city_hint должен реально влиять на его результат, не только на кэш-ключ.
|
Yandex Geocoder удалён) — city_hint должен реально влиять на его результат,
|
||||||
|
не только на кэш-ключ. `region_code=66` (дефолт, #3051) — byte-identical
|
||||||
|
прежнему поведению для всех вызовов без аргумента.
|
||||||
"""
|
"""
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||||||
|
|
@ -778,18 +850,18 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco
|
||||||
"Accept-Language": "ru,en;q=0.8",
|
"Accept-Language": "ru,en;q=0.8",
|
||||||
"Referer": "https://tradein-mvp.local/",
|
"Referer": "https://tradein-mvp.local/",
|
||||||
}
|
}
|
||||||
city, _ = _resolve_city_for_geocode(address, city_hint)
|
city, _ = _resolve_city_for_geocode(address, city_hint, region_code)
|
||||||
query = f"{city}, {address}" if city else address
|
query = f"{city}, {address}" if city else address
|
||||||
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
|
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
|
||||||
# Tier 1: оригинал
|
# Tier 1: оригинал
|
||||||
item = await _nominatim_query(client, query)
|
item = await _nominatim_query(client, query, region_code)
|
||||||
|
|
||||||
# Tier 2: typo-variants
|
# Tier 2: typo-variants
|
||||||
if item is None:
|
if item is None:
|
||||||
for variant in _typo_variants(address, limit=4):
|
for variant in _typo_variants(address, limit=4):
|
||||||
variant_city, _ = _resolve_city_for_geocode(variant, city_hint)
|
variant_city, _ = _resolve_city_for_geocode(variant, city_hint, region_code)
|
||||||
variant_query = f"{variant_city}, {variant}" if variant_city else variant
|
variant_query = f"{variant_city}, {variant}" if variant_city else variant
|
||||||
item = await _nominatim_query(client, variant_query)
|
item = await _nominatim_query(client, variant_query, region_code)
|
||||||
if item is not None:
|
if item is not None:
|
||||||
logger.info("nominatim typo-fixed: %s → %s", address, variant)
|
logger.info("nominatim typo-fixed: %s → %s", address, variant)
|
||||||
break
|
break
|
||||||
|
|
@ -1753,7 +1825,9 @@ async def suggest(
|
||||||
|
|
||||||
|
|
||||||
# ── Public API ───────────────────────────────────────────────────────────────
|
# ── Public API ───────────────────────────────────────────────────────────────
|
||||||
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
|
async def geocode(
|
||||||
|
address: str, db: Session, city_hint: str | None = None, region_code: int = 66
|
||||||
|
) -> GeocodeResult | None:
|
||||||
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
|
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
|
||||||
|
|
||||||
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
|
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
|
||||||
|
|
@ -1771,8 +1845,21 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
|
||||||
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
|
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
|
||||||
за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и
|
за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и
|
||||||
видимая, а не отказ.
|
видимая, а не отказ.
|
||||||
|
|
||||||
|
`region_code` (дефолт 66, #3051) — какой `REGIONS`-регион искать (bbox,
|
||||||
|
city-словарь, ЕКБ-only локальные тиры). Неизвестный код → `ValueError`
|
||||||
|
сразу, а не глубоко внутри `_nominatim_query`. Все существующие вызовы без
|
||||||
|
аргумента получают region_code=66 — byte-identical прежнему поведению.
|
||||||
|
`_city_substituted` region_code не принимает: инвариант завязан на
|
||||||
|
ЕКБ-bbox координат результата (`is_within_ekb_bbox`), который для другого
|
||||||
|
региона (Москва и т.п.) структурно не совпадает — условие 3 инварианта
|
||||||
|
никогда не сработает, ложного понижения confidence до "locality" не будет.
|
||||||
"""
|
"""
|
||||||
result = await _geocode_resolve(address, db, city_hint)
|
try:
|
||||||
|
_ALL_REGIONS[region_code]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"geocode: unknown region_code={region_code!r}") from exc
|
||||||
|
result = await _geocode_resolve(address, db, city_hint, region_code)
|
||||||
if result is None or not _city_substituted(address, result):
|
if result is None or not _city_substituted(address, result):
|
||||||
return result
|
return result
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -1788,7 +1875,7 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
|
||||||
|
|
||||||
|
|
||||||
async def _geocode_resolve(
|
async def _geocode_resolve(
|
||||||
address: str, db: Session, city_hint: str | None = None
|
address: str, db: Session, city_hint: str | None = None, region_code: int = 66
|
||||||
) -> GeocodeResult | None:
|
) -> GeocodeResult | None:
|
||||||
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
|
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
|
||||||
|
|
||||||
|
|
@ -1802,6 +1889,10 @@ async def _geocode_resolve(
|
||||||
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
|
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
|
||||||
разных городов по одному и тому же тексту адреса не перезатирали
|
разных городов по одному и тому же тексту адреса не перезатирали
|
||||||
друг друга.
|
друг друга.
|
||||||
|
region_code: регион покрытия (дефолт 66, #3051). ЕКБ-only локальные
|
||||||
|
тиры (geoportal/cad_buildings/houses) применяются ТОЛЬКО при 66 —
|
||||||
|
это ЕКБ-специфичные реестры, у других регионов данных в них нет.
|
||||||
|
Прокидывается в Nominatim-тир (bbox/viewbox/city-словарь).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
GeocodeResult или None если ни один провайдер не отвечает.
|
GeocodeResult или None если ни один провайдер не отвечает.
|
||||||
|
|
@ -1812,7 +1903,7 @@ async def _geocode_resolve(
|
||||||
if not address or len(address.strip()) < 3:
|
if not address or len(address.strip()) < 3:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_, city_specified = _resolve_city_for_geocode(address, city_hint)
|
_, city_specified = _resolve_city_for_geocode(address, city_hint, region_code)
|
||||||
city_ambiguous = not city_specified
|
city_ambiguous = not city_specified
|
||||||
|
|
||||||
addr_norm = _cache_key(normalize_address(address), city_hint)
|
addr_norm = _cache_key(normalize_address(address), city_hint)
|
||||||
|
|
@ -1838,7 +1929,14 @@ async def _geocode_resolve(
|
||||||
# Раньше решение по тексту адреса принималось от противного (список из 37
|
# Раньше решение по тексту адреса принималось от противного (список из 37
|
||||||
# городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион
|
# городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион
|
||||||
# РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#2582).
|
# РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#2582).
|
||||||
use_local_ekb = _ekb_local_tiers_allowed(address, city_hint)
|
#
|
||||||
|
# #3051: `region_code != 66` закрывает эти тиры целиком, ДО вызова
|
||||||
|
# `_ekb_local_tiers_allowed` — geoportal/cad_buildings/houses физически не
|
||||||
|
# содержат данных других регионов (не "город не распознан словарём 66", а
|
||||||
|
# "реестра для этого региона нет вовсе"), а сама `_ekb_local_tiers_allowed`
|
||||||
|
# (её ЕКБ-словари: `_names_non_ekb_city`/`_names_unrecognized_locality`)
|
||||||
|
# region_code не принимает — умышленно не трогаем её сигнатуру.
|
||||||
|
use_local_ekb = region_code == 66 and _ekb_local_tiers_allowed(address, city_hint)
|
||||||
|
|
||||||
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
|
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
|
||||||
if use_local_ekb and parsed is not None:
|
if use_local_ekb and parsed is not None:
|
||||||
|
|
@ -1914,7 +2012,7 @@ async def _geocode_resolve(
|
||||||
|
|
||||||
# 3. Nominatim fallback
|
# 3. Nominatim fallback
|
||||||
try:
|
try:
|
||||||
result = await _nominatim_lookup(address, city_hint)
|
result = await _nominatim_lookup(address, city_hint, region_code)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
result = replace(result, city_ambiguous=city_ambiguous)
|
result = replace(result, city_ambiguous=city_ambiguous)
|
||||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,12 @@ Flags:
|
||||||
plus the geocode hit/miss split. No DB writes.
|
plus the geocode hit/miss split. No DB writes.
|
||||||
--batch LABEL log label (default `deals_nominatim_YYYY-MM-DD`).
|
--batch LABEL log label (default `deals_nominatim_YYYY-MM-DD`).
|
||||||
--stale-days N retry addresses last tried more than N days ago (default 30).
|
--stale-days N retry addresses last tried more than N days ago (default 30).
|
||||||
|
--region-code N which `deals.region_code` to geocode (default 66, Sverdlovsk
|
||||||
|
oblast). #3051: 77 (Москва) — every deal there has
|
||||||
|
address='Москва, <street>' with no city already recognised by
|
||||||
|
`known_city_hint`'s region-66 default, so this flag threads
|
||||||
|
through both the SQL filter (`AND region_code = N`) and
|
||||||
|
`geocoder.{geocode,known_city_hint}(..., region_code=N)`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -182,7 +188,9 @@ class Stats:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> list[AddressGroup]:
|
def _select_pending_addresses(
|
||||||
|
db: Session, *, limit: int, stale_days: int, region_code: int = 66
|
||||||
|
) -> list[AddressGroup]:
|
||||||
"""Distinct (address, city) pairs still needing coords — resume-safe set.
|
"""Distinct (address, city) pairs still needing coords — resume-safe set.
|
||||||
|
|
||||||
Combines the `deals_geocode_pending_idx` partial index predicate
|
Combines the `deals_geocode_pending_idx` partial index predicate
|
||||||
|
|
@ -215,6 +223,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li
|
||||||
" WHERE lat IS NULL "
|
" WHERE lat IS NULL "
|
||||||
" AND address IS NOT NULL "
|
" AND address IS NOT NULL "
|
||||||
" AND length(trim(address)) >= 3 "
|
" AND length(trim(address)) >= 3 "
|
||||||
|
" AND region_code = CAST(:region_code AS int) "
|
||||||
" AND (geocode_tried_at IS NULL "
|
" AND (geocode_tried_at IS NULL "
|
||||||
" OR geocode_tried_at < NOW() "
|
" OR geocode_tried_at < NOW() "
|
||||||
" - make_interval(days => CAST(:stale_days AS int))) "
|
" - make_interval(days => CAST(:stale_days AS int))) "
|
||||||
|
|
@ -223,7 +232,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li
|
||||||
"WHERE running_rows - deals_count < CAST(:limit AS int) "
|
"WHERE running_rows - deals_count < CAST(:limit AS int) "
|
||||||
"ORDER BY deals_count DESC, address ASC, city ASC NULLS FIRST"
|
"ORDER BY deals_count DESC, address ASC, city ASC NULLS FIRST"
|
||||||
),
|
),
|
||||||
{"limit": limit, "stale_days": stale_days},
|
{"limit": limit, "stale_days": stale_days, "region_code": region_code},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
|
|
@ -238,7 +247,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
|
def _count_pending_total(db: Session, *, stale_days: int, region_code: int = 66) -> tuple[int, int]:
|
||||||
"""Full backlog: (distinct (address, city) pairs, total rows) this pass.
|
"""Full backlog: (distinct (address, city) pairs, total rows) this pass.
|
||||||
|
|
||||||
Denominators for the dry-run projection — counts every lat-IS-NULL deal
|
Denominators for the dry-run projection — counts every lat-IS-NULL deal
|
||||||
|
|
@ -257,11 +266,12 @@ def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
|
||||||
"WHERE lat IS NULL "
|
"WHERE lat IS NULL "
|
||||||
" AND address IS NOT NULL "
|
" AND address IS NOT NULL "
|
||||||
" AND length(trim(address)) >= 3 "
|
" AND length(trim(address)) >= 3 "
|
||||||
|
" AND region_code = CAST(:region_code AS int) "
|
||||||
" AND (geocode_tried_at IS NULL "
|
" AND (geocode_tried_at IS NULL "
|
||||||
" OR geocode_tried_at < NOW() "
|
" OR geocode_tried_at < NOW() "
|
||||||
" - make_interval(days => CAST(:stale_days AS int)))"
|
" - make_interval(days => CAST(:stale_days AS int)))"
|
||||||
),
|
),
|
||||||
{"stale_days": stale_days},
|
{"stale_days": stale_days, "region_code": region_code},
|
||||||
).first()
|
).first()
|
||||||
if row is None:
|
if row is None:
|
||||||
return (0, 0)
|
return (0, 0)
|
||||||
|
|
@ -274,9 +284,15 @@ def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
|
||||||
|
|
||||||
|
|
||||||
def _update_deals_geocoded(
|
def _update_deals_geocoded(
|
||||||
db: Session, *, address: str, city: str | None, lat: float, lon: float
|
db: Session,
|
||||||
|
*,
|
||||||
|
address: str,
|
||||||
|
city: str | None,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
region_code: int = 66,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""UPDATE every lat-IS-NULL deal on (address, city); geom via trigger.
|
"""UPDATE every lat-IS-NULL deal on (address, city, region_code); geom via trigger.
|
||||||
|
|
||||||
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
|
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
|
||||||
(002_core_tables.sql, reuses listings_set_geom()) populates geom from the
|
(002_core_tables.sql, reuses listings_set_geom()) populates geom from the
|
||||||
|
|
@ -290,6 +306,12 @@ def _update_deals_geocoded(
|
||||||
FROM` treats NULL=NULL as a match while staying strict for a real city, so
|
FROM` treats NULL=NULL as a match while staying strict for a real city, so
|
||||||
the same street text in another city keeps its own coords (#2603).
|
the same street text in another city keeps its own coords (#2603).
|
||||||
|
|
||||||
|
`region_code` (#3051) — an extra guard on top of (address, city): the same
|
||||||
|
street text can legitimately exist in two different regions (e.g. a
|
||||||
|
"Ленина" in both oblast 66 and Moscow's region-77 corpus), so a run scoped
|
||||||
|
to one region must never touch the other's rows even if address/city
|
||||||
|
happen to collide.
|
||||||
|
|
||||||
Returns the number of deal rows updated.
|
Returns the number of deal rows updated.
|
||||||
"""
|
"""
|
||||||
result = db.execute(
|
result = db.execute(
|
||||||
|
|
@ -300,14 +322,15 @@ def _update_deals_geocoded(
|
||||||
" geocode_tried_at = NOW() "
|
" geocode_tried_at = NOW() "
|
||||||
" WHERE address = CAST(:addr AS text) "
|
" WHERE address = CAST(:addr AS text) "
|
||||||
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
|
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
|
||||||
|
" AND region_code = CAST(:region_code AS int) "
|
||||||
" AND lat IS NULL"
|
" AND lat IS NULL"
|
||||||
),
|
),
|
||||||
{"addr": address, "city": city, "lat": lat, "lon": lon},
|
{"addr": address, "city": city, "lat": lat, "lon": lon, "region_code": region_code},
|
||||||
)
|
)
|
||||||
return result.rowcount
|
return result.rowcount
|
||||||
|
|
||||||
|
|
||||||
def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int:
|
def _mark_deals_tried(db: Session, *, address: str, city: str | None, region_code: int = 66) -> int:
|
||||||
"""Stamp `geocode_tried_at = NOW()` WITHOUT touching lat/lon (geocode miss).
|
"""Stamp `geocode_tried_at = NOW()` WITHOUT touching lat/lon (geocode miss).
|
||||||
|
|
||||||
Critical for resume: an address the geocoder can't resolve must still drop
|
Critical for resume: an address the geocoder can't resolve must still drop
|
||||||
|
|
@ -316,8 +339,9 @@ def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int:
|
||||||
it for `stale_days`. The `AND lat IS NULL` guard means a concurrent success
|
it for `stale_days`. The `AND lat IS NULL` guard means a concurrent success
|
||||||
can't be downgraded.
|
can't be downgraded.
|
||||||
|
|
||||||
Scoped to the (address, city) pair for the same reason as the coords write
|
Scoped to the (address, city, region_code) pair for the same reason as the
|
||||||
— a miss in one city must not defer the other city's retry (#2603).
|
coords write — a miss in one city/region must not defer another
|
||||||
|
region's retry (#2603, #3051).
|
||||||
|
|
||||||
Returns the number of deal rows stamped.
|
Returns the number of deal rows stamped.
|
||||||
"""
|
"""
|
||||||
|
|
@ -327,9 +351,10 @@ def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int:
|
||||||
" SET geocode_tried_at = NOW() "
|
" SET geocode_tried_at = NOW() "
|
||||||
" WHERE address = CAST(:addr AS text) "
|
" WHERE address = CAST(:addr AS text) "
|
||||||
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
|
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
|
||||||
|
" AND region_code = CAST(:region_code AS int) "
|
||||||
" AND lat IS NULL"
|
" AND lat IS NULL"
|
||||||
),
|
),
|
||||||
{"addr": address, "city": city},
|
{"addr": address, "city": city, "region_code": region_code},
|
||||||
)
|
)
|
||||||
return result.rowcount
|
return result.rowcount
|
||||||
|
|
||||||
|
|
@ -345,6 +370,7 @@ async def _run_backfill(
|
||||||
*,
|
*,
|
||||||
batch: str,
|
batch: str,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
|
region_code: int = 66,
|
||||||
) -> Stats:
|
) -> Stats:
|
||||||
"""For each (address, city) pair: geocode once, then UPDATE all its deals.
|
"""For each (address, city) pair: geocode once, then UPDATE all its deals.
|
||||||
|
|
||||||
|
|
@ -357,17 +383,21 @@ async def _run_backfill(
|
||||||
retry/backoff), so we don't add our own sleep here — that would double the
|
retry/backoff), so we don't add our own sleep here — that would double the
|
||||||
walltime. A geocode that raises is treated as a failure for THIS run but is
|
walltime. A geocode that raises is treated as a failure for THIS run but is
|
||||||
NOT stamped (left for the next pass to retry sooner than a clean miss).
|
NOT stamped (left for the next pass to retry sooner than a clean miss).
|
||||||
|
|
||||||
|
`region_code` (#3051, default 66) threads into `known_city_hint`/`geocode`
|
||||||
|
(region's own city dictionary + bbox/viewbox) and into the two DB writers
|
||||||
|
(extra WHERE guard, matches `_select_pending_addresses`'s filter).
|
||||||
"""
|
"""
|
||||||
stats = Stats()
|
stats = Stats()
|
||||||
|
|
||||||
for i, group in enumerate(groups, start=1):
|
for i, group in enumerate(groups, start=1):
|
||||||
address = group.address
|
address = group.address
|
||||||
city = group.city
|
city = group.city
|
||||||
# Only a recognised oblast-66 city is fed to the geocoder; junk Rosreestr
|
# Only a recognised city of `region_code` is fed to the geocoder; junk
|
||||||
# values degrade to None (geocoder.known_city_hint — shared with the
|
# Rosreestr values degrade to None (geocoder.known_city_hint — shared
|
||||||
# other DB-column callers). The raw `city` is still used for the UPDATE
|
# with the other DB-column callers). The raw `city` is still used for
|
||||||
# scope — it identifies the group either way.
|
# the UPDATE scope — it identifies the group either way.
|
||||||
hint = known_city_hint(city)
|
hint = known_city_hint(city, region_code)
|
||||||
|
|
||||||
# The geocoder itself rejects <3 chars, but skip here too so the dry-run
|
# The geocoder itself rejects <3 chars, but skip here too so the dry-run
|
||||||
# report and counters stay honest (no phantom "processed" address).
|
# report and counters stay honest (no phantom "processed" address).
|
||||||
|
|
@ -377,7 +407,7 @@ async def _run_backfill(
|
||||||
|
|
||||||
result: GeocodeResult | None = None
|
result: GeocodeResult | None = None
|
||||||
try:
|
try:
|
||||||
result = await geocode(address, db, city_hint=hint)
|
result = await geocode(address, db, city_hint=hint, region_code=region_code)
|
||||||
except Exception as exc: # defensive — one geocode error must not kill batch
|
except Exception as exc: # defensive — one geocode error must not kill batch
|
||||||
stats.geocode_failed += 1
|
stats.geocode_failed += 1
|
||||||
stats.processed += 1
|
stats.processed += 1
|
||||||
|
|
@ -407,7 +437,7 @@ async def _run_backfill(
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
_mark_deals_tried(db, address=address, city=city)
|
_mark_deals_tried(db, address=address, city=city, region_code=region_code)
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception as exc: # defensive — isolate one bad UPDATE
|
except Exception as exc: # defensive — isolate one bad UPDATE
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
@ -441,7 +471,12 @@ async def _run_backfill(
|
||||||
try:
|
try:
|
||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
n = _update_deals_geocoded(
|
n = _update_deals_geocoded(
|
||||||
db, address=address, city=city, lat=result.lat, lon=result.lon
|
db,
|
||||||
|
address=address,
|
||||||
|
city=city,
|
||||||
|
lat=result.lat,
|
||||||
|
lon=result.lon,
|
||||||
|
region_code=region_code,
|
||||||
)
|
)
|
||||||
# Per-address commit so resume picks up exactly where we crashed.
|
# Per-address commit so resume picks up exactly where we crashed.
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -562,6 +597,16 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
f"{_DEFAULT_STALE_DAYS}). Bounds retries on un-geocodable addresses."
|
f"{_DEFAULT_STALE_DAYS}). Bounds retries on un-geocodable addresses."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--region-code",
|
||||||
|
type=int,
|
||||||
|
default=66,
|
||||||
|
help=(
|
||||||
|
"deals.region_code to geocode (default 66, Sverdlovsk oblast). #3051: "
|
||||||
|
"77 (Moscow) threads through the SQL filter, geocoder.geocode/"
|
||||||
|
"known_city_hint, and the DB writers' WHERE guard."
|
||||||
|
),
|
||||||
|
)
|
||||||
return p.parse_args(argv)
|
return p.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -575,34 +620,44 @@ async def main(argv: list[str] | None = None) -> int:
|
||||||
"""
|
"""
|
||||||
args = _parse_args(argv)
|
args = _parse_args(argv)
|
||||||
logger.info(
|
logger.info(
|
||||||
"starting batch=%s limit=%s stale_days=%s dry_run=%s",
|
"starting batch=%s limit=%s stale_days=%s region_code=%s dry_run=%s",
|
||||||
args.batch,
|
args.batch,
|
||||||
args.limit,
|
args.limit,
|
||||||
args.stale_days,
|
args.stale_days,
|
||||||
|
args.region_code,
|
||||||
args.dry_run,
|
args.dry_run,
|
||||||
)
|
)
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
groups = _select_pending_addresses(db, limit=args.limit, stale_days=args.stale_days)
|
groups = _select_pending_addresses(
|
||||||
|
db, limit=args.limit, stale_days=args.stale_days, region_code=args.region_code
|
||||||
|
)
|
||||||
total_rows = sum(g.deals_count for g in groups)
|
total_rows = sum(g.deals_count for g in groups)
|
||||||
logger.info(
|
logger.info(
|
||||||
"loaded %d distinct (address, city) groups (%d deal rows) needing coords",
|
"loaded %d distinct (address, city) groups (%d deal rows) needing coords "
|
||||||
|
"region_code=%s",
|
||||||
len(groups),
|
len(groups),
|
||||||
total_rows,
|
total_rows,
|
||||||
|
args.region_code,
|
||||||
)
|
)
|
||||||
if not groups:
|
if not groups:
|
||||||
logger.info(
|
logger.info(
|
||||||
"nothing to do — no deals with lat IS NULL eligible (all tried "
|
"nothing to do — no deals with lat IS NULL eligible (all tried "
|
||||||
"within the last %d days, or no addressable rows)",
|
"within the last %d days, or no addressable rows) region_code=%s",
|
||||||
args.stale_days,
|
args.stale_days,
|
||||||
|
args.region_code,
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
stats = await _run_backfill(db, groups, batch=args.batch, dry_run=args.dry_run)
|
stats = await _run_backfill(
|
||||||
|
db, groups, batch=args.batch, dry_run=args.dry_run, region_code=args.region_code
|
||||||
|
)
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
total_streets, backlog_rows = _count_pending_total(db, stale_days=args.stale_days)
|
total_streets, backlog_rows = _count_pending_total(
|
||||||
|
db, stale_days=args.stale_days, region_code=args.region_code
|
||||||
|
)
|
||||||
_report_dry_run(
|
_report_dry_run(
|
||||||
stats,
|
stats,
|
||||||
total_streets=total_streets,
|
total_streets=total_streets,
|
||||||
|
|
@ -611,9 +666,10 @@ async def main(argv: list[str] | None = None) -> int:
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"done: batch=%s processed=%d geocoded=%d geocode_failed=%d "
|
"done: batch=%s region_code=%s processed=%d geocoded=%d geocode_failed=%d "
|
||||||
"skipped=%d deals_updated=%d cache=(hit=%d miss=%d)",
|
"skipped=%d deals_updated=%d cache=(hit=%d miss=%d)",
|
||||||
args.batch,
|
args.batch,
|
||||||
|
args.region_code,
|
||||||
stats.processed,
|
stats.processed,
|
||||||
stats.geocoded,
|
stats.geocoded,
|
||||||
stats.geocode_failed,
|
stats.geocode_failed,
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,7 @@ def test_update_deals_geocoded_sets_lat_lon_tried_at_not_geom():
|
||||||
"city": "Екатеринбург",
|
"city": "Екатеринбург",
|
||||||
"lat": 56.1,
|
"lat": 56.1,
|
||||||
"lon": 60.2,
|
"lon": 60.2,
|
||||||
|
"region_code": 66,
|
||||||
}
|
}
|
||||||
assert n == 7
|
assert n == 7
|
||||||
|
|
||||||
|
|
@ -492,7 +493,7 @@ async def test_run_backfill_passes_known_city_as_hint():
|
||||||
) as mock_geo:
|
) as mock_geo:
|
||||||
await _run_backfill(db, groups, batch="b", dry_run=False)
|
await _run_backfill(db, groups, batch="b", dry_run=False)
|
||||||
|
|
||||||
mock_geo.assert_called_once_with("Победы, 30", db, city_hint="Нижний Тагил")
|
mock_geo.assert_called_once_with("Победы, 30", db, city_hint="Нижний Тагил", region_code=66)
|
||||||
assert coord_updates[0]["city"] == "Нижний Тагил"
|
assert coord_updates[0]["city"] == "Нижний Тагил"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -507,7 +508,7 @@ async def test_run_backfill_junk_city_geocodes_without_hint_but_scopes_update():
|
||||||
) as mock_geo:
|
) as mock_geo:
|
||||||
await _run_backfill(db, groups, batch="b", dry_run=False)
|
await _run_backfill(db, groups, batch="b", dry_run=False)
|
||||||
|
|
||||||
mock_geo.assert_called_once_with("Бессонова, 11", db, city_hint=None)
|
mock_geo.assert_called_once_with("Бессонова, 11", db, city_hint=None, region_code=66)
|
||||||
assert coord_updates[0]["city"] == "Бессонова"
|
assert coord_updates[0]["city"] == "Бессонова"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -547,7 +548,7 @@ async def test_run_backfill_miss_marks_only_its_own_city():
|
||||||
await _run_backfill(db, groups, batch="b", dry_run=False)
|
await _run_backfill(db, groups, batch="b", dry_run=False)
|
||||||
|
|
||||||
assert coord_updates == []
|
assert coord_updates == []
|
||||||
assert tried_updates == [{"addr": "Победы, 30", "city": "Нижний Тагил"}]
|
assert tried_updates == [{"addr": "Победы, 30", "city": "Нижний Тагил", "region_code": 66}]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -806,7 +806,7 @@ async def test_admin_geocode_missing_passes_city_hint(target: str) -> None:
|
||||||
target=target, # type: ignore[arg-type]
|
target=target, # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил")
|
mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил", region_code=66)
|
||||||
assert result["geocoded"] == 1
|
assert result["geocoded"] == 1
|
||||||
assert result["skipped"] == 0
|
assert result["skipped"] == 0
|
||||||
# SELECT адресован именно запрошенной таблице (обе несут колонку city).
|
# SELECT адресован именно запрошенной таблице (обе несут колонку city).
|
||||||
|
|
@ -854,7 +854,7 @@ async def test_admin_geocode_missing_drops_junk_city_hint(target: str) -> None:
|
||||||
target=target, # type: ignore[arg-type]
|
target=target, # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None)
|
mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None, region_code=66)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -420,7 +420,7 @@ async def test_geocode_cache_does_not_mix_cities() -> None:
|
||||||
def fake_cache_put(db, addr_norm, result):
|
def fake_cache_put(db, addr_norm, result):
|
||||||
store[addr_norm] = result
|
store[addr_norm] = result
|
||||||
|
|
||||||
async def fake_nominatim_lookup(address, city_hint=None):
|
async def fake_nominatim_lookup(address, city_hint=None, region_code=66):
|
||||||
if city_hint == "Нижний Тагил":
|
if city_hint == "Нижний Тагил":
|
||||||
return GeocodeResult(
|
return GeocodeResult(
|
||||||
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
|
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue