133 lines
4.1 KiB
Python
133 lines
4.1 KiB
Python
#!/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()
|