server_config: 新增 RRPP 订阅后端规则集下发文档(PAC → classical 规则集)
- 新增 §12:PAC 不能直接下发的原因、三个规则集产物、PAC 更新后的再生成流程、 mihomo/Shadowrocket 两种客户端接入方式与验证口径 - 新增 rrpp-subscription/:API.md(订阅端点/method/rules 参数/规则集说明) 与 gen_rulesets.py(PAC → 客户端规则集转换脚本,幂等、原子替换) - method 新增 shadowrocket(.conf:远程 RULE-SET + FINAL,不猜测 REALITY 参数) - method=clash 新增 &rules=provider|inline|global
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
- [9. systemd 服务文件](#9-systemd-服务文件)
|
- [9. systemd 服务文件](#9-systemd-服务文件)
|
||||||
- [10. 启动顺序与验证清单](#10-启动顺序与验证清单)
|
- [10. 启动顺序与验证清单](#10-启动顺序与验证清单)
|
||||||
- [11. 目录结构与文件索引](#11-目录结构与文件索引)
|
- [11. 目录结构与文件索引](#11-目录结构与文件索引)
|
||||||
|
- [12. RRPP 订阅后端(PAC → 规则集下发)](#12-rrpp-订阅后端pac--规则集下发)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -564,8 +565,74 @@ server_config/
|
|||||||
│ └── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护
|
│ └── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护
|
||||||
├── cron/
|
├── cron/
|
||||||
│ └── sync-nginx-local-ips ← cron 定义(每 5 分钟)
|
│ └── sync-nginx-local-ips ← cron 定义(每 5 分钟)
|
||||||
|
├── rrpp-subscription/ ← RRPP 订阅后端(bj,见 §12)
|
||||||
|
│ ├── API.md ← 订阅端点 / method / 分层 / 规则集文档
|
||||||
|
│ └── gen_rulesets.py ← PAC → 客户端规则集转换脚本
|
||||||
└── fake-site/
|
└── fake-site/
|
||||||
└── index.html ← 伪装站点页面(所有节点共用同一份)
|
└── index.html ← 伪装站点页面(所有节点共用同一份)
|
||||||
```
|
```
|
||||||
|
|
||||||
> 旧的 `traffic-monitor/`、`pac/`、`conf.d/*.conf` 已下线或迁移,不再维护,已从本目录移除。
|
> 旧的 `traffic-monitor/`、`pac/`、`conf.d/*.conf` 已下线或迁移,不再维护,已从本目录移除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. RRPP 订阅后端(PAC → 规则集下发)
|
||||||
|
|
||||||
|
订阅服务运行在 **bj**(`C:/BtSoft/nginx/others/backEnd/api-rrrosmontis-icu/RRPP/`,nssm 服务 `RRPPSubBackend`,监听 `127.0.0.1:5010`,由 nginx `api.rrrosmontis.icu` 反代)。
|
||||||
|
完整端点说明见 `rrpp-subscription/API.md`(bj 上同名文件为主副本,两处内容保持一致)。
|
||||||
|
|
||||||
|
### 12.1 PAC 为什么不能直接下发
|
||||||
|
|
||||||
|
PAC 是浏览器/系统用的 JS 自动配置,**mihomo/Clash 与 Shadowrocket 都读不了**。两者唯一共同能吃的格式是
|
||||||
|
「每行自带规则类型的规则行文本」(Clash 的 `behavior: classical`):
|
||||||
|
|
||||||
|
- mihomo → `rule-providers`(`behavior: classical` + `format: text`)
|
||||||
|
- Shadowrocket → `RULE-SET,<url>,<策略>`(官方手册:规则集内容需包含规则类型)
|
||||||
|
|
||||||
|
因此由后端 `gen_rulesets.py` 把 PAC 数据转成三个 `.list`,两种客户端共用同一份文件。
|
||||||
|
|
||||||
|
### 12.2 规则集产物
|
||||||
|
|
||||||
|
| 规则集 | 文件(`https://cdn.rrrosmontis.icu/RRPP/PAC/`) | 规则数 | 策略 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `rrpp-proxy` | `rrpp-proxy.list` | 3943(`DOMAIN` 539 + `DOMAIN-SUFFIX` 3404) | `RRPP` / `PROXY` |
|
||||||
|
| `rrpp-direct` | `rrpp-direct.list` | 16(`DOMAIN-SUFFIX`) | `DIRECT` |
|
||||||
|
| `rrpp-cn-ip` | `rrpp-cn-ip.list` | 7456(`IP-CIDR`) | `DIRECT` |
|
||||||
|
|
||||||
|
顺序(与 PAC 语义一致):强制代理 → 直连种子 → 中国 IP 直连 → `MATCH` 全代理。
|
||||||
|
|
||||||
|
### 12.3 维护:PAC 更新后重新生成
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# bj 上执行(Windows:C:/BtSoft/nginx/others/htmlResources/cdn/RRPP/PAC/ 为生成目录,同时即 CDN 根)
|
||||||
|
python gen_rulesets.py # 幂等:内容无变化则不写盘;原子替换
|
||||||
|
python gen_rulesets.py --check # 只预览差异,不写盘
|
||||||
|
```
|
||||||
|
|
||||||
|
产物文件名与 URL 固定,**订阅端与客户端均无需改动**:mihomo 按 `interval: 86400` 自动更新,
|
||||||
|
Shadowrocket 在「配置 → 规则集URL」中自动更新。
|
||||||
|
|
||||||
|
### 12.4 客户端接入
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# mihomo / Clash Verge:规则走 rule-providers(YAML 约 2.4KB)
|
||||||
|
...&method=clash
|
||||||
|
# 不认 rule-providers,或要「导入一次到位」:规则内联(约 440KB)
|
||||||
|
...&method=clash&rules=inline
|
||||||
|
# Shadowrocket:只下发规则([General] + 远程 RULE-SET + FINAL,不含节点)
|
||||||
|
...&method=shadowrocket
|
||||||
|
# 兜底:全流量走代理(旧行为)
|
||||||
|
...&method=clash&rules=global
|
||||||
|
```
|
||||||
|
|
||||||
|
> `method=shadowrocket` 的 `.conf` 不写 `[Proxy]` 段——Shadowrocket 的 REALITY 参数写法官方未文档化,不猜测;
|
||||||
|
> 策略 `PROXY` 指向首页当前选中的节点,节点用 `clash`/`v2ray` 订阅导入。
|
||||||
|
> 想在小火箭里「一次导入节点+规则」,用 `method=clash&rules=inline`。
|
||||||
|
|
||||||
|
### 12.5 验证口径
|
||||||
|
|
||||||
|
- 生成:11415 条规则(`regexp:` 跳过 7、非法 1)
|
||||||
|
- CDN:三个 URL HTTP 200,md5 与本地一致(`806808b8` / `8f89397e` / `3e75093d`)
|
||||||
|
- mihomo v1.19.31 真实内核:`-t` 通过;运行时 `GET /providers/rules` 报 `3943 / 16 / 7456`(`behavior=Classical`),
|
||||||
|
落地文件字节数与生成一致;`rules=inline` 亦通过 `-t`
|
||||||
|
- Shadowrocket 为 iOS 应用,未做实机验证(依官方手册 `RULE-SET` 与 Clash YAML 兼容说明实现)
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# RRPP 订阅服务 · SSO 认证接入文档
|
||||||
|
|
||||||
|
架构:`客户端 → nginx(api.rrrosmontis.icu) → RRPP 后端(127.0.0.1:5010) → SSO(127.0.0.1:4550) → nodes.json(国家分层)`
|
||||||
|
|
||||||
|
## 1. 客户端公开端点
|
||||||
|
|
||||||
|
### 统一订阅端点
|
||||||
|
```
|
||||||
|
GET https://api.rrrosmontis.icu/v1/rrpp/subscription?method=<类型>&user=<b64url邮箱>&password=<b64url密码>[&prefer=<协议>][&rules=<模式>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `method` | 是 | `clash` / `v2ray` / `shadowrocket` / `rrpp_client` |
|
||||||
|
| `user` | 是 | URL-safe base64 编码的邮箱 |
|
||||||
|
| `password` | 是 | URL-safe base64 编码的密码 |
|
||||||
|
| `prefer` | 否 | `vless` / `ss` / `http`,指定优先协议;缺省 `vless`,无则回退 vless→ss→http |
|
||||||
|
| `rules` | 否 | 仅 `method=clash`:`provider`(默认) / `inline` / `global`,见 §7 |
|
||||||
|
| `token` | 否 | SSO 会话 token(免密,代登录态);与 `user`/`password` 二选一 |
|
||||||
|
|
||||||
|
### 旧端点(兼容)
|
||||||
|
```
|
||||||
|
GET /v1/rrpp/nodes_update?user=<b64>&password=<b64>
|
||||||
|
```
|
||||||
|
等价于 `method=rrpp_client`。
|
||||||
|
|
||||||
|
## 2. method 说明
|
||||||
|
|
||||||
|
| method | Content-Type | 内容 | 适用客户端 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `clash` | `text/yaml` | Clash YAML:`proxies` + `proxy-groups(RRPP)` + `rule-providers` + `rules`(备注 `R.R.P.P. - 国家代码`) | mihomo / Clash Verge / ClashX Meta |
|
||||||
|
| `v2ray` | `text/plain` | base64 节点串(`vless://` / `ss://` / `http://`) | v2rayN / Shadowrocket / Quantumult |
|
||||||
|
| `shadowrocket` | `text/plain` | Shadowrocket `.conf`:`[General]` + 远程 `RULE-SET` + `FINAL`(**不含节点**) | Shadowrocket |
|
||||||
|
| `rrpp_client` | `application/json` | 旧格式节点 JSON(仅 http,remark = `服务商 流量`) | RRPP 自建客户端 |
|
||||||
|
|
||||||
|
## 3. 返回码
|
||||||
|
|
||||||
|
| 码 | 场景 |
|
||||||
|
|---|---|
|
||||||
|
| 200 | 成功 |
|
||||||
|
| 401 | 凭据缺失/SSO 认证失败 |
|
||||||
|
| 403 | tier=0(RRPP 未开通) |
|
||||||
|
| 404 | method 未知,或 `rules` 取值非法 |
|
||||||
|
| 502 | SSO 不可达 |
|
||||||
|
| 503 | nodes.json 缺失/损坏,或该 tier 无可用节点 |
|
||||||
|
|
||||||
|
## 4. nodes.json 格式(国家分层)
|
||||||
|
|
||||||
|
`C:/BtSoft/nginx/others/htmlResources/private/RRPP/nodes.json`,顶层为国家数组:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"country": "JP", // 国家代码
|
||||||
|
"available_level": [1, 2, 3], // 可见等级
|
||||||
|
"provider": "Azure", // 服务商
|
||||||
|
"traffic": "15G", // 流量限额
|
||||||
|
"domain": "iam.aeolianchi.me", // 域名
|
||||||
|
"ipv6_only": false, // 可选,true 时备注加 (IPv6)
|
||||||
|
"methods": { // 可用协议
|
||||||
|
"http": { "port": ..., "username": ..., "password": ... },
|
||||||
|
"vless": { "port": ..., "uuid": ..., "flow": ..., "sni": ..., "public_key": ..., "short_id": ... },
|
||||||
|
"ss": { "port": ..., "method": ..., "password": ... }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 协议选择逻辑
|
||||||
|
|
||||||
|
- `prefer` 指定则优先用该协议
|
||||||
|
- 缺省默认 `vless`(最好用)
|
||||||
|
- 该国家无 `prefer` 协议时按 `vless → ss → http` 回退
|
||||||
|
- `rrpp_client` 固定只用 `http`(兼容性),备注 = `provider + traffic`(IPv6 加 `(IPv6)`)
|
||||||
|
|
||||||
|
## 6. SSO 认证
|
||||||
|
|
||||||
|
- 后端调 SSO `POST http://127.0.0.1:4550/v1/sso/service/rrpp/nodes`(带 `X-SSO-Service-Token` + `X-Real-IP`)
|
||||||
|
- SSO 返回 `{tier: 0|1|2|3}`,0=未开通
|
||||||
|
- 服务密钥读 `sso-rrrosmontis-icu/secrets.json` 的 `service_tokens.rrpp`
|
||||||
|
|
||||||
|
## 7. 分流规则(PAC → 规则集,随订阅下发)
|
||||||
|
|
||||||
|
### 7.1 为什么不是直接下发 PAC
|
||||||
|
|
||||||
|
PAC 是给浏览器/操作系统用的 JS 自动配置;**mihomo/Clash 与 Shadowrocket 都不能读 PAC**。
|
||||||
|
两者唯一共同能吃的格式是「每行自带规则类型的规则行文本」(Clash 术语 `behavior: classical`):
|
||||||
|
|
||||||
|
- mihomo:`rule-providers`(`behavior: classical` + `format: text`)
|
||||||
|
- Shadowrocket:`RULE-SET,<url>,<策略>`(官方手册要求「规则集的内容需包含规则类型」)
|
||||||
|
|
||||||
|
因此把 PAC 数据**在后端一次性转换成三个规则集文件**,两种客户端共用同一份文件。
|
||||||
|
|
||||||
|
### 7.2 规则集文件(CDN 公开)
|
||||||
|
|
||||||
|
生成目录即 CDN 根下的 `RRPP/PAC/`,公开可访问、无鉴权(规则集本身不含节点信息):
|
||||||
|
|
||||||
|
| 规则集 | 文件 | 规则数 | 组成 | 策略 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `rrpp-proxy` | `rrpp-proxy.list` | 3943 | 539 `DOMAIN` + 3404 `DOMAIN-SUFFIX`(强制代理域名) | `RRPP` / `PROXY` |
|
||||||
|
| `rrpp-direct` | `rrpp-direct.list` | 16 | 16 `DOMAIN-SUFFIX`(保守直连种子) | `DIRECT` |
|
||||||
|
| `rrpp-cn-ip` | `rrpp-cn-ip.list` | 7456 | 7456 `IP-CIDR`(中国 IP 段) | `DIRECT` |
|
||||||
|
|
||||||
|
URL:`https://cdn.rrrosmontis.icu/RRPP/PAC/<文件名>`
|
||||||
|
体积:120366 / 386 / 177443 字节,合计 11415 条规则。
|
||||||
|
|
||||||
|
### 7.3 规则顺序(与 PAC 语义一致)
|
||||||
|
|
||||||
|
```
|
||||||
|
RULE-SET,rrpp-proxy → RRPP # 强制代理优先(PAC 中强制代理规则优先级更高)
|
||||||
|
RULE-SET,rrpp-direct → DIRECT # 直连种子
|
||||||
|
RULE-SET,rrpp-cn-ip → DIRECT # 中国 IP 直连
|
||||||
|
MATCH → RRPP # 其余全走代理
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 维护流程(PAC 更新后)
|
||||||
|
|
||||||
|
转换脚本 `gen_rulesets.py`(与本文件同目录,另在仓库 `server_config/rrpp-subscription/` 存有副本):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python gen_rulesets.py # 读 PAC 目录 → 原子覆写三个 .list(幂等,内容不变则不写)
|
||||||
|
python gen_rulesets.py --check # 只检查将有哪些变化,不写盘
|
||||||
|
python gen_rulesets.py --pac-dir <目录> --out-dir <目录> --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
- 输入:`pac_domains.json` / `pac_direct_domains.json` / `pac_ip_cn.json`
|
||||||
|
- 转换规则:`full:x`→`DOMAIN,x`;`keyword:x`→`DOMAIN-KEYWORD,x`;`regexp:`/`include:`→跳过并计数;行内 `#` 注释剥离;`*.x`/普通域名→`DOMAIN-SUFFIX,x`
|
||||||
|
- 输出排序去重、LF 换行、无注释,写盘用临时文件 + `os.replace` 原子替换
|
||||||
|
- **订阅端无需任何改动**:文件名与 URL 固定,客户端按 `interval` 自动拉新规则
|
||||||
|
|
||||||
|
### 7.5 客户端接入
|
||||||
|
|
||||||
|
| 场景 | 用法 |
|
||||||
|
|---|---|
|
||||||
|
| mihomo / Clash Verge(推荐) | `&method=clash` → 规则走 `rule-providers`,YAML 约 2.4KB,规则每 24h 自动更新 |
|
||||||
|
| 客户端不认 `rule-providers`,或想「导入一次到位」 | `&method=clash&rules=inline` → 规则内联进 YAML(约 440KB),导入即含节点+全部规则 |
|
||||||
|
| Shadowrocket(只想要规则,节点另行导入) | `&method=shadowrocket` → `.conf`,规则走远程 `RULE-SET`(在「配置 → 规则集URL」中可见并自动更新) |
|
||||||
|
| Shadowrocket(想一次导入节点+规则) | 用 `&method=clash&rules=inline`(小火箭兼容 Clash YAML,含节点信息的标准 Clash 链接可同时导入配置与节点) |
|
||||||
|
| 兜底 / 排障 | `&rules=global` → 只有 `MATCH,RRPP`,全流量走代理(等价旧行为) |
|
||||||
|
|
||||||
|
> `method=shadowrocket` 的 `.conf` **不写 `[Proxy]` 段**:Shadowrocket 的 REALITY 参数写法官方未文档化,不猜测;
|
||||||
|
> 该配置的策略 `PROXY` 指向首页当前选中的节点,节点请用 `clash`/`v2ray` 订阅导入。
|
||||||
|
|
||||||
|
### 7.6 验证记录(2026-09-19)
|
||||||
|
|
||||||
|
- 生成:11415 条规则;`regexp:` 跳过 7 条、非法 1 条
|
||||||
|
- CDN:三个 URL 均 HTTP 200,`md5` 与本地生成一致(`806808b8` / `8f89397e` / `3e75093d`)
|
||||||
|
- **mihomo v1.19.31 真实内核运行验证**:`-t` 校验通过;运行时 `GET /providers/rules` 显示
|
||||||
|
`rrpp-proxy=3943`、`rrpp-direct=16`、`rrpp-cn-ip=7456`(`behavior=Classical`),落地文件字节数与生成完全一致;
|
||||||
|
`&rules=inline` 配置亦通过 `-t` 校验
|
||||||
|
- Shadowrocket 为 iOS 应用,**未做实机验证**,仅依据官方手册的 `RULE-SET` / Clash YAML 兼容说明实现
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
RRPP PAC → 规则集转换器
|
||||||
|
|
||||||
|
把 PAC 数据文件(JSON)转换成 **mihomo(Clash) 与 Shadowrocket 通用**的
|
||||||
|
"classical 规则行"文本规则集——每行都含规则类型:
|
||||||
|
|
||||||
|
pac_direct_domains.json -> rrpp-direct.list (DOMAIN-SUFFIX,<域名>)
|
||||||
|
pac_domains.json -> rrpp-proxy.list (DOMAIN-SUFFIX,<域名>)
|
||||||
|
pac_ip_cn.json -> rrpp-cn-ip.list (IP-CIDR,<网段>)
|
||||||
|
|
||||||
|
为什么用这个格式(两端通吃):
|
||||||
|
* mihomo : rule-providers { behavior: classical, format: text }
|
||||||
|
* Shadowrocket: RULE-SET,<url>,<策略>
|
||||||
|
(官方要求"规则集的内容需包含规则类型")
|
||||||
|
|
||||||
|
三种规则集由客户端按各自策略引用:
|
||||||
|
rrpp-proxy.list -> 代理(强制代理域名)
|
||||||
|
rrpp-direct.list -> 直连(保守直连种子)
|
||||||
|
rrpp-cn-ip.list -> 直连(中国 IP 段)
|
||||||
|
|
||||||
|
用法
|
||||||
|
----
|
||||||
|
python gen_rulesets.py # 就地生成(内容有变化才写盘)
|
||||||
|
python gen_rulesets.py --check # 只检查/报告,不写盘
|
||||||
|
python gen_rulesets.py --pac-dir DIR --out-dir DIR
|
||||||
|
python gen_rulesets.py --quiet
|
||||||
|
|
||||||
|
维护
|
||||||
|
----
|
||||||
|
PAC 更新后重新执行本脚本即可。脚本是**幂等**的:内容与现有文件一致时不会重写;
|
||||||
|
写入采用 临时文件 + 原子替换,失败不会损坏已有文件。输出不含注释,只有规则行。
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# ---------- 默认路径(bj 生产环境) ----------
|
||||||
|
DEFAULT_PAC_DIR = r'C:/BtSoft/nginx/others/htmlResources/cdn/RRPP/PAC'
|
||||||
|
DEFAULT_OUT_DIR = DEFAULT_PAC_DIR # 与 PAC 同目录,由 CDN 直接对外提供
|
||||||
|
|
||||||
|
# 映射:PAC 文件 -> (输出文件, JSON 中的列表字段, 类型)
|
||||||
|
# 类型 domains = 域名表(含 v2fly 前缀:full:/keyword:/regexp:/行内注释)
|
||||||
|
# 类型 cidrs = IP 网段表
|
||||||
|
RULESETS = [
|
||||||
|
('pac_domains.json', 'rrpp-proxy.list', 'domains'),
|
||||||
|
('pac_direct_domains.json', 'rrpp-direct.list', 'domains'),
|
||||||
|
('pac_ip_cn.json', 'rrpp-cn-ip.list', 'cidrs'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg, quiet=False):
|
||||||
|
if not quiet:
|
||||||
|
print(msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path):
|
||||||
|
"""读取 JSON;容忍 BOM。"""
|
||||||
|
with open(path, 'r', encoding='utf-8-sig') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_domain(s):
|
||||||
|
if not s or len(s) > 253 or '.' not in s:
|
||||||
|
return False
|
||||||
|
if any(c in s for c in ' /\\:@?#[]'):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def convert_domain_entry(v):
|
||||||
|
"""把一条域名表条目转成规则行。
|
||||||
|
|
||||||
|
支持 v2fly geosite 前缀与行内注释(PAC 数据实测存在):
|
||||||
|
full:<域名> -> DOMAIN,<域名> (精确匹配)
|
||||||
|
keyword:<关键词> -> DOMAIN-KEYWORD,<关键词>
|
||||||
|
regexp:<正则> -> 跳过(两端都无法表达域名正则)
|
||||||
|
<域名> # 注释 -> DOMAIN-SUFFIX,<域名>
|
||||||
|
返回 (规则行 或 None, 跳过原因 或 None)。
|
||||||
|
"""
|
||||||
|
s = str(v or '')
|
||||||
|
if '#' in s: # 行内注释(如 "apple.xn--czr694b # apple.商标")
|
||||||
|
s = s.split('#', 1)[0]
|
||||||
|
s = s.strip().lower()
|
||||||
|
if not s:
|
||||||
|
return None, 'empty'
|
||||||
|
|
||||||
|
if s.startswith('regexp:'):
|
||||||
|
return None, 'regexp'
|
||||||
|
if s.startswith('include:'):
|
||||||
|
return None, 'include'
|
||||||
|
|
||||||
|
if s.startswith('full:'):
|
||||||
|
dom = s[5:].strip().lstrip('*.').rstrip('.')
|
||||||
|
return (f'DOMAIN,{dom}', None) if _valid_domain(dom) else (None, 'invalid')
|
||||||
|
|
||||||
|
if s.startswith('keyword:'):
|
||||||
|
kw = s[8:].strip()
|
||||||
|
return (f'DOMAIN-KEYWORD,{kw}', None) if kw else (None, 'invalid')
|
||||||
|
|
||||||
|
if s.startswith('domain:'):
|
||||||
|
s = s[7:].strip()
|
||||||
|
|
||||||
|
dom = s.lstrip('*.').rstrip('.') # 通配前缀统一按后缀匹配
|
||||||
|
return (f'DOMAIN-SUFFIX,{dom}', None) if _valid_domain(dom) else (None, 'invalid')
|
||||||
|
|
||||||
|
|
||||||
|
def norm_cidr(v):
|
||||||
|
"""CIDR 规范化/校验:返回规范化网段,非法返回 None。"""
|
||||||
|
s = str(v or '').strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(s, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return str(net.network_address) + '/' + str(net.prefixlen)
|
||||||
|
|
||||||
|
|
||||||
|
def build_lines(kind, data, quiet=False):
|
||||||
|
"""由 PAC JSON 生成规则行(排序去重),返回 (lines, stats)。"""
|
||||||
|
field = 'domains' if kind == 'domains' else 'cidrs'
|
||||||
|
raw = data.get(field) if isinstance(data, dict) else None
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise ValueError(f'字段 {field} 不是列表')
|
||||||
|
seen, stats, out = set(), {}, []
|
||||||
|
for item in raw:
|
||||||
|
if kind == 'domains':
|
||||||
|
line, why = convert_domain_entry(item)
|
||||||
|
else:
|
||||||
|
c = norm_cidr(item)
|
||||||
|
line, why = (f'IP-CIDR,{c}', None) if c else (None, 'invalid')
|
||||||
|
if line is None:
|
||||||
|
stats[why] = stats.get(why, 0) + 1
|
||||||
|
continue
|
||||||
|
if line not in seen:
|
||||||
|
seen.add(line)
|
||||||
|
out.append(line)
|
||||||
|
out.sort()
|
||||||
|
if stats:
|
||||||
|
detail = ' '.join(f'{k}={v}' for k, v in sorted(stats.items()))
|
||||||
|
log(f' ! 跳过条目: {detail}', quiet)
|
||||||
|
return out, stats
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def write_if_changed(path, text, check_only=False):
|
||||||
|
"""内容有变化才原子写入。返回 (changed, md5)。"""
|
||||||
|
data = text.encode('utf-8')
|
||||||
|
md5 = hashlib.md5(data).hexdigest()
|
||||||
|
old = None
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
old = f.read()
|
||||||
|
if old == data:
|
||||||
|
return False, md5
|
||||||
|
if check_only:
|
||||||
|
return True, md5
|
||||||
|
tmp = path + '.tmp'
|
||||||
|
with open(tmp, 'wb') as f: # 二进制写:确保 LF、无 BOM
|
||||||
|
f.write(data)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
return True, md5
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description='RRPP PAC -> 规则集转换器')
|
||||||
|
ap.add_argument('--pac-dir', default=DEFAULT_PAC_DIR, help='PAC JSON 所在目录')
|
||||||
|
ap.add_argument('--out-dir', default=DEFAULT_OUT_DIR, help='规则集输出目录')
|
||||||
|
ap.add_argument('--check', action='store_true', help='只检查,不写盘')
|
||||||
|
ap.add_argument('--quiet', action='store_true')
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not os.path.isdir(args.pac_dir):
|
||||||
|
print(f'错误:PAC 目录不存在: {args.pac_dir}', file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
os.makedirs(args.out_dir, exist_ok=True)
|
||||||
|
|
||||||
|
total, changed_cnt, failed = 0, 0, 0
|
||||||
|
log(f'PAC 目录 : {args.pac_dir}')
|
||||||
|
log(f'输出目录 : {args.out_dir}{" (--check 模式,不写盘)" if args.check else ""}')
|
||||||
|
log('')
|
||||||
|
|
||||||
|
for src_name, out_name, kind in RULESETS:
|
||||||
|
src_path = os.path.join(args.pac_dir, src_name)
|
||||||
|
out_path = os.path.join(args.out_dir, out_name)
|
||||||
|
if not os.path.exists(src_path):
|
||||||
|
print(f' [跳过] {src_name} 不存在', file=sys.stderr)
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = read_json(src_path)
|
||||||
|
lines, _stats = build_lines(kind, data, args.quiet)
|
||||||
|
text = '\n'.join(lines) + '\n'
|
||||||
|
changed, md5 = write_if_changed(out_path, text, args.check)
|
||||||
|
size = len(text.encode('utf-8'))
|
||||||
|
ver = data.get('version', '-') if isinstance(data, dict) else '-'
|
||||||
|
state = ('已更新' if changed else '无变化') if not args.check else ('需更新' if changed else '无变化')
|
||||||
|
log(f' {src_name:26s} -> {out_name:18s} {len(lines):5d} 行 {size/1024:7.1f} KB '
|
||||||
|
f'ver={ver:12s} md5={md5[:8]} [{state}]')
|
||||||
|
total += len(lines)
|
||||||
|
changed_cnt += 1 if changed else 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f' [失败] {src_name}: {type(e).__name__}: {e}', file=sys.stderr)
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
log('')
|
||||||
|
log(f'合计 {total} 条规则,{changed_cnt} 个文件{"需更新" if args.check else "已更新"},{failed} 个失败')
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user