Files
proxy-station/edge-nodes/build-delivery-doc.py
YANG JIANKUAN 5326c572a2 feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。

完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。

验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
2026-10-03 23:19:59 +08:00

765 lines
36 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Build the offline fleet delivery page from a private JSON inventory.
Share links and QR codes are read locally and embedded in the output. No network
requests are made. The default inventory and output live in Git-ignored private/.
Add sites to the inventory; do not embed live credentials in this source file.
"""
import argparse
import base64
import html
import json
import os
import sys
import urllib.parse
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--fleet", type=Path, default=Path(__file__).resolve().parent / "private/fleet.json",
help="private inventory; relative links_dir paths resolve beside this file")
parser.add_argument("--output", type=Path, help="output HTML (default: beside the inventory)")
args = parser.parse_args()
fleet_path = args.fleet.resolve()
try:
fleet = json.loads(fleet_path.read_text(encoding="utf-8"))
except FileNotFoundError:
sys.exit("ERROR: fleet inventory not found; see edge-nodes/README.md")
except json.JSONDecodeError:
sys.exit("ERROR: fleet inventory is not valid JSON")
SITES = fleet.get("sites")
if not isinstance(SITES, list) or not SITES:
sys.exit("ERROR: fleet inventory must contain a non-empty sites list")
UPDATED = fleet["updated"]
for site in SITES:
site["links_dir"] = str((fleet_path.parent / site["links_dir"]).resolve())
OUT = str(args.output.resolve() if args.output else fleet_path.parent / "边缘节点交付文档.html")
# ---------------------------------------------------------------- asset loading
def read_link(site, slug):
p = os.path.join(site["links_dir"], slug + ".link")
return open(p).read().strip() if os.path.exists(p) else None
def read_qr(site, slug):
p = os.path.join(site["links_dir"], slug + "-qr.png")
if not os.path.exists(p):
return None
return base64.b64encode(open(p, "rb").read()).decode()
def read_json(site, slug):
p = os.path.join(site["links_dir"], slug + "-singbox.json")
return open(p).read().strip() if os.path.exists(p) else None
missing = []
for s in SITES:
for ln in s["lines"]:
ln["link"] = read_link(s, ln["slug"])
ln["qr"] = read_qr(s, ln["slug"])
ln["json"] = read_json(s, ln["slug"])
if ln["has_link"] and not ln["link"]:
missing.append("%s/%s.link" % (s["id"], ln["slug"]))
if missing:
sys.exit("ERROR: missing link files: " + ", ".join(missing))
TOTAL = sum(len(s["lines"]) for s in SITES)
# ---------------------------------------------------------------- subscription
# One aggregated subscription so the client picks the exit itself, instead of us
# baking a routing policy into a config. Node names follow a fixed grammar:
# [国家码] [站点码] [协议] [Direct|Warp]
# The site code is dropped when it would repeat the country code (SG's only node
# would otherwise read "SG SG ..."), so Singapore lines are three words wide.
#
# Everything here is computed at build time and inlined; the page never fetches
# anything. There is no hosted https:// endpoint on purpose -- see the "自建 URL"
# note in the rendered block.
def sub_name(site, ln):
parts = [site["country"]]
if site["code"] != site["country"]:
parts.append(site["code"])
parts.append(ln["proto"])
parts.append("Direct" if ln["egress"] == "direct" else "Warp")
return " ".join(parts)
def rename_link(link, name):
"""Rewrite a share link's display name, leaving every transport field alone."""
if link.startswith("vmess://"):
raw = link[len("vmess://"):]
raw += "=" * (-len(raw) % 4)
cfg = json.loads(base64.b64decode(raw).decode())
cfg["ps"] = name
blob = json.dumps(cfg, ensure_ascii=False, separators=(",", ":"))
return "vmess://" + base64.b64encode(blob.encode()).decode()
return link.split("#", 1)[0] + "#" + urllib.parse.quote(name)
for s in SITES:
for ln in s["lines"]:
ln["sub_name"] = sub_name(s, ln)
ln["sub_link"] = rename_link(ln["link"], ln["sub_name"]) if ln["link"] else None
names = [ln["sub_name"] for s in SITES for ln in s["lines"]]
dupes = sorted({n for n in names if names.count(n) > 1})
if dupes:
sys.exit("ERROR: duplicate node names: " + ", ".join(dupes))
def group(gid, label, sites):
lines = [ln for s in sites for ln in s["lines"] if ln["sub_link"]]
plain = "\n".join(ln["sub_link"] for ln in lines)
return {
"id": gid, "label": label, "n": len(lines),
"plain": plain,
"b64": base64.b64encode(plain.encode()).decode(),
"items": [{"name": ln["sub_name"], "egress": ln["egress"],
"transport": ln["transport"]} for ln in lines],
}
GROUPS = [group("all", "全部站点", SITES)] + \
[group(s["id"], "%s %s" % (s["code"], s["city"]), [s]) for s in SITES]
# ---------------------------------------------------------------- render helpers
e = html.escape
def kv_rows(pairs, mono_value=True):
cls = ' class="v mono"' if mono_value else ' class="v"'
return "\n".join(
' <div class="kv"><dt>%s</dt><dd%s>%s</dd></div>' % (e(k), cls, e(v))
for k, v in pairs)
def copy_btn(value, label="复制"):
return ('<button class="copy" type="button" data-copy="%s" aria-label="复制%s">%s</button>'
% (e(value), e(label), e(label)))
def line_card(ln, idx):
eg = ln["egress"]
egtxt = "直连出口" if eg == "direct" else "WARP 净出口"
qr_html = ""
if ln["qr"]:
qr_html = ('<button class="qr" type="button" data-qr="%s" data-title="%s" data-link="%s" '
'aria-label="放大查看 %s 的二维码">'
'<img src="data:image/png;base64,%s" alt=""></button>'
% (ln["qr"], e(ln["proto"] + " · " + egtxt), e(ln["link"] or ""),
e(ln["proto"]), ln["qr"]))
else:
qr_html = '<div class="qr noqr" aria-hidden="true"><span>无标准<br>分享链接</span></div>'
body = []
body.append('<div class="ln-head">'
'<span class="proto">%s</span>'
'<span class="pill %s">%s</span>'
'</div>' % (e(ln["proto"]), eg, e(egtxt)))
body.append('<div class="ln-meta"><span>%s</span><span class="sep">·</span><span class="mono">%s</span></div>'
% (e(ln["transport"]), e(ln["endpoint"])))
if ln.get("plaintext"):
body.append('<p class="ln-warn">认证与流量均为明文,链路上可被读取,且特征易被识别。'
'仅适合可信网络内使用,不要用于穿越审查。</p>')
if ln["link"]:
body.append('<div class="ln-link"><code>%s</code>%s</div>'
% (e(ln["link"]), copy_btn(ln["link"], "复制链接")))
if ln["json"]:
body.append('<details class="ln-json"><summary>sing-box 配置片段</summary>'
'<pre>%s</pre>%s</details>'
% (e(ln["json"]), copy_btn(ln["json"], "复制配置")))
cls = eg + (" plain" if ln.get("plaintext") else "")
return (' <article class="line %s">\n %s\n <div class="ln-body">%s</div>\n </article>'
% (cls, qr_html, "".join(body)))
def matrix(site):
"""Compact routing strip: one cell per line, colour = egress. The fleet's shape at a glance."""
cells = []
for ln in site["lines"]:
cls = "plain" if ln.get("plaintext") else ln["egress"]
label = "明文" if ln.get("plaintext") else ("直连" if ln["egress"] == "direct" else "WARP")
cells.append('<span class="cell %s" title="%s · %s">%s</span>'
% (cls, e(ln["proto"]), label, e(ln["proto"][:2])))
return '<div class="matrix">%s</div>' % "".join(cells)
def render_site(site):
p = []
p.append(' <section class="panel" id="panel-%s" role="tabpanel" aria-labelledby="tab-%s" tabindex="0">' % (site["id"], site["id"]))
# identity
p.append(' <div class="ident">')
p.append(' <div class="ident-main"><span class="flag">%s</span>'
'<h2>%s<span class="city">%s</span></h2></div>' % (site["flag"], e(site["code"]), e(site["city"])))
p.append(' <div class="ident-sub"><code>%s</code><span class="tagline">%s</span></div>' % (e(site["domain"]), e(site["tagline"])))
p.append(' %s' % matrix(site))
p.append(' </div>')
# 01 站点
p.append(' <section class="block"><h3><i>01</i>站点</h3>')
p.append(' <dl class="grid">')
p.append(kv_rows([("服务商", site["provider"]), ("区域", site["region"])], mono_value=False))
p.append(kv_rows(site["spec"], mono_value=False))
p.append(kv_rows(site["net"]))
p.append(kv_rows(site["access"]))
p.append(' <div class="kv full"><dt>拥塞控制</dt><dd class="v">%s</dd></div>' % e(site["bbr"]))
p.append(' </dl>')
if site["notes"]:
p.append(' <ul class="notes">%s</ul>' % "".join("<li>%s</li>" % e(n) for n in site["notes"]))
p.append(' </section>')
# 02 账号
p.append(' <section class="block"><h3><i>02</i>账号</h3>')
for pan in site["panels"]:
p.append(' <div class="acct">')
p.append(' <div class="acct-top"><span class="acct-name">%s</span>'
'<a href="%s" target="_blank" rel="noopener">%s</a></div>' % (e(pan["name"]), e(pan["url"]), e(pan["url"])))
p.append(' <div class="acct-cred">'
'<span class="lbl">用户名</span><code>%s</code>%s'
'<span class="lbl">密码</span><code>%s</code>%s</div>'
% (e(pan["user"]), copy_btn(pan["user"], "复制用户名"),
e(pan["pass"]), copy_btn(pan["pass"], "复制密码")))
if pan.get("note"):
p.append(' <p class="acct-note">%s</p>' % e(pan["note"]))
p.append(' </div>')
p.append(' <dl class="grid plain">')
p.append("\n".join(
' <div class="kv"><dt>%s</dt><dd class="v"><a href="%s" target="_blank" rel="noopener">%s</a>'
'<span class="hint">%s</span></dd></div>' % (e(n), e(u), e(u), e(d))
for n, u, d in site["sites_plain"]))
p.append(' </dl>')
p.append(' </section>')
# 03 节点
p.append(' <section class="block"><h3><i>03</i>节点</h3>')
p.append(' <div class="shared"><div class="shared-hd">共用凭据 · 全部 %d 条线路同一套身份</div>' % len(site["lines"]))
for label, val, scope in site["creds"]:
p.append(' <div class="cred"><span class="lbl">%s</span><code>%s</code>%s'
'<span class="scope">%s</span></div>' % (e(label), e(val), copy_btn(val, "复制"+label), e(scope)))
p.append(' <div class="cred"><span class="lbl">实测出口</span>'
'<span class="pill direct">直连 %s</span>'
'<span class="pill warp">WARP Cloudflare</span></div>' % e(site["egress_direct"]))
p.append(' </div>')
p.append(' <div class="lines">')
for i, ln in enumerate(site["lines"]):
p.append(line_card(ln, i))
p.append(' </div>')
p.append(' </section>')
p.append(' </section>')
return "\n".join(p)
def render_subs():
chips = "\n".join(
' <button class="chip" type="button" data-group="%s" aria-pressed="%s">'
'%s<span class="chip-n">%d</span></button>'
% (g["id"], "true" if i == 0 else "false", e(g["label"]), g["n"])
for i, g in enumerate(GROUPS))
data = json.dumps({g["id"]: {"plain": g["plain"], "b64": g["b64"], "items": g["items"]}
for g in GROUPS}, ensure_ascii=False).replace("<", "\\u003c")
return """
<section class="subs" id="subs">
<div class="subs-hd">
<h2>订阅</h2>
<p>把全部线路打成一份订阅,节点名统一为 <code>[国家码] [站点码] [协议] [Direct/Warp]</code>,
导入后在客户端里自行挑线路。内容由本页离线生成,不访问任何服务器。</p>
</div>
<div class="chips" role="group" aria-label="订阅范围">
%(chips)s
</div>
<div class="subs-act">
<button class="act primary" type="button" id="sub-dl">下载订阅文件</button>
<button class="act copy" type="button" id="sub-b64" data-copy="">复制订阅内容(Base64)</button>
<button class="act copy" type="button" id="sub-plain" data-copy="">复制明文链接</button>
<span class="subs-count" id="sub-count"></span>
</div>
<ol class="sub-list" id="sub-list"></ol>
<details class="subs-how">
<summary>怎么导入</summary>
<div class="how-body">
<p><b>v2rayN / v2rayNG / NekoBox</b> 点「复制明文链接」,在客户端里选「从剪贴板导入批量 URL」。
也可先「下载订阅文件」,再用本地文件导入。</p>
<p><b>Shadowrocket / Stash</b> 点「复制订阅内容(Base64)」,回到 App 会提示识别到订阅内容;
或把下载的 .txt 通过 AirDrop / 文件 App 传到手机后用「打开方式」导入。</p>
<p><b>Clash 系(Verge / Meta)</b> 不吃这种 Base64 订阅,需要先转成 Clash 配置;
建议改用 sing-box 或 Xray 内核的客户端,或直接使用上方各线路的 sing-box 配置片段。</p>
</div>
</details>
<details class="subs-how">
<summary>使用 Proxy Station 托管订阅</summary>
<div class="how-body">
<p>在 Proxy Station 的「节点」页粘贴本页对应的 VMess、Trojan、Shadowsocks、Hysteria2
分享链接,解析预览并导入,再到「订阅」页创建订阅并复制客户端 URL。</p>
<p>SOCKS5 和 TUIC 线路目前不属于 Proxy Station 支持的节点类型,需单独使用。
Shadowsocks over WebSocket 在 Surge 订阅中会被排除。</p>
<p>本页含真实凭据,请只在本机或通过受控文件交付使用。</p>
</div>
</details>
</section>
<script type="application/json" id="sub-data">%(data)s</script>
""" % {"chips": chips, "data": data}
subs = render_subs()
tabs = "\n".join(
' <button class="tab" id="tab-%s" role="tab" aria-controls="panel-%s" aria-selected="%s" tabindex="%s">'
'<span class="t-flag">%s</span><span class="t-code">%s</span>'
'<span class="t-city">%s</span><span class="t-n">%d</span></button>'
% (s["id"], s["id"], "true" if i == 0 else "false", "0" if i == 0 else "-1",
s["flag"], e(s["code"]), e(s["city"]), len(s["lines"]))
for i, s in enumerate(SITES))
panels = "\n".join(render_site(s) for s in SITES)
CSS = """
:root{
--ink:#0A0D13; --panel:#121722; --panel2:#171E2B; --rule:#222B3A;
--text:#D9E1EB; --dim:#78848F; --faint:#4C5666;
--direct:#E39B3D; --warp:#3FC7BB; --live:#56C86E; --risk:#E06C6C;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--ink);color:var(--text);font-family:var(--sans);
line-height:1.65;font-size:15px;padding:0 0 64px}
.mono,code,pre{font-family:var(--mono);font-variant-numeric:tabular-nums}
a{color:#6FB2F5;text-decoration:none}
a:hover{text-decoration:underline}
.wrap{max-width:1000px;margin:0 auto;padding:0 20px}
/* masthead */
.masthead{border-bottom:1px solid var(--rule);background:linear-gradient(180deg,#0D121B,#0A0D13);
padding:26px 0 20px;margin-bottom:0}
.mh-in{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap}
.brand{font-size:1.35rem;font-weight:700;letter-spacing:.06em}
.brand span{color:var(--faint);font-weight:400;margin:0 .5em}
.mh-meta{font-family:var(--mono);font-size:.76rem;color:var(--dim);letter-spacing:.04em}
.mh-meta b{color:var(--text);font-weight:600}
/* tabs */
.tabs{display:flex;gap:8px;padding:14px 0 0;flex-wrap:wrap;
position:sticky;top:0;z-index:20;background:var(--ink);border-bottom:1px solid var(--rule)}
.tab{appearance:none;background:transparent;border:1px solid transparent;border-bottom:none;
border-radius:9px 9px 0 0;color:var(--dim);font-family:var(--sans);font-size:.9rem;
padding:9px 16px 11px;cursor:pointer;display:flex;align-items:center;gap:8px;
transition:color .15s,background .15s;margin-bottom:-1px}
.tab:hover{color:var(--text);background:#0F141E}
.tab[aria-selected="true"]{color:var(--text);background:var(--panel);
border-color:var(--rule);box-shadow:inset 0 2px 0 var(--warp)}
.tab:focus-visible{outline:2px solid var(--warp);outline-offset:2px}
.t-flag{font-size:1rem;line-height:1}
.t-code{font-family:var(--mono);font-weight:700;letter-spacing:.06em}
.t-city{color:var(--faint);font-size:.82rem}
.tab[aria-selected="true"] .t-city{color:var(--dim)}
.t-n{font-family:var(--mono);font-size:.7rem;color:var(--faint);
border:1px solid var(--rule);border-radius:20px;padding:0 7px}
/* panel */
.panel{background:var(--panel);border:1px solid var(--rule);border-top:none;
border-radius:0 0 12px 12px;padding:26px 26px 30px}
.panel[hidden]{display:none}
.panel:focus-visible{outline:2px solid var(--warp);outline-offset:-2px}
/* identity */
.ident{border-bottom:1px solid var(--rule);padding-bottom:18px;margin-bottom:4px}
.ident-main{display:flex;align-items:center;gap:12px}
.ident-main .flag{font-size:1.6rem;line-height:1}
.ident h2{margin:0;font-size:1.5rem;letter-spacing:.08em;font-family:var(--mono);font-weight:700;
display:flex;align-items:baseline;gap:12px}
.ident h2 .city{font-family:var(--sans);font-size:.95rem;font-weight:400;letter-spacing:0;color:var(--dim)}
.ident-sub{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:6px}
.ident-sub code{font-size:.86rem;color:#9DB2C8;background:none;padding:0}
.tagline{font-size:.76rem;color:var(--faint);border-left:1px solid var(--rule);padding-left:14px}
/* routing matrix */
.matrix{display:flex;gap:4px;margin-top:14px;flex-wrap:wrap}
.matrix .cell{font-family:var(--mono);font-size:.66rem;letter-spacing:.04em;
padding:3px 7px;border-radius:4px;border:1px solid;line-height:1.4}
.matrix .cell.direct{color:var(--direct);border-color:rgba(227,155,61,.35);background:rgba(227,155,61,.08)}
.matrix .cell.warp{color:var(--warp);border-color:rgba(63,199,187,.35);background:rgba(63,199,187,.08)}
.matrix .cell.plain{color:var(--risk);border-color:rgba(224,108,108,.4);background:rgba(224,108,108,.09)}
/* blocks */
.block{margin-top:30px;scroll-margin-top:72px} /* clear the sticky tab bar */
.block h3{display:flex;align-items:center;gap:12px;margin:0 0 14px;font-size:.95rem;
font-weight:600;letter-spacing:.14em;color:var(--text);
border-bottom:1px solid var(--rule);padding-bottom:9px}
.block h3 i{font-style:normal;font-family:var(--mono);font-size:.72rem;color:var(--faint);
border:1px solid var(--rule);border-radius:4px;padding:2px 6px;letter-spacing:.06em}
/* key-value grid */
.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0;
background:var(--rule);border:1px solid var(--rule);border-radius:9px;overflow:hidden}
.grid.plain{grid-template-columns:1fr;margin-top:14px}
.kv{background:var(--panel2);padding:9px 13px;display:flex;gap:12px;align-items:baseline;min-width:0}
.kv dt{color:var(--dim);font-size:.78rem;flex:0 0 84px;white-space:nowrap}
/* break long unbroken tokens (IPv6, UUID) but never mid-number in a spaced list
like "UDP 443 / 34941 / 52888" -- break-all would split 52888 across lines */
.kv dd{margin:0;min-width:0;word-break:normal;overflow-wrap:break-word;font-size:.85rem}
.kv.full{grid-column:1/-1}
.kv dd.mono{font-family:var(--mono);font-size:.8rem}
.kv .hint{display:block;color:var(--faint);font-size:.74rem;font-family:var(--sans)}
.notes{margin:14px 0 0;padding:0;list-style:none;display:grid;gap:7px}
.notes li{position:relative;padding-left:18px;font-size:.8rem;color:var(--dim);line-height:1.6}
.notes li::before{content:"";position:absolute;left:4px;top:.62em;width:5px;height:5px;
border-radius:50%;background:var(--faint)}
/* accounts */
.acct{background:var(--panel2);border:1px solid var(--rule);border-radius:9px;
padding:14px 16px;margin-bottom:10px}
.acct-top{display:flex;gap:12px;align-items:baseline;flex-wrap:wrap;margin-bottom:9px}
.acct-name{font-weight:600;font-size:.92rem}
.acct-top a{font-family:var(--mono);font-size:.79rem;word-break:break-all}
.acct-cred{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.acct-cred code,.cred code{background:#0D121B;border:1px solid var(--rule);border-radius:5px;
padding:3px 9px;font-size:.8rem;color:#9FE0B0}
.lbl{color:var(--dim);font-size:.75rem}
.acct-note{margin:10px 0 0;font-size:.78rem;color:var(--direct);line-height:1.6}
/* shared credentials */
.shared{background:var(--panel2);border:1px solid var(--rule);border-radius:9px;padding:14px 16px}
.shared-hd{font-size:.78rem;color:var(--dim);margin-bottom:11px;letter-spacing:.03em}
.cred{display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:5px 0}
.cred .scope{font-size:.73rem;color:var(--faint)}
/* pills */
.pill{font-family:var(--mono);font-size:.68rem;letter-spacing:.04em;padding:2px 9px;
border-radius:20px;border:1px solid;white-space:nowrap}
.pill.direct{color:var(--direct);border-color:rgba(227,155,61,.4);background:rgba(227,155,61,.09)}
.pill.warp{color:var(--warp);border-color:rgba(63,199,187,.4);background:rgba(63,199,187,.09)}
/* line cards */
.lines{display:grid;gap:10px;margin-top:14px}
.line{display:flex;gap:15px;background:var(--panel2);border:1px solid var(--rule);
border-left:3px solid var(--rule);border-radius:9px;padding:13px 15px;align-items:flex-start}
.line.direct{border-left-color:var(--direct)}
.line.warp{border-left-color:var(--warp)}
.line.plain{border-left-color:var(--risk)}
.ln-warn{margin:8px 0 0;font-size:.75rem;color:var(--risk);line-height:1.6}
.line .qr{flex:0 0 84px;width:84px;height:84px;padding:5px;background:#fff;border:none;
border-radius:7px;cursor:zoom-in;display:block}
.line .qr img{width:100%;height:100%;display:block;image-rendering:pixelated}
.line .qr:focus-visible{outline:2px solid var(--warp);outline-offset:3px}
.line .qr.noqr{background:#0D121B;border:1px dashed var(--rule);cursor:default;
display:flex;align-items:center;justify-content:center;text-align:center}
.line .qr.noqr span{font-size:.66rem;color:var(--faint);line-height:1.5}
.ln-body{min-width:0;flex:1}
.ln-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.proto{font-weight:600;font-size:.95rem}
.ln-meta{font-size:.77rem;color:var(--dim);margin-top:3px;display:flex;gap:7px;align-items:baseline;flex-wrap:wrap}
.ln-meta .sep{color:var(--faint)}
.ln-link{display:flex;align-items:flex-start;gap:8px;margin-top:9px}
/* a VMess link is a 500-char base64 blob; cap it so it can't dominate the card.
The QR and the copy button are the real affordances -- the text is for reference. */
.ln-link code{flex:1;min-width:0;background:#0D121B;border:1px solid var(--rule);border-radius:6px;
padding:7px 10px;font-size:.71rem;line-height:1.55;word-break:break-all;color:#8FCBA0;
max-height:4.9em;overflow:auto}
.ln-json{margin-top:9px}
.ln-json summary{cursor:pointer;font-size:.77rem;color:var(--dim);width:fit-content}
.ln-json summary:hover{color:var(--text)}
.ln-json summary:focus-visible{outline:2px solid var(--warp);outline-offset:2px;border-radius:3px}
.ln-json pre{margin:8px 0 6px;background:#0D121B;border:1px solid var(--rule);border-radius:6px;
padding:10px 12px;font-size:.71rem;line-height:1.6;overflow-x:auto;color:#A8BDD4}
/* copy button */
.copy{appearance:none;background:#1B2432;border:1px solid var(--rule);color:var(--dim);
font-family:var(--sans);font-size:.71rem;padding:3px 10px;border-radius:5px;cursor:pointer;
white-space:nowrap;transition:color .15s,border-color .15s}
.copy:hover{color:var(--text);border-color:var(--faint)}
.copy:focus-visible{outline:2px solid var(--warp);outline-offset:2px}
.copy.done{color:var(--live);border-color:rgba(86,200,110,.5)}
/* subscription */
.subs{margin-top:26px;background:var(--panel);border:1px solid var(--rule);
border-radius:12px;padding:22px 24px 24px;scroll-margin-top:72px} /* clear the sticky tab bar */
.subs-hd h2{margin:0;font-size:1.05rem;letter-spacing:.14em;font-weight:600}
.subs-hd p{margin:8px 0 0;font-size:.81rem;color:var(--dim);line-height:1.75;max-width:74ch}
.subs-hd code{font-size:.78rem;color:#9DB2C8;background:#0D121B;border:1px solid var(--rule);
border-radius:5px;padding:1px 7px}
.chips{display:flex;gap:7px;flex-wrap:wrap;margin:16px 0 14px}
.chip{appearance:none;background:#0F141E;border:1px solid var(--rule);border-radius:20px;
color:var(--dim);font-family:var(--sans);font-size:.79rem;padding:5px 8px 5px 13px;
cursor:pointer;display:flex;align-items:center;gap:8px;transition:color .15s,border-color .15s}
.chip:hover{color:var(--text);border-color:var(--faint)}
.chip[aria-pressed="true"]{color:var(--ink);background:var(--warp);border-color:var(--warp);font-weight:600}
.chip:focus-visible{outline:2px solid var(--warp);outline-offset:2px}
.chip-n{font-family:var(--mono);font-size:.68rem;border:1px solid currentColor;border-radius:20px;
padding:0 6px;opacity:.75}
.subs-act{display:flex;gap:9px;flex-wrap:wrap;align-items:center}
.subs-act .act{appearance:none;background:#1B2432;border:1px solid var(--rule);color:var(--text);
font-family:var(--sans);font-size:.8rem;padding:7px 15px;border-radius:7px;cursor:pointer;
white-space:nowrap;transition:border-color .15s,color .15s}
.subs-act .act:hover{border-color:var(--faint)}
.subs-act .act:focus-visible{outline:2px solid var(--warp);outline-offset:2px}
.subs-act .act.primary{background:rgba(63,199,187,.12);border-color:rgba(63,199,187,.45);color:var(--warp)}
.subs-act .act.done{color:var(--live);border-color:rgba(86,200,110,.5)}
.subs-count{font-family:var(--mono);font-size:.73rem;color:var(--faint)}
.sub-list{list-style:none;margin:16px 0 0;padding:0;display:grid;gap:1px;
grid-template-columns:repeat(2,minmax(0,1fr));background:var(--rule);
border:1px solid var(--rule);border-radius:9px;overflow:hidden}
.sub-list li{background:var(--panel2);padding:8px 13px;display:flex;align-items:baseline;
gap:10px;min-width:0}
.sub-list .dot{flex:0 0 6px;width:6px;height:6px;border-radius:50%;align-self:center}
.sub-list li.direct .dot{background:var(--direct)}
.sub-list li.warp .dot{background:var(--warp)}
.sub-list .nm{font-family:var(--mono);font-size:.79rem;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis}
.sub-list .tr{margin-left:auto;font-size:.7rem;color:var(--faint);white-space:nowrap}
.subs-how{margin-top:14px;border-top:1px solid var(--rule);padding-top:12px}
.subs-how summary{cursor:pointer;font-size:.8rem;color:var(--dim);width:fit-content}
.subs-how summary:hover{color:var(--text)}
.subs-how summary:focus-visible{outline:2px solid var(--warp);outline-offset:2px;border-radius:3px}
.how-body{font-size:.79rem;color:var(--dim);line-height:1.8;max-width:78ch}
.how-body p{margin:9px 0 0}
.how-body b{color:var(--text);font-weight:600}
.how-body code{font-family:var(--mono);font-size:.75rem;color:#9DB2C8}
.how-body pre{margin:10px 0 0;background:#0D121B;border:1px solid var(--rule);border-radius:6px;
padding:10px 12px;font-size:.72rem;line-height:1.65;overflow-x:auto;color:#A8BDD4}
.mh-jump{font-family:var(--mono);font-size:.74rem;border:1px solid var(--rule);border-radius:6px;
padding:3px 11px;color:var(--warp)}
.mh-jump:hover{border-color:var(--warp);text-decoration:none}
/* warn */
.warn{margin-top:26px;background:rgba(227,155,61,.07);border:1px solid rgba(227,155,61,.28);
border-radius:11px;padding:15px 19px;font-size:.81rem;color:#DEBB86;line-height:1.7}
.warn b{color:var(--direct)}
.foot{text-align:center;color:var(--faint);font-size:.74rem;margin-top:22px}
/* lightbox */
.lb{position:fixed;inset:0;z-index:60;display:none;place-items:center;padding:24px;
background:rgba(5,7,11,.88);backdrop-filter:blur(3px)}
.lb[open]{display:grid}
.lb-card{background:var(--panel);border:1px solid var(--rule);border-radius:13px;
padding:22px;max-width:min(440px,100%);text-align:center}
.lb-card img{width:280px;max-width:100%;height:auto;background:#fff;padding:12px;border-radius:9px;
image-rendering:pixelated;display:block;margin:0 auto}
.lb-title{font-size:.95rem;font-weight:600;margin:14px 0 4px}
.lb-link{font-family:var(--mono);font-size:.68rem;color:var(--dim);word-break:break-all;
max-height:80px;overflow:auto;margin-bottom:14px;text-align:left;
background:#0D121B;border:1px solid var(--rule);border-radius:6px;padding:8px 10px}
.lb-act{display:flex;gap:9px;justify-content:center}
.lb-close{appearance:none;background:#1B2432;border:1px solid var(--rule);color:var(--text);
font-family:var(--sans);font-size:.8rem;padding:6px 16px;border-radius:6px;cursor:pointer}
.lb-close:hover{border-color:var(--faint)}
.lb-close:focus-visible,.lb .copy:focus-visible{outline:2px solid var(--warp);outline-offset:2px}
@media(max-width:720px){
.grid,.sub-list{grid-template-columns:1fr}
.panel,.subs{padding:20px 16px 24px}
.subs-act .act{flex:1 1 auto;text-align:center}
.block,.subs{scroll-margin-top:112px} /* the tab bar wraps to two rows here */
.line{flex-direction:column}
.line .qr{flex:0 0 auto}
.kv dt{flex-basis:76px}
}
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
"""
JS = """
(function(){
var tabs = Array.prototype.slice.call(document.querySelectorAll('.tab'));
var panels = Array.prototype.slice.call(document.querySelectorAll('.panel'));
// writeHash is off during boot: overwriting the hash there would swallow a
// deep link like #subs before the browser has scrolled to it.
function select(i, focus, writeHash){
tabs.forEach(function(t,j){
var on = i===j;
t.setAttribute('aria-selected', on ? 'true':'false');
t.tabIndex = on ? 0 : -1;
panels[j].hidden = !on;
});
if(focus) tabs[i].focus();
if(writeHash){ try{ location.hash = tabs[i].id.replace('tab-',''); }catch(e){} }
}
tabs.forEach(function(t,i){
t.addEventListener('click', function(){ select(i,false,true); });
t.addEventListener('keydown', function(ev){
var k = ev.key, n = null;
if(k==='ArrowRight') n = (i+1)%tabs.length;
else if(k==='ArrowLeft') n = (i-1+tabs.length)%tabs.length;
else if(k==='Home') n = 0;
else if(k==='End') n = tabs.length-1;
if(n!==null){ ev.preventDefault(); select(n,true,true); }
});
});
var initial = 0, h = (location.hash||'').replace('#','');
tabs.forEach(function(t,i){ if(t.id === 'tab-'+h) initial = i; });
select(initial,false,false);
// copy
document.addEventListener('click', function(ev){
var b = ev.target.closest('.copy');
if(!b) return;
var val = b.getAttribute('data-copy'), old = b.textContent;
function done(){ b.textContent='已复制'; b.classList.add('done');
setTimeout(function(){ b.textContent=old; b.classList.remove('done'); },1400); }
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(val).then(done, fallback);
} else fallback();
function fallback(){
var ta=document.createElement('textarea'); ta.value=val;
ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta);
ta.select(); try{ document.execCommand('copy'); done(); }catch(e){}
document.body.removeChild(ta);
}
});
// subscription: everything is inlined, so switching scope is a pure re-render
var SUB = JSON.parse(document.getElementById('sub-data').textContent),
chips = Array.prototype.slice.call(document.querySelectorAll('.chip')),
list = document.getElementById('sub-list'),
count = document.getElementById('sub-count'),
bB64 = document.getElementById('sub-b64'),
bPlain = document.getElementById('sub-plain'),
bDl = document.getElementById('sub-dl'),
cur = 'all';
function renderSub(){
var g = SUB[cur];
list.innerHTML = '';
g.items.forEach(function(it){
var li = document.createElement('li');
li.className = it.egress;
li.innerHTML = '<span class="dot"></span><span class="nm"></span><span class="tr"></span>';
li.querySelector('.nm').textContent = it.name;
li.querySelector('.tr').textContent = it.transport;
list.appendChild(li);
});
count.textContent = g.items.length + ' 条线路';
bB64.setAttribute('data-copy', g.b64);
bPlain.setAttribute('data-copy', g.plain);
}
chips.forEach(function(c){
c.addEventListener('click', function(){
cur = c.getAttribute('data-group');
chips.forEach(function(o){ o.setAttribute('aria-pressed', o === c ? 'true':'false'); });
renderSub();
});
});
renderSub();
bDl.addEventListener('click', function(){
var blob = new Blob([SUB[cur].b64], {type:'text/plain;charset=utf-8'}),
url = URL.createObjectURL(blob), a = document.createElement('a');
a.href = url; a.download = 'edge-nodes-' + cur + '.txt';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(function(){ URL.revokeObjectURL(url); }, 1000);
var old = bDl.textContent;
bDl.textContent = '已下载'; bDl.classList.add('done');
setTimeout(function(){ bDl.textContent = old; bDl.classList.remove('done'); }, 1400);
});
// QR lightbox
var lb = document.getElementById('lb'),
lbImg = document.getElementById('lb-img'),
lbTitle = document.getElementById('lb-title'),
lbLink = document.getElementById('lb-link'),
lbCopy = document.getElementById('lb-copy'),
lbClose = document.getElementById('lb-close'),
lastFocus = null;
function openLb(btn){
lastFocus = btn;
lbImg.src = 'data:image/png;base64,' + btn.getAttribute('data-qr');
lbTitle.textContent = btn.getAttribute('data-title');
var link = btn.getAttribute('data-link') || '';
lbLink.textContent = link;
lbCopy.setAttribute('data-copy', link);
lb.setAttribute('open','');
lbClose.focus();
}
function closeLb(){
lb.removeAttribute('open');
if(lastFocus) lastFocus.focus();
}
document.addEventListener('click', function(ev){
var q = ev.target.closest('.qr[data-qr]');
if(q){ openLb(q); return; }
if(ev.target === lb) closeLb();
});
lbClose.addEventListener('click', closeLb);
document.addEventListener('keydown', function(ev){
if(ev.key === 'Escape' && lb.hasAttribute('open')) closeLb();
});
})();
"""
DOC = """<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>边缘节点 · 交付文档</title>
<style>%(css)s</style>
</head>
<body>
<header class="masthead">
<div class="wrap mh-in">
<div class="brand">边缘节点<span>/</span>交付文档</div>
<div class="mh-meta"><b>%(nsite)d</b> 站点 <b>%(nline)d</b> 条线路 更新于 %(updated)s
<a class="mh-jump" href="#subs">订阅 ↓</a></div>
</div>
</header>
<div class="wrap">
<div class="tabs" role="tablist" aria-label="站点">
%(tabs)s
</div>
%(panels)s
%(subs)s
<div class="warn">
<b>凭据安全</b> 本页含全部站点的明文密码、UUID 与可直接导入的节点链接。子域前缀、面板入口路径、WS 路径与 QUIC 端口共同构成控制面的地图 —— 请勿提交到 git 或上传至任何在线服务;需要转交时直接发文件,不要粘贴内容。<br>
<b>出口区分</b> <span class="pill direct">直连</span> 走服务器本机 IP,速度最快但暴露真实 IP;<span class="pill warp">WARP</span> 经 Cloudflare 净出口,隐藏真实 IP。连上后访问 <code>https://1.1.1.1/cdn-cgi/trace</code> 看 <code>ip=</code> 与 <code>warp=</code> 即可验证。
</div>
<p class="foot">全部线路已在服务器侧实测通过(WS 握手 / QUIC 出口 / 直连与 WARP 分流)。二维码为本地离线生成,未经过任何第三方服务。</p>
</div>
<div class="lb" id="lb" role="dialog" aria-modal="true" aria-labelledby="lb-title">
<div class="lb-card">
<img id="lb-img" alt="节点二维码">
<div class="lb-title" id="lb-title"></div>
<div class="lb-link" id="lb-link"></div>
<div class="lb-act">
<button class="copy" id="lb-copy" type="button" data-copy="">复制链接</button>
<button class="lb-close" id="lb-close" type="button">关闭</button>
</div>
</div>
</div>
<script>%(js)s</script>
</body>
</html>
"""
output = Path(OUT)
output.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(DOC % {"css": CSS, "js": JS, "tabs": tabs, "panels": panels,
"subs": subs, "nsite": len(SITES), "nline": TOTAL,
"updated": UPDATED})
print("wrote %s (%.1f KB, %d sites, %d lines)" % (OUT, os.path.getsize(OUT)/1024, len(SITES), TOTAL))