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:
Su Yijun committed 2026-10-25 01:58:11 +08:00
1 parent ff5043bb8e
commit 636281f492
9 files changed
+382 -14

No files matched your search

+9
View File
@@ -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
View File
@@ -1 +1 @@
__version__ = "0.6.5"
__version__ = "0.6.6"
+147 -5
View File
@@ -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)
+8 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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)
+10
View File
@@ -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 = []
+126
View File
@@ -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()