"""Offline IP lookups: city, region and network owner (ISP) from DB-IP Lite. IP geolocation by DB-IP (https://db-ip.com), licensed CC BY 4.0. The free files are published monthly; `python geo.py` fetches this month's (falling back to last month's while the new one is not out yet) into geoip/. The Docker build runs it, so every deploy carries current data. Without the files every lookup comes back empty and the stats still render. """ import gzip import logging import shutil import urllib.request from datetime import date, datetime, timedelta, timezone from functools import cache, lru_cache from ipaddress import ip_address from pathlib import Path import maxminddb GEO_DIR = Path(__file__).resolve().parent / "geoip" _FILES = {"city": "dbip-city-lite", "asn": "dbip-asn-lite"} _URL = "https://download.db-ip.com/free/{name}-{month}.mmdb.gz" EMPTY = {"country": "", "region": "", "city": "", "isp": ""} _log = logging.getLogger("ajaj.geo") def download(today: date | None = None) -> None: """Fetch this month's DB-IP Lite files, or last month's if not out yet.""" today = today or datetime.now(timezone.utc).date() months = [today, today.replace(day=1) - timedelta(days=1)] GEO_DIR.mkdir(exist_ok=True) for kind, name in _FILES.items(): for month in months: url = _URL.format(name=name, month=month.strftime("%Y-%m")) # The server refuses Python's default user agent. request = urllib.request.Request(url, headers={"User-Agent": "openajaj-geo"}) try: with urllib.request.urlopen(request, timeout=120) as response, \ gzip.open(response) as unzipped, \ (GEO_DIR / f"{kind}.mmdb").open("wb") as file: shutil.copyfileobj(unzipped, file) except OSError as error: _log.warning("%s: %s", url, error) continue print(f"geo: {url}") break else: raise SystemExit(f"geo: no {name} file for {months}") @cache def _reader(kind: str) -> maxminddb.Reader | None: try: return maxminddb.open_database(GEO_DIR / f"{kind}.mmdb") except (OSError, ValueError) as error: _log.warning("no %s database: %s", kind, error) return None def _get(kind: str, ip: str) -> dict: reader = _reader(kind) found = reader.get(ip) if reader else None return found if isinstance(found, dict) else {} def _name(entry: object) -> str: """English name of a DB-IP city/region entry, '' when missing.""" names = entry.get("names", {}) if isinstance(entry, dict) else {} return names.get("en", "") if isinstance(names, dict) else "" @lru_cache(maxsize=8192) def lookup(ip: str) -> dict[str, str]: """{country (ISO code), region, city, isp} for an IP; empty strings when unknown, private or not an address.""" try: if not ip_address(ip).is_global: return EMPTY except ValueError: return EMPTY city = _get("city", ip) subdivisions = city.get("subdivisions") or [{}] return { "country": city.get("country", {}).get("iso_code", ""), "region": _name(subdivisions[0]), "city": _name(city.get("city")), "isp": _get("asn", ip).get("autonomous_system_organization", ""), } if __name__ == "__main__": download()