69 lines
2.1 KiB
Python
69 lines
2.1 KiB
Python
"""Lightweight, dependency-free geocoding.
|
|
|
|
We deliberately avoid calling an external geocoding API per-article (rate
|
|
limits, latency, privacy) and avoid a heavyweight NER model. Instead we match
|
|
article text against a curated gazetteer of cities/regions/countries
|
|
(app/data/gazetteer.csv), preferring the most specific (longest) name found.
|
|
This is a heuristic: it will miss unlisted places and can occasionally match
|
|
the wrong entity for very generic names. Extend data/gazetteer.csv to
|
|
improve coverage.
|
|
"""
|
|
|
|
import csv
|
|
import re
|
|
from dataclasses import dataclass
|
|
from functools import lru_cache
|
|
|
|
from .config import settings
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Place:
|
|
name: str
|
|
lat: float
|
|
lon: float
|
|
type: str
|
|
country: str
|
|
|
|
|
|
def _load_gazetteer() -> list[Place]:
|
|
places: list[Place] = []
|
|
with open(settings.gazetteer_file, newline="", encoding="utf-8") as f:
|
|
for row in csv.DictReader(f):
|
|
places.append(
|
|
Place(
|
|
name=row["name"],
|
|
lat=float(row["lat"]),
|
|
lon=float(row["lon"]),
|
|
type=row["type"],
|
|
country=row["country"],
|
|
)
|
|
)
|
|
# Longest name first so "Gaza Strip" wins over "Gaza", "New York" over "York", etc.
|
|
places.sort(key=lambda p: len(p.name), reverse=True)
|
|
return places
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _gazetteer() -> list[tuple[re.Pattern, Place]]:
|
|
compiled = []
|
|
for place in _load_gazetteer():
|
|
pattern = re.compile(r"\b" + re.escape(place.name) + r"\b", re.IGNORECASE)
|
|
compiled.append((pattern, place))
|
|
return compiled
|
|
|
|
|
|
def locate(text: str) -> Place | None:
|
|
"""Return the most specific gazetteer place mentioned in `text`, if any."""
|
|
if not text:
|
|
return None
|
|
for pattern, place in _gazetteer():
|
|
if pattern.search(text):
|
|
return place
|
|
return None
|
|
|
|
|
|
def cluster_key_for(lat: float, lon: float) -> str:
|
|
"""Grid-snap coordinates (~11km cells) so nearby stories share a bucket."""
|
|
return f"{round(lat, 1)}:{round(lon, 1)}"
|