├── NodeCreator.py ├── README.md └── logo.ico /NodeCreator.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import messagebox, ttk 3 | import base64 4 | import json 5 | import random 6 | import ipaddress 7 | import sys 8 | import os 9 | import re 10 | import requests 11 | from collections import OrderedDict 12 | import threading 13 | import functools 14 | import time 15 | 16 | 17 | class App: 18 | def __init__(self, window): 19 | self.window = window 20 | self.window.title("NodeCreator") 21 | 22 | # 设置窗口图标为 "logo.ico" 23 | if getattr(sys, 'frozen', False): 24 | logo_path = os.path.join(sys._MEIPASS, 'logo.ico') 25 | else: 26 | logo_path = 'logo.ico' 27 | self.window.iconbitmap(logo_path) 28 | 29 | # 原始节点输入框 30 | self.raw_node_label = tk.Label(window, text="原始节点") 31 | self.raw_node_label.grid(row=0, column=0, sticky='nsew') 32 | self.raw_node_text = tk.Text(window, height=10, undo=True) # 启用撤销功能 33 | self.raw_node_text.grid(row=1, column=0, sticky='nsew') 34 | 35 | # IP列表输入框 36 | self.ip_list_label = tk.Label(window, text="IP列表") 37 | self.ip_list_label.grid(row=2, column=0, sticky='nsew') 38 | self.ip_list_text = tk.Text(window, height=10, undo=True) # 启用撤销功能 39 | self.ip_list_text.grid(row=3, column=0, sticky='nsew') 40 | 41 | # IP类型下拉框 42 | self.ip_type_frame = tk.Frame(window) 43 | self.ip_type_frame.grid(row=4, column=0, sticky='nsew') 44 | self.ip_type_frame.columnconfigure(0, weight=1) 45 | 46 | self.inner_ip_type_frame = tk.Frame(self.ip_type_frame) 47 | self.inner_ip_type_frame.pack(anchor='center') 48 | 49 | self.ip_type_label = tk.Label(self.inner_ip_type_frame, text="IP类型:") 50 | self.ip_type_label.pack(side=tk.LEFT) 51 | 52 | self.ip_type_var = tk.StringVar(window) 53 | self.ip_type_var.set("自定义") 54 | self.ip_type_option = ttk.Combobox(self.inner_ip_type_frame, textvariable=self.ip_type_var, 55 | values=["自定义", "Cloudflare官方", "Cloudflare反代", "Cloudflare官方优选", "Cloudflare反代优选", "cfno1优选IP"], width=17) 56 | self.ip_type_option.pack(side=tk.LEFT) 57 | self.ip_type_option.bind("<>", self.update_ip_list) 58 | 59 | # 下拉框和结果数量输入框 60 | self.option_frame = tk.Frame(window) 61 | self.option_frame.grid(row=5, column=0, sticky='nsew') 62 | 63 | self.inner_option_frame = tk.Frame(self.option_frame) 64 | self.inner_option_frame.pack(anchor='center') 65 | 66 | self.result_num_label = tk.Label(self.inner_option_frame, text="生成方式:") 67 | self.result_num_label.pack(side=tk.LEFT) 68 | 69 | self.order_var = tk.StringVar(window) 70 | self.order_var.set("顺序") 71 | self.order_option = ttk.Combobox(self.inner_option_frame, textvariable=self.order_var, values=["顺序", "随机"], 72 | width=7) 73 | self.order_option.pack(side=tk.LEFT) 74 | 75 | self.result_num_label = tk.Label(self.inner_option_frame, text=" 结果数量:") 76 | self.result_num_label.pack(side=tk.LEFT) 77 | 78 | self.result_num_entry = tk.Entry(self.inner_option_frame, width=10) 79 | self.result_num_entry.pack(side=tk.LEFT) 80 | 81 | self.space_label = tk.Label(self.inner_option_frame, text=" ") 82 | self.space_label.pack(side=tk.LEFT) 83 | 84 | # 生成节点按钮 85 | self.generate_button = tk.Button(self.inner_option_frame, text="点击生成节点", command=self.generate_nodes) 86 | self.generate_button.pack(side=tk.LEFT) 87 | 88 | # 生成节点输出框 89 | self.generated_node_label = tk.Label(window, text="生成节点") 90 | self.generated_node_label.grid(row=6, column=0, sticky='nsew') 91 | self.generated_node_text = tk.Text(window, height=20, state='disabled', undo=True) # 启用撤销功能 92 | self.generated_node_text.grid(row=7, column=0, sticky='nsew') 93 | 94 | # 复制按钮 95 | self.copy_button = tk.Button(window, text="点击复制", command=self.copy_to_clipboard) 96 | self.copy_button.grid(row=8, column=0, sticky='nsew') 97 | 98 | # 设置网格权重 99 | window.grid_rowconfigure(0, weight=1) 100 | window.grid_rowconfigure(1, weight=10) 101 | window.grid_rowconfigure(2, weight=1) 102 | window.grid_rowconfigure(3, weight=10) 103 | window.grid_rowconfigure(4, weight=1) 104 | window.grid_rowconfigure(5, weight=1) 105 | window.grid_rowconfigure(6, weight=1) 106 | window.grid_rowconfigure(7, weight=10) 107 | window.grid_rowconfigure(8, weight=1) 108 | window.grid_columnconfigure(0, weight=1) 109 | 110 | # 绑定撤销快捷键 111 | self.bind_undo_redo() 112 | 113 | # 绑定空格键事件 114 | self.raw_node_text.bind("", self.handle_space_press) 115 | self.ip_list_text.bind("", self.handle_space_press) 116 | self.space_press_count = 0 117 | self.last_space_press_time = 0 118 | 119 | # 缓存IP列表 120 | self.ip_list_cache = {} 121 | 122 | def bind_undo_redo(self): 123 | self.raw_node_text.bind("", lambda event: self.raw_node_text.edit_undo()) 124 | self.raw_node_text.bind("", lambda event: self.raw_node_text.edit_redo()) 125 | self.ip_list_text.bind("", lambda event: self.ip_list_text.edit_undo()) 126 | self.ip_list_text.bind("", lambda event: self.ip_list_text.edit_redo()) 127 | self.result_num_entry.bind("", lambda event: self.result_num_entry.delete(0, tk.END)) # 简单的撤销操作 128 | self.generated_node_text.bind("", lambda event: self.generated_node_text.edit_undo()) 129 | self.generated_node_text.bind("", lambda event: self.generated_node_text.edit_redo()) 130 | 131 | # 绑定粘贴事件,以便在粘贴前后添加撤销堆栈标记 132 | self.raw_node_text.bind("", self.handle_paste) 133 | self.ip_list_text.bind("", self.handle_paste) 134 | 135 | def handle_paste(self, event): 136 | widget = event.widget 137 | widget.edit_separator() # 在粘贴前添加撤销堆栈标记 138 | widget.event_generate("<>") 139 | widget.edit_separator() # 在粘贴后添加撤销堆栈标记 140 | return "break" 141 | 142 | def handle_space_press(self, event): 143 | current_time = time.time() 144 | if current_time - self.last_space_press_time > 2: 145 | self.space_press_count = 1 146 | else: 147 | self.space_press_count += 1 148 | 149 | self.last_space_press_time = current_time 150 | 151 | if self.space_press_count >= 3: 152 | widget = event.widget 153 | cursor_index = widget.index(tk.INSERT) 154 | line_start = widget.index(f"{cursor_index} linestart") 155 | line_end = widget.index(f"{cursor_index} lineend") 156 | current_line = widget.get(line_start, line_end) 157 | 158 | if current_line.startswith("http://") or current_line.startswith("https://"): 159 | self.space_press_count = 0 160 | try: 161 | response = requests.get(current_line.strip()) 162 | response_content = response.text.strip() # 使用 strip() 删除可能的尾随换行符或空格 163 | widget.delete(line_start, line_end) 164 | widget.insert(line_start, response_content) 165 | # 直接设置光标位置到插入内容的末尾 166 | widget.mark_set(tk.INSERT, f"{line_start}+{len(response_content)}c") 167 | except Exception as e: 168 | messagebox.showerror("错误", f"无法获取URL内容: {e}") 169 | 170 | def update_ip_list(self, event): 171 | ip_type = self.ip_type_var.get() 172 | if ip_type in self.ip_list_cache: 173 | cache_data, cache_time = self.ip_list_cache[ip_type] 174 | if time.time() - cache_time < 120: # 缓存有效期为2分钟 175 | self._update_ip_list_ui(cache_data) 176 | return 177 | threading.Thread(target=self._update_ip_list_async, args=(ip_type,)).start() 178 | 179 | def _update_ip_list_async(self, ip_type): 180 | if ip_type == "Cloudflare官方": 181 | ip_list = "173.245.48.0/20\n103.21.244.0/22\n103.22.200.0/22\n103.31.4.0/22\n141.101.64.0/18\n108.162.192.0/18\n190.93.240.0/20\n188.114.96.0/20\n197.234.240.0/22\n198.41.128.0/17\n162.158.0.0/15\n104.16.0.0/13\n104.24.0.0/14\n172.64.0.0/13\n131.0.72.0/22" 182 | elif ip_type == "Cloudflare反代": 183 | try: 184 | response = requests.get("https://ipdb.api.030101.xyz/?type=proxy") 185 | ip_list = response.text 186 | except: 187 | messagebox.showerror("错误", "无法获取IP列表,请手动输入") 188 | return 189 | elif ip_type == "Cloudflare官方优选": 190 | try: 191 | response = requests.get("https://ipdb.api.030101.xyz/?type=bestcf") 192 | ip_list = response.text 193 | except: 194 | messagebox.showerror("错误", "无法获取IP列表,请手动输入") 195 | return 196 | elif ip_type == "Cloudflare反代优选": 197 | try: 198 | response = requests.get("https://ipdb.api.030101.xyz/?type=bestproxy&country=true") 199 | ip_list = response.text 200 | except: 201 | messagebox.showerror("错误", "无法获取IP列表,请手动输入") 202 | return 203 | elif ip_type == "cfno1优选IP": 204 | try: 205 | url = "https://cfno1.pages.dev/sub" 206 | response = requests.get(url) 207 | if response.status_code != 200: 208 | raise Exception(f"Failed to fetch URL: {url}") 209 | 210 | encoded_content = response.text.strip() 211 | decoded_content = base64.b64decode(encoded_content).decode('utf-8') 212 | 213 | pattern = re.compile(r'vless://[^\s@]+@(\d+\.\d+\.\d+\.\d+):(\d+)') 214 | matches = pattern.findall(decoded_content) 215 | 216 | ip_list = "\n".join([f"{ip}:{port} #{self.get_region(ip)}" for ip, port in matches]) 217 | except Exception as e: 218 | messagebox.showerror("错误", f"无法获取IP列表: {e}") 219 | return 220 | else: 221 | ip_list = "" 222 | 223 | self.ip_list_cache[ip_type] = (ip_list, time.time()) 224 | self.window.after(0, self._update_ip_list_ui, ip_list) 225 | 226 | def _update_ip_list_ui(self, ip_list): 227 | self.ip_list_text.delete('1.0', tk.END) 228 | self.ip_list_text.insert(tk.END, ip_list) 229 | 230 | def get_region(self, ip): 231 | try: 232 | response = requests.get(f"http://ip-api.com/json/{ip}?fields=countryCode") 233 | if response.status_code == 200: 234 | data = response.json() 235 | return data.get("countryCode", "Unknown") 236 | else: 237 | return "Unknown" 238 | except Exception as e: 239 | return "Unknown" 240 | 241 | def generate_nodes(self): 242 | raw_node = self.raw_node_text.get("1.0", tk.END).strip() 243 | ip_list_input = self.ip_list_text.get("1.0", tk.END).strip() 244 | 245 | if not raw_node: 246 | messagebox.showerror("错误", "请输入节点") 247 | return 248 | if not ip_list_input: 249 | messagebox.showerror("错误", "请输入IP") 250 | return 251 | 252 | # 检查是否是URL 253 | if ip_list_input.startswith("http://") or ip_list_input.startswith("https://"): 254 | try: 255 | response = requests.get(ip_list_input) 256 | ip_list = response.text.strip().split('\n') 257 | except: 258 | messagebox.showerror("错误", "无法从URL获取IP列表,请检查URL") 259 | return 260 | else: 261 | ip_list = ip_list_input.split('\n') 262 | 263 | if raw_node.startswith("vmess://"): 264 | raw_node = raw_node.replace("vmess://", "").strip() 265 | raw_node = raw_node + '=' * ((4 - len(raw_node) % 4) % 4) 266 | protocol = "vmess" 267 | elif raw_node.startswith("vless://"): 268 | protocol = "vless" 269 | else: 270 | messagebox.showerror("错误", "只支持vmess和vless节点") 271 | return 272 | 273 | order = self.order_var.get() 274 | try: 275 | result_num = int(self.result_num_entry.get()) 276 | if result_num <= 0: 277 | raise ValueError 278 | except ValueError: 279 | messagebox.showerror("错误", "结果数量必须是正整数") 280 | return 281 | 282 | expanded_ip_list = [] 283 | for ip in ip_list: 284 | ip = ip.split('#')[0].strip() # 忽略#及其之后的内容 285 | if not ip: 286 | continue 287 | if '*' in ip: 288 | for i in range(256): 289 | expanded_ip_list.append(ip.replace('*', str(i))) 290 | elif '/' in ip: 291 | for ip in ipaddress.IPv4Network(ip): 292 | expanded_ip_list.append(str(ip)) 293 | else: 294 | expanded_ip_list.append(ip) 295 | 296 | # 如果结果数量大于IP列表中的IP总数,则使用IP列表中的IP总数 297 | if result_num > len(expanded_ip_list): 298 | result_num = len(expanded_ip_list) 299 | 300 | try: 301 | if protocol == "vmess": 302 | raw_node = base64.b64decode(raw_node).decode('utf-8') 303 | node = json.loads(raw_node) 304 | original_add = node['add'] 305 | original_port = node['port'] 306 | 307 | generated_nodes = OrderedDict() 308 | for i in range(result_num): 309 | if order == '顺序': 310 | ip_port = expanded_ip_list[i % len(expanded_ip_list)] 311 | else: 312 | ip_port = random.choice(expanded_ip_list) 313 | 314 | ip, port = (ip_port.split(':') + [original_port])[:2] # 分离IP和端口,如果没有端口则使用原来的端口 315 | if ip.count(':') >= 2: # 判断是否是IPv6地址 316 | ip = '[' + ip + ']' 317 | node['add'] = ip 318 | node['port'] = port 319 | if not node.get('host'): 320 | node['host'] = original_add 321 | node_key = "vmess://" + base64.b64encode(json.dumps(node).encode('utf-8')).decode('utf-8') 322 | generated_nodes[node_key] = None 323 | 324 | elif protocol == "vless": 325 | pattern = re.compile(r'@([^:]+):(\d+)') 326 | match = pattern.search(raw_node) 327 | if not match: 328 | messagebox.showerror("错误", "无法解析vless节点中的address和端口") 329 | return 330 | original_address, original_port = match.groups() 331 | 332 | generated_nodes = OrderedDict() 333 | for i in range(result_num): 334 | if order == '顺序': 335 | ip_port_region = expanded_ip_list[i % len(expanded_ip_list)] 336 | else: 337 | ip_port_region = random.choice(expanded_ip_list) 338 | 339 | ip_port, region = (ip_port_region.split('#') + [''])[:2] # 分离IP和地区 340 | ip, port = (ip_port.split(':') + [original_port])[:2] # 分离IP和端口,如果没有端口则使用原来的端口 341 | if ip.count(':') >= 2: # 判断是否是IPv6地址 342 | ip = '[' + ip + ']' 343 | node_key = raw_node.replace(f"{original_address}:{original_port}", f"{ip}:{port}") 344 | if region: 345 | node_key += f" #{region}" 346 | generated_nodes[node_key] = None 347 | 348 | self.generated_node_text.config(state='normal') 349 | self.generated_node_text.delete('1.0', tk.END) 350 | for node in generated_nodes.keys(): 351 | self.generated_node_text.insert(tk.END, node + '\n') 352 | self.generated_node_text.config(state='disabled') 353 | except Exception as e: 354 | messagebox.showerror("错误", f"生成节点时出错: {e}") 355 | 356 | def copy_to_clipboard(self): 357 | generated_nodes = self.generated_node_text.get("1.0", tk.END).strip() 358 | if generated_nodes: 359 | self.window.clipboard_clear() 360 | self.window.clipboard_append(generated_nodes) 361 | node_count = len(generated_nodes.split('\n')) 362 | messagebox.showinfo("提示", f"已复制{node_count}条") 363 | else: 364 | messagebox.showerror("错误", "没有生成的节点可以复制") 365 | 366 | 367 | if __name__ == "__main__": 368 | root = tk.Tk() 369 | app = App(root) 370 | root.mainloop() 371 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NodeCreator 2 | 支持通过优选IP批量创建节点的本地工具 3 | 4 | ## 更新说明 5 | ### v0.6.0: 6 | - 增加缓存机制 7 | - 三击空格间隔须在2s内 8 | 9 | ### v0.5.0: 10 | - 增加cfno1优选IP 11 | - 支持优选IP+端口 12 | - 支持从URL解析到IP列表 13 | - 支持三击空格显示URL解析结果 14 | - 支持ctrl+z撤回 15 | - 提高响应速度 16 | - 自动去除IP中的注释 17 | 18 | ### v0.4.0: 19 | - 可以通过优选之后的节点生成优选节点 20 | 21 | ### v0.3.0: 22 | - 版本内置CF官方、CF反代、CF官方优选以及CF反代优选IP 23 | 24 | ## 使用方法 25 | 26 | 1. 点击右侧Releases,下载NodeCreator.exe 27 | 2. 双击运行 28 | 29 |     ![90526e98365fbfe1ef04c.png](https://img.checo.cc/file/90526e98365fbfe1ef04c.png) 30 | 31 | 3. 输入原始节点(必须已经套了CDN) 32 | 33 |     ![9628e4dd2284a7ef171c9.png](https://img.checo.cc/file/9628e4dd2284a7ef171c9.png) 34 | 35 | 4. 输入优选IP或反代优选IP优选域名(支持`IP+端口`以及`域名+端口`) 36 | 37 | 4.1 手动输入优选IP或优选域名 38 | 39 | ![a31a041c2647af103c984.png](https://img.checo.cc/file/a31a041c2647af103c984.png) 40 | 41 | 4.2 在下拉框中选择优选IP 42 | 43 | ![58f56f6bcea4ffb270513.png](https://img.checo.cc/file/58f56f6bcea4ffb270513.png) 44 | 45 | 4.3 输入URL自动获取优选IP 46 | 47 | ![24dd920cf59edbeca8aec.png](https://img.checo.cc/file/24dd920cf59edbeca8aec.png) 48 | 49 | 5. 选择随机或顺序并输入生成节点数量 50 | 51 |     ![image.png](https://cdn.nlark.com/yuque/0/2024/png/35591949/1710678807420-28cfa073-38e7-4273-9770-3403ce03d5e2.png#averageHue=%23eedfde&clientId=u651cf9be-a887-4&from=paste&height=52&id=u2a2e3bcd&originHeight=65&originWidth=699&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=9786&status=done&style=none&taskId=u715de2f4-afdc-41bf-ac5c-f48d96d8cb2&title=&width=559.2) 52 | 53 | 6. 点击生成节点 54 | 55 | 7. 点击复制按钮即可一键复制 56 | 57 | 8. 将节点导入代理工具即可使用 58 | 59 |     ![image.png](https://cdn.nlark.com/yuque/0/2024/png/35591949/1710679025271-e59bfd90-03ca-4c0c-a606-2f0105dc0872.png#averageHue=%23f6f5f5&clientId=u651cf9be-a887-4&from=paste&height=498&id=ub431f39d&originHeight=622&originWidth=2560&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=58553&status=done&style=none&taskId=u5da51605-482d-4a4a-acf6-78c44a41709&title=&width=2048) 60 | 61 | ## 编译方法(Windows) 62 | ``` 63 | python -m PyInstaller --onefile --windowed --icon=C:\Users\Administrator\Desktop\1\logo.ico --add-data C:\Users\Administrator\Desktop\1\logo.ico;. C:\Users\Administrator\Desktop\1\NodeCreator.py 64 | ``` 65 | 66 | ## 更新计划 67 | 68 | - [x] 增加预置优选IP 69 | - [x] 增加vless节点 70 | - [ ] 增加web版 71 | - [x] 解决复制1条的问题 72 | - [ ] 结果可保存 73 | - [ ] 支持mac 74 | - [x] 支持通过优选节点生成优选节点 75 | - [x] 支持优选IP+端口 76 | - [x] 支持Ctrl+Z撤回 77 | - [x] 支持在IP列表直接输入URL 78 | - [x] 支持连击空格将URL转换为IP列表 79 | - [x] 自动去除IP列表中的#以及#之后的内容 80 | - [x] 提高加载速度 81 | - [x] 增加缓存,缓存已加载数据 82 | - [ ] 增加缓存更新机制 83 | - [ ] 增加启动之后自动预加载 84 | - [ ] 优化UI 85 | - [ ] 增加滚动条 86 | 87 | ## 致谢 88 | 感谢https://github.com/ymyuuu/IPDB 这一项目提供的IP数据 89 | 90 | 感谢[CF NO.1频道](https://t.me/cf_no1) 提供的优选反代IP 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergioperezcheco/NodeCreator/ca97d0c287525065844c4eb19c2445d9c4305fb9/logo.ico --------------------------------------------------------------------------------