网络页焕新:IP 画像/质量检测、链路诊断与功能开关

新增 /ip_profile、/ip_quality API 与网络 UI 组件,默认隐藏摄像头/拉流导航;同步更新 README。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-08 12:47:53 +08:00
parent 119e52cfe9
commit ae9531f2c2
23 changed files with 6966 additions and 543 deletions

414
web2_ip_quality.py Normal file
View File

@@ -0,0 +1,414 @@
"""
IP 质量检测(集成 xykt/IPQuality 思路与数据源)。
脚本来源third_party/IPQuality/ip.sh (v2026-03-29)
可用数据源ipinfo.check.place、ipinfo.io、api.ipapi.is、流媒体解锁探测
"""
from __future__ import annotations
import asyncio
import re
import time
from typing import Any, Optional
import httpx
SCRIPT_VERSION = "v2026-03-29"
UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
CONNECT_TIMEOUT = 8.0
READ_TIMEOUT = 14.0
TYPE_LABELS = {
"business": "商业",
"isp": "运营商",
"hosting": "机房",
"education": "教育",
"government": "政府",
"banking": "金融",
"other": "其他",
}
RISK_LABELS = {
"verylow": "极低",
"low": "",
"elevated": "偏高",
"high": "",
"veryhigh": "极高",
"suspicious": "可疑",
"risky": "风险",
"highrisk": "高危",
}
def _type_label(raw: Optional[str]) -> str:
if not raw:
return "未知"
return TYPE_LABELS.get(str(raw).lower(), str(raw))
def _parse_abuser_score(text: Optional[str]) -> tuple[Optional[float], Optional[str]]:
if not text:
return None, None
m = re.match(r"([\d.]+)\s*\(([^)]+)\)", str(text).strip())
if not m:
return None, None
try:
num = float(m.group(1))
except ValueError:
num = None
risk = m.group(2).strip().lower().replace(" ", "")
risk_key = {
"verylow": "verylow",
"low": "low",
"elevated": "elevated",
"high": "high",
"veryhigh": "veryhigh",
}.get(risk, risk)
return num, risk_key
def _ipqs_risk(score: Optional[int]) -> Optional[str]:
if score is None:
return None
if score < 75:
return "low"
if score < 85:
return "suspicious"
if score < 90:
return "risky"
return "highrisk"
async def _fetch_json(
client: httpx.AsyncClient, url: str, *, label: str = ""
) -> tuple[Optional[dict[str, Any]], Optional[str]]:
try:
r = await client.get(url)
if r.status_code != 200:
return None, f"{label or url}: HTTP {r.status_code}"
data = r.json()
if not isinstance(data, dict):
return None, f"{label or url}: 非 JSON 对象"
return data, None
except Exception as e:
return None, f"{label or url}: {e}"
async def _fetch_text(client: httpx.AsyncClient, url: str, **kwargs: Any) -> tuple[str, Optional[str]]:
try:
r = await client.get(url, **kwargs)
if r.status_code != 200:
return "", f"HTTP {r.status_code}"
return r.text, None
except Exception as e:
return "", str(e)
async def _resolve_public_ip(client: httpx.AsyncClient) -> tuple[Optional[str], Optional[str]]:
for url in ("https://api.ipify.org?format=json", "https://api64.ipify.org?format=json"):
data, err = await _fetch_json(client, url, label="ipify")
if data and data.get("ip"):
return str(data["ip"]), None
if err:
last = err
return None, last if "last" in dir() else "无法获取公网 IP"
async def _fetch_maxmind(client: httpx.AsyncClient, ip: str) -> dict[str, Any]:
data, err = await _fetch_json(client, f"https://ipinfo.check.place/{ip}?lang=cn", label="maxmind")
if not data:
return {"error": err, "lite": True}
return {
"asn": data.get("ASN", {}).get("AutonomousSystemNumber"),
"org": data.get("ASN", {}).get("AutonomousSystemOrganization"),
"city": data.get("City", {}).get("Name"),
"postal": data.get("City", {}).get("PostalCode"),
"lat": data.get("City", {}).get("Latitude"),
"lon": data.get("City", {}).get("Longitude"),
"timezone": (data.get("City", {}).get("Location") or {}).get("TimeZone"),
"country_code": data.get("Country", {}).get("IsoCode"),
"country": data.get("Country", {}).get("Name"),
"reg_country_code": (data.get("Country", {}).get("RegisteredCountry") or {}).get("IsoCode"),
"reg_country": (data.get("Country", {}).get("RegisteredCountry") or {}).get("Name"),
"continent": (data.get("City", {}).get("Continent") or {}).get("Name"),
"lite": False,
}
async def _fetch_ipinfo_widget(client: httpx.AsyncClient, ip: str) -> dict[str, Any]:
data, err = await _fetch_json(client, f"https://ipinfo.io/widget/demo/{ip}", label="ipinfo")
if not data:
return {"error": err}
inner = data.get("data") if isinstance(data.get("data"), dict) else {}
privacy = inner.get("privacy") or {}
asn = inner.get("asn") or {}
loc = str(inner.get("loc") or "")
lat, lon = (loc.split(",", 1) + [None, None])[:2]
return {
"usage_type": asn.get("type"),
"company_type": (inner.get("company") or {}).get("type"),
"country_code": inner.get("country"),
"city": inner.get("city"),
"timezone": inner.get("timezone"),
"asn": str(asn.get("asn", "")).replace("AS", ""),
"org": asn.get("name"),
"lat": lat,
"lon": lon,
"proxy": privacy.get("proxy"),
"tor": privacy.get("tor"),
"vpn": privacy.get("vpn"),
"hosting": privacy.get("hosting"),
}
async def _fetch_ipapi(client: httpx.AsyncClient, ip: str) -> dict[str, Any]:
data, err = await _fetch_json(client, f"https://api.ipapi.is/?q={ip}", label="ipapi")
if not data:
return {"error": err}
score_num, risk_key = _parse_abuser_score((data.get("company") or {}).get("abuser_score"))
loc = data.get("location") or {}
return {
"usage_type": (data.get("asn") or {}).get("type"),
"company_type": (data.get("company") or {}).get("type"),
"country_code": loc.get("country_code"),
"city": loc.get("city"),
"country": loc.get("country"),
"timezone": loc.get("timezone"),
"asn": (data.get("asn") or {}).get("asn"),
"org": (data.get("asn") or {}).get("org"),
"lat": loc.get("latitude"),
"lon": loc.get("longitude"),
"proxy": data.get("is_proxy"),
"tor": data.get("is_tor"),
"vpn": data.get("is_vpn"),
"hosting": data.get("is_datacenter"),
"abuser": data.get("is_abuser"),
"crawler": data.get("is_crawler"),
"score_num": score_num,
"score_pct": round(score_num * 100, 2) if score_num is not None else None,
"risk_key": risk_key,
}
async def _fetch_check_place_db(client: httpx.AsyncClient, ip: str, db: str) -> dict[str, Any]:
data, err = await _fetch_json(client, f"https://ipinfo.check.place/{ip}?db={db}", label=db)
if not data:
return {"error": err}
return data
async def _test_youtube(client: httpx.AsyncClient) -> dict[str, Any]:
cookies = (
"YSC=BiCUU3-5Gdk; CONSENT=YES+cb.20220301-11-p0.en+FX+700; "
"GPS=1; VISITOR_INFO1_LIVE=4VwPMkB7W5A; PREF=tz=Asia.Shanghai"
)
text, err = await _fetch_text(
client,
"https://www.youtube.com/premium",
headers={"Accept-Language": "en", "Cookie": cookies},
)
if err:
return {"status": "error", "status_label": "检测失败", "region": None, "error": err}
if "www.google.cn" in text:
return {"status": "cn", "status_label": "中国版", "region": "CN"}
if "Premium is not available in your country" in text:
return {"status": "no_premium", "status_label": "无 Premium", "region": None}
region_m = re.search(r'"contentRegion":"([^"]+)"', text)
region = region_m.group(1) if region_m else None
if "ad-free" in text:
return {"status": "yes", "status_label": "Premium 可用", "region": region}
return {"status": "bad", "status_label": "状态未知", "region": region}
async def _test_tiktok(client: httpx.AsyncClient) -> dict[str, Any]:
text, err = await _fetch_text(client, "https://www.tiktok.com/", headers={"Accept-Language": "en"})
if err:
return {"status": "error", "status_label": "检测失败", "region": None, "error": err}
if "Please wait" in text:
return {"status": "blocked", "status_label": "需验证/封锁", "region": None}
region_m = re.search(r'"region":"([A-Z]{2})"', text) or re.search(r'"countryCode":"([A-Z]{2})"', text)
region = region_m.group(1) if region_m else None
if region:
return {"status": "yes", "status_label": "可访问", "region": region}
return {"status": "maybe", "status_label": "页面可达", "region": region}
def _bool_factor(value: Any) -> Optional[bool]:
if value is True or value == "true":
return True
if value is False or value == "false":
return False
return None
def _build_info(ip: str, maxmind: dict[str, Any], ipinfo: dict[str, Any], ipapi: dict[str, Any]) -> dict[str, Any]:
if not maxmind.get("lite") and not maxmind.get("error"):
src = maxmind
ip_type = "原生 IP" if src.get("country_code") == src.get("reg_country_code") else "广播 IP"
else:
src = ipinfo if not ipinfo.get("error") else ipapi
cc = src.get("country_code")
ip_type = "原生 IP" if cc else "未知"
return {
"ip": ip,
"asn": maxmind.get("asn") or ipinfo.get("asn") or ipapi.get("asn"),
"organization": maxmind.get("org") or ipinfo.get("org") or ipapi.get("org"),
"city": maxmind.get("city") or ipinfo.get("city") or ipapi.get("city"),
"country": maxmind.get("country") or ipapi.get("country"),
"country_code": maxmind.get("country_code") or ipinfo.get("country_code") or ipapi.get("country_code"),
"timezone": maxmind.get("timezone") or ipinfo.get("timezone") or ipapi.get("timezone"),
"latitude": maxmind.get("lat") or ipinfo.get("lat") or ipapi.get("lat"),
"longitude": maxmind.get("lon") or ipinfo.get("lon") or ipapi.get("lon"),
"ip_type": ip_type,
"mode_lite": bool(maxmind.get("lite")),
}
def _build_type_usage(ipinfo: dict[str, Any], ipapi: dict[str, Any]) -> dict[str, str]:
out: dict[str, str] = {}
if not ipinfo.get("error"):
out["IPinfo"] = _type_label(ipinfo.get("usage_type"))
out["IPinfo_company"] = _type_label(ipinfo.get("company_type"))
if not ipapi.get("error"):
out["ipapi"] = _type_label(ipapi.get("usage_type"))
out["ipapi_company"] = _type_label(ipapi.get("company_type"))
return out
def _build_scores(ipapi: dict[str, Any], ipqs: dict[str, Any], scam: dict[str, Any]) -> list[dict[str, Any]]:
scores: list[dict[str, Any]] = []
if ipapi.get("score_pct") is not None:
scores.append(
{
"source": "ipapi",
"value": ipapi["score_pct"],
"unit": "%",
"risk": ipapi.get("risk_key"),
"risk_label": RISK_LABELS.get(str(ipapi.get("risk_key") or ""), ipapi.get("risk_key")),
}
)
if ipqs.get("fraud_score") is not None:
fs = int(ipqs["fraud_score"])
rk = _ipqs_risk(fs)
scores.append(
{
"source": "IPQS",
"value": fs,
"unit": "",
"risk": rk,
"risk_label": RISK_LABELS.get(rk or "", rk),
}
)
scam_score = scam.get("score") or scam.get("scamalytics_score")
if scam_score is not None:
try:
sv = float(scam_score)
scores.append({"source": "SCAMALYTICS", "value": sv, "unit": "", "risk": None, "risk_label": ""})
except (TypeError, ValueError):
pass
return scores
def _build_factors(
ipinfo: dict[str, Any], ipapi: dict[str, Any], ipqs: dict[str, Any]
) -> dict[str, dict[str, Optional[bool]]]:
factors: dict[str, dict[str, Optional[bool]]] = {
"Proxy": {},
"VPN": {},
"Tor": {},
"Hosting": {},
}
if not ipinfo.get("error"):
factors["Proxy"]["IPinfo"] = _bool_factor(ipinfo.get("proxy"))
factors["VPN"]["IPinfo"] = _bool_factor(ipinfo.get("vpn"))
factors["Tor"]["IPinfo"] = _bool_factor(ipinfo.get("tor"))
factors["Hosting"]["IPinfo"] = _bool_factor(ipinfo.get("hosting"))
if not ipapi.get("error"):
factors["Proxy"]["ipapi"] = _bool_factor(ipapi.get("proxy"))
factors["VPN"]["ipapi"] = _bool_factor(ipapi.get("vpn"))
factors["Tor"]["ipapi"] = _bool_factor(ipapi.get("tor"))
factors["Hosting"]["ipapi"] = _bool_factor(ipapi.get("hosting"))
if not ipqs.get("error"):
factors["Proxy"]["IPQS"] = _bool_factor(ipqs.get("proxy"))
factors["VPN"]["IPQS"] = _bool_factor(ipqs.get("vpn"))
factors["Tor"]["IPQS"] = _bool_factor(ipqs.get("tor"))
return factors
async def run_ip_quality() -> dict[str, Any]:
timeout = httpx.Timeout(READ_TIMEOUT, connect=CONNECT_TIMEOUT)
limits = httpx.Limits(max_keepalive_connections=8, max_connections=12)
errors: list[str] = []
async with httpx.AsyncClient(
timeout=timeout,
limits=limits,
verify=True,
headers={"User-Agent": UA},
follow_redirects=True,
) as client:
ip, ip_err = await _resolve_public_ip(client)
if not ip:
return {"error": ip_err or "无法获取公网 IP", "meta": {"at": time.time()}}
maxmind_t = asyncio.create_task(_fetch_maxmind(client, ip))
ipinfo_t = asyncio.create_task(_fetch_ipinfo_widget(client, ip))
ipapi_t = asyncio.create_task(_fetch_ipapi(client, ip))
ipqs_t = asyncio.create_task(_fetch_check_place_db(client, ip, "ipqualityscore"))
scam_t = asyncio.create_task(_fetch_check_place_db(client, ip, "scamalytics"))
yt_t = asyncio.create_task(_test_youtube(client))
tt_t = asyncio.create_task(_test_tiktok(client))
maxmind, ipinfo, ipapi, ipqs_raw, scam_raw, youtube, tiktok = await asyncio.gather(
maxmind_t, ipinfo_t, ipapi_t, ipqs_t, scam_t, yt_t, tt_t
)
ipqs = ipqs_raw if not ipqs_raw.get("error") else {}
scam = scam_raw if not scam_raw.get("error") else {}
for label, blob in (("maxmind", maxmind), ("ipinfo", ipinfo), ("ipapi", ipapi), ("ipqs", ipqs_raw), ("scam", scam_raw)):
if blob.get("error"):
errors.append(f"{label}: {blob['error']}")
info = _build_info(ip, maxmind, ipinfo, ipapi)
usage = _build_type_usage(ipinfo, ipapi)
scores = _build_scores(ipapi, ipqs, scam)
factors = _build_factors(ipinfo, ipapi, ipqs)
media = {
"Youtube": youtube,
"TikTok": tiktok,
}
proxy_flags = [v for row in factors.values() for v in row.values() if v is True]
headline = "出口较干净,类型偏住宅/运营商" if not proxy_flags else "检测到代理/VPN/机房特征,录制前请确认"
return {
"head": {
"ip": ip,
"version": SCRIPT_VERSION,
"project": "IPQuality",
"github": "https://github.com/xykt/IPQuality",
},
"info": info,
"type": {"usage": usage},
"scores": scores,
"factors": factors,
"media": media,
"summary": {"headline": headline, "proxy_like": bool(proxy_flags)},
"meta": {
"at": time.time(),
"mode": "lite" if info.get("mode_lite") else "full",
"sources": [
"ipinfo.check.place",
"ipinfo.io",
"api.ipapi.is",
"xykt/IPQuality",
],
"errors": errors,
"disclaimer": "部分数据库IPQS/Scamalytics可能因网络环境不可用结果仅供参考。",
},
}