重寫 Surge-Geosite

2026年3月3日星期二 / , , ,
建議先閱讀以下文章,以了解相關背景。
簡體中文正體中文
摘要:
Geosite 轉換服務跑了一年半,規則誤判遲遲沒有解決,於是我把它整個重寫了,並搭配了一個預覽面板。

之前的 Geosite 專案已經跑了一年半,穩定性一直不錯,只有幾個關於請求超限和規則誤判的 issue。請求超限比較好解決,做了幾個暫行處理就先修好了;但規則誤判的問題一直沒解決。最近有空,便趁這個機會把整個專案重寫。

舊版的原理和實作在上一篇文章裡寫過了,這篇主要說說新版。

為什麼要重寫

規則誤判的兩個 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 是兩套完全獨立的 Git 歷史,原本的 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 自動重新導向。搜尋框加入了 debounce,快速切換清單時,晚到的舊回應會被丟棄。

這樣 surge.bojin.co 基本上是瞬間就能打開並且開始使用。

panel-dashboard.png
最終效果

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