Surge-Geosite を書き直す

2026年3月3日火曜日 / , , ,
この記事を読む前に、コンテキストをより理解するために以下の記事を先に読むことをお勧めします。
簡体中文日本語
要約:
Geosite 変換サービスは1年半稼働してきましたが、ルールの誤判定がなかなか解決しなかったため、全体を書き直し、プレビューパネルも追加しました。

以前の Geosite プロジェクトは 1 年半ほど安定して稼働していて、寄せられた issue もリクエスト上限の超過とルールの誤判定に関する数件だけでした。リクエスト上限のほうは、いくつか回避策を入れてひとまず解決しました。しかし、誤判定の問題はなかなか直せませんでした。最近ようやく時間ができたので、プロジェクト全体を書き直すことにしました。

旧版の原理と実装は前回の記事で書いたので、今回は主に新版について説明します。

なぜ書き直すのか

ルール誤判定の2つの 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 の後には暫定的な回避策として、? と * だけからなる結果をスキップするようにしました。しかし #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 のモノレポで、core、cli、worker、panel の 4 パッケージに分かれています。

Mermaid diagram

core はすべて純粋関数で、Geosite のテキストを入力として Surge のルールを出力し、ファイルの読み書きもリクエスト送信も行いません。処理は4つのステップに分かれます。parser は各リストを型と行番号を持つエントリに解析し、エラー時にはどのリストの何行目かが分かります。resolver は include の展開、属性のフィルタリング、重複排除を担当し、循環参照は即座にエラーになります。regex は正規表現を個別に処理します。最後に surge がルールのテキストを出力します。

先ほどの include の問題は、resolver では1つの関数だけで済みます。include:xxx @a @-b は parser で「属性 a が必要」「属性 b は不可」という 2 種類の条件に分けられ、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 展開後の中間結果、各リストの統計を出力します。変換ロジックを変更した後にローカルで実行すれば、どのルールが変わったかを確認できます。

変換モード

正規表現の変換に唯一の正解はありません。完全な等価性を重視し、多少ルールが減ってもよいと考える人もいれば、できるだけ広くカバーし、多少多くマッチしても許容する人もいます。そこで変換を3つのモードに分けました。

strict は等価であることを証明できる変換だけを出力します。たとえば ^a\.b$ は DOMAIN に、(^|\.)a\.b$ は DOMAIN-SUFFIX に対応します。それ以外は出力しません。balanced はデフォルトのモードで、ヒューリスティックな方法で DOMAIN-WILDCARD に変換します。ゼロ幅アサーションや後方参照などワイルドカードで表現できないものや、意味のない変換結果はスキップします。full は balanced をベースに、変換できない正規表現からリテラルなドメインの末尾部分を取り出そうとします。それでも無理なら * を出力します。

当時の上流データで一通り変換してみたところ、異なる正規表現は全部で 181 件ありました。strict では1件も残りませんでした。厳密に等価な形で Surge のルールに変換できるものが1つもなかったため、strict はこのデータでは正規表現をすべて捨てるのとほぼ同じです。balanced では 180 件を変換でき、full では全 181 件を変換できます。たとえば .+\.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 の2つのエンドポイントを追加しました。これらは 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 で接続し、インターネットを経由しません。

また、UI には中国語と英語の2種類があり、Accept-Language に応じて自動的にリダイレクトします。検索ボックスにはデバウンスを入れ、リストを素早く切り替えた場合は、遅れて届いた古いレスポンスを破棄します。

これで surge.bojin.co はほぼ瞬時に開き、すぐ使い始められます。

panel-dashboard.png
完成イメージ

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