feat: 发布 0.6.6,识别库智能化:上游更新自动建规则库、自动重算历史;修复 AdGuard Home 读到 0 条(debug14)
- 修复 AdGuard Home 新版查询日志 API 返回 {"data": [...]} 导致 0.6.5 按旧版 entries 解析、应答恒为 0
- 上游(blackmatrix7/Hagezi/UT1)出现新目录或列表时自动登记成新应用:类别按名称猜测,存 data/lists/rules_auto.yaml,手工映射优先
- 重算历史分类对纯 IP 行反查 DNS 缓存拿域名再分类;缓存过期不把已归类的行搬回笼统分类(防倒退)
- 识别库更新 / DNS 映射积累后自动重算历史(内容指纹判断,跟随「自动更新」开关);「立即检查更新」后同样触发
This commit is contained in:
1 parent
ff5043bb8e
commit
636281f492
9 files changed
+382
-14
No files matched your search
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## [0.6.6] - 2026-10-25
|
||||
|
||||
识别库智能化:上游更新自动建规则库、自动重算历史;修复 AdGuard Home 读到 0 条(debug14)
|
||||
|
||||
- **修复 AdGuard Home 一条应答都读不到**(0.6.5):新版 AdGuard Home 的查询日志 API 返回 `{"data": [...]}`,0.6.5 按旧版的 `{"entries": [...]}` 解析,结果"连接成功"却永远是 0 条。现在两种格式(以及直接数组)都认。
|
||||
- **上游规则库更新后自动建新规则库**(debug14):blackmatrix7 / Hagezi / UT1 的上游出现新的目录或列表时,自动登记成新应用——类别按目录名猜测(视频、游戏、购物、银行、AI 等 20 多组关键词,猜不出的归「常用网站」;分类列表猜不出则跳过),登记结果存在 `data/lists/rules_auto.yaml`,重启不丢;手工映射(bm7_map.yaml)优先级更高。汇总 / 区域 / 安全类目录(China、Global、Advertising 等)不参与自动登记。不再需要等新版本手工补映射。
|
||||
- **「重算历史分类」会反查 DNS 缓存**:纯 IP 的历史行先查 DNS 缓存(嗅探 / MosDNS / AdGuard Home 积累的 IP→域名映射)拿到域名再分类,AdGuard 接入后积累的映射能回溯到历史流量上;DNS 缓存过期后,已经归到具体应用的行保持不动,不会倒退回「国内网络 / 未识别」。
|
||||
- **识别库更新后自动重算历史分类**:识别规则保持「自动更新」开启时,识别库有实际变化(规则更新、DNS 映射积累)就自动执行一次历史重算(按内容指纹判断,没变化时只有几条计数查询的开销);点「立即检查更新」后同样触发。识别规则页的手动按钮保留。
|
||||
|
||||
## [0.6.5] - 2026-10-25
|
||||
|
||||
外部数据源支持自有 DNS 服务器(AdGuard Home)、ASN 识别中国自营内容商、历史分类重算;DPI 协议 17 色去重(debug13)
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.6.5"
|
||||
__version__ = "0.6.6"
|
||||
@@ -5,6 +5,9 @@
|
||||
- Hagezi DNS blocklists(GPL-3.0):成人、博彩、VPN/代理、社交、广告、各厂商设备遥测等专项分类列表。每天更新。
|
||||
- UT1(Université Toulouse Capitole,CC BY-SA 4.0):游戏、购物、新闻、求职、银行等分类列表。每天更新。
|
||||
|
||||
上游出现新的目录 / 列表时自动登记成新应用(debug14):类别按目录名猜测,登记结果存在
|
||||
data/lists/rules_auto.yaml,下次更新自动带上;手工映射仍在 bm7_map.yaml,优先级更高。
|
||||
|
||||
识别顺序和权重(详见 app/classify/__init__.py):
|
||||
- blackmatrix7 是应用级:和内置库 / v2fly 一起按域名匹配,匹配到的后缀更长者赢,一样长时内置库和 v2fly 优先。
|
||||
- Hagezi / UT1 是分类级:只知道是哪类网站,排在所有应用级信号(域名、IP 段、端口、ASN)之后、国内域名列表之前;
|
||||
@@ -16,6 +19,7 @@
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
@@ -32,6 +36,47 @@ MAP_FILE = os.path.join(os.path.dirname(__file__), "bm7_map.yaml")
|
||||
BM7_URL = "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/{0}/{0}.list"
|
||||
HAGEZI_URL = "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/wildcard/{0}-onlydomains.txt"
|
||||
UT1_URL = "https://raw.githubusercontent.com/olbat/ut1-blacklists/master/blacklists/{0}/domains"
|
||||
# 上游目录清单(自动发现用,每次更新最多查一次)
|
||||
BM7_LIST_API = "https://api.github.com/repos/blackmatrix7/ios_rule_script/contents/rule/Clash"
|
||||
HAGEZI_LIST_API = "https://api.github.com/repos/hagezi/dns-blocklists/contents/wildcard"
|
||||
UT1_LIST_API = "https://api.github.com/repos/olbat/ut1-blacklists/contents/blacklists"
|
||||
LIST_API = {"bm7": BM7_LIST_API, "hagezi": HAGEZI_LIST_API, "ut1": UT1_LIST_API}
|
||||
# bm7 的汇总 / 区域 / 安全类目录:不是单个应用,不自动登记
|
||||
BM7_SKIP = {
|
||||
"Global", "Proxy", "Direct", "Lan", "Reject", "AdBlock", "Advertising", "AdvertisingLite", "AntiAd",
|
||||
"China", "ChinaMedia", "ChinaTest", "ChinaMax", "ChinaMaxNoIP", "ChinaMaxNoMedia", "ChinaNoMedia",
|
||||
"ChinaIPs", "ChinaIPsBGP", "ChinaDNS", "DomesticIPs", "PublicDNS", "LocalDNS", "PrivateDNS", "DNS",
|
||||
"GoogleDNS", "ProxyGFW", "GFW", "GreatFire", "AntiPolicy", "Hijacking", "PotentialMalware", "Safety",
|
||||
"Spam", "TrackersList", "ZoneTelekom", "Classic", "3Type", "Cloud", "GlobalMedia", "IPTVOther",
|
||||
"Speedtest", "MicrosoftEdge", "AllAI", "Download", "Crypto", "Cryptocurrency", "Developer", "Anime",
|
||||
"AsianMedia", "Game", "Mail", "GoogleSearch", "Antutu", "Region", "TldCheap", "TldFake",
|
||||
}
|
||||
# 目录名 / 列表名 → 类别的猜测规则(debug14:上游新增内容自动归类;猜不出 bm7 归「常用网站」、分类列表跳过)
|
||||
GUESS = [
|
||||
(r"adult|porn|nsfw|xxx", "adult"), (r"gambl|bet|casino|lotter", "gambling"),
|
||||
(r"spotify|music|radio|podcast|tidal|deezer|netease_music|kugou|kuwo", "music"),
|
||||
(r"(^|[-_])(live|stream)([-_]|$)|twitch|douyu|huya|yy", "live"),
|
||||
(r"video|tv|movie|film|cinema|anime|manga|mgtv|youku|iqiyi|bilibili|netflix|hbo|disney|emby|iptv|cctv", "video"),
|
||||
(r"game|steam|epic|blizzard|riot|xbox|playstation|nintendo|4399|9game|taptap|mihoyo", "game"),
|
||||
(r"shop|taobao|tmall|jd|jingdong|pinduoduo|suning|vipshop|aliexpress|amazon|ebay|shopee|gome", "shopping"),
|
||||
(r"bank|icbc|ccb|cmb|abc|psbc|boc|bocom|citic|spdb|cib|ceb|cgb|spdb|unionpay|alipay|tenpay|paypal|pay", "bank"),
|
||||
(r"mail|smtp|imap|webmail", "mail"),
|
||||
(r"news|media|press|bbc|cnn|reuters|nytimes", "news"),
|
||||
(r"social|weibo|twitter|facebook|instagram|tiktok|douyin|reddit|forum|snapchat|linkedin|pin", "social"),
|
||||
(r"wechat|weixin|whatsapp|telegram|line|slack|dingtalk|feishu|skype|zoom|meeting", "im"),
|
||||
(r"openai|claude|gemini|copilot|chatgpt|anthropic|perplexity|midjourney|hf|\bai\b", "ai"),
|
||||
(r"cloud|drive|pan|onedrive|dropbox|aliyundrive|quark|s3|oss", "cloud"),
|
||||
(r"github|gitlab|gitcode|gitee|docker|npm|python|jetbrains|dev", "dev"),
|
||||
(r"proxy|vpn|tor|gfw|warp|outline|shadowsocks|v2ray|trojan|clash", "proxy"),
|
||||
(r"travel|trip|hotel|flight|airline|booking|ctrip|qunar|fliggy|12306|ticket", "travel"),
|
||||
(r"edu|mooc|university|course|learn|school|xueersi|yuanfudao|zuoyebang", "education"),
|
||||
(r"health|hospital|medic|pharma|doctor|clinic", "life"),
|
||||
(r"food|takeaway|waimai|meituan|eleme|restaurant|recipe", "life"),
|
||||
(r"cdn|static|cache|cloudfront|akamai|cloudflare|fastly|edge", "cdn"),
|
||||
(r"tracker|warez|pt|download|thunder|xunlei", "download"),
|
||||
(r"mine|mining|pool|nicehash", "mining"),
|
||||
(r"remote|ssh|rdp|vnc|teamviewer|todesk|rustdesk|anydesk", "remote"),
|
||||
]
|
||||
|
||||
# 分类列表:(列表名, 归到的应用)。顺序就是同一域名在多个列表里时的优先级
|
||||
HAGEZI = [(f"native.{v}", "gen_telemetry") for v in
|
||||
@@ -138,6 +183,15 @@ def load_map() -> dict:
|
||||
return {"owner": owner, "apps": m.get("apps") or {}}
|
||||
|
||||
|
||||
def guess_category(name: str) -> tuple[str, bool]:
|
||||
"""按目录名 / 列表名猜类别(debug14 自动登记用)。返回 (类别, 是否有把握);猜不出归「常用网站」"""
|
||||
low = name.lower()
|
||||
for pat, cat in GUESS:
|
||||
if re.search(pat, low):
|
||||
return cat, True
|
||||
return "site", False
|
||||
|
||||
|
||||
class Lists:
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
@@ -148,22 +202,53 @@ class Lists:
|
||||
self.cat = _HashTable()
|
||||
self.state: dict[str, dict] = {} # 来源 id -> {count, apps, updated, error}
|
||||
self._map = None
|
||||
self._auto: dict = {} # 自动登记的应用(debug14),存 data/lists/rules_auto.yaml
|
||||
self._load_auto()
|
||||
|
||||
@property
|
||||
def map(self) -> dict:
|
||||
if self._map is None:
|
||||
self._map = load_map()
|
||||
base = load_map()
|
||||
for aid, a in self._auto.items():
|
||||
base["apps"].setdefault(aid, {"category": a["category"], "name": a["name"], "name_en": a["name_en"]})
|
||||
if a.get("source") == "bm7" and a.get("dir"):
|
||||
base["owner"].setdefault(a["dir"], aid)
|
||||
self._map = base
|
||||
return self._map
|
||||
|
||||
def _dir(self, sid: str) -> str:
|
||||
return os.path.join(os.environ.get("DATA_DIR", "data"), "lists", sid)
|
||||
|
||||
def _auto_file(self) -> str:
|
||||
return os.path.join(self._dir("bm7"), "rules_auto.yaml")
|
||||
|
||||
def _load_auto(self):
|
||||
try:
|
||||
with open(self._auto_file(), encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
self._auto = data.get("apps") or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
self._auto = {}
|
||||
|
||||
def _save_auto(self):
|
||||
path = self._auto_file()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("# 自动发现并登记的第三方规则(debug14):上游出现新目录 / 新列表时自动生成。\n"
|
||||
"# 手工映射在镜像内的 bm7_map.yaml,优先级更高;删除本文件后,下次更新按当时的上游重新生成。\n")
|
||||
yaml.safe_dump({"apps": self._auto}, f, allow_unicode=True, sort_keys=True)
|
||||
|
||||
def _spec(self, sid: str) -> list[tuple[str, str]]:
|
||||
"""分类列表的 (列表名, 应用):手工映射在前,自动登记的追加在后面"""
|
||||
base = HAGEZI if sid == "hagezi" else UT1
|
||||
extra = [(a["list"], aid) for aid, a in self._auto.items() if a.get("source") == sid]
|
||||
return list(base) + extra
|
||||
|
||||
def _files(self, sid: str) -> list[tuple[str, str]]:
|
||||
"""(本地文件名, 下载地址)"""
|
||||
if sid == "bm7":
|
||||
return [(f"{n}.list", BM7_URL.format(urllib.parse.quote(n))) for n in sorted(self.map["owner"])]
|
||||
spec, url = (HAGEZI, HAGEZI_URL) if sid == "hagezi" else (UT1, UT1_URL)
|
||||
return [(f"{n}.txt", url.format(n)) for n, _app in spec]
|
||||
return [(f"{n}.txt", (HAGEZI_URL if sid == "hagezi" else UT1_URL).format(n)) for n, _app in self._spec(sid)]
|
||||
|
||||
# ---------- 加载 ----------
|
||||
@staticmethod
|
||||
@@ -201,9 +286,8 @@ class Lists:
|
||||
return suffix, full, ips, n, len(apps)
|
||||
|
||||
def _load_cat(self, sid: str) -> list[tuple[str, list[str]]]:
|
||||
spec = HAGEZI if sid == "hagezi" else UT1
|
||||
out = []
|
||||
for name, app in spec:
|
||||
for name, app in self._spec(sid):
|
||||
doms = []
|
||||
for line in self._read(os.path.join(self._dir(sid), f"{name}.txt")):
|
||||
s = line.strip().lower().lstrip("*.").strip(".")
|
||||
@@ -250,6 +334,11 @@ class Lists:
|
||||
def update(self, force: bool = False) -> bool:
|
||||
"""各来源满了更新周期(bm7 一周、其余一天)就重新下载;单个文件失败保留旧文件"""
|
||||
changed = False
|
||||
if _enabled("bm7") and (force or time.time() - self._stamp("bm7") >= MAX_AGE["bm7"]):
|
||||
try:
|
||||
self.discover() # debug14:上游新增的目录 / 列表先登记,同一轮就会下载
|
||||
except Exception: # noqa: BLE001 发现不了(没外网 / API 限流)不影响本轮更新
|
||||
pass
|
||||
for sid in ("bm7", "hagezi", "ut1"):
|
||||
if not _enabled(sid):
|
||||
continue
|
||||
@@ -263,6 +352,59 @@ class Lists:
|
||||
self.load()
|
||||
return changed
|
||||
|
||||
def discover(self) -> int:
|
||||
"""上游仓库出现新的规则目录 / 分类列表时自动登记成应用(debug14)。返回新登记个数。"""
|
||||
added = self._discover_bm7() + self._discover_spec("hagezi") + self._discover_spec("ut1")
|
||||
if added:
|
||||
self._save_auto()
|
||||
self._map = None # 让 map 重新合并,新应用 / 新目录立即生效
|
||||
return added
|
||||
|
||||
@staticmethod
|
||||
def _list_names(url: str, kind: str) -> set[str]:
|
||||
"""GitHub 目录清单:kind=dir 取目录名;kind=file 取去掉 onlydomains.txt 后缀的文件名"""
|
||||
r = httpx.get(url, timeout=30.0, headers={"User-Agent": "ros-dpi", "Accept": "application/vnd.github+json"})
|
||||
r.raise_for_status()
|
||||
out = set()
|
||||
for x in r.json():
|
||||
name = x.get("name", "")
|
||||
if x.get("type") == kind and not name.startswith("."):
|
||||
if kind == "file" and name.endswith("-onlydomains.txt"):
|
||||
name = name[:-len("-onlydomains.txt")]
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
def _register(self, source: str, names: set[str], strict: bool) -> int:
|
||||
added = 0
|
||||
known = set(self.map["owner"])
|
||||
for n in sorted(names):
|
||||
if n in known or n in BM7_SKIP:
|
||||
continue
|
||||
cat, confident = guess_category(n)
|
||||
if strict and not confident:
|
||||
continue # 分类列表猜不出类别就跳过:宁缺毋滥,等手工映射
|
||||
aid = "gen_" + re.sub(r"[^a-z0-9]+", "_", n.lower()).strip("_") if source != "bm7" \
|
||||
else "bm7_" + re.sub(r"[^a-z0-9]+", "_", n.lower()).strip("_")
|
||||
if aid in self._auto or aid in self.map["apps"] or aid in self.map["owner"].values():
|
||||
continue
|
||||
entry = {"category": cat, "name": n, "name_en": n, "source": source}
|
||||
if source == "bm7":
|
||||
entry["dir"] = n
|
||||
else:
|
||||
entry["list"] = n
|
||||
self._auto[aid] = entry
|
||||
known.add(n)
|
||||
added += 1
|
||||
return added
|
||||
|
||||
def _discover_bm7(self) -> int:
|
||||
return self._register("bm7", self._list_names(LIST_API["bm7"], "dir"), strict=False)
|
||||
|
||||
def _discover_spec(self, sid: str) -> int:
|
||||
if sid == "hagezi":
|
||||
return self._register("hagezi", self._list_names(LIST_API[sid], "file"), strict=True)
|
||||
return self._register("ut1", self._list_names(LIST_API[sid], "dir"), strict=True)
|
||||
|
||||
def _fetch_all(self, sid: str) -> tuple[int, int]:
|
||||
d = self._dir(sid)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
@@ -259,9 +259,14 @@ class AdguardPoller:
|
||||
self._connect()
|
||||
r = self._client.get("/control/querylog", params={"limit": self.PAGE, "response_status": "all"})
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
# 新版本返回 {"entries": [...]},旧版本直接返回数组
|
||||
return (data.get("entries") if isinstance(data, dict) else data) or []
|
||||
return self._entries(r.json())
|
||||
|
||||
@staticmethod
|
||||
def _entries(data) -> list:
|
||||
# 新版本返回 {"data": [...]},旧版本 {"entries": [...]} 或直接数组
|
||||
if isinstance(data, dict):
|
||||
return data.get("data") or data.get("entries") or []
|
||||
return data or []
|
||||
|
||||
def poll(self):
|
||||
if not self.enabled:
|
||||
|
||||
+17
-1
@@ -46,7 +46,8 @@ def _on_db_ready():
|
||||
"""数据库连接成功后(启动时或安装向导保存后)执行。各步互不依赖:一步出错只记日志,不挡住后面的
|
||||
(0.6.0 / 0.6.1 的主域名回填出错时,后面的外部数据源配置、自定义规则都没加载,外部数据源显示“未启用”)"""
|
||||
ensure_admin() # 建管理员失败要报出来(安装向导靠这个提示)
|
||||
for name, fn in (("load_state", engine.load_state), ("apply_settings", _apply_saved)):
|
||||
for name, fn in (("load_state", engine.load_state), ("apply_settings", _apply_saved),
|
||||
("auto_reclass", _auto_reclassify)):
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -70,6 +71,19 @@ def _auto_update_rules():
|
||||
updater.check(reload=engine.reload_classifier)
|
||||
|
||||
|
||||
def _auto_reclassify():
|
||||
"""识别库更新 / DNS 映射积累后自动重算历史分类(debug14):指纹没变就不动,变了才重算"""
|
||||
if not _auto_update_on():
|
||||
return
|
||||
from app.reclass import auto_reclassify
|
||||
with database.session() as db:
|
||||
res = auto_reclassify(engine, db)
|
||||
db.commit()
|
||||
if res:
|
||||
print(f"[reclass] 识别规则有更新,已自动重算历史分类:搬移 {res['bytes_moved'] / 1e6:.1f} MB"
|
||||
f"({res['seconds']}s)", flush=True)
|
||||
|
||||
|
||||
def _auto_update_db(fn):
|
||||
"""ASN / 补充库的定期下载跟随同一个开关;关掉后只用已下载的数据,手动「立即检查更新」仍然可用"""
|
||||
def run():
|
||||
@@ -104,6 +118,8 @@ async def lifespan(app: FastAPI):
|
||||
sched.add_job(_safe(integrations.singbox.poll, "singbox"), "interval", seconds=5, max_instances=1, coalesce=True)
|
||||
sched.add_job(_safe(integrations.mosdns.poll, "mosdns"), "interval", seconds=5, max_instances=1, coalesce=True)
|
||||
sched.add_job(_safe(integrations.adguard.poll, "adguard"), "interval", seconds=5, max_instances=1, coalesce=True)
|
||||
# 识别库更新 / DNS 映射积累后的自动重算:跟在规则更新任务后面跑,指纹没变时开销只有几条计数查询
|
||||
sched.add_job(_safe(_auto_reclassify, "auto-reclass"), "interval", hours=6, max_instances=1, coalesce=True)
|
||||
sched.add_job(_safe(_auto_update_rules, "rules-update"), "interval", hours=6, max_instances=1, coalesce=True)
|
||||
# 审计报表:日报每天 08:00 推送昨天的,周报每周一 08:30 推送近 7 天的(在审计 → 报表里开启)
|
||||
sched.add_job(_safe(lambda: audit.push_report("daily"), "report-daily"), "cron", hour=8, minute=0)
|
||||
|
||||
+55
-4
@@ -1,17 +1,27 @@
|
||||
"""按当前规则重算历史分类。
|
||||
"""历史分类重算:规则 / 识别库升级后,把历史流量搬到新归属。
|
||||
|
||||
以 host_domain_day(每天每终端每域名,31 天)为源:对每一行用现在的规则重新分类,分类结果变化的行,
|
||||
把 traffic_day 里对应 (天, 终端, 旧应用) 的上下行与连接数按字节占比搬到新应用,并把 host_domain_day 的
|
||||
app 标记同步改掉(保证重复执行幂等)。host_domain_day 只覆盖有域名、或进了当分钟 Top30 IP 的流量,
|
||||
其余流量保持原分类;traffic_minute / traffic_hour 不动,随保留期自然滚动。
|
||||
|
||||
纯 IP 的历史行会用 dns_cache 反查域名再分类(嗅探 / MosDNS / AdGuard Home 积累的映射)——DNS 缓存
|
||||
会过期,所以已经归到具体应用、又拿不到新域名证据的行保持不动,避免缓存过期让历史分类倒退。
|
||||
|
||||
识别库更新 / DNS 映射积累后自动重算:fingerprint() 覆盖各层库的规模和 DNS 缓存行数,指纹变了才执行
|
||||
(auto_reclassify),识别规则页的手动「重算历史分类」直接调 reclassify()。
|
||||
"""
|
||||
import ipaddress
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models import HostDomainDay, TrafficDay
|
||||
from app.models import DnsCache, HostDomainDay, Setting, TrafficDay
|
||||
|
||||
FP_KEY = "reclass_fp"
|
||||
# 纯 IP 行没有域名证据时,只有原本就归在这些笼统应用里的才重算(ASN / IP 段升级的直接受益者)
|
||||
GENERIC_OLD = {"cn_net", "cn_web", "other", "unknown", "https", "quic", "http", "web", "site"}
|
||||
|
||||
|
||||
def _is_ip(s: str) -> bool:
|
||||
@@ -22,9 +32,17 @@ def _is_ip(s: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _dns_domain_map(db) -> dict[str, str]:
|
||||
"""未过期的 IP→域名映射(嗅探 / MosDNS / AdGuard Home 积累的),给纯 IP 的历史行反查域名"""
|
||||
now = int(time.time())
|
||||
rows = db.execute(select(DnsCache.ip, DnsCache.name).where(DnsCache.expires > now)).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
def reclassify(engine, db) -> dict:
|
||||
t0 = time.time()
|
||||
classifier = engine.classifier
|
||||
cache = _dns_domain_map(db)
|
||||
moves: dict = defaultdict(lambda: defaultdict(int)) # (ts, host, 旧应用) -> {新应用: 字节}
|
||||
marks: list[dict] = []
|
||||
rows = db.execute(
|
||||
@@ -34,7 +52,13 @@ def reclassify(engine, db) -> dict:
|
||||
if not b:
|
||||
continue
|
||||
if _is_ip(dom):
|
||||
new_app = classifier.classify(dom, 6, 0, None, None)
|
||||
qname = cache.get(dom)
|
||||
if qname:
|
||||
new_app = classifier.classify(dom, 6, 0, qname, None)
|
||||
elif old_app in GENERIC_OLD:
|
||||
new_app = classifier.classify(dom, 6, 0, None, None)
|
||||
else:
|
||||
continue # 已归到具体应用、又没有新的域名证据:保持不动(DNS 缓存过期不应让历史倒退)
|
||||
else:
|
||||
new_app = classifier.classify("0.0.0.0", 6, 443, dom, None)
|
||||
new_app = new_app or "other"
|
||||
@@ -90,3 +114,30 @@ def reclassify(engine, db) -> dict:
|
||||
dst.flows += flows
|
||||
return {"scanned": len(rows), "rows_moved": len(dsts), "bytes_moved": sum(v[0] + v[1] for v in dsts.values()),
|
||||
"flows_moved": sum(v[2] for v in dsts.values()), "seconds": round(time.time() - t0, 1)}
|
||||
|
||||
|
||||
def fingerprint(engine, db) -> str:
|
||||
"""识别规则的有效指纹:任何一层库变了(v2fly 提交、第三方库规模、ASN 行数)或 DNS 映射积累变多都不一样"""
|
||||
from app.asn import asndb
|
||||
from app.classify.lists import lists as extra_lists
|
||||
from app.classify.updater import updater
|
||||
now = int(time.time())
|
||||
dns_n = db.scalar(select(func.count()).select_from(DnsCache).where(DnsCache.expires > now)) or 0
|
||||
libs = "|".join(f"{r['id']}:{r.get('domains', r.get('rules', 0))}" for r in extra_lists.rows())
|
||||
return "|".join([f"dns{dns_n}", f"asn{asndb.rows}", f"v2{updater.state.get('latest') or ''}",
|
||||
f"apps{len(engine.classifier.apps)}", libs])
|
||||
|
||||
|
||||
def auto_reclassify(engine, db) -> dict | None:
|
||||
"""识别库更新 / DNS 映射积累后自动重算(debug14):指纹没变就什么都不做,变了才重算并记住新指纹"""
|
||||
fp = fingerprint(engine, db)
|
||||
row = db.get(Setting, FP_KEY)
|
||||
if row is not None and row.value == fp:
|
||||
return None
|
||||
res = reclassify(engine, db)
|
||||
row = db.get(Setting, FP_KEY)
|
||||
if row is None:
|
||||
db.add(Setting(key=FP_KEY, value=fp))
|
||||
else:
|
||||
row.value = fp
|
||||
return res
|
||||
@@ -297,6 +297,15 @@ def library_update(request: Request, user: str = Depends(current_user), db: Sess
|
||||
from app.classify.lists import lists
|
||||
lists.update(force=True)
|
||||
oplog.log(db, request, user, "library_update", {"result": updater.state.get("result")})
|
||||
try:
|
||||
from app.reclass import auto_reclassify
|
||||
res = auto_reclassify(engine, db)
|
||||
if res:
|
||||
db.commit()
|
||||
oplog.log(db, request, user, "reclassify", res)
|
||||
except Exception as e: # noqa: BLE001 重算失败不影响更新结果
|
||||
db.rollback()
|
||||
print(f"[reclass] 手动更新后自动重算失败:{type(e).__name__}: {e}", flush=True)
|
||||
return _library(db)
|
||||
|
||||
|
||||
|
||||
@@ -65,6 +65,16 @@ def test_adguard_parse_entry():
|
||||
assert p._parse_entry({"domain": "nx.example.com", "client": "192.168.9.6"}) is None
|
||||
|
||||
|
||||
def test_adguard_entries_shapes():
|
||||
"""新版 AdGuard 返回 {"data": [...]}(线上实测),旧版 {"entries": [...]},更老的直接数组"""
|
||||
p = integrations.AdguardPoller(_engine())
|
||||
assert p._entries({"data": [1, 2], "oldest": "x"}) == [1, 2]
|
||||
assert p._entries({"entries": [3]}) == [3]
|
||||
assert p._entries([4]) == [4]
|
||||
assert p._entries({}) == []
|
||||
assert p._entries({"data": None}) == []
|
||||
|
||||
|
||||
class _FakeEngine:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""debug14:上游规则库自动发现、自动登记成应用、识别库更新后自动重算历史分类。"""
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.classify import lists as L
|
||||
from app.classify.lists import guess_category, lists
|
||||
from app.engine import Engine
|
||||
from app.models import DnsCache, HostDomainDay, Setting, TrafficDay
|
||||
from app.reclass import auto_reclassify, reclassify
|
||||
|
||||
DAY = 1792900000
|
||||
HOST = "IP:192.168.8.31"
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, payload):
|
||||
self._p = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._p
|
||||
|
||||
|
||||
def test_guess_category():
|
||||
assert guess_category("MangoTV")[0] == "video"
|
||||
assert guess_category("ICBCBank")[0] == "bank"
|
||||
assert guess_category("OpenAI")[0] == "ai"
|
||||
assert guess_category("IPTVMainland2")[0] == "video"
|
||||
# 猜不出:bm7 用「常用网站」兜底登记;Hagezi / UT1 分类列表直接跳过
|
||||
assert guess_category("RandomCorp") == ("site", False)
|
||||
|
||||
|
||||
def test_discover_registers_new_dirs(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
fake = [{"name": n, "type": "dir"} for n in ["BiliBili", "MangoTV", "RandomCorp", "Global", "HotMovies"]]
|
||||
called = []
|
||||
monkeypatch.setattr(L.httpx, "get",
|
||||
lambda url, **kw: called.append(url) or (_Resp(fake) if "ios_rule_script" in url else _Resp([])))
|
||||
try:
|
||||
lists.reset()
|
||||
lists._map = None
|
||||
lists._auto = {}
|
||||
lists._load_auto()
|
||||
added = lists.discover()
|
||||
assert added == 3 # BiliBili 已映射、Global 在跳过名单;其余 3 个自动登记
|
||||
m = lists.map
|
||||
assert m["apps"]["bm7_mangotv"]["category"] == "video"
|
||||
assert m["apps"]["bm7_randomcorp"]["category"] == "site"
|
||||
assert m["owner"]["MangoTV"] == "bm7_mangotv" and m["owner"]["HotMovies"] == "bm7_hotmovies"
|
||||
assert os.path.exists(lists._auto_file())
|
||||
assert called and "rule/Clash" in called[0]
|
||||
assert lists.discover() == 0 # 再发现一次:没有新增
|
||||
# 自动登记的应用分类器认得出(和手工映射同一条管线)
|
||||
from app.classify import Classifier
|
||||
c = Classifier()
|
||||
assert "bm7_mangotv" in c.apps and c.apps["bm7_mangotv"].category == "video"
|
||||
finally:
|
||||
lists.reset()
|
||||
lists._map = None
|
||||
lists._auto = {}
|
||||
|
||||
|
||||
def test_reclass_uses_dns_cache_for_ip_rows():
|
||||
import app.database as database
|
||||
database.connect_from_saved(force=True)
|
||||
s = database.SessionLocal()
|
||||
try:
|
||||
now = int(time.time())
|
||||
s.add(DnsCache(ip="203.0.113.9", name="v.douyin.com", expires=now + 3600))
|
||||
s.add(HostDomainDay(ts=DAY, host=HOST, domain="203.0.113.9", app="cn_net", base=None, bytes=500))
|
||||
s.add(TrafficDay(ts=DAY, host=HOST, app="cn_net", up=100, down=900, flows=10))
|
||||
s.commit()
|
||||
|
||||
class _C:
|
||||
@staticmethod
|
||||
def classify(ip, proto, port, qname, cname):
|
||||
return "douyin" if qname == "v.douyin.com" else "cn_net"
|
||||
|
||||
eng = Engine()
|
||||
eng.classifier = _C()
|
||||
res = reclassify(eng, s)
|
||||
s.commit()
|
||||
assert res["bytes_moved"] >= 500
|
||||
marks = {d.domain: d.app for d in s.query(HostDomainDay).filter(HostDomainDay.host == HOST).all()}
|
||||
assert marks["203.0.113.9"] == "douyin"
|
||||
|
||||
# 防倒退:DNS 缓存过期后,已归到具体应用的行不再被搬回笼统分类
|
||||
s.query(DnsCache).filter(DnsCache.ip == "203.0.113.9").delete()
|
||||
s.commit()
|
||||
res2 = reclassify(eng, s)
|
||||
s.commit()
|
||||
assert res2["bytes_moved"] == 0
|
||||
marks = {d.domain: d.app for d in s.query(HostDomainDay).filter(HostDomainDay.host == HOST).all()}
|
||||
assert marks["203.0.113.9"] == "douyin"
|
||||
finally:
|
||||
s.query(TrafficDay).filter(TrafficDay.host == HOST).delete()
|
||||
s.query(HostDomainDay).filter(HostDomainDay.host == HOST).delete()
|
||||
s.query(DnsCache).filter(DnsCache.ip == "203.0.113.9").delete()
|
||||
s.commit()
|
||||
s.close()
|
||||
|
||||
|
||||
def test_auto_reclassify_runs_on_fingerprint_change():
|
||||
import app.database as database
|
||||
database.connect_from_saved(force=True)
|
||||
s = database.SessionLocal()
|
||||
try:
|
||||
s.query(Setting).filter(Setting.key == "reclass_fp").delete()
|
||||
s.commit()
|
||||
eng = Engine()
|
||||
r1 = auto_reclassify(eng, s)
|
||||
s.commit()
|
||||
assert r1 is not None and "scanned" in r1
|
||||
assert auto_reclassify(eng, s) is None # 指纹没变:什么都不做
|
||||
s.add(DnsCache(ip="198.51.100.1", name="x.example.com", expires=int(time.time()) + 600))
|
||||
s.commit()
|
||||
assert auto_reclassify(eng, s) is not None # DNS 映射变多:指纹变化 → 重算
|
||||
finally:
|
||||
s.query(Setting).filter(Setting.key == "reclass_fp").delete()
|
||||
s.query(DnsCache).filter(DnsCache.ip == "198.51.100.1").delete()
|
||||
s.commit()
|
||||
s.close()
|
||||
Reference in new issue
Block a user