讓 Surge 用上 Geosite

2024年9月13日星期五 / , , ,
簡體中文正體中文
摘要:
Surge 不能直接使用 Geosite 規則集。我用 Cloudflare Worker 寫了一個即時轉換的服務,正規表示式也盡量轉成了 DOMAIN-WILDCARD。

眾所周知,Surge 不支援直接使用 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...