mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-10 23:37:51 +08:00
🦄 Sync 2026-10-02 14:52:05
This commit is contained in:
1 parent
4c85793342
commit
0bfe84f1e8
133 files changed
+7983
-3225
No files matched your search
@@ -3,7 +3,6 @@
|
||||
|
||||
local util = require("substore.util")
|
||||
local node = require("substore.node")
|
||||
local parser_yaml = require("substore.parser_yaml")
|
||||
local parser_clash_yaml = require("substore.parser_clash_yaml")
|
||||
local parser_json_config = require("substore.parser_json_config")
|
||||
local parser_surge = require("substore.parser_surge")
|
||||
@@ -97,6 +96,98 @@ function M.detect(content)
|
||||
return "unknown"
|
||||
end
|
||||
|
||||
-- 「混合格式」提示里各格式的显示名
|
||||
local FORMAT_LABELS = {
|
||||
uri = "URI 链接",
|
||||
json = "JSON",
|
||||
yaml = "Clash YAML",
|
||||
["wireguard-conf"] = "WireGuard .conf",
|
||||
surge = "Surge/Loon 配置",
|
||||
}
|
||||
|
||||
-- 行首锚定,数出「整行就是一条节点链接」的行数。
|
||||
--
|
||||
-- 不能用 detect() 里那条宽松的 `content:find("://")`:Clash YAML 的
|
||||
-- `url: https://…`、sing-box JSON 的 `"url": "https://…"` 都含 "://",
|
||||
-- 拿它当 URI 判据的话,一份**完全正常**、只是带了个订阅地址的配置
|
||||
-- 会被判成「YAML + URI 混合」而拒绝导入 —— 误判比漏报更糟。
|
||||
-- 锚定行首之后,只有「一行就是一条链接」才算数,那正是 URI 订阅的形态。
|
||||
local function count_uri_lines(content)
|
||||
local n = 0
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
local scheme = line:match("^%s*(%a[%w]*):/")
|
||||
if scheme and SUPPORTED[scheme:lower()] then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- INI 段头:`[Interface]` / `[Peer]` / `[Proxy]` / `[server_local]`
|
||||
-- 它们同样以 "[" 开头,但**不是** JSON 数组。
|
||||
-- detect() 靠判断次序避开了这一点(surge 与 .conf 都排在 "[" 之前,先命中就返回),
|
||||
-- 而 detect_all 是「全部收集」,次序挡不住,必须显式排除 ——
|
||||
-- 否则一份完全正常的 Surge 配置会被判成「Surge + JSON 混合」而拒绝导入。
|
||||
local function is_ini_section_head(line)
|
||||
return line:match("^%s*%[%a[%w_%-%s]*%]%s*$") ~= nil
|
||||
end
|
||||
|
||||
-- 行首的 JSON 对象起始:`{` 单独一行,或 `{"key"…`。
|
||||
-- 对象的第一个键必然是带引号的字符串,因此 YAML 的流式映射 `{path: /x}`
|
||||
-- (未加引号的键)不会被误判成 JSON。
|
||||
local function has_json_object_line(content)
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
if line:match("^%s*{%s*$") or line:match('^%s*{%s*"') then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- 行首的 JSON 数组起始,排除 INI 段头
|
||||
local function has_json_array_line(content)
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
if line:match("^%s*%[") and not is_ini_section_head(line) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- 内容**同时**命中的全部格式,按 detect() 的优先级排列。
|
||||
--
|
||||
-- 只服务于「混合格式」提示(见 M.parse_local 的文本模式):detect() 只返回
|
||||
-- 优先级最高的那一个,其余格式的内容会被**静默丢弃** —— 粘进「URI + WG conf」,
|
||||
-- 只导入到 WG 节点;粘进「URI + JSON」,只导入到 URI 节点。而 parse 返回的是
|
||||
-- 合法表,同步报成功,用户以为整份都导进来了。
|
||||
--
|
||||
-- 判据必须从严:这个返回值会被用来**拒绝导入**,误判会把一份正常配置挡在门外。
|
||||
-- 因此一律用「整份文档级标记 + 行首锚定」,宁可漏报,不可误报。
|
||||
function M.detect_all(content)
|
||||
content = util.trim(content or "")
|
||||
content = content:gsub("^\239\187\191", "")
|
||||
local out = {}
|
||||
if content == "" then return out end
|
||||
local lower = content:lower()
|
||||
|
||||
-- .conf 的双条件与 detect() 一致
|
||||
local wg_conf = lower:match("%[interface%]") and lower:match("privatekey%s*=")
|
||||
if wg_conf then out[#out + 1] = "wireguard-conf" end
|
||||
|
||||
if content:match("^[%s]*proxies:") or content:match("^[%s]*outbounds:") then
|
||||
out[#out + 1] = "yaml"
|
||||
else
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
if line:match("^%s*proxies:%s*$") or line:match("^%s*outbounds:%s*$") then
|
||||
out[#out + 1] = "yaml"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if parser_surge.is_config(content) then out[#out + 1] = "surge" end
|
||||
if count_uri_lines(content) > 0 then out[#out + 1] = "uri" end
|
||||
-- JSON 排在最后:它的判据最宽(一份 JSON 配置里也可能出现 URI 行),
|
||||
-- 放前面不影响结果,但排最后读起来与 detect() 的优先级一致。
|
||||
if (has_json_object_line(content) or has_json_array_line(content)) and not wg_conf then
|
||||
out[#out + 1] = "json"
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---------- 协议解析 ----------
|
||||
local function parse_ss(body)
|
||||
-- ss:// 兼容多种变体:base64(method:password@host:port)、method:password@host:port、
|
||||
@@ -1087,6 +1178,42 @@ local function parse_yaml_content(content)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- sing-box 的 transport 同样是嵌套 map:{type, path, headers.Host,
|
||||
-- service_name, host}。字段名对照 parser_json_config.parse_singbox_json
|
||||
-- (本项目读取 sing-box 出站的权威实现),不另立一套。
|
||||
-- 不展开的话 net 只会取到 n.net / n.network —— sing-box 出站里这两个
|
||||
-- 字段都不存在,ws / grpc 节点会全部按 tcp 导入,客户端拿明文 tcp 去连
|
||||
-- 只开了 ws 的端口,握手必然失败且不报错。
|
||||
local transport_map = type(n.transport) == "table" and n.transport or nil
|
||||
local t_net, t_path, t_host
|
||||
if transport_map then
|
||||
local tt = transport_map.type
|
||||
if tt == "ws" then
|
||||
t_net = "ws"
|
||||
t_path = transport_map.path
|
||||
if type(transport_map.headers) == "table" then
|
||||
t_host = transport_map.headers.Host or transport_map.headers.host
|
||||
end
|
||||
elseif tt == "grpc" then
|
||||
t_net = "grpc"
|
||||
t_path = transport_map.service_name
|
||||
elseif tt == "http" then
|
||||
t_net = "http"
|
||||
t_path = transport_map.path
|
||||
-- http 的 host 是数组,取首个;httpupgrade 的是单个字符串
|
||||
if type(transport_map.host) == "table" then
|
||||
t_host = transport_map.host[1]
|
||||
elseif type(transport_map.host) == "string" and transport_map.host ~= "" then
|
||||
t_host = transport_map.host
|
||||
end
|
||||
elseif tt == "httpupgrade" then
|
||||
t_net = "http"
|
||||
t_path = transport_map.path
|
||||
if type(transport_map.host) == "string" and transport_map.host ~= "" then
|
||||
t_host = transport_map.host
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 注意不能用 `(cond) and nil or x` 写法:Lua 的 and/or 在 cond 为真时
|
||||
-- 结果是 nil,会被后面的 or 继续兜底,等于没生效
|
||||
local tls_security = n.security
|
||||
@@ -1127,7 +1254,12 @@ local function parse_yaml_content(content)
|
||||
-- 行解析器读出来的是字符串,而 "false" / "0" 在 Lua 里也是真值,
|
||||
-- 直接透传会让 skip-cert-verify=false 变成「跳过证书校验」,故显式判假。
|
||||
["skip-cert-verify"] = skip_cert_verify,
|
||||
net = n.net or n.network,
|
||||
-- transport 展开的传输层优先:它才是 sing-box 的权威写法
|
||||
net = t_net or n.net or n.network,
|
||||
-- 简易解析器此前**完全没有**把 path / host 带进 node_data,
|
||||
-- 于是走这条回退路径的 ws / grpc 节点连 path 与 Host 都丢了
|
||||
path = t_path or n.path,
|
||||
host = t_host or n.host,
|
||||
alterId = tonumber(n.alterId),
|
||||
}
|
||||
result[#result + 1] = node.normalize(node_data)
|
||||
@@ -1322,6 +1454,23 @@ function M.parse_local_link(url)
|
||||
end
|
||||
|
||||
-- 解析订阅内容,返回 { nodes = {...}, format = "..." } 或 nil, err
|
||||
-- 统一兜底:丢弃残缺节点(缺 server,或端口为空 / 不在 1–65535)。
|
||||
--
|
||||
-- 这类节点会被写成客户端加载不了的配置,而 mihomo / sing-box / Xray 都是
|
||||
-- 「一个坏节点废掉整份文件」——用户看到的是整个订阅不可用,而不是少一个节点。
|
||||
-- URI 路径一直在各解析器内部做这个校验(valid_hostport),Clash YAML /
|
||||
-- sing-box JSON / Surge 路径漏了:一份 `port: 1e999` 的 YAML 能产出 port=inf
|
||||
-- 的节点,落盘读回来又是 nil。各解析器只管把字段填对,校验统一放在这里。
|
||||
local function finish(nodes, format)
|
||||
local out = {}
|
||||
for _, n in ipairs(nodes or {}) do
|
||||
if type(n) == "table" and valid_hostport(n.server, n.port) then
|
||||
out[#out + 1] = n
|
||||
end
|
||||
end
|
||||
return { nodes = out, format = format }
|
||||
end
|
||||
|
||||
function M.parse(content)
|
||||
if not content or content == "" then return { nodes = {}, format = "empty" } end
|
||||
local format = M.detect(content)
|
||||
@@ -1332,7 +1481,7 @@ function M.parse(content)
|
||||
-- 不是格式不认识。与空串保持同一返回形态(0 节点、格式 empty)。
|
||||
return { nodes = {}, format = "empty" }
|
||||
elseif format == "uri" then
|
||||
return { nodes = parse_lines(split_lines(content)), format = "uri" }
|
||||
return finish(parse_lines(split_lines(content)), "uri")
|
||||
elseif format == "base64" then
|
||||
-- 兼容标准 base64 与 base64url(- _ 无 padding):base64_url_decode 两者皆可
|
||||
local decoded = util.base64_url_decode(content)
|
||||
@@ -1348,21 +1497,21 @@ function M.parse(content)
|
||||
if res then res.format = "base64" end
|
||||
return res, err
|
||||
end
|
||||
return { nodes = parse_lines(split_lines(decoded)), format = "base64" }
|
||||
return finish(parse_lines(split_lines(decoded)), "base64")
|
||||
elseif format == "json" then
|
||||
local nodes, err = parse_json_content(content)
|
||||
if not nodes then return nil, err end
|
||||
return { nodes = nodes, format = "json" }
|
||||
return finish(nodes, "json")
|
||||
elseif format == "yaml" then
|
||||
local nodes = M.parse_yaml(content)
|
||||
return { nodes = nodes, format = "yaml" }
|
||||
return finish(nodes, "yaml")
|
||||
elseif format == "surge" then
|
||||
local nodes = parser_surge.parse(content)
|
||||
return { nodes = nodes, format = "surge" }
|
||||
return finish(nodes, "surge")
|
||||
elseif format == "wireguard-conf" then
|
||||
local nodes, err = parse_wireguard_conf(content)
|
||||
if not nodes then return nil, err end
|
||||
return { nodes = nodes, format = "wireguard-conf" }
|
||||
return finish(nodes, "wireguard-conf")
|
||||
end
|
||||
return nil, "无法识别的订阅格式"
|
||||
end
|
||||
@@ -1454,7 +1603,20 @@ function M.parse_local(content, mode)
|
||||
end
|
||||
return { nodes = nodes, format = "local-form" }
|
||||
end
|
||||
-- 文本模式:使用通用解析
|
||||
-- 文本模式:使用通用解析。
|
||||
--
|
||||
-- 但先挡住「一份文本里混了多种格式」:detect() 只认优先级最高的那一种,
|
||||
-- 其余部分被静默丢弃,而 parse 返回的是合法表 —— 同步报成功,
|
||||
-- 用户以为整份都导进来了(实测:「URI + WG conf」只剩 WG 节点,
|
||||
-- 「URI + JSON」只剩 URI 节点,err 均为 nil)。
|
||||
-- 一次只导入一种格式,混用就明确报错,而不是默默少一半节点。
|
||||
local formats = M.detect_all(content)
|
||||
if #formats > 1 then
|
||||
local names = {}
|
||||
for i, f in ipairs(formats) do names[i] = FORMAT_LABELS[f] or f end
|
||||
return nil, "同一份文本里混用了多种格式(" .. table.concat(names, " + ") ..
|
||||
"):一次只能导入一种格式,请拆开后分次导入"
|
||||
end
|
||||
return M.parse(content)
|
||||
end
|
||||
|
||||
|
||||
Reference in new issue
Block a user