feat: deploy GitHub subscription sync to Cloudflare with ermao branding

This commit is contained in:
ermaozi 2026-09-08 08:03:12 +08:00
parent 8d8688283d
commit 40bb7ea095
8 changed files with 323 additions and 4 deletions

View File

@ -33,6 +33,8 @@ jobs:
python -m pip install -r requirements.txt
- name: 回归检查
run: python -m unittest discover -s tests
- name: Worker 回归检查
run: node --test worker/sub.test.mjs
- name: 执行任务
run: |
python main.py

2
.gitignore vendored
View File

@ -2,3 +2,5 @@ venv/
.venv/
__pycache__/
*.pyc
.wrangler/

View File

@ -68,5 +68,11 @@ https://www.ermao.net/sub/v2ray/ermao.net
- `subscribe/unreachable/`:TCP 连接或 DNS 解析失败的节点。
- `subscribe/untested/`:UDP/QUIC 协议、无法解析地址或非公网地址,不判定为不可用。
`subscribe/health.json` 保存检测时间和分类数量。
在线分类订阅(将链接中的 `clash` 换成 `v2ray` 可获取对应格式):
- [TCP 可连接](https://www.ermao.net/sub/reachable/clash/ermao.net)
- [连接失败](https://www.ermao.net/sub/unreachable/clash/ermao.net)
- [未检测](https://www.ermao.net/sub/untested/clash/ermao.net)
[检测报告](https://www.ermao.net/sub/health.json)保存检测时间和分类数量。
这不是代理认证、出口访问或速度测试;可连接不等于代理可用,失败也可能是运行机器的网络限制。

20
main.py
View File

@ -170,6 +170,26 @@ def _merge_clash(sources):
[renamed.get(member, member) for member in members] + [p["name"] for p in proxies]
))
config["proxies"] = proxies
# 仅给前两个选择组加入站点标识,同时保持规则和组间引用一致。
groups = config.get("proxy-groups", [])
branded = {}
used_names = {p["name"] for p in proxies} | {g["name"] for g in groups}
for group in [g for g in groups if g.get("type") in {"select", "url-test"}][:2]:
old = group["name"]
if "ermao.net" in old:
continue
name = f"ermao.net | {old}"
while name in used_names:
name += " ·"
branded[old] = name
used_names.add(name)
group["name"] = name
for group in groups:
if "proxies" in group:
group["proxies"] = [branded.get(name, name) for name in group["proxies"]]
if "rules" in config:
config["rules"] = [",".join(branded.get(part, part) for part in rule.split(","))
for rule in config["rules"]]
return yaml.safe_dump(config, allow_unicode=True, sort_keys=False)

View File

@ -55,14 +55,16 @@ class SubscriptionTests(unittest.TestCase):
os.chdir(previous)
def test_merge_deduplicates_and_keeps_groups_resolvable(self):
first = CLASH + 'proxy-groups:\n - {name: select, type: select, proxies: [test, DIRECT]}\nrules: ["MATCH,select"]\n'
first = CLASH + 'proxy-groups:\n - {name: select, type: select, proxies: [test, DIRECT, auto]}\n - {name: auto, type: url-test, proxies: [test]}\nrules: ["MATCH,select"]\n'
duplicate = CLASH.replace('name: test', 'name: another')
collision = CLASH.replace('example.com', 'other.example')
data = yaml.safe_load(main._merge_clash([('original', first), ('NoMoreWalls', duplicate), ('ProxyPool', collision)]))
names = [p['name'] for p in data['proxies']]
self.assertEqual(names, ['test', 'ProxyPool | test'])
self.assertEqual(data['proxy-groups'][0]['proxies'], ['test', 'DIRECT', 'ProxyPool | test'])
self.assertEqual(data['rules'], ['MATCH,select'])
self.assertEqual(data['proxy-groups'][0]['proxies'], ['test', 'DIRECT', 'ermao.net | auto', 'ProxyPool | test'])
self.assertEqual(data['proxy-groups'][0]['name'], 'ermao.net | select')
self.assertEqual(data['proxy-groups'][1]['name'], 'ermao.net | auto')
self.assertEqual(data['rules'], ['MATCH,ermao.net | select'])
def test_direct_sources_work_when_rss_fails(self):
session = Mock()

163
worker/sub.mjs Normal file
View File

@ -0,0 +1,163 @@
const REPOSITORY = 'ermaozi/get_subscribe';
const PREFIX = 'files/subscribe/';
const CACHE_SECONDS = 300;
const MAX_FILE_BYTES = 4 * 1024 * 1024;
const FILES = ['clash.yml', 'v2ray.txt', ...['reachable', 'unreachable', 'untested']
.flatMap(group => [`${group}/clash.yml`, `${group}/v2ray.txt`]), 'health.json'];
function json(body, status = 200) {
return Response.json(body, { status, headers: { 'Cache-Control': 'no-store' } });
}
function fileForPath(path) {
if (path === '/sub/health.json') return 'health.json';
const match = path.match(/^\/sub\/(?:(reachable|unreachable|untested)\/)?(clash|v2ray)\/ermao\.net$/);
return match ? `${match[1] ? match[1] + '/' : ''}${match[2] === 'clash' ? 'clash.yml' : 'v2ray.txt'}` : null;
}
function contentType(file) {
if (file.endsWith('.json')) return 'application/json; charset=utf-8';
return file.endsWith('.yml') ? 'text/yaml; charset=utf-8' : 'text/plain; charset=utf-8';
}
async function fetchText(url, limit = MAX_FILE_BYTES) {
const response = await fetch(url, {
headers: { 'User-Agent': 'ermao-subscription-sync' },
redirect: 'manual', signal: AbortSignal.timeout(20000),
cf: { cacheTtl: 0, cacheEverything: false },
});
if (response.status !== 200) {
await response.body?.cancel();
throw new Error(`Upstream HTTP ${response.status}`);
}
// 只读取有明确大小上限的文本,防止异常响应耗尽 Worker 内存。
const reader = response.body?.getReader();
if (!reader) return { text: '', bytes: 0 };
const decoder = new TextDecoder('utf-8', { fatal: true });
let text = '', bytes = 0;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > limit) throw new Error('Upstream file exceeds size limit');
text += decoder.decode(chunk.value, { stream: true });
}
text += decoder.decode();
return { text, bytes };
} finally {
await reader.cancel();
}
}
function validate(file, text) {
if (file === 'health.json') {
const report = JSON.parse(text);
if (!Number.isFinite(Date.parse(report.checked_at)) || report.method !== 'TCP connect') {
throw new Error('Invalid health report');
}
for (const group of ['reachable', 'unreachable', 'untested']) {
for (const kind of ['clash', 'v2ray']) {
const count = report.counts?.[group]?.[kind];
if (!Number.isInteger(count) || count < 0) throw new Error('Invalid health counts');
}
}
return;
}
// YAML 结构已由主仓库回归测试和采集程序检查;此处防止错误页覆盖订阅。
if (file.endsWith('.yml')) {
if (!/^proxies:\s/m.test(text)) throw new Error('Invalid Clash artifact');
} else {
const nodes = text.trim() ? text.trim().split(/\r?\n/) : [];
if ((!file.includes('/') && !nodes.length) || nodes.some(node =>
!/^(vmess|vless|trojan|ss|ssr|hysteria2?|hy2|tuic|anytls|mieru|https?|socks[45]?):\/\/\S+$/.test(node))) {
throw new Error('Invalid V2Ray artifact');
}
}
}
export async function refreshAll(env) {
if (!env.SUBSCRIBE_BUCKET) throw new Error('Missing subscription storage');
const ref = await fetchText(`https://api.github.com/repos/${REPOSITORY}/git/ref/heads/main`, 16384);
const commit = JSON.parse(ref.text).object?.sha;
if (!/^[a-f0-9]{40}$/.test(commit)) throw new Error('Invalid upstream commit');
const previous = await env.SUBSCRIBE_BUCKET.head(PREFIX + 'health.json');
if (previous?.customMetadata?.commit === commit) {
return { ok: true, commit, files: FILES.length, unchanged: true, updatedAt: previous.customMetadata.updatedAt };
}
const downloads = [];
let totalBytes = 0;
// 同一次同步固定到同一提交,所有文件验证完成后才写入 R2。
for (const file of FILES) {
const url = `https://raw.githubusercontent.com/${REPOSITORY}/${commit}/subscribe/${file}`;
const body = await fetchText(url);
totalBytes += body.bytes;
if (totalBytes > 16 * 1024 * 1024) throw new Error('Subscription batch exceeds size limit');
validate(file, body.text);
downloads.push({ file, url, text: body.text });
}
const updatedAt = new Date().toISOString();
for (const { file, url, text } of downloads) {
await env.SUBSCRIBE_BUCKET.put(PREFIX + file, text, {
httpMetadata: { contentType: contentType(file), cacheControl: `public, max-age=0, s-maxage=${CACHE_SECONDS}` },
customMetadata: { commit, source: url, updatedAt },
});
}
console.log(JSON.stringify({ event: 'subscription_sync', commit, files: downloads.length, bytes: totalBytes }));
return { ok: true, commit, files: downloads.length, updatedAt };
}
async function serve(request, env, file) {
let object = await env.SUBSCRIBE_BUCKET.get(PREFIX + file);
if (!object) {
await refreshAll(env);
object = await env.SUBSCRIBE_BUCKET.get(PREFIX + file);
}
if (!object) throw new Error('Subscription not available');
const headers = new Headers({
'Content-Type': contentType(file), 'Cache-Control': `public, max-age=0, s-maxage=${CACHE_SECONDS}`,
'X-Source': 'r2', 'ETag': object.httpEtag,
});
if (file !== 'health.json') {
const group = file.includes('/') ? file.split('/')[0] : 'all';
const label = { all: '免费订阅', reachable: 'TCP 可连接', unreachable: '连接失败', untested: '未检测' }[group];
const title = `ermao.net · ${label}`;
headers.set('Profile-Title', 'base64:' + btoa(String.fromCharCode(...new TextEncoder().encode(title))));
headers.set('Profile-Web-Page-Url', 'https://www.ermao.net/');
headers.set('Support-Url', 'https://www.ermao.net/');
headers.set('Content-Disposition', `attachment; filename="ermao.net-${file.replaceAll('/', '-')}"`);
}
const metadata = object.customMetadata || {};
if (/^[a-f0-9]{40}$/.test(metadata.commit)) headers.set('X-Git-Commit', metadata.commit);
if (metadata.updatedAt) headers.set('X-Updated-At', metadata.updatedAt.replace(/[\r\n]/g, ''));
if (request.headers.get('If-None-Match') === object.httpEtag) return new Response(null, { status: 304, headers });
return new Response(request.method === 'HEAD' ? null : object.body, { headers });
}
export default {
async fetch(request, env) {
if (!['GET', 'HEAD'].includes(request.method)) return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'GET, HEAD' } });
const path = new URL(request.url).pathname.toLowerCase();
if (['/', '/health', '/sub/health'].includes(path)) {
return json({ ok: true, service: 'subscribe-worker', version: 'github-sync-v1', endpoints: [
'/sub/clash/ermao.net', '/sub/v2ray/ermao.net', '/sub/reachable/clash/ermao.net', '/sub/health.json',
] });
}
const refresh = path.match(/^\/sub\/refresh\/(all|clash|v2ray)$/);
const file = refresh && refresh[1] !== 'all' ? `${refresh[1] === 'clash' ? 'clash.yml' : 'v2ray.txt'}` : fileForPath(path);
if (!refresh && !file) return new Response('Not Found', { status: 404 });
try {
if (refresh) {
const result = await refreshAll(env);
if (refresh[1] === 'all') return json(result);
}
return await serve(request, env, file);
} catch (error) {
console.error(JSON.stringify({ event: 'subscription_error', message: String(error.message || error) }));
return json({ ok: false, error: 'Subscription refresh or storage unavailable' }, 502);
}
},
async scheduled(_controller, env, ctx) {
ctx.waitUntil(refreshAll(env));
},
};

95
worker/sub.test.mjs Normal file
View File

@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import worker, { refreshAll } from './sub.mjs';
const commit = 'a'.repeat(40);
const clash = 'proxies:\n - {name: test, type: ss, server: example.com, port: 443}\n';
const report = JSON.stringify({ checked_at: '2026-09-08T00:00:00Z', method: 'TCP connect', counts: {
reachable: { clash: 1, v2ray: 1 }, unreachable: { clash: 0, v2ray: 0 }, untested: { clash: 0, v2ray: 0 },
} });
function storage() {
const objects = new Map();
return { objects, SUBSCRIBE_BUCKET: {
async head(key) { return objects.get(key) || null; },
async get(key) {
const item = objects.get(key);
return item ? { ...item, body: new Response(item.text).body, httpEtag: '"etag"' } : null;
},
async put(key, text, options) { objects.set(key, { text, ...options }); },
} };
}
function upstream(url) {
if (url.startsWith('https://api.github.com/')) return Response.json({ object: { sha: commit } });
assert.ok(url.startsWith(`https://raw.githubusercontent.com/ermaozi/get_subscribe/${commit}/subscribe/`));
if (url.endsWith('health.json')) return new Response(report);
const empty = /\/(unreachable|untested)\//.test(url);
return new Response(url.endsWith('.yml') ? (empty ? 'proxies: []\n' : clash) : (empty ? '' : 'vmess://test\n'));
}
test('syncs one commit, all routes serve R2, unchanged revision does not download again', async t => {
const env = storage();
const fetch = t.mock.method(globalThis, 'fetch', async url => upstream(url));
const result = await refreshAll(env);
assert.equal(result.files, 9);
assert.equal(env.objects.size, 9);
assert.equal(fetch.mock.callCount(), 10);
for (const group of ['', 'reachable/', 'unreachable/', 'untested/']) {
for (const kind of ['clash', 'v2ray']) {
const response = await worker.fetch(new Request(`https://www.ermao.net/sub/${group}${kind}/ermao.net`), env);
assert.equal(response.status, 200);
assert.equal(response.headers.get('X-Git-Commit'), commit);
const title = Buffer.from(response.headers.get('Profile-Title').slice(7), 'base64').toString('utf-8');
assert.match(title, /^ermao.net · /);
assert.equal(response.headers.get('Profile-Web-Page-Url'), 'https://www.ermao.net/');
assert.match(response.headers.get('Content-Disposition'), /ermao\.net-/);
await response.text();
}
}
const health = await worker.fetch(new Request('https://www.ermao.net/sub/health.json'), env);
assert.deepEqual(await health.json(), JSON.parse(report));
assert.equal((await refreshAll(env)).unchanged, true);
assert.equal(fetch.mock.callCount(), 11);
});
test('invalid upstream file leaves existing objects unchanged; reads work during outage', async t => {
const env = storage();
env.objects.set('files/subscribe/clash.yml', { text: clash, customMetadata: {} });
t.mock.method(globalThis, 'fetch', async url => url.endsWith('health.json') ? new Response('<html>error</html>') : upstream(url));
await assert.rejects(refreshAll(env));
assert.equal(env.objects.size, 1);
assert.equal(env.objects.get('files/subscribe/clash.yml').text, clash);
const response = await worker.fetch(new Request('https://www.ermao.net/sub/clash/ermao.net'), env);
assert.equal(response.status, 200);
assert.equal(await response.text(), clash);
});
test('rejects invalid commits, excessive responses, unknown paths and unsupported methods', async t => {
const env = storage();
const fetch = t.mock.method(globalThis, 'fetch', async () => Response.json({ object: { sha: '../other' } }));
await assert.rejects(refreshAll(env), /Invalid upstream commit/);
fetch.mock.mockImplementation(async url => url.startsWith('https://api.github.com/')
? upstream(url) : new Response('x'.repeat(4 * 1024 * 1024 + 1)));
await assert.rejects(refreshAll(env), /size limit/);
assert.equal(env.objects.size, 0);
assert.equal((await worker.fetch(new Request('https://example.com/sub/nope'), env)).status, 404);
assert.equal((await worker.fetch(new Request('https://example.com/sub/refresh/all', { method: 'POST' }), env)).status, 405);
});
test('preserves refresh endpoints, HEAD, ETag and scheduled handler', async t => {
const env = storage();
t.mock.method(globalThis, 'fetch', async url => upstream(url));
const refresh = await worker.fetch(new Request('https://example.com/sub/refresh/all'), env);
assert.equal(refresh.status, 200);
assert.equal((await refresh.json()).commit, commit);
const head = await worker.fetch(new Request('https://example.com/sub/clash/ermao.net', { method: 'HEAD' }), env);
assert.equal(await head.text(), '');
const cached = await worker.fetch(new Request('https://example.com/sub/clash/ermao.net', { headers: { 'If-None-Match': '"etag"' } }), env);
assert.equal(cached.status, 304);
const pending = [];
await worker.scheduled({}, env, { waitUntil(promise) { pending.push(promise); } });
await Promise.all(pending);
const subscription = await worker.fetch(new Request('https://example.com/sub/refresh/v2ray'), env);
assert.equal(await subscription.text(), 'vmess://test\n');
});

29
worker/wrangler.jsonc Normal file
View File

@ -0,0 +1,29 @@
{
"name": "sub",
"main": "sub.mjs",
"account_id": "b0021b5742f923f992a9e15ad797de45",
"compatibility_date": "2026-06-03",
"workers_dev": true,
"preview_urls": false,
"routes": [
{
"pattern": "*.ermao.net/sub/*",
"zone_name": "ermao.net"
}
],
"triggers": {
"crons": [
"0 * * * *"
]
},
"r2_buckets": [
{
"binding": "SUBSCRIBE_BUCKET",
"bucket_name": "ermao-net"
}
],
"observability": {
"enabled": true,
"head_sampling_rate": 0.1
}
}