fix: 发布 0.6.7,修复历史重算反复搬移 / 降级、识别库自动登记误判、手动深色主题配色重叠(debug13、debug14 复查)
- 重算只处理今天以前的数据:0.6.6 每次自动重算都把当天累计字节再搬一遍 - 重算只升级不降级:不再把实时按 IP 段 / 端口认出的应用改回 HTTPS、把 HTTPS 改成其他 - 自动重算按识别库变化或过零点触发,不再看 DNS 缓存行数;启动 15 分钟后才首跑 - 上游新目录下载后按现有识别库投票定归属(并入已有应用 / 建该类应用),名字整词匹配兜底;跳过公司级大杂烩和广告隐私列表 - 自动登记的新应用无需重启生效 - 手动深色主题换成 17 色;操作日志脱敏 AdGuard 密码 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
636281f492
commit
f2d4f4145a
14 files changed
+491
-163
No files matched your search
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## [0.6.7] - 2026-10-25
|
||||
|
||||
修复 0.6.5 / 0.6.6 的历史重算和识别库自动登记(debug13、debug14 复查)
|
||||
|
||||
- **自动重算不再反复搬走今天的流量**:今天的行每分钟落库都会把应用改回实时分类、字节继续累加,0.6.6 每次自动重算都把当天累计的字节再搬一遍(实测 3 次后某应用当天流量被搬空)。现在重算(含手动按钮)只处理今天以前的数据;今天的流量本来就按最新规则实时统计,过了零点自动补算。顺带避免了重算与落库同时写同一行。
|
||||
- **重算只升级、不降级**:历史行只存了域名或 IP,没有端口、目标 IP、sing-box 规则这些实时信号,0.6.6 用 `0.0.0.0:443`(域名行)和端口 0(IP 行)重跑,会把实时按 IP 段认出的应用改成 HTTPS、把 HTTPS 改成「其他」,反而抬高「其他 / 未识别」。现在新结果是笼统分类(HTTPS / 国内网站 / 国内网络 / 未识别 / CDN)时不搬;原本是笼统分类的认出具体应用才搬;原本已是具体应用的,只有域名规则直接命中另一个应用时才改。
|
||||
- **自动重算的触发条件**:不再看 DNS 缓存行数(它几乎时刻在变,0.6.6 等于每 6 小时全量重算一次 31 天),改为识别库版本 / 规模变化或过了零点;启动后等 15 分钟再跑第一次,不会用还没加载完的识别库去算历史。
|
||||
- **上游新目录改为“投票”定归属**:0.6.6 按目录名子串猜类别,会把 Facebook 认成银行(含 ceb)、Stripe 认成旅行、HeroesoftheStorm 认成代理,还会把 Tencent、Google、Alibaba 这类公司级大杂烩和 AdGuardSDNSFilter、EasyPrivacy 等广告 / 隐私列表登记成应用。现在新目录先下载,再拿它的域名和 IP 段问现有识别库:多数属于同一个已有应用就并入(如 Facebook、TelegramSG),多数属于同一大类就建该类应用,都不够才按目录名猜(按整词匹配),猜不出归「常用网站」;公司级大杂烩和广告 / 隐私 / 拦截 / 纯 IP 汇总列表不登记。Hagezi / UT1 新列表名字能明确猜出类别才登记,下载后投票明显不符的作废;登记的列表并到该类的「其他 xx 网站」,不多建应用。
|
||||
- 自动登记的新应用不用重启即生效(0.6.6 要等重启或改自定义规则后分类器才认得)。登记结果在 `data/lists/bm7/rules_auto.yaml`。
|
||||
- **手动选「深色」主题时 DPI 大类配色仍是旧的 9 色循环**(0.6.5 只改了浅色和跟随系统的深色),HTTP协议 与 流量代理 等 8 对同色;改为同一套 17 色。
|
||||
- 操作日志不再明文记录 AdGuard Home 密码。
|
||||
|
||||
## [0.6.6] - 2026-10-25
|
||||
|
||||
识别库智能化:上游更新自动建规则库、自动重算历史;修复 AdGuard Home 读到 0 条(debug14)
|
||||
|
||||
@@ -8,6 +8,9 @@ Source & docs: https://github.com/suyijun8182/ros-dpi
|
||||
|
||||
## What's new
|
||||
|
||||
- **0.6.7**: fixes for the 0.6.5 / 0.6.6 history reclassification and rule auto-registration. Reclassification now only touches days before today (0.6.6 kept re-moving today's bytes on every run because the live writer resets each row's app every minute) and only upgrades vague rows (HTTPS / Chinese websites / unknown / CDN) to a specific app — it no longer downgrades apps that were identified live by IP range, port or ASN. It runs when a rule library changes or after midnight, starting 15 minutes after boot. New upstream blackmatrix7 directories are downloaded first and then assigned by a vote of the existing libraries (merge into an existing app, or create an app in the majority group), falling back to whole-word name matching; company-wide bundles (Tencent, Google…) and ad / privacy lists are skipped. Also: the manual dark theme now uses the 17 distinct group colors, and the AdGuard Home password is masked in the operation log.
|
||||
- **0.6.6**: AdGuard Home's newer query-log format (`{"data": [...]}`) is now read (0.6.5 always got 0 answers); new upstream rule directories / lists are registered automatically; history reclassification looks up the DNS cache for IP-only rows and runs automatically after library updates.
|
||||
- **0.6.5**: AdGuard Home as an external DNS source (for double-NAT setups where the router can't see downstream DNS); ASN ranges for Chinese first-party content providers (ByteDance, Bilibili, iQIYI, miHoYo, JD, Taobao, NetEase…); "Reclassify history" on the Rules page re-assigns the last 31 days with current rules; 17 distinct DPI group colors.
|
||||
- **0.6.4**: app-group colors now match WayOS's DPI pie (ECharts' 9 default colors cycled over the 17 groups), and the traffic-mix pie is drawn in the fixed group order like WayOS; the rule-library panel is grouped by tier (apps by domain, apps by IP, categories by domain, fallbacks) with a one-line explanation per tier; the four official IP-range feeds are merged into one row.
|
||||
- **0.6.3**: app groups now follow WayOS's 17 DPI classes (HTTP, online games, video/TV, P2P, common protocols, messaging, music, stock trading, VoIP, proxies, databases, mobile apps, special protocols, mining pools, PCDN, other, unknown), always listed in that order; new categories HTTPS/QUIC, databases (by port), mining pools and PCDN. Category ids are unchanged, so history is kept; saved analysis settings and group-based audit rules are migrated automatically. On the Applications page, "Details" now opens a WayOS-style "DPI protocol details" dialog that drills down group → category → app.
|
||||
- **0.6.2**: fixes external sources (sing-box / MosDNS) showing "not enabled" after 0.6.0 — a broken upgrade backfill aborted startup initialisation, so saved sing-box / MosDNS settings, LAN ranges and custom rules were not loaded after a restart; no need to re-enter anything. Three more rule sources, downloaded at runtime (not in the image): **blackmatrix7** per-app rules (GPL-2.0, weekly) add domains / IP ranges to ~460 existing apps and ~100 new apps; **Hagezi** (GPL-3.0) and **UT1** (CC BY-SA 4.0) category lists (adult, gambling, VPN/proxy, device telemetry, games, shopping, news, job search, banks…). Order and weights: app-level domain sources (built-in, v2fly, blackmatrix7) are compared by longest matching suffix, ties go to built-in > v2fly > blackmatrix7; then app-level IP (CIDR, ports, ASN); then category lists (Hagezi > UT1); then the China domain list and IP-range fallbacks. On the Tranco top-10k, the share mapped to a specific category went from 19.6% to 35.7%. New category: Gambling. Disable with `BM7_DB` / `HAGEZI_DB` / `UT1_DB=false`. Also: notification channels now match EasySub exactly — 14 channels with the same fields: Telegram (API proxy, HTTP proxy, check bot / get Chat ID), Feishu app bot, new QQ bot, Bark icon, email SSL switch, Pushplus channel, WeCom group robot **or self-built app** (CorpID / AgentId / Secret, API proxy, user / department / tag, text card, "check app"), webhook timeout and retries. The scenario (Home / Business) is now a global setting under **Settings → Scenario & network**; in Business mode the menu says "Employees" instead of "Family members", and the last scenario is cached so the menu no longer flashes the wrong label. Identification order: domain fallbacks (China domain list) are tried before IP-range fallbacks, and overlapping IP ranges use longest-prefix match (before, a small range inside a big one could hide the big one). The "Auto update" switch now also controls the ASN and fallback list downloads. Note: Feishu now uses an app bot (App ID / Secret / Chat IDs) like EasySub; an old Feishu group-webhook config has to be re-entered.
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
- **日报 / 周报**和**操作日志**。
|
||||
- **管控命令**:在人员或终端详情里,按应用、时段生成 RouterOS 命令(封禁应用、定时断网、限速),附撤销命令。ROS-DPI 只生成命令,不会修改路由器。
|
||||
- **识别规则**:按域名后缀、IP 段、端口自定义识别规则;列出“未识别域名 Top”,一键归类;查看识别库的版本和更新状态。
|
||||
- **系统设置**:接入与自检(检查 RouterOS 上的镜像规则)、外部数据源(sing-box / MosDNS)、**日志与留存**(Syslog 外发、各类数据保留天数、数据库占用)、**用户管理**(管理员可以修改一切;普通用户只读,可以改自己的密码)。
|
||||
- **系统设置**:接入与自检(检查 RouterOS 上的镜像规则)、外部数据源(sing-box / MosDNS / AdGuard Home)、**日志与留存**(Syslog 外发、各类数据保留天数、数据库占用)、**用户管理**(管理员可以修改一切;普通用户只读,可以改自己的密码)。
|
||||
- 顶栏功能搜索(终端、IP、MAC、应用、页面),按 / 键聚焦。中英双语,深浅色主题,手机可用。
|
||||
|
||||

|
||||
@@ -205,6 +205,8 @@ MySQL 第一次初始化需要 20~60 秒。
|
||||
|
||||
应用识别靠三份库:内置规则库(手工维护,随版本更新)、[v2fly/domain-list-community](https://github.com/v2fly/domain-list-community)(每天自动检查更新,自检通过后热加载)、[iptoasn.com](https://iptoasn.com) 的 IP 归属库(每天自动下载,也用来显示国家 / 地区)。三者的版本和状态在 **识别规则 → 识别库** 查看,也可以关闭自动更新。终端类型识别另外用 IEEE 公开的 MAC 厂商库(OUI,每 30 天更新)。自动更新需要容器能访问外网;文件保存在 `./data` 卷里。没有外网时设 `ASN_DB=false`、`OUI_DB=false`,其余功能不受影响。
|
||||
|
||||
blackmatrix7 上游新增的规则目录会自动登记:先下载,再用现有识别库给它的域名和 IP 段“投票”——多数属于某个已有应用就并入,多数属于同一大类就建该类应用,都不够才按目录名猜;公司级大杂烩(Tencent、Google 等)和广告 / 隐私列表不登记。识别库有变化或过了零点,会自动把今天以前的历史流量按新规则重算(只把 HTTPS / 国内网站 / 未识别这类笼统分类升级成具体应用,不会降级);**识别规则** 页也可以手动执行。
|
||||
|
||||
世界地图使用 [Natural Earth](https://www.naturalearthdata.com/)(公有领域)的简化数据。
|
||||
|
||||
### 环境变量
|
||||
@@ -277,7 +279,7 @@ backend/.venv/bin/python scripts/simulate.py --minutes 5
|
||||
- **Daily / weekly reports** and an **operation log**.
|
||||
- **Control commands**: generate RouterOS commands that block apps, block internet on a schedule or set rate limits for a person or device, with undo commands. ROS-DPI never writes to the router.
|
||||
- **Rules**: custom domain / CIDR / port rules, top unidentified domains, and library versions and update status.
|
||||
- **Settings**: setup & self-check of the RouterOS mirror rules, external sources (sing-box / MosDNS), **logging & retention** (Syslog forwarding, retention per data type, database usage), and **users** (admins change everything; regular users are read-only).
|
||||
- **Settings**: setup & self-check of the RouterOS mirror rules, external sources (sing-box / MosDNS / AdGuard Home), **logging & retention** (Syslog forwarding, retention per data type, database usage), and **users** (admins change everything; regular users are read-only).
|
||||
- Chinese and English UI, light and dark themes, works on phones.
|
||||
|
||||
**How it works.** RouterOS exports IPFIX (`/ip traffic-flow`) for byte counts, and mirrors DNS answers plus the first packets of each HTTPS/QUIC connection via `sniff-tzsp`. The TLS / QUIC handshake carries the target domain (SNI) per connection; DNS answers map remote IPs back to the domain the client looked up. A rule library maps domains to apps. An optional read-only REST user provides hostnames, IP-to-MAC, WAN rate and LAN prefixes. Data is aggregated per minute, hour and day into MySQL.
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.6.6"
|
||||
__version__ = "0.6.7"
|
||||
@@ -108,11 +108,7 @@ class Classifier:
|
||||
self._add(a["id"], a.get("domains", []), [], [], first_wins=True)
|
||||
for d in a.get("full", []):
|
||||
self.full.setdefault(d, a["id"])
|
||||
# blackmatrix7 新增的应用(规则运行时下载,见 lists.py;对应关系在 bm7_map.yaml)
|
||||
from app.classify.lists import lists as bm7
|
||||
for aid, a in bm7.map["apps"].items():
|
||||
if aid not in self.apps and a.get("category") in self.categories:
|
||||
self.apps[aid] = App(aid, str(a["name"]), str(a.get("name_en", a["name"])), a["category"])
|
||||
self._sync_list_apps()
|
||||
self.apps[UNKNOWN_APP] = App(UNKNOWN_APP, "其他", "Other", "unknown")
|
||||
|
||||
custom_nets, custom_ports = [], []
|
||||
@@ -136,6 +132,14 @@ class Classifier:
|
||||
self._cache: dict[tuple, str] = {}
|
||||
self._ver = None
|
||||
|
||||
def _sync_list_apps(self):
|
||||
"""blackmatrix7 新增的应用(规则运行时下载,见 lists.py;对应关系在 bm7_map.yaml,另有上游新目录自动登记的)。
|
||||
识别库重新加载后也调一次:自动登记的新应用不用重启就生效"""
|
||||
from app.classify.lists import lists as bm7
|
||||
for aid, a in bm7.map["apps"].items():
|
||||
if aid not in self.apps and a.get("category") in self.categories:
|
||||
self.apps[aid] = App(aid, str(a["name"]), str(a.get("name_en", a["name"])), a["category"])
|
||||
|
||||
def _add(self, app_id, domains, cidrs, ports, first_wins):
|
||||
for d in domains:
|
||||
d = d.lower().strip(".")
|
||||
@@ -181,6 +185,7 @@ class Classifier:
|
||||
if ver != self._ver: # 第三方库重新加载过:之前的结果作废
|
||||
self._cache.clear()
|
||||
self._ver = ver
|
||||
self._sync_list_apps()
|
||||
key = (remote_ip, proto, port, qname, cname)
|
||||
hit = self._cache.get(key)
|
||||
if hit:
|
||||
|
||||
+200
-78
@@ -5,8 +5,11 @@
|
||||
- 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,优先级更高。
|
||||
上游出现新的目录 / 列表时自动登记(debug14),结果存在 data/lists/bm7/rules_auto.yaml,手工映射(bm7_map.yaml)优先:
|
||||
- blackmatrix7 新目录先下载,再拿它的域名去问现有识别库“投票”:多数域名属于同一个已有应用 → 并入该应用;
|
||||
多数属于同一大类 → 新建该类应用;投票不够(多是没人认识的新应用)才按目录名猜类别,猜不出归「常用网站」。
|
||||
公司级大杂烩(Tencent、Google…)、广告 / 隐私 / 汇总列表不登记。
|
||||
- Hagezi / UT1 新列表只有列表名能明确猜出类别时才登记,下载后投票明显不符的作废。
|
||||
|
||||
识别顺序和权重(详见 app/classify/__init__.py):
|
||||
- blackmatrix7 是应用级:和内置库 / v2fly 一起按域名匹配,匹配到的后缀更长者赢,一样长时内置库和 v2fly 优先。
|
||||
@@ -25,6 +28,7 @@ import time
|
||||
import urllib.parse
|
||||
from array import array
|
||||
from bisect import bisect_left
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import httpx
|
||||
@@ -41,41 +45,55 @@ BM7_LIST_API = "https://api.github.com/repos/blackmatrix7/ios_rule_script/conten
|
||||
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 的汇总 / 区域 / 安全类目录、公司级大杂烩:不是单个应用,不自动登记(同 bm7_map.yaml 开头的说明)
|
||||
BM7_SKIP = {
|
||||
"Global", "Proxy", "Direct", "Lan", "Reject", "AdBlock", "Advertising", "AdvertisingLite", "AntiAd",
|
||||
"Global", "Proxy", "ProxyLite", "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",
|
||||
"Alibaba", "Baidu", "ByteDance", "Tencent", "Google", "Microsoft", "Apple", "Huawei", "Amazon",
|
||||
"BlockHttpDNS", "SystemOTA", "STUN", "Scholar", "GlobalScholar", "UKMedia", "USMedia",
|
||||
}
|
||||
# 目录名 / 列表名 → 类别的猜测规则(debug14:上游新增内容自动归类;猜不出 bm7 归「常用网站」、分类列表跳过)
|
||||
# 名字像广告 / 隐私 / 拦截 / 纯 IP 列表的也跳过(AdGuardSDNSFilter、EasyPrivacy、AdvertisingMiTV、AmazonIP…)
|
||||
BM7_SKIP_RE = re.compile(r"^(Ad[A-Z]|Advertising|AdGuard|Easy)|Privacy|Marketing|Block|Tracker|Filter|IPs?$")
|
||||
# 投票:至少这么多条被现有库认出才算数(小目录认出一半以上也算);同一应用占 ≥60% 并入该应用,同一大类占 ≥50% 定为该类
|
||||
VOTE_MIN, VOTE_APP, VOTE_CAT, VOTE_SAMPLE = 5, 0.6, 0.5, 5000
|
||||
# 目录名 / 列表名 → 类别(投票不够时才用)。按单词匹配:短词(<5 个字母)必须是完整的单词,
|
||||
# 免得 Facebook 里的 ceb 认成银行、Minecraft 里的 mine 认成挖矿、HeroesoftheStorm 里的 tor 认成代理
|
||||
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"),
|
||||
(("adult", "porn", "nsfw", "xxx"), "adult"), (("gambl", "bet", "betting", "casino", "lotter"), "gambling"),
|
||||
(("spotify", "music", "radio", "podcast", "tidal", "deezer", "kugou", "kuwo"), "music"),
|
||||
(("live", "stream", "twitch", "douyu", "huya", "yy"), "live"),
|
||||
(("video", "tv", "movie", "film", "cinema", "anime", "manga", "mgtv", "youku", "iqiyi", "bilibili", "netflix",
|
||||
"hbo", "disney", "emby", "iptv", "cctv"), "video"),
|
||||
(("game", "games", "steam", "epic", "blizzard", "riot", "xbox", "playstation", "nintendo", "4399", "taptap",
|
||||
"mihoyo"), "game"),
|
||||
(("shop", "shopping", "taobao", "tmall", "jd", "jingdong", "pinduoduo", "suning", "vipshop", "aliexpress",
|
||||
"amazon", "ebay", "shopee", "gome"), "shopping"),
|
||||
(("bank", "icbc", "ccb", "cmb", "abc", "psbc", "boc", "bocom", "citic", "spdb", "cib", "ceb", "cgb",
|
||||
"unionpay", "alipay", "tenpay", "paypal", "pay"), "bank"),
|
||||
(("mail", "smtp", "imap", "webmail"), "mail"),
|
||||
(("news", "media", "press", "bbc", "cnn", "reuters", "nytimes"), "news"),
|
||||
(("social", "weibo", "twitter", "facebook", "instagram", "tiktok", "douyin", "reddit", "forum", "forums",
|
||||
"snapchat", "linkedin", "pinterest"), "social"),
|
||||
(("wechat", "weixin", "whatsapp", "telegram", "line", "slack", "dingtalk", "feishu", "skype", "zoom",
|
||||
"meeting", "chat"), "im"),
|
||||
(("openai", "claude", "gemini", "copilot", "chatgpt", "anthropic", "perplexity", "midjourney", "hf", "ai"), "ai"),
|
||||
(("cdn", "static", "cache", "cloudfront", "akamai", "cloudflare", "fastly", "edge"), "cdn"),
|
||||
(("cloud", "drive", "pan", "onedrive", "dropbox", "aliyundrive", "quark", "s3", "oss"), "cloud"),
|
||||
(("github", "gitlab", "gitcode", "gitee", "docker", "npm", "python", "jetbrains", "dev"), "dev"),
|
||||
(("proxy", "vpn", "tor", "gfw", "warp", "outline", "shadowsocks", "v2ray", "trojan", "clash"), "proxy"),
|
||||
(("travel", "trip", "hotel", "flight", "airline", "booking", "ctrip", "qunar", "fliggy", "12306", "ticket"),
|
||||
"travel"),
|
||||
(("edu", "mooc", "university", "course", "learn", "school", "xueersi", "yuanfudao", "zuoyebang"), "education"),
|
||||
(("health", "hospital", "medic", "pharma", "doctor", "clinic", "food", "takeaway", "waimai", "meituan",
|
||||
"eleme", "restaurant", "recipe"), "life"),
|
||||
(("tracker", "warez", "pt", "download", "thunder", "xunlei"), "download"),
|
||||
(("mining", "mine", "pool", "nicehash"), "mining"),
|
||||
(("remote", "ssh", "rdp", "vnc", "teamviewer", "todesk", "rustdesk", "anydesk"), "remote"),
|
||||
]
|
||||
|
||||
# 分类列表:(列表名, 归到的应用)。顺序就是同一域名在多个列表里时的优先级
|
||||
@@ -183,15 +201,43 @@ def load_map() -> dict:
|
||||
return {"owner": owner, "apps": m.get("apps") or {}}
|
||||
|
||||
|
||||
def _words(name: str) -> set[str]:
|
||||
"""MangoTV → {mango, tv};ICBCBank → {icbc, bank};native.tiktok → {native, tiktok}"""
|
||||
return {w.lower() for w in re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+", name)}
|
||||
|
||||
|
||||
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
|
||||
"""按目录名 / 列表名猜类别(debug14 自动登记、投票不够时用)。返回 (类别, 是否有把握);猜不出归「常用网站」"""
|
||||
low, words = name.lower(), _words(name)
|
||||
for keys, cat in GUESS:
|
||||
for k in keys:
|
||||
if (k in low) if len(k) >= 5 else (k in words):
|
||||
return cat, True
|
||||
return "site", False
|
||||
|
||||
|
||||
def _vote(classifier, cat_of, domains: list[str], ips: list[str] = ()) -> tuple[str | None, float, str | None, float, int]:
|
||||
"""拿一份新列表的域名(和 IP 段)问现有识别库:返回 (最多的应用, 占比, 最多的大类, 占比, 认出的条数)。
|
||||
协议 / CDN / 未识别这类笼统结果不算票。IP 段取网络地址按 IP 段 / ASN 识别(TelegramSG 这类只有 IP 的目录)"""
|
||||
from app.classify import GENERIC_APPS, UNIDENTIFIED_CATEGORIES, WEAK_PREFIXES
|
||||
apps, cats = Counter(), Counter()
|
||||
found = [classifier.match_domain(d) or cat_of(d) for d in domains[:VOTE_SAMPLE]]
|
||||
found += [classifier.classify(ip, 6, 0, None, None) for ip in ips[:VOTE_SAMPLE]]
|
||||
for aid in found:
|
||||
if not aid or aid in GENERIC_APPS or aid.startswith(WEAK_PREFIXES):
|
||||
continue
|
||||
cat = classifier.app_info(aid).category
|
||||
if cat in UNIDENTIFIED_CATEGORIES:
|
||||
continue
|
||||
apps[aid] += 1
|
||||
cats[cat] += 1
|
||||
n = sum(apps.values())
|
||||
if not n:
|
||||
return None, 0.0, None, 0.0, 0
|
||||
(app, na), (cat, nc) = apps.most_common(1)[0], cats.most_common(1)[0]
|
||||
return app, na / n, cat, nc / n, n
|
||||
|
||||
|
||||
class Lists:
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
@@ -210,9 +256,12 @@ class Lists:
|
||||
if self._map is None:
|
||||
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("pending") or a.get("rejected"):
|
||||
continue # 还没投票 / 投票作废的不进映射
|
||||
if not a.get("extend") and not a.get("app"):
|
||||
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)
|
||||
base["owner"].setdefault(a["dir"], a.get("extend") or aid)
|
||||
self._map = base
|
||||
return self._map
|
||||
|
||||
@@ -241,14 +290,21 @@ class Lists:
|
||||
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]
|
||||
extra = [(a["list"], a.get("app") or aid) for aid, a in self._auto.items()
|
||||
if a.get("source") == sid and not a.get("pending") and not a.get("rejected")]
|
||||
return list(base) + extra
|
||||
|
||||
def _pending(self, sid: str) -> list[str]:
|
||||
"""已发现、等下载后投票的目录 / 列表名"""
|
||||
return [a.get("dir") or a.get("list") for a in self._auto.values() if a.get("source") == sid and a.get("pending")]
|
||||
|
||||
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"])]
|
||||
return [(f"{n}.txt", (HAGEZI_URL if sid == "hagezi" else UT1_URL).format(n)) for n, _app in self._spec(sid)]
|
||||
names = set(self.map["owner"]) | set(self._pending("bm7"))
|
||||
return [(f"{n}.list", BM7_URL.format(urllib.parse.quote(n))) for n in sorted(names)]
|
||||
names = [n for n, _app in self._spec(sid)] + self._pending(sid)
|
||||
return [(f"{n}.txt", (HAGEZI_URL if sid == "hagezi" else UT1_URL).format(n)) for n in names]
|
||||
|
||||
# ---------- 加载 ----------
|
||||
@staticmethod
|
||||
@@ -265,11 +321,7 @@ class Lists:
|
||||
d = self._dir("bm7")
|
||||
for name, app in self.map["owner"].items():
|
||||
got = False
|
||||
for line in self._read(os.path.join(d, f"{name}.list")):
|
||||
p = [x.strip() for x in line.split(",")]
|
||||
if len(p) < 2 or line.startswith("#"):
|
||||
continue
|
||||
kind, val = p[0].upper(), p[1].lower().strip(".")
|
||||
for kind, val in self._bm7_rules(os.path.join(d, f"{name}.list")):
|
||||
if kind == "DOMAIN-SUFFIX" and _is_domain(val):
|
||||
suffix.setdefault(val, app)
|
||||
elif kind == "DOMAIN" and _is_domain(val):
|
||||
@@ -285,16 +337,23 @@ class Lists:
|
||||
ips.build()
|
||||
return suffix, full, ips, n, len(apps)
|
||||
|
||||
def _bm7_rules(self, path: str):
|
||||
for line in self._read(path):
|
||||
p = [x.strip() for x in line.split(",")]
|
||||
if len(p) < 2 or line.startswith("#"):
|
||||
continue
|
||||
yield p[0].upper(), p[1].lower().strip(".")
|
||||
|
||||
def _cat_domains(self, path: str) -> list[str]:
|
||||
doms = []
|
||||
for line in self._read(path):
|
||||
s = line.strip().lower().lstrip("*.").strip(".")
|
||||
if s and not s.startswith("#") and _is_domain(s):
|
||||
doms.append(s)
|
||||
return doms
|
||||
|
||||
def _load_cat(self, sid: str) -> list[tuple[str, list[str]]]:
|
||||
out = []
|
||||
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(".")
|
||||
if s and not s.startswith("#") and _is_domain(s):
|
||||
doms.append(s)
|
||||
out.append((app, doms))
|
||||
return out
|
||||
return [(app, self._cat_domains(os.path.join(self._dir(sid), f"{name}.txt"))) for name, app in self._spec(sid)]
|
||||
|
||||
def _stamp(self, sid: str) -> int:
|
||||
try:
|
||||
@@ -348,16 +407,20 @@ class Lists:
|
||||
changed = changed or ok > 0
|
||||
err = f"{failed} 个文件下载失败,继续用旧数据" if failed else ""
|
||||
self.state[sid] = {**self.state.get(sid, {}), "error": err}
|
||||
try:
|
||||
if self.resolve(): # 刚下载的新目录 / 列表投票定归属
|
||||
changed = True
|
||||
except Exception as e: # noqa: BLE001 投票失败下次再来,不影响本轮更新
|
||||
print(f"[lists] 新规则投票失败:{type(e).__name__}: {e}", flush=True)
|
||||
if changed or not self.state:
|
||||
self.load()
|
||||
return changed
|
||||
|
||||
def discover(self) -> int:
|
||||
"""上游仓库出现新的规则目录 / 分类列表时自动登记成应用(debug14)。返回新登记个数。"""
|
||||
"""上游仓库出现新的规则目录 / 分类列表时先登记为待定,下载后由 resolve() 投票定归属(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
|
||||
@@ -374,36 +437,95 @@ class Lists:
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
def _register(self, source: str, names: set[str], strict: bool) -> int:
|
||||
@staticmethod
|
||||
def _aid(source: str, name: str) -> str:
|
||||
return ("bm7_" if source == "bm7" else "gen_") + re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
|
||||
|
||||
def _discover_bm7(self) -> int:
|
||||
known = set(self.map["owner"]) | {a.get("dir") for a in self._auto.values() if a.get("source") == "bm7"}
|
||||
added = 0
|
||||
known = set(self.map["owner"])
|
||||
for n in sorted(names):
|
||||
if n in known or n in BM7_SKIP:
|
||||
for n in sorted(self._list_names(LIST_API["bm7"], "dir")):
|
||||
if n in known or n in BM7_SKIP or BM7_SKIP_RE.search(n):
|
||||
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():
|
||||
aid = self._aid("bm7", n)
|
||||
if aid in self._auto or aid in self.map["apps"]:
|
||||
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)
|
||||
self._auto[aid] = {"source": "bm7", "dir": n, "pending": True}
|
||||
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)
|
||||
kind = "file" if sid == "hagezi" else "dir"
|
||||
base = HAGEZI if sid == "hagezi" else UT1
|
||||
known = {n for n, _app in base} | {a.get("list") for a in self._auto.values() if a.get("source") == sid}
|
||||
added = 0
|
||||
for n in sorted(self._list_names(LIST_API[sid], kind)):
|
||||
if n in known:
|
||||
continue
|
||||
cat, confident = guess_category(n)
|
||||
if not confident:
|
||||
continue # 分类列表猜不出类别就跳过:宁缺毋滥,等手工映射
|
||||
aid = self._aid(sid, n)
|
||||
if aid in self._auto or aid in self.map["apps"]:
|
||||
continue
|
||||
self._auto[aid] = {"source": sid, "list": n, "category": cat, "pending": True}
|
||||
added += 1
|
||||
return added
|
||||
|
||||
def resolve(self, classifier=None) -> int:
|
||||
"""给已下载的待定目录 / 列表投票定归属(见模块说明)。返回定下来的个数;没下载到的留到下次"""
|
||||
pend = [(aid, a) for aid, a in self._auto.items() if a.get("pending")]
|
||||
if not pend:
|
||||
return 0
|
||||
if classifier is None:
|
||||
from app.engine import engine
|
||||
classifier = engine.classifier
|
||||
done = 0
|
||||
for aid, a in pend:
|
||||
sid = a["source"]
|
||||
if sid == "bm7":
|
||||
path = os.path.join(self._dir("bm7"), f"{a['dir']}.list")
|
||||
rules = list(self._bm7_rules(path))
|
||||
doms = [v for k, v in rules if k in ("DOMAIN", "DOMAIN-SUFFIX") and _is_domain(v)]
|
||||
ips = []
|
||||
for k, v in rules:
|
||||
if k in ("IP-CIDR", "IP-CIDR6"):
|
||||
try:
|
||||
ips.append(str(ipaddress.ip_network(v, strict=False).network_address))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
path = os.path.join(self._dir(sid), f"{a['list']}.txt")
|
||||
doms, ips = self._cat_domains(path), []
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
app, app_share, cat, cat_share, n = _vote(classifier, self.category, doms, ips)
|
||||
# 小目录(只有几条)认出一半以上也算数
|
||||
enough = n >= VOTE_MIN or (n >= 2 and n * 2 >= len(doms) + len(ips))
|
||||
if sid == "bm7":
|
||||
name = a["dir"]
|
||||
if enough and app_share >= VOTE_APP and app in classifier.apps:
|
||||
entry = {"extend": app, "by": "vote"}
|
||||
elif enough and cat_share >= VOTE_CAT:
|
||||
entry = {"category": cat, "name": name, "name_en": name, "by": "vote"}
|
||||
else:
|
||||
entry = {"category": guess_category(name)[0], "name": name, "name_en": name, "by": "name"}
|
||||
self._auto[aid] = {"source": "bm7", "dir": name, **entry}
|
||||
else:
|
||||
name, guess = a["list"], a["category"]
|
||||
if enough and cat != guess and cat_share >= VOTE_CAT:
|
||||
self._auto[aid] = {"source": sid, "list": name, "rejected": True, "vote": cat}
|
||||
else:
|
||||
entry = {"source": sid, "list": name, "category": guess, "name": name, "name_en": name}
|
||||
gen = classifier.apps.get(f"gen_{guess}")
|
||||
if gen is not None and gen.category == guess:
|
||||
entry["app"] = gen.id # 并到该类的“其他 xx 网站”,不多建应用
|
||||
self._auto[aid] = entry
|
||||
done += 1
|
||||
if done:
|
||||
self._save_auto()
|
||||
self._map = None # 让 map 重新合并,新应用 / 新目录立即生效
|
||||
return done
|
||||
|
||||
def _fetch_all(self, sid: str) -> tuple[int, int]:
|
||||
d = self._dir(sid)
|
||||
|
||||
+7
-5
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from fastapi import FastAPI, Request
|
||||
@@ -46,8 +47,7 @@ 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),
|
||||
("auto_reclass", _auto_reclassify)):
|
||||
for name, fn in (("load_state", engine.load_state), ("apply_settings", _apply_saved)):
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -72,7 +72,7 @@ def _auto_update_rules():
|
||||
|
||||
|
||||
def _auto_reclassify():
|
||||
"""识别库更新 / DNS 映射积累后自动重算历史分类(debug14):指纹没变就不动,变了才重算"""
|
||||
"""识别库更新或过了零点后自动重算历史分类(debug14):指纹没变就不动,变了才重算"""
|
||||
if not _auto_update_on():
|
||||
return
|
||||
from app.reclass import auto_reclassify
|
||||
@@ -118,8 +118,10 @@ 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)
|
||||
# 识别库更新 / 过了零点后的自动重算:指纹没变时开销只有一次设置查询。启动后等 15 分钟再跑第一次,
|
||||
# 第三方库、ASN、国内列表是在后台线程加载的,不能用没加载完的识别器去算历史
|
||||
sched.add_job(_safe(_auto_reclassify, "auto-reclass"), "interval", hours=1, max_instances=1, coalesce=True,
|
||||
next_run_time=datetime.now(sched.timezone) + timedelta(minutes=15))
|
||||
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)
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import Request
|
||||
|
||||
from app.models import OpLog
|
||||
|
||||
SECRET_KEYS = {"singbox_secret", "password", "old_password", "new_password", "token", "secret"}
|
||||
SECRET_KEYS = {"singbox_secret", "adguard_password", "password", "old_password", "new_password", "token", "secret"}
|
||||
|
||||
|
||||
def client_ip(request: Request | None) -> str:
|
||||
|
||||
+66
-26
@@ -5,23 +5,38 @@
|
||||
app 标记同步改掉(保证重复执行幂等)。host_domain_day 只覆盖有域名、或进了当分钟 Top30 IP 的流量,
|
||||
其余流量保持原分类;traffic_minute / traffic_hour 不动,随保留期自然滚动。
|
||||
|
||||
纯 IP 的历史行会用 dns_cache 反查域名再分类(嗅探 / MosDNS / AdGuard Home 积累的映射)——DNS 缓存
|
||||
会过期,所以已经归到具体应用、又拿不到新域名证据的行保持不动,避免缓存过期让历史分类倒退。
|
||||
只重算今天以前的数据:今天的行每分钟落库都会把 app 改回实时分类、字节继续累加,重算今天会把同一批字节
|
||||
反复搬走,还会和落库争写同一行。今天的流量本来就按最新规则实时统计,过了零点由自动重算补上。
|
||||
|
||||
识别库更新 / DNS 映射积累后自动重算:fingerprint() 覆盖各层库的规模和 DNS 缓存行数,指纹变了才执行
|
||||
(auto_reclassify),识别规则页的手动「重算历史分类」直接调 reclassify()。
|
||||
历史行只存了域名(或 IP),没有端口、目标 IP、sing-box 规则这些实时信号,所以只“升级”不“降级”:
|
||||
- 新结果是笼统分类(HTTPS / 国内网站 / 未识别…)的不搬,免得把实时认出的应用改回笼统分类;
|
||||
- 原来就是笼统分类的,认出具体应用就搬;
|
||||
- 原来已是具体应用的,只有域名规则直接命中另一个应用时才改(例如识别库把某域名改归了别的应用)。
|
||||
纯 IP 的历史行用 dns_cache 反查域名(嗅探 / MosDNS / AdGuard Home 积累的映射)再按上面的规则判断。
|
||||
|
||||
自动重算(auto_reclassify):fingerprint() 覆盖各层库的版本 / 规模和日期,识别库变了或过了零点才执行;
|
||||
识别规则页的手动「重算历史分类」直接调 reclassify()。
|
||||
"""
|
||||
import ipaddress
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app import timeutil
|
||||
from app.classify import GENERIC_APPS, UNIDENTIFIED_CATEGORIES, WEAK_PREFIXES
|
||||
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"}
|
||||
# 只说明协议、地区或“是个网站”的笼统分类:可以被升级成具体应用,但重算不会把行改成它们
|
||||
VAGUE_APPS = GENERIC_APPS | {"cn_net", "cn_web", "unknown"}
|
||||
|
||||
|
||||
def _vague(classifier, app: str | None) -> bool:
|
||||
if not app or app in VAGUE_APPS or app.startswith(WEAK_PREFIXES):
|
||||
return True
|
||||
return classifier.app_info(app).category in UNIDENTIFIED_CATEGORIES
|
||||
|
||||
|
||||
def _is_ip(s: str) -> bool:
|
||||
@@ -32,6 +47,24 @@ def _is_ip(s: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _deadlock(e: Exception) -> bool:
|
||||
"""MySQL 死锁(1213)或锁等待超时(1205):落库任务每分钟在写同一批表,撞上重试即可"""
|
||||
s = str(e)
|
||||
return "(1213" in s or "(1205" in s
|
||||
|
||||
|
||||
def reclassify(engine, db, before: int | None = None, attempts: int = 3) -> dict:
|
||||
"""重算 before(默认今天零点)以前的历史分类"""
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return _reclassify_once(engine, db, before)
|
||||
except OperationalError as e:
|
||||
db.rollback()
|
||||
if i == attempts - 1 or not _deadlock(e):
|
||||
raise
|
||||
time.sleep(2 + i * 2)
|
||||
|
||||
|
||||
def _dns_domain_map(db) -> dict[str, str]:
|
||||
"""未过期的 IP→域名映射(嗅探 / MosDNS / AdGuard Home 积累的),给纯 IP 的历史行反查域名"""
|
||||
now = int(time.time())
|
||||
@@ -39,30 +72,38 @@ def _dns_domain_map(db) -> dict[str, str]:
|
||||
return dict(rows)
|
||||
|
||||
|
||||
def reclassify(engine, db) -> dict:
|
||||
def _new_app(classifier, dom: str, old_app: str, cache: dict) -> str | None:
|
||||
"""按现在的规则给一行历史重新归类;不该改时返回 None(规则见模块说明)"""
|
||||
if _is_ip(dom):
|
||||
qname = cache.get(dom)
|
||||
new_app = classifier.classify(dom, 6, 0, qname, None)
|
||||
else:
|
||||
qname = dom
|
||||
new_app = classifier.classify("0.0.0.0", 6, 443, dom, None)
|
||||
if not new_app or new_app == old_app or _vague(classifier, new_app):
|
||||
return None
|
||||
if _vague(classifier, old_app):
|
||||
return new_app
|
||||
# 原来已是具体应用:只认域名规则的直接证据(IP 段 / ASN / 端口当时按真实目标判断,历史行里没有这些信息)
|
||||
return new_app if qname and classifier.match_domain(qname) == new_app else None
|
||||
|
||||
|
||||
def _reclassify_once(engine, db, before: int | None) -> dict:
|
||||
t0 = time.time()
|
||||
classifier = engine.classifier
|
||||
before = timeutil.day_bucket(time.time()) if before is None else before
|
||||
cache = _dns_domain_map(db)
|
||||
moves: dict = defaultdict(lambda: defaultdict(int)) # (ts, host, 旧应用) -> {新应用: 字节}
|
||||
marks: list[dict] = []
|
||||
rows = db.execute(
|
||||
select(HostDomainDay.ts, HostDomainDay.host, HostDomainDay.domain, HostDomainDay.app, HostDomainDay.bytes)
|
||||
.where(HostDomainDay.ts < before)
|
||||
).all()
|
||||
for ts, host, dom, old_app, b in rows:
|
||||
if not b:
|
||||
continue
|
||||
if _is_ip(dom):
|
||||
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"
|
||||
if new_app != old_app:
|
||||
new_app = _new_app(classifier, dom, old_app, cache)
|
||||
if new_app:
|
||||
moves[(int(ts), host, old_app)][new_app] += int(b)
|
||||
marks.append({"ts": int(ts), "host": host, "domain": dom, "app": new_app})
|
||||
if marks:
|
||||
@@ -117,19 +158,18 @@ def reclassify(engine, db) -> dict:
|
||||
|
||||
|
||||
def fingerprint(engine, db) -> str:
|
||||
"""识别规则的有效指纹:任何一层库变了(v2fly 提交、第三方库规模、ASN 行数)或 DNS 映射积累变多都不一样"""
|
||||
"""识别规则的有效指纹:任何一层库变了(v2fly 提交、第三方库规模、ASN 行数)或过了零点(昨天成了可重算的历史,
|
||||
期间积累的 DNS 映射也一并用上)都不一样。不再用 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])
|
||||
return "|".join([f"day{timeutil.day_bucket(time.time())}", 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):指纹没变就什么都不做,变了才重算并记住新指纹"""
|
||||
"""识别库更新或过了零点后自动重算(debug14):指纹没变就什么都不做,变了才重算并记住新指纹"""
|
||||
fp = fingerprint(engine, db)
|
||||
row = db.get(Setting, FP_KEY)
|
||||
if row is not None and row.value == fp:
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.classify import Classifier
|
||||
from app.engine import Engine
|
||||
from app.reclass import reclassify
|
||||
|
||||
DAY = 1792900000 # 2026-10-25 附近
|
||||
DAY = 1792900000 - 7 * 86400 # 一周前(重算只处理今天以前的数据)
|
||||
HOST = "IP:192.168.8.20"
|
||||
|
||||
|
||||
@@ -135,9 +135,8 @@ def test_reclass_moves_history():
|
||||
s.add(HostDomainDay(ts=DAY, host=HOST, domain="portal.example.cn", app="cn_net", base=None, bytes=100))
|
||||
s.commit()
|
||||
|
||||
class _C:
|
||||
@staticmethod
|
||||
def classify(ip, proto, port, qname, cname):
|
||||
class _C(Classifier):
|
||||
def classify(self, ip, proto, port, qname, cname):
|
||||
if qname == "v.douyin.com":
|
||||
return "douyin"
|
||||
return "cn_net"
|
||||
|
||||
+163
-20
@@ -6,11 +6,12 @@ import pytest
|
||||
|
||||
from app.classify import lists as L
|
||||
from app.classify.lists import guess_category, lists
|
||||
from app.classify import Classifier
|
||||
from app.engine import Engine
|
||||
from app.models import DnsCache, HostDomainDay, Setting, TrafficDay
|
||||
from app.reclass import auto_reclassify, reclassify
|
||||
|
||||
DAY = 1792900000
|
||||
DAY = 1792900000 - 7 * 86400 # 一周前(重算只处理今天以前的数据)
|
||||
HOST = "IP:192.168.8.31"
|
||||
|
||||
|
||||
@@ -34,9 +35,27 @@ def test_guess_category():
|
||||
assert guess_category("RandomCorp") == ("site", False)
|
||||
|
||||
|
||||
def test_discover_registers_new_dirs(tmp_path, monkeypatch):
|
||||
def test_guess_category_whole_words():
|
||||
# 0.6.6 按子串猜:Facebook 含 ceb → 银行、Minecraft 含 mine → 挖矿、HeroesoftheStorm 含 tor → 代理
|
||||
assert guess_category("Facebook")[0] == "social"
|
||||
assert guess_category("Minecraft")[0] != "mining"
|
||||
assert guess_category("HeroesoftheStorm")[0] != "proxy"
|
||||
assert guess_category("Stripe")[0] != "travel"
|
||||
assert guess_category("Alphabet")[0] != "gambling"
|
||||
assert guess_category("Airline")[0] == "travel"
|
||||
|
||||
|
||||
def _write_bm7(tmp_path, name, domains):
|
||||
d = tmp_path / "lists" / "bm7"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / f"{name}.list").write_text("".join(f"DOMAIN-SUFFIX,{x}\n" for x in domains), encoding="utf-8")
|
||||
|
||||
|
||||
def test_discover_then_vote(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
fake = [{"name": n, "type": "dir"} for n in ["BiliBili", "MangoTV", "RandomCorp", "Global", "HotMovies"]]
|
||||
names = ["BiliBili", "FbMirror", "VideoBundle", "HotMovies", "RandomCorp", "Missing",
|
||||
"Global", "Tencent", "AdGuardSDNSFilter", "EasyPrivacy", "AmazonIP"]
|
||||
fake = [{"name": n, "type": "dir"} for n in names]
|
||||
called = []
|
||||
monkeypatch.setattr(L.httpx, "get",
|
||||
lambda url, **kw: called.append(url) or (_Resp(fake) if "ios_rule_script" in url else _Resp([])))
|
||||
@@ -45,19 +64,67 @@ def test_discover_registers_new_dirs(tmp_path, monkeypatch):
|
||||
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"
|
||||
fb = c.match_domain("facebook.com")
|
||||
video = ["youtube.com", "netflix.com", "iqiyi.com", "youku.com", "mgtv.com", "hulu.com"]
|
||||
assert fb and all(c.app_info(c.match_domain(x)).category == "video" for x in video)
|
||||
|
||||
# BiliBili 已映射;Global / Tencent / 广告隐私 / 纯 IP 列表不登记;其余先登记为待定,不进映射
|
||||
assert lists.discover() == 5
|
||||
assert called and "rule/Clash" in called[0]
|
||||
assert "FbMirror" not in lists.map["owner"]
|
||||
assert sorted(lists._pending("bm7")) == ["FbMirror", "HotMovies", "Missing", "RandomCorp", "VideoBundle"]
|
||||
assert {f for f, _u in lists._files("bm7")} >= {"FbMirror.list", "Missing.list"} # 待定的也下载
|
||||
|
||||
_write_bm7(tmp_path, "FbMirror", [f"s{i}.facebook.com" for i in range(8)])
|
||||
_write_bm7(tmp_path, "VideoBundle", [f"x.{d}" for d in video])
|
||||
_write_bm7(tmp_path, "HotMovies", [f"zz{i}.example-movies.test" for i in range(8)])
|
||||
_write_bm7(tmp_path, "RandomCorp", ["random-corp.test"])
|
||||
assert lists.resolve(c) == 4 # Missing 没下载到:留到下次
|
||||
m = lists.map
|
||||
assert m["owner"]["FbMirror"] == fb # 多数域名属于已有应用:并入
|
||||
assert "bm7_fbmirror" not in m["apps"]
|
||||
assert m["apps"]["bm7_videobundle"]["category"] == "video" # 多数属于同一大类:新建该类应用
|
||||
assert m["apps"]["bm7_hotmovies"]["category"] == "video" # 没人认识:按名字猜
|
||||
assert m["apps"]["bm7_randomcorp"]["category"] == "site" # 名字也猜不出:常用网站
|
||||
assert lists._pending("bm7") == ["Missing"]
|
||||
assert os.path.exists(lists._auto_file())
|
||||
assert lists.discover() == 0 # 再发现一次:没有新增
|
||||
|
||||
# 自动登记的应用不用重启就生效:识别库重新加载(version 变化)后分类器补上新应用
|
||||
assert "bm7_videobundle" not in c.apps
|
||||
lists.load()
|
||||
c.classify("1.1.1.1", 6, 443, "x.youtube.com", None)
|
||||
assert c.apps["bm7_videobundle"].category == "video"
|
||||
assert Classifier().apps["bm7_hotmovies"].category == "video"
|
||||
finally:
|
||||
lists.reset()
|
||||
lists._map = None
|
||||
lists._auto = {}
|
||||
|
||||
|
||||
def test_discover_category_lists(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
ut1 = [{"name": n, "type": "dir"} for n in ["shopping", "cooking", "games_online", "sports_betting"]]
|
||||
monkeypatch.setattr(L.httpx, "get", lambda url, **kw: _Resp(ut1) if "ut1" in url else _Resp([]))
|
||||
try:
|
||||
lists.reset()
|
||||
lists._map = None
|
||||
lists._auto = {}
|
||||
c = Classifier()
|
||||
assert lists.discover() == 2 # shopping 已映射、cooking 猜不出;其余两个待定
|
||||
d = tmp_path / "lists" / "ut1"
|
||||
d.mkdir(parents=True)
|
||||
(d / "games_online.txt").write_text("\n".join(f"g{i}.unknown-games.test" for i in range(6)), encoding="utf-8")
|
||||
# 名字像博彩,内容却全是视频网站:投票不符,作废
|
||||
(d / "sports_betting.txt").write_text("\n".join(["youtube.com", "netflix.com", "iqiyi.com", "youku.com",
|
||||
"mgtv.com", "hulu.com"]), encoding="utf-8")
|
||||
assert lists.resolve(c) == 2
|
||||
spec = dict(lists._spec("ut1"))
|
||||
assert spec["games_online"] == "gen_game" # 并到该类的“其他游戏网站”
|
||||
assert "sports_betting" not in spec
|
||||
assert lists._auto["gen_sports_betting"]["rejected"]
|
||||
assert lists.discover() == 0 # 作废的不再登记
|
||||
finally:
|
||||
lists.reset()
|
||||
lists._map = None
|
||||
@@ -75,9 +142,8 @@ def test_reclass_uses_dns_cache_for_ip_rows():
|
||||
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):
|
||||
class _C(Classifier):
|
||||
def classify(self, ip, proto, port, qname, cname):
|
||||
return "douyin" if qname == "v.douyin.com" else "cn_net"
|
||||
|
||||
eng = Engine()
|
||||
@@ -104,8 +170,9 @@ def test_reclass_uses_dns_cache_for_ip_rows():
|
||||
s.close()
|
||||
|
||||
|
||||
def test_auto_reclassify_runs_on_fingerprint_change():
|
||||
def test_auto_reclassify_runs_on_fingerprint_change(monkeypatch):
|
||||
import app.database as database
|
||||
from app import reclass, timeutil
|
||||
database.connect_from_saved(force=True)
|
||||
s = database.SessionLocal()
|
||||
try:
|
||||
@@ -118,9 +185,85 @@ def test_auto_reclassify_runs_on_fingerprint_change():
|
||||
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 映射变多:指纹变化 → 重算
|
||||
assert auto_reclassify(eng, s) is None # DNS 映射变多不再触发(0.6.6 每次都全量重算)
|
||||
real = timeutil.day_bucket
|
||||
monkeypatch.setattr(reclass.timeutil, "day_bucket", lambda ts: real(ts) + 86400)
|
||||
assert auto_reclassify(eng, s) is not None # 过了零点:昨天成了可重算的历史
|
||||
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()
|
||||
|
||||
|
||||
# ---------- 0.6.7:重算只升级、不碰今天(用真实识别器) ----------
|
||||
|
||||
def _rows(s, ts):
|
||||
return {r.app: r.up + r.down for r in s.query(TrafficDay).filter(TrafficDay.ts == ts, TrafficDay.host == HOST).all()}
|
||||
|
||||
|
||||
def _cleanup(s):
|
||||
s.query(TrafficDay).filter(TrafficDay.host == HOST).delete()
|
||||
s.query(HostDomainDay).filter(HostDomainDay.host == HOST).delete()
|
||||
s.commit()
|
||||
|
||||
|
||||
def test_reclass_never_downgrades():
|
||||
import app.database as database
|
||||
database.connect_from_saved(force=True)
|
||||
s = database.SessionLocal()
|
||||
eng = Engine()
|
||||
c = eng.classifier
|
||||
# 前提:实时按端口 443 认成 HTTPS 的纯 IP、实时按 Telegram IP 段认出的无名域名,历史行里都没有这些信号
|
||||
assert c.classify("203.0.113.7", 6, 443, None, None) == "https"
|
||||
assert c.classify("91.108.4.10", 6, 443, "x.unknown-zz.test", None) == "telegram"
|
||||
assert c.classify("0.0.0.0", 6, 443, "x.unknown-zz.test", None) != "telegram"
|
||||
try:
|
||||
s.add(TrafficDay(ts=DAY, host=HOST, app="https", up=0, down=1000, flows=10))
|
||||
s.add(TrafficDay(ts=DAY, host=HOST, app="telegram", up=0, down=2000, flows=20))
|
||||
s.add(HostDomainDay(ts=DAY, host=HOST, domain="203.0.113.7", app="https", bytes=1000))
|
||||
s.add(HostDomainDay(ts=DAY, host=HOST, domain="x.unknown-zz.test", app="telegram", bytes=2000))
|
||||
s.commit()
|
||||
res = reclassify(eng, s)
|
||||
s.commit()
|
||||
assert _rows(s, DAY) == {"https": 1000, "telegram": 2000} # 0.6.6:https → other、telegram → https
|
||||
assert res["bytes_moved"] == 0
|
||||
finally:
|
||||
_cleanup(s)
|
||||
s.close()
|
||||
|
||||
|
||||
def test_reclass_skips_today():
|
||||
"""今天的行每分钟落库都会把 app 改回实时分类、字节累加;0.6.6 重算今天会把同一批字节反复搬走"""
|
||||
import app.database as database
|
||||
from app import timeutil
|
||||
database.connect_from_saved(force=True)
|
||||
s = database.SessionLocal()
|
||||
today = timeutil.day_bucket(time.time())
|
||||
|
||||
class _C(Classifier):
|
||||
def classify(self, ip, proto, port, qname, cname):
|
||||
return "douyin" if qname == "v.douyin.com" else "cn_net"
|
||||
|
||||
eng = Engine()
|
||||
eng.classifier = _C()
|
||||
try:
|
||||
s.add(TrafficDay(ts=today, host=HOST, app="cn_net", up=0, down=4000, flows=40))
|
||||
s.add(HostDomainDay(ts=today, host=HOST, domain="v.douyin.com", app="cn_net", bytes=2000))
|
||||
s.commit()
|
||||
for _ in range(3):
|
||||
assert reclassify(eng, s)["bytes_moved"] == 0
|
||||
s.commit()
|
||||
assert _rows(s, today) == {"cn_net": 4000}
|
||||
# 显式包含今天(before=明天零点)时才会动
|
||||
assert reclassify(eng, s, before=today + 86400)["bytes_moved"] == 2000
|
||||
s.commit()
|
||||
finally:
|
||||
_cleanup(s)
|
||||
s.close()
|
||||
|
||||
|
||||
def test_oplog_scrubs_adguard_password():
|
||||
from app.oplog import scrub
|
||||
assert scrub({"adguard_password": "p@ss", "adguard_url": "http://x"}) == {"adguard_password": "***",
|
||||
"adguard_url": "http://x"}
|
||||
@@ -109,8 +109,8 @@ export default {
|
||||
patternHint: { domain: 'e.g. example.com (includes subdomains)', cidr: 'e.g. 1.2.3.0/24', port: 'e.g. tcp/8080 or udp/3000-3010' },
|
||||
existingApp: 'Existing app', newApp: 'New app', appId: 'App ID (lowercase, digits, underscore)', appName: 'App name',
|
||||
classify: 'Classify', test: 'Test a domain', testResult: 'Matches: {app}', testNone: 'No domain rule matches', bytes: 'Traffic',
|
||||
reclassify: 'Re-classify history', reclassifyHint: 'After rules or libraries change, re-attribute the last 31 days of traffic under the new rules (daily stats)',
|
||||
reclassifyConfirm: 'Re-classify history under the current rules? Traffic whose classification changed will be moved to the new app (may take a while).',
|
||||
reclassify: 'Re-classify history', reclassifyHint: 'After rules or libraries change, re-attribute the last 31 days (excluding today) under the new rules (daily stats); only upgrades vague categories to specific apps',
|
||||
reclassifyConfirm: 'Re-classify history under the current rules? Vague traffic from the last 31 days (excluding today, which is already live-classified) that now maps to a specific app will be moved (may take a while).',
|
||||
reclassifying: 'Re-classifying…', reclassifyDone: 'Done: moved {b} ({s}s)',
|
||||
},
|
||||
lib: {
|
||||
|
||||
@@ -109,8 +109,8 @@ export default {
|
||||
patternHint: { domain: '如 example.com(含子域名)', cidr: '如 1.2.3.0/24', port: '如 tcp/8080 或 udp/3000-3010' },
|
||||
existingApp: '已有应用', newApp: '新建应用', appId: '应用 ID(小写字母/数字/下划线)', appName: '应用名称',
|
||||
classify: '归类', test: '测试域名', testResult: '识别为:{app}', testNone: '未命中任何域名规则', bytes: '流量',
|
||||
reclassify: '重算历史分类', reclassifyHint: '规则或识别库升级后,把近 31 天的历史流量按新规则重新归类(按天统计)',
|
||||
reclassifyConfirm: '按当前规则重算历史分类?会把近 31 天里归类变化的历史流量搬到新的应用(可能需要十几秒)。',
|
||||
reclassify: '重算历史分类', reclassifyHint: '规则或识别库升级后,把近 31 天(不含今天)的历史流量按新规则重新归类(按天统计);只把笼统分类升级成具体应用',
|
||||
reclassifyConfirm: '按当前规则重算历史分类?会把近 31 天(不含今天,今天的流量已按新规则实时统计)里能认出具体应用的笼统分类流量搬过去(可能需要十几秒)。',
|
||||
reclassifying: '重算中…', reclassifyDone: '已重算:搬移 {b} 字节({s} 秒)',
|
||||
},
|
||||
lib: {
|
||||
|
||||
+17
-17
@@ -133,23 +133,23 @@
|
||||
--line-up: var(--up);
|
||||
--line-down: var(--down);
|
||||
--down: #3fc4c8;
|
||||
--g-http: #5470c6;
|
||||
--g-game: #91cc75;
|
||||
--g-tv: #fac858;
|
||||
--g-p2p: #ee6666;
|
||||
--g-common: #73c0de;
|
||||
--g-im: #3ba272;
|
||||
--g-music: #fc8452;
|
||||
--g-stock: #9a60b4;
|
||||
--g-phone: #ea7ccc;
|
||||
--g-proxy: #5470c6;
|
||||
--g-database: #91cc75;
|
||||
--g-mobile: #fac858;
|
||||
--g-special: #ee6666;
|
||||
--g-mining: #73c0de;
|
||||
--g-pcdn: #3ba272;
|
||||
--g-other: #fc8452;
|
||||
--g-unknown: #9a60b4;
|
||||
--g-http: #4a7dc4;
|
||||
--g-game: #55a754;
|
||||
--g-tv: #e0762f;
|
||||
--g-p2p: #dd5a54;
|
||||
--g-common: #43b0c6;
|
||||
--g-im: #2f9d6e;
|
||||
--g-music: #d9a41f;
|
||||
--g-stock: #9a55d4;
|
||||
--g-phone: #e56c9f;
|
||||
--g-proxy: #6b62d9;
|
||||
--g-database: #a97c50;
|
||||
--g-mobile: #c24fb0;
|
||||
--g-special: #9cba32;
|
||||
--g-mining: #b3ae54;
|
||||
--g-pcdn: #3cb9a1;
|
||||
--g-other: #8a97a5;
|
||||
--g-unknown: #5d6870;
|
||||
--g-social: var(--g-im);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user