让 Surge 用上 Geosite

/ , , ,
要約:
Surge 不能直接用 Geosite 规则集。我用 Cloudflare Worker 写了一个实时转换的服务,正则也尽量转成了 DOMAIN-WILDCARD。

众所周知,Surge 是不支持直接使用 Geosite 规则集的,它的规则是使用一些通配符的域名匹配规则,所以如果想要用上大而全的 Geosite 规则集,就需要将 Geosite 的规则集转换成 Surge 支持的规则格式。

GitHub 上有很多仓库使用 GitHub Action 定时转换 Geosite 规则集为 Surge 支持的规则格式,但是由于 Action 是定时跑的,所以当 Geosite 规则集更新时,Surge 的规则集不会立即更新,这就导致了 Surge 的规则集总是滞后于 Geosite 的规则集。并且我查看的几个仓库在对 Geosite 中正则规则选择了直接丢弃不处理,导致很多规则实际丢失了大量的匹配项。

于是我决定通过 Cloudflare Worker 做一个能够实时转换且乐观缓存上游规则集的服务,并对正则规则尽可能多地处理,确保不会漏掉规则。

然后 xxxbrian/Surge-Geosite 就诞生了,并且部署了公开服务在 surge.bojin.co。

使用方法

把 Geosite 里的列表名拼在路径后面,需要筛选的话再接一个 @属性。

TEXT
https://surge.bojin.co/geosite/<列表名>[@<属性>]

apple 是 Apple 的全部域名,apple@cn 则只保留上游里标了 @cn 的部分。在 Surge 里直接当 RULE-SET 使用就行。

INI
[Rule]
RULE-SET,https://surge.bojin.co/geosite/apple@cn,DIRECT
RULE-SET,https://surge.bojin.co/geosite/category-ads-all,REJECT
RULE-SET,https://surge.bojin.co/geosite/openai,Proxy,update-interval=3600

所有可用的列表名可以在 surge.bojin.co/geosite 拿到一份 JSON。

关于实现

Surge 的域名规则使用的是 DOMAIN、DOMAIN-SUFFIX、DOMAIN-KEYWORD、DOMAIN-WILDCARD 这几种,然后可以使用 RULE-SET 引用一份远程列表。

而 Geosite 的规则集使用的是 domain:、full:、keyword:、regexp:、include:,规则末尾还能挂例如 @cn 来筛选特定的地区。

前几种规则可以一一对应地直接转换,但是 regexp: 和 include: 就比较麻烦了。

一一对应的规则

Geosite

Surge

domain:a.com,不写前缀也是这一种

DOMAIN-SUFFIX,a.com

full:a.com

DOMAIN,a.com

keyword:abc

DOMAIN-KEYWORD,abc

实现上就是逐行判断前缀,换成对应的 Surge 规则输出。行尾的 @cn 和注释会原样保留成一段 # 注释 跟在规则后面,这样在 Surge 里看到某条规则,就能知道它在上游带着什么属性。比如转换 apple 大概是这样。

TEXT
# include:apple-dev
# include:swift
DOMAIN-SUFFIX,swiftui.cn # @cn
DOMAIN-SUFFIX,swiftui.com.cn # @cn

include

include 的处理是递归的。Worker 遇到 include:xxx,就去 raw.githubusercontent.com 把对应的列表拉下来,用同样的逻辑转换,再贴到原来的位置,上面留一行 # include:xxx 当标记。这样最终输出的是一份完整的列表,Surge 不需要关心上游有 include 这层关系。

Note

每个 include 都会产生一次请求,所以大一点的列表第一次访问会慢一些。转换后的结果我用 Hono 的 cache 中间件缓存了 30 分钟,之后的请求直接命中。所谓的实时,其实是上游更新后半小时左右,新规则就会出现在服务里,再加上 Surge 自己的更新间隔。相比每天跑一次的 Action,已经快了很多。

正则

Surge 的域名规则里没有 regex 类型,最接近的是 DOMAIN-WILDCARD,但 DOMAIN-WILDCARD 是使用通配符的方式来匹配的,只支持 * 和 ?,所以需要想办法将复杂的正则尽可能地转换为 *。

我先把上游的正则统计了一下。当时一共有 1283 个列表,regexp: 有 208 行,其中 category-porn 占了 134 行,qcloud 29 行,apple 6 行,剩下的比较分散。没有一条是纯字面量,所以没有哪条能直接换成 DOMAIN 或者 DOMAIN-SUFFIX。大部分都长这样,这三条来自 apple。

TEXT
regexp:^a[1-5]\.mzstatic\.com$ @cn
regexp:^cl[1-5]\.apple\.com$ @cn
regexp:^s[1-5]?\.mzstatic\.com$ @cn

它们描述的是一批编号不同的 CDN 主机名,a1 到 a5 之类的,只能靠通配符来表示。

最开始我直接把 regexp: 换成了 DOMAIN-KEYWORD,正则原封不动放进去,后来才发现 DOMAIN-KEYWORD 只是子串匹配,^、[、( 这些字符在主机名里根本不会出现,转换出来的规则永远不可能命中。

字符串替换我也想过,但是分组、分支、字符类、量词、转义可以互相嵌套,一个字符一个字符地处理很容易在括号上出错。所以最后用 regjsparser 把正则解析成语法树,再对每种节点分别决定怎么输出。

TypeScript
import { parse } from "regjsparser";

const convertNodeToWildcard = (node): string => {
  switch (node.type) {
    case "alternative": return node.body.map(convertNodeToWildcard).join("");
    case "anchor":      return "";   // ^ 和 $
    case "disjunction": return "*";  // a|b
    case "quantifier":  return "*";  // x* x+ x? x{m,n}
    case "reference":   return "*";  // 反向引用
    case "group":       return convertNodeToWildcard(node.body[0]);
    case "dot":
    case "characterClass":
    case "characterClassEscape":
    case "unicodePropertyEscape": return "?"; // 恰好一个字符
    case "value":       // 字面字符
      return typeof node.codePoint === "number" ? String.fromCodePoint(node.codePoint) : "?";
  }
};

这里把几个返回值一样的分支合并了。规则总结起来就是,字面字符原样保留;^ 和 $ 直接去掉,因为通配符本来就是整段匹配;.、[a-z]、\d 这种恰好匹配一个字符的变成 ?;*、+、?、{m,n} 这类数量不定的变成 *;分支 a|b 没法表示,同样变成 *;括号里的内容递归处理。

按这个规则,上面三条会转换成这样。

TEXT
a?.mzstatic.com
cl?.apple.com
s*.mzstatic.com

如果正则解析失败,转换函数会返回空字符串,输出就是一行不完整的 DOMAIN-WILDCARD,。Surge 对规则集里的无效行会直接跳过并给出警告,不会影响整个列表,所以我没有额外处理。

Warning

这样转换出来的规则肯定会比原来的正则宽。比如 [1-5] 变成了 ?,a?.mzstatic.com 也能匹配 a9.mzstatic.com。占大多数的 (^|\.) 开头也一样,两个分支整个被换成了 *,(^|\.)nnbw[0-2]\.xyz$ 会变成 *nnbw?.xyz,原本要求 nnbw 前面必须是开头或者点,现在 xnnbw0.xyz 也会匹配。不过我的目标本来就是不漏规则,多匹配几个长得像的域名,比整批规则丢掉要好,所以这个我接受。

和 Action 对比

和 Action 定时转换相比,更新延迟从一天缩短到了半小时左右。正则也不再直接丢弃,而是转换成 DOMAIN-WILDCARD。列表和属性的组合不用提前生成,apple@cn 这种请求到了才会转换,想要哪个都行。而且产物不放在 GitHub 上,直接由 Cloudflare 的边缘节点提供,也不用担心 GitHub 访问不顺畅。

整个服务的代码不多,感兴趣的话可以直接看仓库。仓库里还有一个每天跑一次的 Action,只负责更新上游的 submodule 和重新生成 README 里的列表目录。

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...