重写 Surge-Geosite

/ , , ,
建議先閱讀以下文章,以了解相關背景。
摘要:
Geosite 转换服务跑了一年半,规则误判迟迟没有解决,于是我把它整个重写了,并配上了一个预览面板。

之前的 Geosite 项目已经跑了一年半,稳定性一直不错,只有寥寥几个 issue,分别是关于请求超限和规则误判的。请求超限比较好解决,做了一些 workaround 就临时修复了;但是后面的规则误判问题迟迟没有解决掉。最近有空,便趁着机会打算把这个项目进行一次完整的重写。

旧版的原理和实现在上一篇文章里写过了,这篇主要说说新版。

为什么要重写

规则误判的两个 issue,都是正则被错误转换导致的。#3 是 private 列表里原本的 ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$ 被直接转换成了 DOMAIN-WILDCARD,?*,导致任意域名都会被匹配。#4 则是 category-porn 里的 (^|\.)[a-z][1-9][0-9][a-z]\.com$ 被转换成了 *????.com,这甚至会让 google.com 都被匹配为 porn。

这些问题其实是预期内的,在设计转换算法的时候就很清楚,字符类全部变成 ?,量词和分支全部变成 *,正则里例如"第一个字符必须是字母""最多 62 个字符""不能以连字符结尾"这些约束,转换后就全都没有了,只剩下非常宽的通配符。#3 出现之后,我临时加了个 workaround,把只由 ? 和 * 组成的结果直接跳过,但是 #4 出现的时候,*????.com 这种就排除不了了。

而且重新检查代码的时候,还发现了更严重的问题。Geosite 的 include: 后面可以挂属性,比如 category-ads 里有这样一行。

TEXT
include:adobe @ads

意思是只引入 adobe 里带有 @ads 的规则。但是旧版在处理 include 的时候,是这样取列表名的。

TypeScript
const subContentName = line.split(" ")[0].replace("include:", "");
Warning

后面的属性直接被丢掉了,整个 adobe 列表都被展开到了 category-ads 里。刚写的时候上游还没有这种写法,现在 1566 行 include 里已经有 77 行带了属性。我把旧版的逻辑拿出来,用现在的上游数据测了一下,category-ads 会输出 8045 条规则,里面有 adobe.com、amazon.com、alibaba.com,而正确的结果只有 638 条。要是把它挂在 REJECT 上,amazon.com 也会被一起拒掉。

这些问题不是改几行代码能解决的,所以我决定把整个项目重写。新版在仓库的 next 分支,和 main 是两套完全独立的历史,原来的 URL 全部保持兼容。

整体结构

新版是一个 pnpm 的 monorepo,分成 core、cli、worker、panel 四个包。

Mermaid diagram

core 里全是纯函数,输入 Geosite 的文本,输出 Surge 的规则,不读文件也不发请求。它分成四步。parser 把每个列表解析成带类型和行号的条目,出错的时候能知道是哪个列表的第几行。resolver 负责展开 include、过滤属性和去重,循环引用会直接报错。regex 单独处理正则。最后由 surge 输出规则文本。

上面那个 include 的问题,在 resolver 里只需要一个函数。include:xxx @a @-b 会在 parser 里被拆成"必须带 a"和"必须不带 b"两组,resolver 对每条规则逐个判断。

TypeScript
function matchesAttrFilters(rule: DomainRule, inclusion: Inclusion): boolean {
  if (inclusion.mustAttrs.length === 0 && inclusion.banAttrs.length === 0) return true;
  if (rule.attrs.length === 0) return inclusion.mustAttrs.length === 0;
  for (const attr of inclusion.mustAttrs) if (!rule.attrs.includes(attr)) return false;
  for (const attr of inclusion.banAttrs) if (rule.attrs.includes(attr)) return false;
  return true;
}

转换的同时 core 还会输出一份报告,记录每条正则最后是怎么处理的,是等价转换、被放宽还是放弃了,以及原因。

CLI 就是在 core 外面包了一层。指定本地的 domain-list-community 的 data 目录,它会把所有列表在每个档位下都转换一遍,输出规则、展开 include 之后的中间结果和每个列表的统计。改了转换逻辑之后在本地跑一遍,就能看到哪些规则变了。

转换档位

正则的转换没有唯一的答案,有的人想要完全等价,宁可少几条,有的人想要尽量覆盖,可以接受多匹配一些,所以我把转换分成了三个档位。

strict 只输出能证明等价的转换,比如 ^a\.b$ 对应 DOMAIN,(^|\.)a\.b$ 对应 DOMAIN-SUFFIX,其他的都不要。balanced 是默认档位,会用启发式的方法转换成 DOMAIN-WILDCARD,遇到零宽断言、反向引用这种通配符表达不了的,或者转换出来没有意义的结果,就直接放弃。full 在 balanced 的基础上,对转换不了的正则尝试取字面的域名后缀,实在不行就给一个 *。

我拿当时的上游数据转换了一遍,一共有 181 条不同的正则。strict 下一条都留不下来,因为没有一条能严格等价地转换成 Surge 的规则,所以 strict 基本等于把正则全部丢掉。balanced 能转换 180 条,full 全部转换。像 .+\.dkr\.ecr\.[^\.]+\.amazonaws\.com$ 在 balanced 下会变成 *.dkr.ecr.*.amazonaws.com,而 #3 里那条 ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$,balanced 会直接放弃,只有 full 才会变成 *。

Worker

旧版是请求进来之后才去拉上游、解压、转换,新版把这些都挪到了定时任务里,请求只需要读 R2。

Cron 每 5 分钟执行一次,给上游的 ZIP 发一个 HEAD 请求,对比 ETag,没有变化就什么都不做。有变化才会下载一次,解压出 data 目录里所有的列表,用 core 完整解析和展开一遍,没有报错再把快照和索引写进 R2,最后切换 state/latest.json。每个版本的目录都以上游的 ETag 命名,新旧快照可以共存。

TEXT
state/latest.json
snapshots/{etag}/sources.json.gz
snapshots/{etag}/index/geosite.json
artifacts/{etag}/{mode}/{name[@filter]}.txt

请求进来之后的流程是这样的。

Mermaid diagram

转换好的规则集按"列表、档位、属性"的组合存成产物,第一次有人请求才会生成并写入 R2,之后直接读取。如果当前版本还没有这份产物,但是上一个版本有,就先把旧的返回,同时用 waitUntil 在后台生成新的,请求不用等。响应头里带了 ETag 和 x-upstream-etag,返回旧产物的时候会多一个 x-stale: 1,Cache-Control 平时是 public, max-age=300, s-maxage=1800, stale-while-revalidate=86400,返回旧产物时缩短成 max-age=60, s-maxage=120。

这样请求路径上就不会再碰 GitHub 了,上游慢了或者抖了,只影响下一次刷新,已有的规则集不受影响。

Tip

旧的 URL 全部保留,/geosite/apple@cn 照常可用,默认使用 balanced。想指定档位就放在列表名前面,比如 /geosite/strict/apple@cn。另外加了 /geosite-srs/:name 和 /geosite-mrs/:name 两个接口,只是带缓存地转发 sing-geosite 和 meta-rules-dat 已经转换好的二进制规则集,给用其他客户端的人使用。

整个域名下,/geosite* 交给这个 Worker,其余路径交给面板。

面板

老版本只支持规则集的转换和简单的列表,想要查询可用的规则集,要么看 README 里我定时更新的超长列表,要么就得去上游仓库的 data 目录里翻,那个页面在 GitHub 上打开能直接卡死浏览器。为了能在配置 Surge 时轻松查找某个列表,看它转换出来是什么样,换个档位又会变成什么样,带上 @cn 之后剩下哪些,所以决定给这个项目加一个前端面板。

不过由于整个项目都部署在 Cloudflare 上,配置规则的时候访问面板的人不一定开着代理。Surge 拉规则集可以走代理,打开网页的人很可能是直连,所以面板必须足够轻、产物要足够小,这正是 Svelte 的用武之地。

面板用的是 SvelteKit,加上 Tailwind 4 和 shadcn-svelte,运行时的依赖只有一个图标库 @lucide/svelte。

既然想要首屏体验,那必然是要上 SSR 的,不过上游有 1.4k 多个列表,如果把完整的索引在首屏就塞给浏览器,哪怕经过 gzip 也会很大。所以我做了一个折中,服务端渲染只输出前 40 个列表名,大概 7 KB,页面一打开就能看,剩下的等页面加载完,在浏览器里接着取。面板的服务端也做了缓存,最近 64 份预览会留着,每隔 20 秒带着 If-None-Match 去校验一次索引有没有变,访问 Worker 走的是 service binding,不经过公网。

另外界面有中英文两套,根据 Accept-Language 自动跳转,搜索框加了防抖,快速切换列表的时候,晚到的旧响应会被丢弃。

这样 surge.bojin.co 基本上是瞬间就能打开并且开始使用。

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