Add Live tab world chain map with hop geo arcs.
Plots YOU through each hop to EXIT on an equirectangular map with great-circle connectors, extends exit intel with lat/lon, and auto-refreshes on chain rotation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -38,6 +38,8 @@ class ExitIntel:
|
||||
city: str = ""
|
||||
region: str = ""
|
||||
timezone: str = ""
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
asn: str = ""
|
||||
org: str = ""
|
||||
isp: str = ""
|
||||
@@ -76,6 +78,7 @@ def _parse_ipapi(payload: dict[str, Any]) -> ExitIntel:
|
||||
asn = asn_raw.split()[0].lstrip("AS").strip() if asn_raw else ""
|
||||
org = str(payload.get("org") or payload.get("isp") or "")
|
||||
isp = str(payload.get("isp") or "")
|
||||
lat_raw, lon_raw = payload.get("lat"), payload.get("lon")
|
||||
return ExitIntel(
|
||||
ok=True,
|
||||
ip=str(payload.get("query") or ""),
|
||||
@@ -84,6 +87,8 @@ def _parse_ipapi(payload: dict[str, Any]) -> ExitIntel:
|
||||
city=str(payload.get("city") or ""),
|
||||
region=str(payload.get("regionName") or ""),
|
||||
timezone=str(payload.get("timezone") or ""),
|
||||
lat=float(lat_raw) if lat_raw is not None else None,
|
||||
lon=float(lon_raw) if lon_raw is not None else None,
|
||||
asn=asn,
|
||||
org=org,
|
||||
isp=isp,
|
||||
@@ -103,6 +108,7 @@ def _parse_ipwhois(payload: dict[str, Any]) -> ExitIntel:
|
||||
asn = str(asn_val) if asn_val is not None else ""
|
||||
org = str(conn.get("org") or "")
|
||||
isp = str(conn.get("isp") or "")
|
||||
lat_raw, lon_raw = payload.get("latitude"), payload.get("longitude")
|
||||
return ExitIntel(
|
||||
ok=True,
|
||||
ip=str(payload.get("ip") or ""),
|
||||
@@ -111,6 +117,8 @@ def _parse_ipwhois(payload: dict[str, Any]) -> ExitIntel:
|
||||
city=str(payload.get("city") or ""),
|
||||
region=str(payload.get("region") or ""),
|
||||
timezone=str((payload.get("timezone") or {}).get("id") or ""),
|
||||
lat=float(lat_raw) if lat_raw is not None else None,
|
||||
lon=float(lon_raw) if lon_raw is not None else None,
|
||||
asn=asn,
|
||||
org=org,
|
||||
isp=isp,
|
||||
@@ -122,18 +130,21 @@ def _parse_ipwhois(payload: dict[str, Any]) -> ExitIntel:
|
||||
)
|
||||
|
||||
|
||||
def fetch_exit_intel(proxy_url: str, timeout_seconds: float = 12.0) -> ExitIntel:
|
||||
"""Look up exit IP geo/ASN/datacenter through the given proxy.
|
||||
_IPAPI_FIELDS = (
|
||||
"status,message,country,countryCode,regionName,city,lat,lon,timezone,"
|
||||
"isp,org,as,mobile,proxy,hosting,query"
|
||||
)
|
||||
|
||||
Returns an ExitIntel with ok=False and detail set on failure. Never raises.
|
||||
"""
|
||||
|
||||
def _fetch_intel(
|
||||
*,
|
||||
proxy_url: str | None,
|
||||
timeout_seconds: float,
|
||||
) -> ExitIntel:
|
||||
"""Shared ip-api / ipwho.is lookup. ``proxy_url=None`` uses a direct connection."""
|
||||
timeout = httpx.Timeout(timeout_seconds, connect=min(6.0, timeout_seconds))
|
||||
sources = [
|
||||
(
|
||||
"http://ip-api.com/json/?fields=status,message,country,countryCode,"
|
||||
"regionName,city,timezone,isp,org,as,mobile,proxy,hosting,query",
|
||||
_parse_ipapi,
|
||||
),
|
||||
(f"http://ip-api.com/json/?fields={_IPAPI_FIELDS}", _parse_ipapi),
|
||||
("https://ipwho.is/", _parse_ipwhois),
|
||||
]
|
||||
last_err = ""
|
||||
@@ -162,3 +173,54 @@ def fetch_exit_intel(proxy_url: str, timeout_seconds: float = 12.0) -> ExitIntel
|
||||
except Exception as e:
|
||||
return ExitIntel(ok=False, detail=f"{type(e).__name__}: {e}")
|
||||
return ExitIntel(ok=False, detail=last_err or "no source responded")
|
||||
|
||||
|
||||
def fetch_exit_intel(proxy_url: str, timeout_seconds: float = 12.0) -> ExitIntel:
|
||||
"""Look up exit IP geo/ASN/datacenter through the given proxy.
|
||||
|
||||
Returns an ExitIntel with ok=False and detail set on failure. Never raises.
|
||||
"""
|
||||
return _fetch_intel(proxy_url=proxy_url, timeout_seconds=timeout_seconds)
|
||||
|
||||
|
||||
def fetch_ip_geo(ip: str, timeout_seconds: float = 8.0) -> ExitIntel:
|
||||
"""Direct geo lookup for a specific IP (used for hop / origin mapping).
|
||||
|
||||
Never raises.
|
||||
"""
|
||||
target = (ip or "").strip()
|
||||
if not target:
|
||||
return ExitIntel(ok=False, detail="empty ip")
|
||||
timeout = httpx.Timeout(timeout_seconds, connect=min(5.0, timeout_seconds))
|
||||
sources = [
|
||||
(
|
||||
f"http://ip-api.com/json/{target}?fields={_IPAPI_FIELDS}",
|
||||
_parse_ipapi,
|
||||
),
|
||||
(f"https://ipwho.is/{target}", _parse_ipwhois),
|
||||
]
|
||||
last_err = ""
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=timeout,
|
||||
verify=False,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
) as c:
|
||||
for url, parser in sources:
|
||||
try:
|
||||
r = c.get(url)
|
||||
if r.status_code != 200 or not r.content:
|
||||
last_err = f"{url} -> HTTP {r.status_code}"
|
||||
continue
|
||||
data = r.json()
|
||||
out = parser(data)
|
||||
if out.ok:
|
||||
return out
|
||||
last_err = out.detail
|
||||
except Exception as e:
|
||||
last_err = f"{type(e).__name__}: {e}"
|
||||
continue
|
||||
except Exception as e:
|
||||
return ExitIntel(ok=False, detail=f"{type(e).__name__}: {e}")
|
||||
return ExitIntel(ok=False, detail=last_err or "no source responded")
|
||||
|
||||
Reference in New Issue
Block a user