Surge는 Geosite 규칙 세트를 직접 사용할 수 없습니다. 저는 Cloudflare Worker로 실시간 변환 서비스를 만들었고, 정규식도 최대한 DOMAIN-WILDCARD로 변환했습니다.
Surge는 Geosite 규칙 세트를 직접 지원하지 않고, 와일드카드를 이용한 도메인 매칭 규칙을 사용합니다. 방대한 Geosite 규칙 세트를 쓰려면 먼저 Surge가 지원하는 형식으로 변환해야 합니다.
GitHub에는 GitHub Actions로 Geosite 규칙 세트를 Surge용으로 정기 변환하는 저장소가 많습니다. 하지만 정해진 주기로 실행되기 때문에 Geosite가 업데이트되어도 바로 반영되지 않아 항상 업스트림보다 늦습니다. 제가 확인한 몇몇 저장소는 정규식 규칙을 아예 버리기도 했습니다. 그 결과 원래 매칭되어야 할 도메인이 상당수 누락되었습니다.
그래서 저는 Cloudflare Worker를 통해 업스트림 규칙 세트를 실시간으로 변환하고 낙관적으로 캐시하는 서비스를 만들고, 정규식 규칙을 최대한 많이 처리해 규칙이 누락되지 않도록 하기로 했습니다.
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을 변환하면 대략 다음과 같습니다.
include 처리는 재귀적으로 이루어집니다. Worker가 include:xxx를 만나면 raw.githubusercontent.com에서 해당 목록을 가져와 같은 로직으로 변환한 뒤 원래 위치에 넣고, 그 위에 # include:xxx 한 줄을 표시로 남깁니다. 이렇게 하면 최종 출력은 완전한 목록이 되며, Surge는 업스트림에 include라는 관계가 있다는 것을 신경 쓸 필요가 없습니다.
Note
각 include는 요청을 한 번씩 발생시키므로 목록이 클수록 처음 접근할 때 느릴 수 있습니다. 변환된 결과는 Hono의 cache 미들웨어로 30분 동안 캐시했으며, 이후 요청은 바로 캐시에서 응답합니다. 이른바 실시간은 사실 업스트림이 업데이트된 뒤 약 30분이 지나면 새 규칙이 서비스에 나타난다는 뜻이며, 여기에 Surge 자체의 업데이트 간격도 더해집니다. 하루에 한 번 실행되는 Action과 비교하면 이미 훨씬 빠릅니다.
정규식
Surge의 도메인 규칙에는 regex 유형이 없고, 가장 가까운 것은 DOMAIN-WILDCARD입니다. 하지만 DOMAIN-WILDCARD는 와일드카드로 매칭하며 *와 ?만 지원합니다. 따라서 복잡한 정규식을 최대한 *로 바꿀 방법을 찾아야 합니다.
먼저 업스트림의 정규식을 집계해 보았습니다. 당시 목록은 총 1283개였고, regexp: 규칙은 208줄이었습니다. 그중 category-porn이 134줄, qcloud 29줄, apple 6줄이었고 나머지는 비교적 분산되어 있었습니다. 순수 리터럴은 한 줄도 없어서 DOMAIN이나 DOMAIN-SUFFIX로 바로 바꿀 수 있는 규칙은 없었습니다. 대부분은 이런 형태이며, 이 세 줄은 apple에서 가져왔습니다.
이들은 번호가 다른 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의 정기 변환과 비교하면 업데이트 지연이 하루에서 약 30분으로 줄었습니다. 정규식도 더 이상 그대로 버리지 않고 DOMAIN-WILDCARD로 변환합니다. 목록과 속성의 조합을 미리 생성할 필요가 없으며, apple@cn 같은 요청이 들어올 때 변환하므로 원하는 조합을 사용할 수 있습니다. 산출물은 GitHub에 올리는 대신 Cloudflare 엣지 노드에서 직접 제공하므로 GitHub 접속 문제를 걱정할 필요도 없습니다.
서비스 전체 코드는 많지 않으므로 관심 있다면 저장소를 직접 보면 됩니다. 저장소에는 하루에 한 번 실행되는 Action도 있는데, 이 Action은 업스트림 submodule을 업데이트하고 README의 목록 목차를 다시 생성하는 일만 담당합니다.
Surge는 Geosite 규칙 세트를 직접 지원하지 않고, 와일드카드를 이용한 도메인 매칭 규칙을 사용합니다. 방대한 Geosite 규칙 세트를 쓰려면 먼저 Surge가 지원하는 형식으로 변환해야 합니다.
GitHub에는 GitHub Actions로 Geosite 규칙 세트를 Surge용으로 정기 변환하는 저장소가 많습니다. 하지만 정해진 주기로 실행되기 때문에 Geosite가 업데이트되어도 바로 반영되지 않아 항상 업스트림보다 늦습니다. 제가 확인한 몇몇 저장소는 정규식 규칙을 아예 버리기도 했습니다. 그 결과 원래 매칭되어야 할 도메인이 상당수 누락되었습니다.
그래서 저는 Cloudflare Worker를 통해 업스트림 규칙 세트를 실시간으로 변환하고 낙관적으로 캐시하는 서비스를 만들고, 정규식 규칙을 최대한 많이 처리해 규칙이 누락되지 않도록 하기로 했습니다.
그렇게 xxxbrian/Surge-Geosite가 탄생했고, 공개 서비스를 surge.bojin.co에 배포했습니다.
사용 방법
Geosite의 목록 이름을 경로 뒤에 붙입니다. 속성으로 필터링하려면 @属性 형식을 이어 붙이면 됩니다.
apple은 Apple의 모든 도메인을 포함하며, apple@cn은 업스트림에서 @cn이 붙은 부분만 남깁니다. Surge에서는 그대로 RULE-SET으로 사용하면 됩니다.
사용 가능한 모든 목록 이름은 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을 변환하면 대략 다음과 같습니다.
include
include 처리는 재귀적으로 이루어집니다. Worker가 include:xxx를 만나면 raw.githubusercontent.com에서 해당 목록을 가져와 같은 로직으로 변환한 뒤 원래 위치에 넣고, 그 위에 # include:xxx 한 줄을 표시로 남깁니다. 이렇게 하면 최종 출력은 완전한 목록이 되며, Surge는 업스트림에 include라는 관계가 있다는 것을 신경 쓸 필요가 없습니다.
각 include는 요청을 한 번씩 발생시키므로 목록이 클수록 처음 접근할 때 느릴 수 있습니다. 변환된 결과는 Hono의 cache 미들웨어로 30분 동안 캐시했으며, 이후 요청은 바로 캐시에서 응답합니다. 이른바 실시간은 사실 업스트림이 업데이트된 뒤 약 30분이 지나면 새 규칙이 서비스에 나타난다는 뜻이며, 여기에 Surge 자체의 업데이트 간격도 더해집니다. 하루에 한 번 실행되는 Action과 비교하면 이미 훨씬 빠릅니다.
정규식
Surge의 도메인 규칙에는 regex 유형이 없고, 가장 가까운 것은 DOMAIN-WILDCARD입니다. 하지만 DOMAIN-WILDCARD는 와일드카드로 매칭하며 *와 ?만 지원합니다. 따라서 복잡한 정규식을 최대한 *로 바꿀 방법을 찾아야 합니다.
먼저 업스트림의 정규식을 집계해 보았습니다. 당시 목록은 총 1283개였고, regexp: 규칙은 208줄이었습니다. 그중 category-porn이 134줄, qcloud 29줄, apple 6줄이었고 나머지는 비교적 분산되어 있었습니다. 순수 리터럴은 한 줄도 없어서 DOMAIN이나 DOMAIN-SUFFIX로 바로 바꿀 수 있는 규칙은 없었습니다. 대부분은 이런 형태이며, 이 세 줄은 apple에서 가져왔습니다.
이들은 번호가 다른 CDN 호스트 이름을 나타냅니다. 예를 들어 a1부터 a5까지 번호가 붙은 호스트를 와일드카드로 표현해야 합니다.
처음에는 regexp:를 곧바로 DOMAIN-KEYWORD로 바꾸고 정규식을 그대로 넣었는데, 나중에야 DOMAIN-KEYWORD는 단순히 부분 문자열을 찾을 뿐이라는 사실을 깨달았습니다. ^, [, ( 같은 문자는 호스트 이름에 아예 등장하지 않으므로, 변환한 규칙은 매칭될 수 없습니다.
문자열 치환도 생각해 보았지만, 그룹, 분기, 문자 클래스, 수량자, 이스케이프는 서로 중첩될 수 있어 문자 하나씩 처리하면 괄호에서 쉽게 오류가 납니다. 그래서 결국 regjsparser로 정규식을 구문 트리로 파싱한 뒤 각 노드 유형의 출력 방식을 정했습니다.
여기서는 반환값이 같은 몇몇 분기를 합쳤습니다. 규칙을 요약하면, 리터럴 문자는 그대로 유지하고 ^와 $는 제거합니다. 와일드카드는 원래 문자열 전체를 매칭하기 때문입니다. ., [a-z], \d처럼 정확히 한 문자에 대응하는 패턴은 ?로 바꿉니다. *, +, ?, {m,n} 같은 수량자는 *로 바꿉니다. 분기 a|b는 표현할 수 없으므로 역시 *로 바꾸고, 괄호 안은 재귀적으로 처리합니다.
이 규칙에 따르면 위의 세 줄은 다음과 같이 변환됩니다.
정규식 파싱에 실패하면 변환 함수가 빈 문자열을 반환하므로 출력은 DOMAIN-WILDCARD,만 남은 불완전한 한 줄이 됩니다. Surge는 규칙 세트의 유효하지 않은 줄을 건너뛰고 경고를 표시하므로 전체 목록에는 영향을 주지 않습니다. 그래서 저는 별도로 처리하지 않았습니다.
이렇게 변환하면 원래 정규식보다 매칭 범위가 넓어집니다. 예를 들어 [1-5]를 ?로 바꾸면 a?.mzstatic.com은 a9.mzstatic.com도 매칭할 수 있습니다. 대부분을 차지하는 (^|\.)로 시작하는 경우도 같습니다. 분기 전체를 *로 바꾸므로 (^|\.)nnbw[0-2]\.xyz$는 *nnbw?.xyz가 되며, 원래는 nnbw가 문자열 맨 앞이나 점 바로 뒤에 와야 했지만, 이제는 xnnbw0.xyz도 매칭됩니다. 애초에 규칙을 누락하지 않는 것이 목표였습니다. 비슷한 도메인이 몇 개 더 매칭되더라도 규칙을 통째로 버리는 것보다는 낫다고 생각해 이 점은 받아들였습니다.
Action과 비교
Action의 정기 변환과 비교하면 업데이트 지연이 하루에서 약 30분으로 줄었습니다. 정규식도 더 이상 그대로 버리지 않고 DOMAIN-WILDCARD로 변환합니다. 목록과 속성의 조합을 미리 생성할 필요가 없으며, apple@cn 같은 요청이 들어올 때 변환하므로 원하는 조합을 사용할 수 있습니다. 산출물은 GitHub에 올리는 대신 Cloudflare 엣지 노드에서 직접 제공하므로 GitHub 접속 문제를 걱정할 필요도 없습니다.
서비스 전체 코드는 많지 않으므로 관심 있다면 저장소를 직접 보면 됩니다. 저장소에는 하루에 한 번 실행되는 Action도 있는데, 이 Action은 업스트림 submodule을 업데이트하고 README의 목록 목차를 다시 생성하는 일만 담당합니다.