- Add fuel_coords_db.py to download OSM fuel station data - 1299 CPC/related stations with coordinates - Query order: cache -> OSM database -> Nominatim API - Build database with: python3 fuel_coords_db.py --download Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
190 lines
6.0 KiB
Python
190 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
||
"""建立台灣加油站座標資料庫(來源:OpenStreetMap Overpass API)
|
||
|
||
用法:
|
||
# 下載 OSM 加油站資料並建立資料庫
|
||
python3 fuel_coords_db.py --download
|
||
|
||
# 查詢座標
|
||
python3 fuel_coords_db.py --query "台灣中油 社子"
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import math
|
||
import os
|
||
import urllib.request
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional
|
||
|
||
DB_FILE = Path(__file__).parent / "fuel_stations.json"
|
||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||
|
||
|
||
def download_osm_fuel_stations() -> List[Dict]:
|
||
"""從 OpenStreetMap Overpass API 下載台灣加油站資料。"""
|
||
query = """
|
||
[out:json][timeout:300];
|
||
area["name:zh-Hant"="臺灣"]->.searchArea;
|
||
(
|
||
node["amenity"="fuel"]["operator"~"CPC|中油|台灣中油"](area.searchArea);
|
||
way["amenity"="fuel"]["operator"~"CPC|中油|台灣中油"](area.searchArea);
|
||
relation["amenity"="fuel"]["operator"~"CPC|中油|台灣中油"](area.searchArea);
|
||
node["amenity"="fuel"]["brand"~"CPC|中油|台灣中油"](area.searchArea);
|
||
way["amenity"="fuel"]["brand"~"CPC|中油|台灣中油"](area.searchArea);
|
||
relation["amenity"="fuel"]["brand"~"CPC|中油|台灣中油"](area.searchArea);
|
||
node["amenity"="fuel"]["name"~"台塑|台亞"](area.searchArea);
|
||
way["amenity"="fuel"]["name"~"台塑|台亞"](area.searchArea);
|
||
);
|
||
out center;
|
||
"""
|
||
print("下載 OSM 加油站資料...", flush=True)
|
||
req = urllib.request.Request(
|
||
OVERPASS_URL,
|
||
data=query.encode("utf-8"),
|
||
headers={"Content-Type": "text/plain"},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=300) as response:
|
||
data = json.loads(response.read().decode("utf-8"))
|
||
print(f"找到 {len(data.get('elements', []))} 個加油站", flush=True)
|
||
return data.get("elements", [])
|
||
|
||
|
||
def build_database(elements: List[Dict]) -> Dict[str, Dict]:
|
||
"""建立站名 -> 座標 的對應資料庫。"""
|
||
db = {}
|
||
addresses = {} # 地址 -> 座標 (用於地址匹配)
|
||
|
||
for elem in elements:
|
||
tags = elem.get("tags", {})
|
||
name = tags.get("name") or tags.get("name:zh") or tags.get("name:zh-Hant")
|
||
if not name:
|
||
name = "加油站"
|
||
|
||
# 取得座標
|
||
if elem["type"] == "node":
|
||
lat, lon = elem["lat"], elem["lon"]
|
||
else:
|
||
lat, lon = elem.get("center", {}).get("lat"), elem.get("center", {}).get("lon")
|
||
if lat is None or lon is None:
|
||
continue
|
||
|
||
city = tags.get("addr:city") or tags.get("addr:city:zh") or ""
|
||
street = tags.get("addr:street") or tags.get("addr:street:zh") or ""
|
||
number = tags.get("addr:housenumber") or ""
|
||
|
||
# 建立多種查詢鍵
|
||
keys = [name]
|
||
if city:
|
||
keys.extend([f"{city}{name}", f"{name} ({city})"])
|
||
|
||
brand = tags.get("brand") or tags.get("operator")
|
||
if brand and brand not in name:
|
||
keys.append(f"{brand} {name}")
|
||
|
||
# 如果有完整地址,也加入地址鍵
|
||
if street:
|
||
full_addr = f"{city}{street}{number}".strip()
|
||
if full_addr:
|
||
addresses[full_addr] = {"lat": lat, "lon": lon, "source": "osm"}
|
||
keys.append(full_addr)
|
||
|
||
for key in keys:
|
||
key = key.strip()
|
||
if key and key not in db:
|
||
db[key] = {"lat": lat, "lon": lon, "source": "osm"}
|
||
|
||
# 合併地址庫
|
||
db.update(addresses)
|
||
return db
|
||
|
||
|
||
def save_database(db: Dict) -> None:
|
||
"""儲存資料庫。"""
|
||
with open(DB_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(db, f, ensure_ascii=False, indent=2)
|
||
print(f"資料庫已儲存至 {DB_FILE}")
|
||
|
||
|
||
def load_database() -> Dict:
|
||
"""載入資料庫。"""
|
||
if DB_FILE.exists():
|
||
with open(DB_FILE, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
return {}
|
||
|
||
|
||
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||
"""計算兩點間的距離(公里)。"""
|
||
R = 6371 # 地球半徑(公里)
|
||
dlat = math.radians(lat2 - lat1)
|
||
dlon = math.radians(lon2 - lon1)
|
||
a = (math.sin(dlat / 2) ** 2 +
|
||
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
|
||
math.sin(dlon / 2) ** 2)
|
||
c = 2 * math.asin(math.sqrt(a))
|
||
return R * c
|
||
|
||
|
||
def query_coords(name: str, city: str = "", db: Dict | None = None) -> Optional[Dict[str, float]]:
|
||
"""查詢加油站座標。"""
|
||
if db is None:
|
||
db = load_database()
|
||
|
||
if not db:
|
||
return None
|
||
|
||
# 精確匹配
|
||
queries = [name]
|
||
if city:
|
||
queries.extend([f"{city}{name}", f"{name} ({city})"])
|
||
|
||
for q in queries:
|
||
if q in db:
|
||
return db[q]
|
||
|
||
# 模糊匹配(包含站名)
|
||
for key, value in db.items():
|
||
if name in key or key in name:
|
||
return value
|
||
|
||
return None
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="台灣加油站座標資料庫")
|
||
parser.add_argument("--download", action="store_true", help="下載 OSM 資料並建立資料庫")
|
||
parser.add_argument("--query", help="查詢加油站座標")
|
||
parser.add_argument("--city", default="", help="查詢時指定縣市")
|
||
args = parser.parse_args()
|
||
|
||
if args.download:
|
||
elements = download_osm_fuel_stations()
|
||
db = build_database(elements)
|
||
save_database(db)
|
||
print(f"總共 {len(db)} 個查詢鍵")
|
||
|
||
elif args.query:
|
||
db = load_database()
|
||
if not db:
|
||
print("資料庫不存在,請先執行 --download", file=__import__("sys").stderr)
|
||
return
|
||
result = query_coords(args.query, args.city, db)
|
||
if result:
|
||
print(f"座標: {result['lat']}, {result['lon']}")
|
||
else:
|
||
print(f"找不到: {args.query}")
|
||
|
||
else:
|
||
db = load_database()
|
||
if db:
|
||
print(f"資料庫包含 {len(db)} 個查詢鍵")
|
||
else:
|
||
print("資料庫不存在,請先執行 --download", file=__import__("sys").stderr)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|