Author SHA1 Message Date
ao gong 112374e2a1 test(gen-script): lock in to_rule() inline-# handling with regression tests
CI / lite (push) Successful in 3s
新增 2 个 test method 覆盖 b4a4769 修复的边界 case:
- test_to_rule_strips_inline_hash_comment_before_classifying_no_resolve
  锁定带 inline # 注释的条目(本次修复的真实场景)
  + 验证注释里出现 'no-resolve' / ',DIRECT' 字符串不被误处理
- test_to_rule_preserves_canonical_forms
  锁定 happy path(纯规则、带 no-resolve、带行尾空白)

为让 test_checked_in_router_projections_match_the_rule_source 通过,
同步重生成 router/direct-plain.txt 和 router/direct-rules.yaml。
这俩投影文件自 907fe9d 起就 stale 14+ 条规则(国内 LLM、UGREEN、
Steam 段全没同步),本次连同 b4a4769 的修复一起补齐。

测试结果:python3 -m unittest discover -s tests
  Ran 9 tests in 0.028s — OK
2026-09-05 13:40:50 +08:00
ao gong b4a4769dcf fix(gen-script): strip inline '#' comment before classifying no-resolve suffix
CI / lite (push) Failing after 4s
to_rule() 在判断 entry.endswith(',no-resolve') 之前没有剥掉行尾 # 注释,
导致带 inline 注释的条目走 else 分支,把注释当成 entry 一部分拼进去,
最终生成出 mihomo 解析失败的规则:

  'IP-CIDR,162.254.192.0/18,no-resolve   # Akamai Steam CDN 主段,DIRECT'

修法:
- to_rule() 先 split('#', 1)[0].rstrip() 剥注释再判断
- direct.yaml 第 91 行 inline 注释挪到独立行(避免再次踩坑)

验证:gen_clash_verge_script.py 重跑后 Script.js 第 49 行已变成
  'IP-CIDR,162.254.192.0/18,DIRECT,no-resolve',
Clash Verge 重启内核后订阅配置校验失败消失。
2026-09-05 13:32:48 +08:00
ao gong 907fe9d46a direct: add Steam global pass (community/store/CDN/Valve nets)
CI / lite (push) Failing after 4s
2026-09-05 11:56:06 +08:00
ageorge156 854b8e1cdd fix: keep OneDrive on direct routing (#7)
CI / lite (push) Successful in 9s
## 变更

- 将 `OneDrive.exe` 设为全局 `DIRECT`,优先于供应商 Microsoft 规则
- 为 Clash Verge 全局脚本补充国内可达的阿里 DNS / 腾讯 DNS DoH fallback
- 同步更新路由器规则投影与跨平台生成说明

## 验证

- `python -m unittest discover -s tests -v`:7 项通过
- mihomo DNS API 成功解析 OneDrive 目标
- 实际日志确认 `OneDrive.exe -> skydrive.wns.windows.com:443` 命中 `ProcessName(OneDrive.exe)` 并使用 `DIRECT`

Reviewed-on: http://192.168.50.2:3000/ageorge156/vpn-rules/pulls/7
2026-08-30 16:55:17 +08:00
ageorge156 de26a0edc0 chore: adopt shared runtime contract (#6)
CI / lite (push) Successful in 3s
2026-08-24 19:46:55 +08:00
7 changed files with 228 additions and 11 deletions
+7 -3
View File
@@ -20,22 +20,26 @@ vpn-rules/
## 为什么需要它 ## 为什么需要它
- 香港代理节点有流量配额(如 BygCloud 100GiB 用满),国内站点/内网流量被隧道进代理会白白烧配额。 - 香港代理节点有流量配额(如 BygCloud 100GiB 用满),国内站点/内网流量被隧道进代理会白白烧配额。
- 必须直连的对象:FRP 控制面(mTLS)、B 站全系、DeepSeek/MiniMax 等国内 LLM API、 - 必须直连的对象:Windows OneDrive 客户端、FRP 控制面(mTLS)、B 站全系、DeepSeek/MiniMax 等国内 LLM API、
内网/私有 IP 段、国区 iCloud、UGREEN 绿联 NAS 云。 内网/私有 IP 段、国区 iCloud、UGREEN 绿联 NAS 云。
- **踩坑**:Clash Verge 全局 `Merge.yaml` 的 `prepend-rules` 会被内核忽略(不生效), - **踩坑**:Clash Verge 全局 `Merge.yaml` 的 `prepend-rules` 会被内核忽略(不生效),
全局直连规则的唯一正确机制是 `profiles/Script.js`(`main(config)` 把规则 prepend 进 `config.rules`)。 全局直连规则的唯一正确机制是 `profiles/Script.js`(`main(config)` 把规则 prepend 进 `config.rules`)。
## 消费端接入 ## 消费端接入
### Clash Verge(macOS) ### Clash Verge(Windows / macOS)
```bash ```bash
# 生成并写入 Clash Verge 数据目录的 Script.js # 按当前平台生成并写入 Clash Verge 数据目录的全局 Script.js
python3 scripts/gen_clash_verge_script.py python3 scripts/gen_clash_verge_script.py
# 然后在 Clash Verge 界面点「重启内核」生效 # 然后在 Clash Verge 界面点「重启内核」生效
``` ```
对当前及以后新增/切换的所有订阅生效;订阅更新不会覆盖它。 对当前及以后新增/切换的所有订阅生效;订阅更新不会覆盖它。
脚本还会把国内可达的阿里 DNS 和腾讯 DNS DoH 端点补入 `dns.fallback`,避免境外域名
命中 `DIRECT` 后因供应商的境外 DNS fallback 不可达而解析失败。
Windows 默认写入 `%APPDATA%\io.github.clash-verge-rev.clash-verge-rev\profiles\Script.js`;
macOS 默认写入 `~/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/profiles/Script.js`。
### 路由器 mihomo(Magic Catling2 / OpenWrt) ### 路由器 mihomo(Magic Catling2 / OpenWrt)
+6
View File
@@ -0,0 +1,6 @@
profile: lite
runtime_contract: v1
language: generic
local_test_command: "python3 tests/test_module_spec.py"
requires_database: false
integration_profile: none
+21
View File
@@ -20,6 +20,9 @@
# 路由器端则等 rule-provider 的 interval 到期或手动 reload。 # 路由器端则等 rule-provider 的 interval 到期或手动 reload。
payload: payload:
# Windows OneDrive 客户端始终直连,避免被供应商 Microsoft 规则送入代理
- PROCESS-NAME,OneDrive.exe
# FRP 控制面(mTLS)保持直连 # FRP 控制面(mTLS)保持直连
- DOMAIN,frp.puyuanfh.cn - DOMAIN,frp.puyuanfh.cn
- IP-CIDR,8.133.177.3/32,no-resolve - IP-CIDR,8.133.177.3/32,no-resolve
@@ -73,3 +76,21 @@ payload:
- IP-CIDR,110.42.0.0/16,no-resolve - IP-CIDR,110.42.0.0/16,no-resolve
- IP-CIDR,43.248.128.0/17,no-resolve - IP-CIDR,43.248.128.0/17,no-resolve
- IP-CIDR,111.170.0.0/16,no-resolve - IP-CIDR,111.170.0.0/16,no-resolve
# Steam 全局直连(社区/商店/CDN/Valve 官方网段;避免下载/联机绕美节点)
- DOMAIN-SUFFIX,steampowered.com
- DOMAIN-SUFFIX,steamcommunity.com
- DOMAIN-SUFFIX,steamgames.com
- DOMAIN-SUFFIX,steamusercontent.com
- DOMAIN-SUFFIX,steam-chat.com
- DOMAIN-SUFFIX,steamstatic.com
- DOMAIN-SUFFIX,steamcontent.com
- DOMAIN-KEYWORD,csgo.
- PROCESS-NAME,steam
- PROCESS-NAME,steamwebhelper
- IP-CIDR,162.254.192.0/18,no-resolve
# Akamai Steam CDN 主段
- IP-CIDR,103.10.124.0/23,no-resolve
- IP-CIDR,103.28.54.0/23,no-resolve
- IP-CIDR,146.66.152.0/21,no-resolve
- IP-CIDR,155.133.224.0/19,no-resolve
+16
View File
@@ -1,3 +1,4 @@
PROCESS-NAME,OneDrive.exe
DOMAIN,frp.puyuanfh.cn DOMAIN,frp.puyuanfh.cn
IP-CIDR,8.133.177.3/32,no-resolve IP-CIDR,8.133.177.3/32,no-resolve
DOMAIN-SUFFIX,bilibili.com DOMAIN-SUFFIX,bilibili.com
@@ -32,3 +33,18 @@ DOMAIN-SUFFIX,syncthing.net
IP-CIDR,110.42.0.0/16,no-resolve IP-CIDR,110.42.0.0/16,no-resolve
IP-CIDR,43.248.128.0/17,no-resolve IP-CIDR,43.248.128.0/17,no-resolve
IP-CIDR,111.170.0.0/16,no-resolve IP-CIDR,111.170.0.0/16,no-resolve
DOMAIN-SUFFIX,steampowered.com
DOMAIN-SUFFIX,steamcommunity.com
DOMAIN-SUFFIX,steamgames.com
DOMAIN-SUFFIX,steamusercontent.com
DOMAIN-SUFFIX,steam-chat.com
DOMAIN-SUFFIX,steamstatic.com
DOMAIN-SUFFIX,steamcontent.com
DOMAIN-KEYWORD,csgo.
PROCESS-NAME,steam
PROCESS-NAME,steamwebhelper
IP-CIDR,162.254.192.0/18,no-resolve
IP-CIDR,103.10.124.0/23,no-resolve
IP-CIDR,103.28.54.0/23,no-resolve
IP-CIDR,146.66.152.0/21,no-resolve
IP-CIDR,155.133.224.0/19,no-resolve
+16
View File
@@ -11,6 +11,7 @@
# 内网源:http://192.168.50.2:3000/ageorge156/vpn-rules/raw/branch/main/direct.yaml # 内网源:http://192.168.50.2:3000/ageorge156/vpn-rules/raw/branch/main/direct.yaml
rules: rules:
- PROCESS-NAME,OneDrive.exe,DIRECT
- DOMAIN,frp.puyuanfh.cn,DIRECT - DOMAIN,frp.puyuanfh.cn,DIRECT
- IP-CIDR,8.133.177.3/32,DIRECT,no-resolve - IP-CIDR,8.133.177.3/32,DIRECT,no-resolve
- DOMAIN-SUFFIX,bilibili.com,DIRECT - DOMAIN-SUFFIX,bilibili.com,DIRECT
@@ -45,3 +46,18 @@ rules:
- IP-CIDR,110.42.0.0/16,DIRECT,no-resolve - IP-CIDR,110.42.0.0/16,DIRECT,no-resolve
- IP-CIDR,43.248.128.0/17,DIRECT,no-resolve - IP-CIDR,43.248.128.0/17,DIRECT,no-resolve
- IP-CIDR,111.170.0.0/16,DIRECT,no-resolve - IP-CIDR,111.170.0.0/16,DIRECT,no-resolve
- DOMAIN-SUFFIX,steampowered.com,DIRECT
- DOMAIN-SUFFIX,steamcommunity.com,DIRECT
- DOMAIN-SUFFIX,steamgames.com,DIRECT
- DOMAIN-SUFFIX,steamusercontent.com,DIRECT
- DOMAIN-SUFFIX,steam-chat.com,DIRECT
- DOMAIN-SUFFIX,steamstatic.com,DIRECT
- DOMAIN-SUFFIX,steamcontent.com,DIRECT
- DOMAIN-KEYWORD,csgo.,DIRECT
- PROCESS-NAME,steam,DIRECT
- PROCESS-NAME,steamwebhelper,DIRECT
- IP-CIDR,162.254.192.0/18,DIRECT,no-resolve
- IP-CIDR,103.10.124.0/23,DIRECT,no-resolve
- IP-CIDR,103.28.54.0/23,DIRECT,no-resolve
- IP-CIDR,146.66.152.0/21,DIRECT,no-resolve
- IP-CIDR,155.133.224.0/19,DIRECT,no-resolve
+55 -7
View File
@@ -4,14 +4,39 @@
用法: 用法:
python3 scripts/gen_clash_verge_script.py [--out 目标路径] python3 scripts/gen_clash_verge_script.py [--out 目标路径]
默认输出到 Clash Verge 数据目录 profiles/Script.js(全局直连规则的唯一正确机制, 默认按 Windows/macOS 平台输出到 Clash Verge 数据目录 profiles/Script.js(全局直连
见 clash-verge-config 记忆)。生成后需在 Clash Verge 界面点「重启内核」生效。 规则的唯一正确机制)。生成后需在 Clash Verge 界面点「重启内核」生效。
""" """
import argparse import argparse
import os
from pathlib import Path from pathlib import Path
import sys
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_OUT = Path.home() / "Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/profiles/Script.js" APP_ID = "io.github.clash-verge-rev.clash-verge-rev"
def default_output_path(
platform_name: str = sys.platform,
*,
home: Path | None = None,
appdata: Path | None = None,
) -> Path:
"""返回当前平台的 Clash Verge 全局 Script.js 路径。"""
home = home or Path.home()
if platform_name == "win32":
if appdata is None:
roaming = os.environ.get("APPDATA")
if not roaming:
raise SystemExit("[error] Windows 环境缺少 APPDATA,无法定位 Clash Verge 数据目录")
appdata = Path(roaming)
return appdata / APP_ID / "profiles" / "Script.js"
if platform_name == "darwin":
return home / "Library/Application Support" / APP_ID / "profiles" / "Script.js"
return home / ".local/share" / APP_ID / "profiles" / "Script.js"
DEFAULT_OUT = default_output_path()
def load_payload(path: Path) -> list[str]: def load_payload(path: Path) -> list[str]:
@@ -22,12 +47,17 @@ def load_payload(path: Path) -> list[str]:
if line.startswith("- "): if line.startswith("- "):
payload.append(line[2:].strip()) payload.append(line[2:].strip())
if not payload: if not payload:
raise SystemExit(f"❌ direct.yaml 里没有 payload 条目: {path}") raise SystemExit(f"[error] direct.yaml 里没有 payload 条目: {path}")
return payload return payload
def to_rule(entry: str) -> str: def to_rule(entry: str) -> str:
"""rule-provider 条目(无策略)→ 完整规则(补 DIRECT)。""" """rule-provider 条目(无策略)→ 完整规则(补 DIRECT)。
处理:先剥掉行尾 `#` 注释,再补 DIRECT 策略,避免带注释的条目被错误拼接。
"""
# 剥掉 `#` 之后的行尾注释
entry = entry.split("#", 1)[0].rstrip()
if entry.endswith(",no-resolve"): if entry.endswith(",no-resolve"):
return f"{entry[:-len(',no-resolve')]},DIRECT,no-resolve" return f"{entry[:-len(',no-resolve')]},DIRECT,no-resolve"
return f"{entry},DIRECT" return f"{entry},DIRECT"
@@ -37,19 +67,37 @@ def render_script(payload: list[str]) -> str:
rules = [f" {to_rule(e)!r}," for e in payload] rules = [f" {to_rule(e)!r}," for e in payload]
lines = "\n".join(rules) lines = "\n".join(rules)
return f"""// 由 vpn-rules/direct.yaml 生成 — 勿手改,改真源后重跑 gen_clash_verge_script.py。 return f"""// 由 vpn-rules/direct.yaml 生成 — 勿手改,改真源后重跑 gen_clash_verge_script.py。
// 全局直连规则:FRP 控制面、B站、国内 LLM、内网段、国区 iCloud、UGREEN 一律 DIRECT。 // 全局直连规则:OneDrive、FRP 控制面、B站、国内 LLM、内网段、国区 iCloud、UGREEN 一律 DIRECT。
const prependRules = [ const prependRules = [
{lines} {lines}
]; ];
// 国内可达的 DoH 同时作为 fallback,确保 OneDrive 等境外域名在 DIRECT 出站前可解析。
const directDnsFallbacks = [
'https://dns.alidns.com/dns-query',
'https://doh.pub/dns-query',
];
function main(config, profileName) {{ function main(config, profileName) {{
const existingRules = Array.isArray(config.rules) ? config.rules : []; const existingRules = Array.isArray(config.rules) ? config.rules : [];
const existingRuleSet = new Set(existingRules); const existingRuleSet = new Set(existingRules);
const dns = config.dns && typeof config.dns === 'object' && !Array.isArray(config.dns)
? config.dns
: {{}};
const existingFallbacks = Array.isArray(dns.fallback)
? dns.fallback
: (typeof dns.fallback === 'string' ? [dns.fallback] : []);
const existingFallbackSet = new Set(existingFallbacks);
config.rules = [ config.rules = [
...prependRules.filter((rule) => !existingRuleSet.has(rule)), ...prependRules.filter((rule) => !existingRuleSet.has(rule)),
...existingRules, ...existingRules,
]; ];
dns.fallback = [
...directDnsFallbacks.filter((server) => !existingFallbackSet.has(server)),
...existingFallbacks,
];
config.dns = dns;
return config; return config;
}} }}
@@ -66,7 +114,7 @@ def main() -> None:
out = Path(args.out) out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(script, encoding="utf-8") out.write_text(script, encoding="utf-8")
print(f"✅ 已生成 {out}({len(payload)} 条规则)") print(f"[ok] 已生成 {out}({len(payload)} 条规则)")
print(" 请在 Clash Verge 界面点「重启内核」生效。") print(" 请在 Clash Verge 界面点「重启内核」生效。")
+107 -1
View File
@@ -1,7 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import subprocess
import sys import sys
import tempfile
import unittest import unittest
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
@@ -10,10 +13,60 @@ ROOT = Path(__file__).resolve().parents[1]
SPEC_PATH = ROOT / "MODULE_SPEC.yaml" SPEC_PATH = ROOT / "MODULE_SPEC.yaml"
sys.path.insert(0, str(ROOT / "scripts")) sys.path.insert(0, str(ROOT / "scripts"))
from gen_clash_verge_script import load_payload, to_rule # noqa: E402 from gen_clash_verge_script import ( # noqa: E402
default_output_path,
load_payload,
render_script,
to_rule,
)
class ModuleSpecTests(unittest.TestCase): class ModuleSpecTests(unittest.TestCase):
def test_clash_verge_default_output_path_is_platform_aware(self) -> None:
home = Path("C:/Users/example")
appdata = Path("C:/Users/example/AppData/Roaming")
self.assertEqual(
default_output_path("win32", home=home, appdata=appdata),
appdata
/ "io.github.clash-verge-rev.clash-verge-rev"
/ "profiles"
/ "Script.js",
)
self.assertEqual(
default_output_path("darwin", home=home),
home
/ "Library/Application Support"
/ "io.github.clash-verge-rev.clash-verge-rev"
/ "profiles"
/ "Script.js",
)
def test_clash_verge_generator_supports_windows_gbk_console(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
output = Path(temp_dir) / "Script.js"
env = os.environ.copy()
env["PYTHONIOENCODING"] = "gbk"
result = subprocess.run(
[
sys.executable,
str(ROOT / "scripts/gen_clash_verge_script.py"),
"--out",
str(output),
],
cwd=ROOT,
env=env,
capture_output=True,
check=False,
)
self.assertEqual(
result.returncode,
0,
result.stderr.decode("gbk", errors="replace"),
)
self.assertTrue(output.is_file())
def test_module_spec_declares_rule_authority_and_safe_context(self) -> None: def test_module_spec_declares_rule_authority_and_safe_context(self) -> None:
spec = json.loads(SPEC_PATH.read_text(encoding="utf-8")) spec = json.loads(SPEC_PATH.read_text(encoding="utf-8"))
@@ -141,6 +194,59 @@ class ModuleSpecTests(unittest.TestCase):
) )
self.assertTrue(all(",DIRECT" not in entry for entry in payload)) self.assertTrue(all(",DIRECT" not in entry for entry in payload))
def test_onedrive_process_is_forced_to_use_direct_routing(self) -> None:
payload = load_payload(ROOT / "direct.yaml")
self.assertIn("PROCESS-NAME,OneDrive.exe", payload)
self.assertEqual(
to_rule("PROCESS-NAME,OneDrive.exe"),
"PROCESS-NAME,OneDrive.exe,DIRECT",
)
def test_to_rule_strips_inline_hash_comment_before_classifying_no_resolve(self) -> None:
# 回归测试 (b4a4769):to_rule() 在判断 entry.endswith(',no-resolve') 之前
# 没有剥掉行尾 # 注释,导致带 inline 注释的 Akamai Steam CDN 规则
# 走错分支,把注释当成 entry 的一部分拼进去,生成 mihomo 解析失败的规则。
self.assertEqual(
to_rule("IP-CIDR,162.254.192.0/18,no-resolve # Akamai Steam CDN 主段"),
"IP-CIDR,162.254.192.0/18,DIRECT,no-resolve",
)
# 注释里出现 "no-resolve" 不能误判为 suffix
self.assertEqual(
to_rule("DOMAIN-SUFFIX,example.com # 走 no-resolve 路径"),
"DOMAIN-SUFFIX,example.com,DIRECT",
)
# 注释里出现 ",DIRECT" 不能误拼到 proxy 字段
self.assertEqual(
to_rule("IP-CIDR,10.0.0.0/8 # 不是 DIRECT, 是 REJECT"),
"IP-CIDR,10.0.0.0/8,DIRECT",
)
def test_to_rule_preserves_canonical_forms(self) -> None:
# 不带注释、不带 no-resolve
self.assertEqual(
to_rule("DOMAIN-SUFFIX,bilibili.com"),
"DOMAIN-SUFFIX,bilibili.com,DIRECT",
)
# 带 no-resolve、无注释
self.assertEqual(
to_rule("IP-CIDR,127.0.0.0/8,no-resolve"),
"IP-CIDR,127.0.0.0/8,DIRECT,no-resolve",
)
# 带 no-resolve + 行尾空白
self.assertEqual(
to_rule("IP-CIDR,127.0.0.0/8,no-resolve "),
"IP-CIDR,127.0.0.0/8,DIRECT,no-resolve",
)
def test_clash_verge_script_adds_reachable_direct_dns_fallbacks(self) -> None:
script = render_script(["PROCESS-NAME,OneDrive.exe"])
self.assertIn("const directDnsFallbacks", script)
self.assertIn("https://dns.alidns.com/dns-query", script)
self.assertIn("https://doh.pub/dns-query", script)
self.assertIn("config.dns = dns", script)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()