Explorar o código

luci-app-ssr-plus: Add number for same alias.

zxlhhyccc hai 2 semanas
pai
achega
868dfbd890
Modificáronse 1 ficheiros con 304 adicións e 271 borrados
  1. 304 271
      luci-app-ssr-plus/root/usr/share/shadowsocksr/subscribe.lua

+ 304 - 271
luci-app-ssr-plus/root/usr/share/shadowsocksr/subscribe.lua

@@ -217,7 +217,7 @@ local function checkTabValue(tab)
 end
 -- JSON完整性检查
 local function isCompleteJSON(str)
-    -- 检查JSON格式
+	-- 检查JSON格式
 	if type(str) ~= "string" or str:match("^%s*$") then
         return false
     end
@@ -269,7 +269,9 @@ local function processData(szType, content, cfgid)
 			result.v2ray_protocol = has_xray_hy2_type
 		end
 
-		result.alias = url.fragment and UrlDecode(url.fragment) or nil
+		local raw_alias = url.fragment and UrlDecode(url.fragment) or nil
+		result.raw_alias = raw_alias   -- 新增
+		result.alias = raw_alias       -- 临时赋值(后面会被覆盖)
 		result.type = hy2_type
 		result.server = url.host
 		result.server_port = url.port or 443
@@ -360,10 +362,11 @@ local function processData(szType, content, cfgid)
 		-- 拼接 alias
 		local alias = ""
 		if group ~= "" then
-			alias = "[" .. group .. "] "
+			raw_alias = "[" .. group .. "] "
 		end
-		alias = alias .. remarks
-		result.alias = alias
+		raw_alias = raw_alias .. remarks
+		result.raw_alias = raw_alias   -- 新增
+		result.alias = raw_alias       -- 临时赋值(后面会被覆盖)
 	elseif szType == "vmess" then
 		-- 去掉前后空白和注释
 		local link = trim(content:gsub("#.*$", ""))
@@ -387,7 +390,8 @@ local function processData(szType, content, cfgid)
 		result.server_port = info.port
 		result.alter_id = info.aid
 		result.vmess_id = info.id
-		result.alias = info.ps
+		result.raw_alias = info.ps   -- 新增
+		result.alias = info.ps       -- 临时赋值(后面会被覆盖)
 
 		-- 调整传输协议
 		if info.net == "tcp" then
@@ -515,7 +519,9 @@ local function processData(szType, content, cfgid)
 			alias = content:sub(idx_sp + 1, -1)
 			content = content:sub(0, idx_sp - 1):gsub("/%?", "?")
 		end
-		result.alias = UrlDecode(alias)
+		local raw_alias = UrlDecode(alias)
+		result.raw_alias = raw_alias   -- 新增
+		result.alias = raw_alias       -- 临时赋值(后面会被覆盖)
 
 		-- 拆 base64 主体和 ? 参数部分
 		local info = content
@@ -582,13 +588,13 @@ local function processData(szType, content, cfgid)
 				if not pwd:find("%%[0-9A-Fa-f][0-9A-Fa-f]") then
 					return false
 				end
-					local ok, decoded = pcall(UrlDecode, pwd)
-					return ok and urlEncode(decoded) == pwd
+				local ok, decoded = pcall(UrlDecode, pwd)
+				return ok and urlEncode(decoded) == pwd
 			end
 
 			local decoded = UrlDecode(password)
-				if isURLEncodedPassword(password) and decoded then
-					password = decoded
+			if isURLEncodedPassword(password) and decoded then
+				password = decoded
 			end
 
 			-- 解析服务器地址和端口(兼容 IPv6)
@@ -642,18 +648,16 @@ local function processData(szType, content, cfgid)
 					if decoded_tls then
 						local ok, st = pcall(jsonParse, decoded_tls)
 						if ok and st then
-
 							result.plugin = "shadow-tls"
 							result.enable_plugin = 1
-					
 							local version_flag = ""
 							if st.version and tonumber(st.version) then
-					    		version_flag = string.format("v%s=1;", st.version)
+								version_flag = string.format("v%s=1;", st.version)
 							end
 					
 							-- 合成 plugin_opts 格式:v%s=1;host=xxx;password=xxx
 							result.plugin_opts = string.format("%shost=%s;passwd=%s",
-					    		version_flag,
+								version_flag,
 								st.host or "",
 								st.password or "")
 						else
@@ -694,14 +698,14 @@ local function processData(szType, content, cfgid)
         		-- 新格式:method:password
         		result.encrypt_method_ss, result.password = is_base64:match("^(.-):(.*)$")
 			else
-        		-- 旧格式:UUID 直接作为密码
-        		result.password = url.user
-        		result.encrypt_method_ss = params.encryption or "none"
+				-- 旧格式:UUID 直接作为密码
+				result.password = url.user
+				result.encrypt_method_ss = params.encryption or "none"
 			end
 
 			if params.udp then
-        		-- 处理 udp 参数
-        		result.uot = params.udp
+				-- 处理 udp 参数
+				result.uot = params.udp
 			end
 
 			result.transport = params.type or "raw"
@@ -806,7 +810,7 @@ local function processData(szType, content, cfgid)
 				result.grpc_mode = params.mode or "gun"
 			elseif result.transport == "tcp" or result.transport == "raw" then
 				result.tcp_guise = params.headerType or "none"
-			if result.tcp_guise == "http" then
+				if result.tcp_guise == "http" then
 					result.tcp_host = params.host and UrlDecode(params.host) or nil
 					result.tcp_path = params.path and UrlDecode(params.path) or nil
 				end
@@ -826,7 +830,8 @@ local function processData(szType, content, cfgid)
 		result.encrypt_method_ss = content.method
 		result.plugin = content.plugin
 		result.plugin_opts = content.plugin_opts
-		result.alias = content.remarks
+		result.raw_alias = content.remarks   -- 新增
+		result.alias = content.remarks       -- 临时赋值(后面会被覆盖)
 		if not checkTabValue(encrypt_methods_ss)[content.method] then
 			result.server = nil
 		end
@@ -843,7 +848,9 @@ local function processData(szType, content, cfgid)
 		result.password = content.password
 		result.encrypt_method_ss = content.method
 		result.plugin_opts = content.plugin_options
-		result.alias = "[" .. content.airport .. "] " .. content.remarks
+		local raw_alias = "[" .. content.airport .. "] " .. content.remarks
+		result.raw_alias = raw_alias   -- 新增
+		result.alias = raw_alias       -- 临时赋值(后面会被覆盖)
 		if content.plugin == "simple-obfs" then
 			result.plugin = "obfs-local"
 		else
@@ -860,11 +867,13 @@ local function processData(szType, content, cfgid)
 			alias = content:sub(idx_sp + 1, -1)
 			content = content:sub(0, idx_sp - 1)
 		end
-		result.alias = UrlDecode(alias)
+		local raw_alias = UrlDecode(alias)
+		result.raw_alias = raw_alias   -- 新增
+		result.alias = raw_alias       -- 临时赋值(后面会被覆盖)
 
 		-- 分离和提取 password		
 		local Info = content
-		local params = {} 
+		local params = {}
 		if Info:find("@") then
 			local contents = split(Info, "@")
 			result.password = UrlDecode(contents[1])
@@ -984,7 +993,7 @@ local function processData(szType, content, cfgid)
 					if success and type(Data) == "table" then
 						local address = (Data.extra and Data.extra.downloadSettings and Data.extra.downloadSettings.address)
 							or (Data.downloadSettings and Data.downloadSettings.address)
-						result.download_address = (address and address ~= "")  and address:gsub("^%[", ""):gsub("%]$", "")
+						result.download_address = (address and address ~= "") and address:gsub("^%[", ""):gsub("%]$", "")
 					else
 						-- 如果解析失败,清空下载地址
 						result.download_address = nil
@@ -1027,7 +1036,9 @@ local function processData(szType, content, cfgid)
 		local url = URL.parse("http://" .. content)
 		local params = url.query
 
-		result.alias = url.fragment and UrlDecode(url.fragment) or nil
+		local raw_alias = url.fragment and UrlDecode(url.fragment) or nil
+		result.raw_alias = raw_alias   -- 新增
+		result.alias = raw_alias       -- 临时赋值(后面会被覆盖)
 		result.type = "v2ray"
 		result.v2ray_protocol = "vless"
 		result.server = url.host
@@ -1113,11 +1124,9 @@ local function processData(szType, content, cfgid)
 		if result.transport == "ws" then
 			result.ws_host = (result.tls ~= "1" and result.reality ~= "1") and (params.host and UrlDecode(params.host)) or nil
 			result.ws_path = params.path and UrlDecode(params.path) or "/"
-
 		elseif result.transport == "httpupgrade" then
 			result.httpupgrade_host = (result.tls ~= "1" and result.reality ~= "1") and (params.host and UrlDecode(params.host)) or nil
 			result.httpupgrade_path = params.path and UrlDecode(params.path) or "/"
-
 		elseif result.transport == "xhttp" then
 			result.xhttp_mode = params.mode or "auto"
 			result.xhttp_host = params.host and UrlDecode(params.host) or nil
@@ -1134,7 +1143,7 @@ local function processData(szType, content, cfgid)
 			if success and type(Data) == "table" then
 				local address = (Data.extra and Data.extra.downloadSettings and Data.extra.downloadSettings.address)
 					or (Data.downloadSettings and Data.downloadSettings.address)
-				result.download_address = (address and address ~= "")  and address:gsub("^%[", ""):gsub("%]$", "")
+				result.download_address = (address and address ~= "") and address:gsub("^%[", ""):gsub("%]$", "")
 			else
 				result.download_address = nil
 			end
@@ -1180,7 +1189,9 @@ local function processData(szType, content, cfgid)
 			alias = content:sub(idx_sp + 1, -1)
 			content = content:sub(0, idx_sp - 1)
 		end
-		result.alias = UrlDecode(alias)
+		local raw_alias = UrlDecode(alias)
+		result.raw_alias = raw_alias   -- 新增
+		result.alias = raw_alias       -- 临时赋值(后面会被覆盖)
 
 		-- 分离和提取 uuid 和 password
 		local Info = content
@@ -1242,7 +1253,7 @@ local function processData(szType, content, cfgid)
 		if params.disable_sni then
 			if params.disable_sni == "1" or params.disable_sni == "0" then
 				result.disable_sni = params.disable_sni
-		else
+			else
 				result.disable_sni = string.lower(params.disable_sni) == "true" and "1" or "0"
 			end
 		end
@@ -1251,7 +1262,7 @@ local function processData(szType, content, cfgid)
 		if params.zero_rtt_handshake then
 			if params.zero_rtt_handshake == "1" or params.zero_rtt_handshake == "0" then
 				result.zero_rtt_handshake = params.zero_rtt_handshake
-		else
+			else
 				result.zero_rtt_handshake = string.lower(params.zero_rtt_handshake) == "true" and "1" or "0"
 			end
 		end
@@ -1260,7 +1271,7 @@ local function processData(szType, content, cfgid)
 		if params.dual_stack then
 			if params.dual_stack == "1" or params.dual_stack == "0" then
 				result.dual_stack = params.dual_stack
-		else
+			else
 				result.dual_stack = string.lower(params.dual_stack) == "true" and "1" or "0"
 			end
 			-- 处理 ipstack_prefer 参数
@@ -1277,12 +1288,14 @@ local function processData(szType, content, cfgid)
 			end
 		end
 	end
+
 	if not result.alias then
 		if result.server and result.server_port then
 			result.alias = result.server .. ':' .. result.server_port
 		else
 			result.alias = "NULL"
 		end
+		result.raw_alias = result.alias
 	end
 	-- alias 不参与 hashkey 计算
 	local alias = result.alias
@@ -1352,44 +1365,42 @@ local function curl(url, user_agent)
 end
 
 local function check_filer(result)
-	do
-		-- 过滤的关键词列表
-		local filter_word = split(filter_words, "/")
-		-- 保留的关键词列表
-		local check_save = false
-		if save_words ~= nil and save_words ~= "" and save_words ~= "NULL" then
-			check_save = true
-		end
-		local save_word = split(save_words, "/")
-
-		-- 检查结果
-		local filter_result = false
-		local save_result = true
-
-		-- 检查是否存在过滤关键词
-		for i, v in pairs(filter_word) do
-			if tostring(result.alias):find(v, nil, true) then
-				filter_result = true
-			end
+	-- 过滤的关键词列表
+	local filter_word = split(filter_words, "/")
+	-- 保留的关键词列表
+	local check_save = false
+	if save_words ~= nil and save_words ~= "" and save_words ~= "NULL" then
+		check_save = true
+	end
+	local save_word = split(save_words, "/")
+
+	-- 检查结果
+	local filter_result = false
+	local save_result = true
+
+	-- 检查是否存在过滤关键词
+	for i, v in pairs(filter_word) do
+		if tostring(result.alias):find(v, nil, true) then
+			filter_result = true
 		end
+	end
 
-		-- 检查是否打开了保留关键词检查,并且进行过滤
-		if check_save == true then
-			for i, v in pairs(save_word) do
-				if tostring(result.alias):find(v, nil, true) then
-					save_result = false
-				end
+	-- 检查是否打开了保留关键词检查,并且进行过滤
+	if check_save == true then
+		for i, v in pairs(save_word) do
+			if tostring(result.alias):find(v, nil, true) then
+				save_result = false
 			end
-		else
-			save_result = false
 		end
+	else
+		save_result = false
+	end
 
-		-- 不等时返回
-		if filter_result == true or save_result == true then
-			return true
-		else
-			return false
-		end
+	-- 不等时返回
+	if filter_result == true or save_result == true then
+		return true
+	else
+		return false
 	end
 end
 
@@ -1410,131 +1421,154 @@ local function loadOldNodes(groupHash)
 end
 
 local execute = function()
-	-- exec
-	do
-		--local updated = false 
-		local service_stopped = false
-		for k, url in ipairs(subscribe_url) do
-			local raw, new_md5 = curl(url)
-			log("raw 长度: "..#raw)
-			local groupHash = md5(url)
-			local old_md5 = read_old_md5(groupHash)
-
-			log("处理订阅: " .. url)
-			log("groupHash: " .. groupHash)
-			log("old_md5: " .. tostring(old_md5))
-			log("new_md5: " .. tostring(new_md5))
-
-			if #raw > 0 then
-				if old_md5 and new_md5 == old_md5 then
-					log("订阅未变化, 跳过无需更新的订阅: " .. url)
-					-- 防止 diff 阶段误删未更新订阅节点
-					loadOldNodes(groupHash)
-					--ucic:foreach(name, uciType, function(s)
-					--	if s.grouphashkey == groupHash and s.hashkey then
-					--		cache[groupHash][s.hashkey] = s
-					--		tinsert(nodeResult[index], s)
-					--	end
-					--end)
-				else
-					updated = true
-					-- 保存更新后的 MD5 值到以 groupHash 为标识的临时文件中,用于下次订阅更新时进行对比
-					write_new_md5(groupHash, new_md5)
-
-					-- 暂停服务(仅当 MD5 有变化时才执行)
-					if proxy == '0' and not service_stopped then
-						log('服务正在暂停')
-						luci.sys.init.stop(name)
-						service_stopped = true
-					end
+	local updated = false
+	local service_stopped = false
+	for k, url in ipairs(subscribe_url) do
+		local raw, new_md5 = curl(url)
+		log("raw 长度: "..#raw)
+		local groupHash = md5(url)
+		local old_md5 = read_old_md5(groupHash)
+
+		log("处理订阅: " .. url)
+		log("groupHash: " .. groupHash)
+		log("old_md5: " .. tostring(old_md5))
+		log("new_md5: " .. tostring(new_md5))
+
+		if #raw > 0 then
+			if old_md5 and new_md5 == old_md5 then
+				log("订阅未变化, 跳过无需更新的订阅: " .. url)
+				-- 防止 diff 阶段误删未更新订阅节点
+				loadOldNodes(groupHash)
+				--ucic:foreach(name, uciType, function(s)
+				--	if s.grouphashkey == groupHash and s.hashkey then
+				--		cache[groupHash][s.hashkey] = s
+				--		tinsert(nodeResult[index], s)
+				--	end
+				--end)
+			else
+				updated = true
+				-- 保存更新后的 MD5 值到以 groupHash 为标识的临时文件中,用于下次订阅更新时进行对比
+				write_new_md5(groupHash, new_md5)
+
+				-- 暂停服务(仅当 MD5 有变化时才执行)
+				if proxy == '0' and not service_stopped then
+					log('服务正在暂停')
+					luci.sys.init.stop(name)
+					service_stopped = true
+				end
 
-					cache[groupHash] = {}
-					tinsert(nodeResult, {})
-					local index = #nodeResult
-					local nodes, szType
-
-					-- SSD 似乎是这种格式 ssd:// 开头的
-					if raw:find('ssd://') then
-						szType = 'ssd'
-						local nEnd = select(2, raw:find('ssd://'))
-						nodes = base64Decode(raw:sub(nEnd + 1, #raw))
-						nodes = jsonParse(nodes)
-						local extra = {
-							airport = nodes.airport,
-							port = nodes.port,
-							encryption = nodes.encryption,
-							password = nodes.password
-						}
-						local servers = {}
-						-- SS里面包着 干脆直接这样
-						for _, server in ipairs(nodes.servers or {}) do
-							tinsert(servers, setmetatable(server, {__index = extra}))
-						end
-						nodes = servers
-					-- SS SIP008 直接使用 Json 格式
-					elseif jsonParse(raw) then
-						nodes = jsonParse(raw).servers or jsonParse(raw)
-						if nodes[1] and nodes[1].server and nodes[1].method then
-							szType = 'sip008'
-						end
-					-- 其他 base64 格式
-					else
-						-- ssd 外的格式
-						nodes = split(base64Decode(raw):gsub("\r\n", "\n"), "\n")
+				cache[groupHash] = {}
+				tinsert(nodeResult, {})
+				local index = #nodeResult
+				local nodes, szType
+
+				-- SSD 似乎是这种格式 ssd:// 开头的
+				if raw:find('ssd://') then
+					szType = 'ssd'
+					local nEnd = select(2, raw:find('ssd://'))
+					nodes = base64Decode(raw:sub(nEnd + 1, #raw))
+					nodes = jsonParse(nodes)
+					local extra = {
+						airport = nodes.airport,
+						port = nodes.port,
+						encryption = nodes.encryption,
+						password = nodes.password
+					}
+					local servers = {}
+					-- SS里面包着 干脆直接这样
+					for _, server in ipairs(nodes.servers or {}) do
+						tinsert(servers, setmetatable(server, {__index = extra}))
+					end
+					nodes = servers
+				-- SS SIP008 直接使用 Json 格式
+				elseif jsonParse(raw) then
+					nodes = jsonParse(raw).servers or jsonParse(raw)
+					if nodes[1] and nodes[1].server and nodes[1].method then
+						szType = 'sip008'
 					end
-					for _, v in ipairs(nodes) do
-						if v and not string.match(v, "^%s*$") then
-							xpcall(function()
-								local result
-								if szType then
-									result = processData(szType, v)
-								elseif not szType then
-									local node = trim(v)
-									-- 一些奇葩的链接用"&"、"<"当做"&","#"前后带空格
-									local link = node:gsub("&[a-zA-Z]+;", "&"):gsub("%s*#%s*", "#")
-									local dat = split(link, "://")
-									if dat and dat[1] and dat[2] then
-										local dat3 = ""
-										if dat[3] then
-											dat3 = "://" .. dat[3]
-										end
-										if dat[1] == 'ss' or dat[1] == 'trojan' or dat[1] == 'tuic' then
-											result = processData(dat[1], dat[2] .. dat3)
-										else
-											result = processData(dat[1], base64Decode(dat[2]))
-										end
+				-- 其他 base64 格式
+				else
+					-- ssd 外的格式
+					nodes = split(base64Decode(raw):gsub("\r\n", "\n"), "\n")
+				end
+
+				-- 临时存储该订阅解析出的节点(带原始别名)
+				local groupRawNodes = {}
+
+				for _, v in ipairs(nodes) do
+					if v and not string.match(v, "^%s*$") then
+						xpcall(function()
+							local result
+							if szType then
+								result = processData(szType, v)
+							elseif not szType then
+								local node = trim(v)
+								-- 一些奇葩的链接用"&"、"<"当做"&","#"前后带空格
+								local link = node:gsub("&[a-zA-Z]+;", "&"):gsub("%s*#%s*", "#")
+								local dat = split(link, "://")
+								if dat and dat[1] and dat[2] then
+									local dat3 = ""
+									if dat[3] then
+										dat3 = "://" .. dat[3]
 									end
-								else
-									log('跳过未知类型: ' .. szType)
-								end
-								-- log(result)
-								if result then
-									-- 中文做地址的 也没有人拿中文域名搞,就算中文域也有Puny Code SB 机场
-									if not result.server or not result.server_port
-										or result.server == "127.0.0.1"
-										or result.alias == "NULL"
-										or check_filer(result)
-										or result.server:match("[^0-9a-zA-Z%-_%.%s]")
-										or cache[groupHash][result.hashkey]
-									then
-										log('丢弃无效节点: ' .. result.alias)
+									if dat[1] == 'ss' or dat[1] == 'trojan' or dat[1] == 'tuic' then
+										result = processData(dat[1], dat[2] .. dat3)
 									else
-										-- log('成功解析: ' .. result.type ..' 节点, ' .. result.alias)
-										result.grouphashkey = groupHash
-										tinsert(nodeResult[index], result)
-										cache[groupHash][result.hashkey] = nodeResult[index][#nodeResult[index]]
+										result = processData(dat[1], base64Decode(dat[2]))
 									end
 								end
-							end, function(err)
-								log(string.format("解析节点出错: %s\n原始数据: %s", tostring(err), tostring(v)))
-							end)	
-						end
+							else
+								log('跳过未知类型: ' .. szType)
+							end
+							-- log(result)
+							if result then
+								-- 中文做地址的 也没有人拿中文域名搞,就算中文域也有Puny Code SB 机场
+								if not result.server or not result.server_port
+									or result.server == "127.0.0.1"
+									or result.alias == "NULL"
+									or check_filer(result)
+									or result.server:match("[^0-9a-zA-Z%-_%.%s]")
+									or cache[groupHash][result.hashkey] then
+									log('丢弃无效节点: ' .. result.alias)
+								else
+									-- 暂存节点
+									table.insert(groupRawNodes, result)
+								end
+							end
+						end, function(err)
+							log(string.format("解析节点出错: %s\n原始数据: %s", tostring(err), tostring(v)))
+						end)
 					end
-					log('成功解析节点数量: ' .. #nodes)
 				end
-			else
-				log(url .. ': 获取内容为空')
+
+				-- 对该组节点进行别名编号:重复节点加后缀,唯一节点不加
+				local freq = {}
+				for _, node in ipairs(groupRawNodes) do
+					local raw = node.raw_alias or ""
+					freq[raw] = (freq[raw] or 0) + 1
+				end
+				local aliasCount = {}
+				for _, node in ipairs(groupRawNodes) do
+					local raw = node.raw_alias or ""
+					if freq[raw] > 1 then
+						local count = (aliasCount[raw] or 0) + 1
+						aliasCount[raw] = count
+						node.alias = raw .. "_" .. count
+					else
+						node.alias = raw
+					end
+					-- 清理临时字段
+					node.raw_alias = nil
+					-- 存入 nodeResult
+					node.grouphashkey = groupHash
+					table.insert(nodeResult[index], node)
+					cache[groupHash][node.hashkey] = node
+				end
+
+				log('成功解析节点数量: ' .. #groupRawNodes)
 			end
+		else
+			log(url .. ': 获取内容为空')
 		end
 	end
 	-- 输出日志并判断是否需要进行 diff
@@ -1543,106 +1577,105 @@ local execute = function()
 		log('保留手动添加的节点。')
 		return
 	end
-	-- diff
-	do
-		if next(nodeResult) == nil then
-			log("更新失败,没有可用的节点信息")
-			if proxy == '0' then
-				luci.sys.init.start(name)
-				log('订阅失败, 恢复服务')
-			end
-			return
-		end
-		local add, del = 0, 0
-		ucic:foreach(name, uciType, function(old)
-			if old.grouphashkey or old.hashkey then -- 没有 hash 的不参与删除
-				if not nodeResult[old.grouphashkey] or not nodeResult[old.grouphashkey][old.hashkey] then
-					ucic:delete(name, old['.name'])
-					del = del + 1
-				else
-					local dat = nodeResult[old.grouphashkey][old.hashkey]
-					ucic:tset(name, old['.name'], dat)
-					-- 标记一下
-					setmetatable(nodeResult[old.grouphashkey][old.hashkey], {__index = {_ignore = true}})
-				end
+
+	-- diff 阶段
+	if next(nodeResult) == nil then
+		log("更新失败,没有可用的节点信息")
+		if proxy == '0' then
+			luci.sys.init.start(name)
+			log('订阅失败, 恢复服务')
+		end
+		return
+	end
+	local add, del = 0, 0
+	ucic:foreach(name, uciType, function(old)
+		if old.grouphashkey or old.hashkey then -- 没有 hash 的不参与删除
+			if not nodeResult[old.grouphashkey] or not nodeResult[old.grouphashkey][old.hashkey] then
+				ucic:delete(name, old['.name'])
+				del = del + 1
 			else
-				if not old.alias then
-					if old.server or old.server_port then
-						old.alias = old.server .. ':' .. old.server_port
-						log('忽略手动添加的节点: ' .. old.alias)
-					else
-						ucic:delete(name, old['.name'])
-					end
-				else
+				local dat = nodeResult[old.grouphashkey][old.hashkey]
+				ucic:tset(name, old['.name'], dat)
+				-- 标记一下
+				setmetatable(nodeResult[old.grouphashkey][old.hashkey], {__index = {_ignore = true}})
+			end
+		else
+			if not old.alias then
+				if old.server or old.server_port then
+					old.alias = old.server .. ':' .. old.server_port
 					log('忽略手动添加的节点: ' .. old.alias)
+				else
+					ucic:delete(name, old['.name'])
 				end
+			else
+				log('忽略手动添加的节点: ' .. old.alias)
 			end
-		end)
-		-- 1583-1620 行为生成 sid
-		-- 记录已使用编号
-		local used_sid = {}
-		local next_sid = 1
-		-- 扫描已有 section
-		ucic:foreach(name, uciType, function(s)
-			local num = s[".name"]:match("^cfg(%x%x)")  -- 提取两位十六进制序号
-			if num then
-				local n = tonumber(num, 16)
-				used_sid[n] = true
-			end
-		end)
-		-- 获取下一个可用编号(O(1))
-		local function get_next_sid()
-			while used_sid[next_sid] do
-				next_sid = next_sid + 1
-			end
-			used_sid[next_sid] = true
-			return next_sid
-		end
-		for _, v in ipairs(nodeResult) do
-			for _, vv in ipairs(v) do
-				if not vv._ignore then
-					local sid = ucic:add(name, uciType)
-					if sid then
-						local suffix = sid:sub(-4)
-						ucic:delete(name, sid)
-						local id = get_next_sid()
-						local cfgid = string.format("cfg%02x%s", id, suffix)
-						local section = ucic:section(name, uciType, cfgid)
-						if section then
-							ucic:tset(name, section, vv)
-							ucic:set(name, section, "switch_enable", switch)
-							add = add + 1
-						end
-					end
+		end
+	end)
+	-- 1615-1653 行为生成 sid
+	-- 记录已使用编号
+	local used_sid = {}
+	local next_sid = 1
+	-- 扫描已有 section
+	ucic:foreach(name, uciType, function(s)
+		local num = s[".name"]:match("^cfg(%x%x)")  -- 提取两位十六进制序号
+		if num then
+			local n = tonumber(num, 16)
+			used_sid[n] = true
+		end
+	end)
+	-- 获取下一个可用编号(O(1))
+	local function get_next_sid()
+		while used_sid[next_sid] do
+			next_sid = next_sid + 1
+		end
+		used_sid[next_sid] = true
+		return next_sid
+	end
 
+	for _, v in ipairs(nodeResult) do
+		for _, vv in ipairs(v) do
+			if not vv._ignore then
+				local sid = ucic:add(name, uciType)
+				if sid then
+					local suffix = sid:sub(-4)
+					ucic:delete(name, sid)
+					local id = get_next_sid()
+					local cfgid = string.format("cfg%02x%s", id, suffix)
+					local section = ucic:section(name, uciType, cfgid)
+					if section then
+						ucic:tset(name, section, vv)
+						ucic:set(name, section, "switch_enable", switch)
+						add = add + 1
+					end
 				end
 			end
 		end
-		ucic:commit(name)
-		-- 如果原有服务器节点已经不见了就尝试换为第一个节点
-		local globalServer = ucic:get_first(name, 'global', 'global_server', '')
-		if globalServer ~= "nil" then
-			local firstServer = ucic:get_first(name, uciType)
-			if firstServer then
-				if not ucic:get(name, globalServer) then
-					luci.sys.call("/etc/init.d/" .. name .. " stop > /dev/null 2>&1 &")
-					ucic:commit(name)
-					ucic:set(name, ucic:get_first(name, 'global'), 'global_server', firstServer)
-					ucic:commit(name)
-					log('当前主服务器节点已被删除,正在自动更换为第一个节点。')
-					luci.sys.call("/etc/init.d/" .. name .. " start > /dev/null 2>&1 &")
-				else
-					log('维持当前主服务器节点。')
-					luci.sys.call("/etc/init.d/" .. name .. " restart > /dev/null 2>&1 &")
-				end
-			else
-				log('没有服务器节点了,停止服务')
+	end
+	ucic:commit(name)
+	-- 如果原有服务器节点已经不见了就尝试换为第一个节点
+	local globalServer = ucic:get_first(name, 'global', 'global_server', '')
+	if globalServer ~= "nil" then
+		local firstServer = ucic:get_first(name, uciType)
+		if firstServer then
+			if not ucic:get(name, globalServer) then
 				luci.sys.call("/etc/init.d/" .. name .. " stop > /dev/null 2>&1 &")
+				ucic:commit(name)
+				ucic:set(name, ucic:get_first(name, 'global'), 'global_server', firstServer)
+				ucic:commit(name)
+				log('当前主服务器节点已被删除,正在自动更换为第一个节点。')
+				luci.sys.call("/etc/init.d/" .. name .. " start > /dev/null 2>&1 &")
+			else
+				log('维持当前主服务器节点。')
+				luci.sys.call("/etc/init.d/" .. name .. " restart > /dev/null 2>&1 &")
 			end
+		else
+			log('没有服务器节点了,停止服务')
+			luci.sys.call("/etc/init.d/" .. name .. " stop > /dev/null 2>&1 &")
 		end
-		log('新增节点数量: ' .. add .. ', 删除节点数量: ' .. del)
-		log('订阅更新成功')
 	end
+	log('新增节点数量: ' .. add .. ', 删除节点数量: ' .. del)
+	log('订阅更新成功')
 end
 
 if subscribe_url and #subscribe_url > 0 then