78 lines
2.5 KiB
Python
Executable File
78 lines
2.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""IP geolocation with a provider fallback chain, cached with a TTL.
|
|
|
|
Prints JSON: {lat, lon, city, country, ip, source}. Diagnostics go to stderr,
|
|
non-zero exit on total failure. Used by services/location.py (and runnable
|
|
standalone for testing).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astal-menu" / "location.json"
|
|
TTL = 1800 # seconds
|
|
|
|
PROVIDERS = [
|
|
("ip-api", "http://ip-api.com/json/?fields=lat,lon,city,country,query",
|
|
lambda d: {"lat": d["lat"], "lon": d["lon"], "city": d.get("city"),
|
|
"country": d.get("country"), "ip": d.get("query")}),
|
|
("ipapi.co", "https://ipapi.co/json/",
|
|
lambda d: {"lat": d["latitude"], "lon": d["longitude"], "city": d.get("city"),
|
|
"country": d.get("country_name"), "ip": d.get("ip")}),
|
|
("ipinfo", "https://ipinfo.io/json",
|
|
lambda d: {"lat": float(d["loc"].split(",")[0]), "lon": float(d["loc"].split(",")[1]),
|
|
"city": d.get("city"), "country": d.get("country"), "ip": d.get("ip")}),
|
|
]
|
|
|
|
|
|
def _cached() -> dict | None:
|
|
try:
|
|
blob = json.loads(CACHE.read_text())
|
|
if time.time() - blob.get("_ts", 0) < TTL:
|
|
return blob["data"]
|
|
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def _store(data: dict) -> None:
|
|
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
|
CACHE.write_text(json.dumps({"_ts": time.time(), "data": data}))
|
|
|
|
|
|
def _fetch(url: str) -> dict:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "astal-menu/1.0"})
|
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
return json.loads(resp.read().decode())
|
|
|
|
|
|
def main() -> int:
|
|
if "--no-cache" not in sys.argv:
|
|
hit = _cached()
|
|
if hit:
|
|
print(json.dumps(hit))
|
|
return 0
|
|
for name, url, parse in PROVIDERS:
|
|
try:
|
|
data = parse(_fetch(url))
|
|
data["source"] = name
|
|
if data.get("lat") is None or data.get("lon") is None:
|
|
raise ValueError("no coordinates")
|
|
_store(data)
|
|
print(json.dumps(data))
|
|
return 0
|
|
except Exception as exc: # noqa: BLE001 — try the next provider
|
|
print(f"{name}: {exc}", file=sys.stderr)
|
|
print("all geolocation providers failed", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|