#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 将自定义节点 JSON 转换为可导入 GUI 的 ss:// 链接。 用法: python to_ss_links.py -i nodes.json -o links.txt JSON 输入可为: - 单个对象 - 对象数组 字段映射: - 仅处理 method == "aes-256-cfb" (不区分大小写) - server -> 主机 - server_port -> 端口 - PluginOption -> 密码 (若为空/None, 回退到 password) - mx_head_str -> 作为 ?mx= 参数 - name -> 作为 #TAG 输出: - 文本中每行一个 ss:// 链接 (平铺样式),例如: ss://aes-256-cfb:dwz1GtF7@112.54.161.34:22404?mx=com.win64.oppc.game.common%3A22021709%2C102024080020541279#韩国专线139 """ from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any, Dict, Iterable, List, Tuple from urllib.parse import quote METHOD = "aes-256-cfb" def _wrap_ipv6(host: str) -> str: """IPv6 主机在 URL 中需要方括号。""" return f"[{host}]" if ":" in host and not host.startswith("[") else host def build_ss_link(server: str, port: int, password: str, mx: str, name: str) -> str: """构造平铺样式 ss 链接,所有可变字段进行 URL 编码。 Args: server: 服务器地址 (IPv4/IPv6/域名) port: 服务器端口 password: 密码 (优先取 PluginOption) mx: mx_head_str name: 显示名称 (作为 #TAG) Returns: str: ss:// 链接 """ # 密码、mx、name 都要百分号编码;server 若是 IPv6 要加 [] enc_pwd = quote(password, safe="") enc_mx = quote(mx or "", safe="") enc_name = quote(name or "", safe="") host = _wrap_ipv6(server) return f"ss://{METHOD}:{enc_pwd}@{host}:{port}?mx={enc_mx}#{enc_name}" def load_items(path: Path) -> Iterable[Dict[str, Any]]: """加载 JSON,支持单对象或数组。""" data = json.loads(path.read_text(encoding="utf-8")) if isinstance(data, dict): yield data elif isinstance(data, list): for item in data: if isinstance(item, dict): yield item else: raise ValueError("JSON 根节点必须是对象或数组") def convert(items: Iterable[Dict[str, Any]]) -> List[Tuple[str, str]]: """将条目转换为 (name, ss_link) 列表;仅保留 aes-256-cfb。""" out: List[Tuple[str, str]] = [] for i, it in enumerate(items): method = str(it.get("method", "")).lower() if method != METHOD: continue # 只读 cfb server = str(it.get("server", "")).strip() port = int(it.get("server_port", 0)) # 密码优先用 PluginOption;为空则回退 password password = str(it.get("PluginOption") or it.get("password") or "").strip() mx = str(it.get("mx_head_str", "")).strip() name = str(it.get("name", f"node-{i}")).strip() # 基本校验 if not server or port <= 0 or not password: # 丢弃无效条目 continue link = build_ss_link(server, port, password, mx, name) out.append((name, link)) return out def main() -> None: parser = argparse.ArgumentParser(description="将自定义 JSON 转换为 GUI 可用的 ss:// 链接") parser.add_argument("-i", "--input", default="nodes.json", help="输入 JSON 文件路径") parser.add_argument("-o", "--output", help="输出 txt 文件路径(可选,默认只打印 stdout)") args = parser.parse_args() in_path = Path(args.input) if not in_path.exists(): print(f"[错误] 输入文件不存在: {in_path}", file=sys.stderr) sys.exit(2) try: pairs = convert(load_items(in_path)) except Exception as e: print(f"[错误] 解析失败: {e}", file=sys.stderr) sys.exit(3) if not pairs: print("[提示] 没有符合条件 (aes-256-cfb) 的条目。", file=sys.stderr) # 打印到 stdout for _, link in pairs: print(link) # 可选写文件 if args.output: out_path = Path(args.output) out_path.write_text("\n".join(link for _, link in pairs), encoding="utf-8") print(f"[完成] 已写入: {out_path}", file=sys.stderr) if __name__ == "__main__": main()