directory_geocoding.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. from __future__ import annotations
  2. import asyncio
  3. from contextlib import suppress
  4. from typing import Any
  5. from aiohttp import ClientError, ClientSession, ClientTimeout
  6. from wbb import log
  7. from wbb.utils.dbdirectory import (
  8. claim_geocode_job,
  9. complete_geocode_job,
  10. fail_geocode_job,
  11. get_directory_settings,
  12. )
  13. NOMINATIM_ATTRIBUTION = "© OpenStreetMap contributors"
  14. NOMINATIM_ATTRIBUTION_URL = "https://www.openstreetmap.org/copyright"
  15. _worker_task: asyncio.Task[None] | None = None
  16. _stop_event: asyncio.Event | None = None
  17. def parse_nominatim_region(payload: dict[str, Any]) -> dict[str, str]:
  18. address = payload.get("address") if isinstance(payload.get("address"), dict) else {}
  19. province = str(address.get("state") or address.get("province") or "").strip()
  20. city = str(
  21. address.get("city")
  22. or address.get("municipality")
  23. or address.get("town")
  24. or address.get("county")
  25. or ""
  26. ).strip()
  27. district = str(
  28. address.get("city_district")
  29. or address.get("district")
  30. or address.get("borough")
  31. or address.get("suburb")
  32. or address.get("county")
  33. or ""
  34. ).strip()
  35. values = list(dict.fromkeys(value for value in (province, city, district) if value))
  36. return {
  37. "province": province,
  38. "city": city,
  39. "district": district,
  40. "label": " ".join(values) if values else "区域暂不可用",
  41. "provider": "nominatim",
  42. }
  43. async def reverse_geocode(
  44. *,
  45. endpoint: str,
  46. contact: str,
  47. user_agent: str,
  48. longitude: float,
  49. latitude: float,
  50. ) -> dict[str, str]:
  51. headers = {
  52. "User-Agent": f"{user_agent} ({contact})",
  53. "Accept": "application/json",
  54. "Accept-Language": "zh-CN,zh;q=0.9",
  55. }
  56. params = {
  57. "format": "jsonv2",
  58. "lat": repr(float(latitude)),
  59. "lon": repr(float(longitude)),
  60. "zoom": "12",
  61. "addressdetails": "1",
  62. "accept-language": "zh-CN",
  63. }
  64. timeout = ClientTimeout(total=12)
  65. async with ClientSession(timeout=timeout, headers=headers) as session:
  66. async with session.get(endpoint, params=params) as response:
  67. if response.status == 429:
  68. retry_after = int(response.headers.get("Retry-After") or 60)
  69. raise NominatimRateLimited(retry_after)
  70. if response.status >= 400:
  71. text = await response.text()
  72. raise RuntimeError(
  73. f"Nominatim 返回 HTTP {response.status}: {text[:200]}"
  74. )
  75. payload = await response.json()
  76. if not isinstance(payload, dict):
  77. raise RuntimeError("Nominatim 返回了无效数据。")
  78. return parse_nominatim_region(payload)
  79. class NominatimRateLimited(RuntimeError):
  80. def __init__(self, retry_after_seconds: int):
  81. super().__init__("Nominatim 请求已被限流。")
  82. self.retry_after_seconds = max(1, int(retry_after_seconds))
  83. async def _geocode_worker(stop_event: asyncio.Event) -> None:
  84. last_request_at = 0.0
  85. while not stop_event.is_set():
  86. settings = await get_directory_settings()
  87. enabled = bool(settings.get("nominatim_enabled"))
  88. contact = str(settings.get("nominatim_contact") or "").strip()
  89. if not enabled or not contact:
  90. with suppress(TimeoutError):
  91. await asyncio.wait_for(stop_event.wait(), timeout=2)
  92. continue
  93. job = await claim_geocode_job()
  94. if not job:
  95. with suppress(TimeoutError):
  96. await asyncio.wait_for(stop_event.wait(), timeout=1)
  97. continue
  98. loop = asyncio.get_running_loop()
  99. wait_seconds = 1.1 - (loop.time() - last_request_at)
  100. if wait_seconds > 0:
  101. with suppress(TimeoutError):
  102. await asyncio.wait_for(stop_event.wait(), timeout=wait_seconds)
  103. if stop_event.is_set():
  104. return
  105. try:
  106. last_request_at = loop.time()
  107. region = await reverse_geocode(
  108. endpoint=str(settings["nominatim_endpoint"]),
  109. contact=contact,
  110. user_agent=str(settings["nominatim_user_agent"]),
  111. longitude=float(job["longitude"]),
  112. latitude=float(job["latitude"]),
  113. )
  114. await complete_geocode_job(job=job, region=region)
  115. except NominatimRateLimited as exc:
  116. await fail_geocode_job(
  117. job=job,
  118. error=str(exc),
  119. retry_after_seconds=exc.retry_after_seconds,
  120. )
  121. except (ClientError, TimeoutError, ValueError, RuntimeError) as exc:
  122. log.error(f"Nominatim 区域解析失败:{exc}")
  123. await fail_geocode_job(job=job, error=str(exc), retry_after_seconds=60)
  124. except Exception as exc:
  125. log.error(f"Nominatim 区域解析出现未预期错误:{exc}")
  126. await fail_geocode_job(job=job, error="区域解析服务异常。")
  127. async def start_directory_geocoder() -> None:
  128. global _worker_task, _stop_event
  129. if _worker_task and not _worker_task.done():
  130. return
  131. _stop_event = asyncio.Event()
  132. _worker_task = asyncio.create_task(
  133. _geocode_worker(_stop_event),
  134. name="directory-geocoder",
  135. )
  136. async def stop_directory_geocoder() -> None:
  137. global _worker_task, _stop_event
  138. if _stop_event:
  139. _stop_event.set()
  140. if _worker_task:
  141. with suppress(asyncio.CancelledError):
  142. await asyncio.wait_for(_worker_task, timeout=3)
  143. _worker_task = None
  144. _stop_event = None