32c15f5365
- GeoIP: MMDB (GeoLite2-City + ASN) support with text fallback - Upload: two-phase flow with advanced preset filter builder - Clustering: IP communication network community discovery (/24 + HDBSCAN) - Map: per-IP node rendering, node click detail, edge click detail - Offline: Cache API tile caching with fallback - Export: node table + edge table CSV download - Fix: null-guard DOM operations throughout Co-Authored-By: Claude <noreply@anthropic.com>
300 lines
9.5 KiB
Python
300 lines
9.5 KiB
Python
"""
|
|
离线 GeoIP 查询 — 支持 MMDB (GeoLite2-City / GeoLite2-ASN) 和文本格式回退。
|
|
|
|
优先级:
|
|
1. GeoLite2-City.mmdb (MaxMind MMDB 格式,城市级精度)
|
|
2. GeoLite2-ASN.mmdb (可选,提供 ISP / 组织信息)
|
|
3. geoip_data.txt (文本格式回退,803 段覆盖 30 城市)
|
|
|
|
MMDB 查询使用 ``maxminddb`` 库,文本格式使用二分查找 O(log N)。
|
|
"""
|
|
import bisect
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data structures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# MMDB readers (lazy-loaded, None if unavailable)
|
|
_mmdb_city: Optional[object] = None # maxminddb.Reader for City DB
|
|
_mmdb_asn: Optional[object] = None # maxminddb.Reader for ASN DB
|
|
|
|
# Text-format data (fallback)
|
|
# [(start_int, end_int, lat, lon, city, country), ...]
|
|
_GEO_DATA: list[tuple[int, int, float, float, str, str]] = []
|
|
_STARTS: list[int] = []
|
|
_RANGES: list[tuple[int, int, float, float, str, str]] = []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# IP conversion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def ip_to_int(ip_str: str) -> Optional[int]:
|
|
"""Convert an IPv4 dotted-string to its 32-bit integer representation.
|
|
|
|
Returns ``None`` if *ip_str* is not a valid IPv4 address.
|
|
"""
|
|
parts = ip_str.strip().split('.')
|
|
if len(parts) != 4:
|
|
return None
|
|
try:
|
|
octets = [int(p) for p in parts]
|
|
if any(o < 0 or o > 255 for o in octets):
|
|
return None
|
|
return (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MMDB loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_mmdb() -> None:
|
|
"""Try to load MaxMind MMDB databases from ``data/`` directory.
|
|
|
|
Looks for:
|
|
- ``data/GeoLite2-City.mmdb`` (primary — city, country, lat/lon)
|
|
- ``data/GeoLite2-ASN.mmdb`` (optional — ISP, organization, ASN)
|
|
"""
|
|
global _mmdb_city, _mmdb_asn
|
|
|
|
data_dir = Path(__file__).parent.parent / 'data'
|
|
|
|
city_path = data_dir / 'GeoLite2-City.mmdb'
|
|
asn_path = data_dir / 'GeoLite2-ASN.mmdb'
|
|
|
|
if not city_path.exists():
|
|
logger.info('[GEOIP] GeoLite2-City.mmdb not found at %s — will use text fallback', city_path)
|
|
return
|
|
|
|
try:
|
|
import maxminddb
|
|
_mmdb_city = maxminddb.open_database(str(city_path))
|
|
logger.info('[GEOIP] loaded GeoLite2-City.mmdb (MMDB format)')
|
|
|
|
if asn_path.exists():
|
|
_mmdb_asn = maxminddb.open_database(str(asn_path))
|
|
logger.info('[GEOIP] loaded GeoLite2-ASN.mmdb (ISP/ORG)')
|
|
except Exception as e:
|
|
logger.warning('[GEOIP] failed to open MMDB: %s', e)
|
|
_mmdb_city = None
|
|
_mmdb_asn = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Text-format loading (fallback)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_index() -> tuple[list[int], list[tuple[int, int, float, float, str, str]]]:
|
|
if not _GEO_DATA:
|
|
return [], []
|
|
sorted_data = sorted(_GEO_DATA, key=lambda x: x[0])
|
|
starts = [entry[0] for entry in sorted_data]
|
|
return starts, sorted_data
|
|
|
|
|
|
def _load_data_file() -> None:
|
|
"""Load IP range data from ``data/geoip_data.txt`` (text fallback)."""
|
|
data_path = Path(__file__).parent.parent / 'data' / 'geoip_data.txt'
|
|
|
|
if not data_path.exists():
|
|
logger.warning('[GEOIP] text data file not found: %s', data_path)
|
|
return
|
|
|
|
loaded: list[tuple[int, int, float, float, str, str]] = []
|
|
skipped = 0
|
|
|
|
with open(data_path, 'r', encoding='utf-8') as f:
|
|
for raw in f:
|
|
stripped = raw.strip()
|
|
if not stripped or stripped.startswith('#'):
|
|
continue
|
|
parts = [p.strip() for p in stripped.split(',')]
|
|
if len(parts) != 6:
|
|
skipped += 1
|
|
continue
|
|
start_ip_str, end_ip_str, lat_str, lon_str, city, country = parts
|
|
start_int = ip_to_int(start_ip_str)
|
|
end_int = ip_to_int(end_ip_str)
|
|
if start_int is None or end_int is None:
|
|
skipped += 1
|
|
continue
|
|
try:
|
|
lat = float(lat_str)
|
|
lon = float(lon_str)
|
|
except ValueError:
|
|
skipped += 1
|
|
continue
|
|
loaded.append((start_int, end_int, lat, lon, city, country))
|
|
|
|
global _GEO_DATA, _STARTS, _RANGES
|
|
_GEO_DATA.clear()
|
|
_GEO_DATA.extend(loaded)
|
|
_STARTS, _RANGES = _build_index()
|
|
|
|
logger.info('[GEOIP] text fallback: loaded %d ranges from %s (%d skipped)',
|
|
len(loaded), data_path, skipped)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MMDB lookup helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _lookup_mmdb(ip_str: str) -> Optional[dict]:
|
|
"""Query MMDB database for an IP. Returns rich result with ISP/ORG when
|
|
ASN database is also available."""
|
|
if _mmdb_city is None:
|
|
return None
|
|
|
|
try:
|
|
city_result = _mmdb_city.get(ip_str)
|
|
if city_result is None:
|
|
return None
|
|
|
|
# Extract location
|
|
location = city_result.get('location', {})
|
|
lat = location.get('latitude')
|
|
lon = location.get('longitude')
|
|
|
|
if lat is None or lon is None:
|
|
return None
|
|
|
|
# Extract names (English preferred)
|
|
city = None
|
|
city_names = city_result.get('city', {}).get('names', {})
|
|
if city_names:
|
|
city = city_names.get('en') or next(iter(city_names.values()), None)
|
|
|
|
country = None
|
|
country_info = city_result.get('country', {})
|
|
country = country_info.get('iso_code') or ''
|
|
country_names = country_info.get('names', {})
|
|
country_name = country_names.get('en') or next(iter(country_names.values()), None) if country_names else None
|
|
|
|
# Subdivisions
|
|
subdivisions = city_result.get('subdivisions', [])
|
|
region = None
|
|
if subdivisions:
|
|
sub_names = subdivisions[0].get('names', {})
|
|
region = sub_names.get('en') or next(iter(sub_names.values()), None) if sub_names else None
|
|
|
|
result = {
|
|
'lat': round(float(lat), 4),
|
|
'lon': round(float(lon), 4),
|
|
'city': city or region or '',
|
|
'country': country,
|
|
'country_name': country_name or '',
|
|
'region': region or '',
|
|
}
|
|
|
|
# Try ASN database for ISP/ORG
|
|
if _mmdb_asn is not None:
|
|
try:
|
|
asn_result = _mmdb_asn.get(ip_str)
|
|
if asn_result:
|
|
result['isp'] = asn_result.get('autonomous_system_organization', '')
|
|
result['asn'] = asn_result.get('autonomous_system_number', '')
|
|
except Exception:
|
|
pass
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.debug('[GEOIP] MMDB lookup error for %s: %s', ip_str, e)
|
|
return None
|
|
|
|
|
|
def _lookup_text(ip_str: str) -> Optional[dict]:
|
|
"""Query text-format database (fallback)."""
|
|
target = ip_to_int(ip_str)
|
|
if target is None:
|
|
return None
|
|
if not _STARTS:
|
|
return None
|
|
|
|
idx = bisect.bisect_right(_STARTS, target) - 1
|
|
if idx < 0:
|
|
return None
|
|
|
|
start, end, lat, lon, city, country = _RANGES[idx]
|
|
if start <= target <= end:
|
|
return {
|
|
'lat': lat,
|
|
'lon': lon,
|
|
'city': city,
|
|
'country': country,
|
|
'country_name': '',
|
|
'region': '',
|
|
}
|
|
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def lookup(ip_str: str) -> Optional[dict]:
|
|
"""查询 IP 对应的地理位置。
|
|
|
|
Tries MMDB first (if available), then falls back to text format.
|
|
|
|
Parameters
|
|
----------
|
|
ip_str:
|
|
IPv4 address as a dotted string (e.g. ``'8.8.8.8'``).
|
|
|
|
Returns
|
|
-------
|
|
dict or None
|
|
Keys: ``lat``, ``lon``, ``city``, ``country`` (ISO code),
|
|
``country_name``, ``region``.
|
|
When ASN database is available, also includes: ``isp``, ``asn``.
|
|
"""
|
|
if not ip_str or ip_str in ('None', '', '+', '-'):
|
|
return None
|
|
|
|
# MMDB first
|
|
if _mmdb_city is not None:
|
|
result = _lookup_mmdb(ip_str)
|
|
if result is not None:
|
|
return result
|
|
# MMDB miss — fall through to text
|
|
|
|
# Text fallback
|
|
return _lookup_text(ip_str)
|
|
|
|
|
|
def is_available() -> bool:
|
|
"""Return True if at least one GeoIP source is loaded."""
|
|
return _mmdb_city is not None or bool(_STARTS)
|
|
|
|
|
|
def source_name() -> str:
|
|
"""Return human-readable name of the active GeoIP source."""
|
|
if _mmdb_city is not None:
|
|
name = 'GeoLite2-City.mmdb'
|
|
if _mmdb_asn is not None:
|
|
name += ' + GeoLite2-ASN.mmdb'
|
|
return name
|
|
if _STARTS:
|
|
return 'geoip_data.txt (text fallback)'
|
|
return '(none)'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level initialisation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_load_mmdb()
|
|
if _mmdb_city is None:
|
|
_load_data_file()
|
|
|
|
logger.info('[GEOIP] active source: %s', source_name())
|