Add coordinate lookup via Nominatim OSM API
- Add --coords flag to enable geocoding - Add --delay option for rate limiting (default 1.0s) - Try multiple query formats: full address, station+city, district - Include latitude/longitude in JSON and CSV output Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,11 +21,12 @@ import csv
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from html import unescape
|
from html import unescape
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
BASE_URL = "https://vipmbr.cpc.com.tw/mbwebs/service_search.aspx"
|
BASE_URL = "https://vipmbr.cpc.com.tw/mbwebs/service_search.aspx"
|
||||||
USER_AGENT = "Mozilla/5.0 (OpenClaw; cpc-direct-stations)"
|
USER_AGENT = "Mozilla/5.0 (OpenClaw; cpc-direct-stations)"
|
||||||
@@ -134,6 +135,63 @@ def extract_hidden_fields(html: str) -> Dict[str, str]:
|
|||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def geocode(address: str, station_name: str, city: str, district: str) -> Optional[Dict[str, float]]:
|
||||||
|
"""使用 Nominatim (OpenStreetMap) API 將地址轉換為座標。
|
||||||
|
|
||||||
|
API: https://nominatim.openstreetmap.org/search
|
||||||
|
台灣地址格式支援有限,依序嘗試:完整地址、站名+縣市、行政區
|
||||||
|
"""
|
||||||
|
# Nominatim 要求完整的 email 在 User-Agent
|
||||||
|
user_agent = f"{USER_AGENT} (geocoding)"
|
||||||
|
|
||||||
|
# 嘗試多種查詢格式
|
||||||
|
queries = []
|
||||||
|
if address:
|
||||||
|
queries.append(address)
|
||||||
|
queries.append(f"{city}{address}") # 縣市 + 地址
|
||||||
|
queries.append(f"{station_name} {city}") # 站名 + 縣市
|
||||||
|
if district:
|
||||||
|
queries.append(f"{city}{district}") # 縣市 + 行政區(作為備案)
|
||||||
|
|
||||||
|
for query in queries[:4]: # 最多試 4 種格式
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
"q": query,
|
||||||
|
"format": "jsonv2",
|
||||||
|
"limit": "1",
|
||||||
|
"countrycodes": "tw",
|
||||||
|
}
|
||||||
|
url = f"https://nominatim.openstreetmap.org/search?{urllib.parse.urlencode(params)}"
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": user_agent})
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as response:
|
||||||
|
data = json.loads(response.read().decode("utf-8"))
|
||||||
|
if data:
|
||||||
|
return {"lat": float(data[0]["lat"]), "lon": float(data[0]["lon"])}
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def add_coordinates(stations: List[Dict[str, str]], delay: float = 1.0) -> List[Dict[str, str]]:
|
||||||
|
"""為站點資料加上座標資訊。"""
|
||||||
|
for station in stations:
|
||||||
|
full_address = f"{station['縣市']}{station['鄉鎮區']}{station['地址']}"
|
||||||
|
coords = geocode(
|
||||||
|
address=full_address,
|
||||||
|
station_name=station["站名"],
|
||||||
|
city=station["縣市"],
|
||||||
|
district=station["鄉鎮區"],
|
||||||
|
)
|
||||||
|
if coords:
|
||||||
|
station["緯度"] = str(coords["lat"])
|
||||||
|
station["經度"] = str(coords["lon"])
|
||||||
|
else:
|
||||||
|
station["緯度"] = ""
|
||||||
|
station["經度"] = ""
|
||||||
|
time.sleep(delay) # 避免 Nominatim 請求過快
|
||||||
|
return stations
|
||||||
|
|
||||||
|
|
||||||
def build_query_html(city: str = "全部縣市", keyword: str = "") -> str:
|
def build_query_html(city: str = "全部縣市", keyword: str = "") -> str:
|
||||||
city_value = CITY_MAP.get(city, city)
|
city_value = CITY_MAP.get(city, city)
|
||||||
if city not in CITY_MAP and city != "全部縣市":
|
if city not in CITY_MAP and city != "全部縣市":
|
||||||
@@ -221,7 +279,7 @@ def output_json(stations: List[Dict[str, str]], out_path: str | None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def output_csv(stations: List[Dict[str, str]], out_path: str | None) -> None:
|
def output_csv(stations: List[Dict[str, str]], out_path: str | None) -> None:
|
||||||
fieldnames = ["縣市", "鄉鎮區", "類別", "站名", "站代號", "地址", "電話", "營業時間"]
|
fieldnames = ["縣市", "鄉鎮區", "類別", "站名", "站代號", "地址", "電話", "營業時間", "緯度", "經度"]
|
||||||
if out_path:
|
if out_path:
|
||||||
f = open(out_path, "w", encoding="utf-8-sig", newline="")
|
f = open(out_path, "w", encoding="utf-8-sig", newline="")
|
||||||
else:
|
else:
|
||||||
@@ -238,8 +296,11 @@ def output_csv(stations: List[Dict[str, str]], out_path: str | None) -> None:
|
|||||||
def output_text(stations: List[Dict[str, str]], out_path: str | None) -> None:
|
def output_text(stations: List[Dict[str, str]], out_path: str | None) -> None:
|
||||||
lines = [f"共 {len(stations)} 站"]
|
lines = [f"共 {len(stations)} 站"]
|
||||||
for idx, station in enumerate(stations, start=1):
|
for idx, station in enumerate(stations, start=1):
|
||||||
|
coord = ""
|
||||||
|
if station.get("緯度") and station.get("經度"):
|
||||||
|
coord = f" ({station['緯度']},{station['經度']})"
|
||||||
lines.append(
|
lines.append(
|
||||||
f"{idx}. {station['站名']}({station['站代號']})|{station['縣市']}{station['鄉鎮區']}|{station['地址']}|{station['電話']}|{station['營業時間']}"
|
f"{idx}. {station['站名']}({station['站代號']})|{station['縣市']}{station['鄉鎮區']}|{station['地址']}|{station['電話']}|{station['營業時間']}{coord}"
|
||||||
)
|
)
|
||||||
text = "\n".join(lines)
|
text = "\n".join(lines)
|
||||||
if out_path:
|
if out_path:
|
||||||
@@ -255,12 +316,17 @@ def main() -> None:
|
|||||||
parser.add_argument("--keyword", default="", help="關鍵字,可篩站名或地址")
|
parser.add_argument("--keyword", default="", help="關鍵字,可篩站名或地址")
|
||||||
parser.add_argument("--format", choices=["text", "json", "csv"], default="text")
|
parser.add_argument("--format", choices=["text", "json", "csv"], default="text")
|
||||||
parser.add_argument("--out", help="輸出到檔案路徑")
|
parser.add_argument("--out", help="輸出到檔案路徑")
|
||||||
|
parser.add_argument("--coords", action="store_true", help="加上座標資訊(使用 Nominatim OSM API)")
|
||||||
|
parser.add_argument("--delay", type=float, default=1.0, help="座標查詢間隔秒數(預設 1.0,Nominatim 限制)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
html = build_query_html(city=args.city, keyword=args.keyword)
|
html = build_query_html(city=args.city, keyword=args.keyword)
|
||||||
stations = parse_stations(html)
|
stations = parse_stations(html)
|
||||||
stations = filter_stations(stations, args.keyword)
|
stations = filter_stations(stations, args.keyword)
|
||||||
|
|
||||||
|
if args.coords:
|
||||||
|
stations = add_coordinates(stations, delay=args.delay)
|
||||||
|
|
||||||
if args.format == "json":
|
if args.format == "json":
|
||||||
output_json(stations, args.out)
|
output_json(stations, args.out)
|
||||||
elif args.format == "csv":
|
elif args.format == "csv":
|
||||||
|
|||||||
Reference in New Issue
Block a user