#!/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( '
%s
%s
' % (e(k), cls, e(v)) for k, v in pairs) def copy_btn(value, label="复制"): return ('' % (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 = ('' % (ln["qr"], e(ln["proto"] + " · " + egtxt), e(ln["link"] or ""), e(ln["proto"]), ln["qr"])) else: qr_html = '' body = [] body.append('
' '%s' '%s' '
' % (e(ln["proto"]), eg, e(egtxt))) body.append('
%s·%s
' % (e(ln["transport"]), e(ln["endpoint"]))) if ln.get("plaintext"): body.append('

认证与流量均为明文,链路上可被读取,且特征易被识别。' '仅适合可信网络内使用,不要用于穿越审查。

') if ln["link"]: body.append('' % (e(ln["link"]), copy_btn(ln["link"], "复制链接"))) if ln["json"]: body.append('
sing-box 配置片段' '
%s
%s
' % (e(ln["json"]), copy_btn(ln["json"], "复制配置"))) cls = eg + (" plain" if ln.get("plaintext") else "") return ('
\n %s\n
%s
\n
' % (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('%s' % (cls, e(ln["proto"]), label, e(ln["proto"][:2]))) return '
%s
' % "".join(cells) def render_site(site): p = [] p.append('
' % (site["id"], site["id"])) # identity p.append('
') p.append('
%s' '

%s%s

' % (site["flag"], e(site["code"]), e(site["city"]))) p.append('
%s%s
' % (e(site["domain"]), e(site["tagline"]))) p.append(' %s' % matrix(site)) p.append('
') # 01 站点 p.append('

01站点

') p.append('
') 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('
拥塞控制
%s
' % e(site["bbr"])) p.append('
') if site["notes"]: p.append(' ' % "".join("
  • %s
  • " % e(n) for n in site["notes"])) p.append('
    ') # 02 账号 p.append('

    02账号

    ') for pan in site["panels"]: p.append('
    ') p.append('
    %s' '%s
    ' % (e(pan["name"]), e(pan["url"]), e(pan["url"]))) p.append('
    ' '用户名%s%s' '密码%s%s
    ' % (e(pan["user"]), copy_btn(pan["user"], "复制用户名"), e(pan["pass"]), copy_btn(pan["pass"], "复制密码"))) if pan.get("note"): p.append('

    %s

    ' % e(pan["note"])) p.append('
    ') p.append('
    ') p.append("\n".join( '
    %s
    %s' '%s
    ' % (e(n), e(u), e(u), e(d)) for n, u, d in site["sites_plain"])) p.append('
    ') p.append('
    ') # 03 节点 p.append('

    03节点

    ') p.append('
    共用凭据 · 全部 %d 条线路同一套身份
    ' % len(site["lines"])) for label, val, scope in site["creds"]: p.append('
    %s%s%s' '%s
    ' % (e(label), e(val), copy_btn(val, "复制"+label), e(scope))) p.append('
    实测出口' '直连 %s' 'WARP Cloudflare
    ' % e(site["egress_direct"])) p.append('
    ') p.append('
    ') for i, ln in enumerate(site["lines"]): p.append(line_card(ln, i)) p.append('
    ') p.append('
    ') p.append('
    ') return "\n".join(p) def render_subs(): chips = "\n".join( ' ' % (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 """

    订阅

    把全部线路打成一份订阅,节点名统一为 [国家码] [站点码] [协议] [Direct/Warp], 导入后在客户端里自行挑线路。内容由本页离线生成,不访问任何服务器。

    %(chips)s
      怎么导入

      v2rayN / v2rayNG / NekoBox 点「复制明文链接」,在客户端里选「从剪贴板导入批量 URL」。 也可先「下载订阅文件」,再用本地文件导入。

      Shadowrocket / Stash 点「复制订阅内容(Base64)」,回到 App 会提示识别到订阅内容; 或把下载的 .txt 通过 AirDrop / 文件 App 传到手机后用「打开方式」导入。

      Clash 系(Verge / Meta) 不吃这种 Base64 订阅,需要先转成 Clash 配置; 建议改用 sing-box 或 Xray 内核的客户端,或直接使用上方各线路的 sing-box 配置片段。

      使用 Proxy Station 托管订阅

      在 Proxy Station 的「节点」页粘贴本页对应的 VMess、Trojan、Shadowsocks、Hysteria2 分享链接,解析预览并导入,再到「订阅」页创建订阅并复制客户端 URL。

      SOCKS5 和 TUIC 线路目前不属于 Proxy Station 支持的节点类型,需单独使用。 Shadowsocks over WebSocket 在 Surge 订阅中会被排除。

      本页含真实凭据,请只在本机或通过受控文件交付使用。

      """ % {"chips": chips, "data": data} subs = render_subs() tabs = "\n".join( ' ' % (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 = ''; 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 = """ 边缘节点 · 交付文档
      边缘节点/交付文档
      %(nsite)d 站点 %(nline)d 条线路 更新于 %(updated)s 订阅 ↓
      %(tabs)s
      %(panels)s %(subs)s
      凭据安全 本页含全部站点的明文密码、UUID 与可直接导入的节点链接。子域前缀、面板入口路径、WS 路径与 QUIC 端口共同构成控制面的地图 —— 请勿提交到 git 或上传至任何在线服务;需要转交时直接发文件,不要粘贴内容。
      出口区分 直连 走服务器本机 IP,速度最快但暴露真实 IP;WARP 经 Cloudflare 净出口,隐藏真实 IP。连上后访问 https://1.1.1.1/cdn-cgi/trace 看 ip= 与 warp= 即可验证。

      全部线路已在服务器侧实测通过(WS 握手 / QUIC 出口 / 直连与 WARP 分流)。二维码为本地离线生成,未经过任何第三方服务。

      """ 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))