diff --git a/8.py b/8.py index 85a1d18..542b5d4 100644 --- a/8.py +++ b/8.py @@ -1,678 +1,734 @@ -import tkinter as tk -from tkinter import ttk, filedialog, messagebox -import requests -import json -import os -import logging -from datetime import datetime -import re -import time - -# --- 日志与错误报告功能 --- - -LOG_FILENAME = 'api_runner_log.txt' -logging.basicConfig(filename=LOG_FILENAME, level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s') - -def log_error_report(message, api_data=None): - """记录错误并生成详细的错误报告文件.""" - error_time = datetime.now().strftime("%Y%m%d_%H%M%S") - report_filename = f'ERROR_REPORT_{error_time}.txt' - - logging.error(message) - - with open(report_filename, 'w', encoding='utf-8') as f: - f.write(f"--- API Runner 错误报告 ---\n") - f.write(f"时间戳: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") - f.write(f"错误信息: {message}\n") - f.write(f"--- 当前 API 配置 ---\n") - if api_data: - f.write(f"URL: {api_data.get('url', 'N/A')}\n") - f.write(f"Webapp ID: {api_data.get('webappId', 'N/A')}\n") - f.write(f"API Key: {api_data.get('apiKey', 'N/A')[:4]}...\n") - f.write(f"------------------------------\n") - - return f"操作失败。已生成错误报告文件:{report_filename}" - -# --- Tkinter GUI 应用类 --- - -class APIRunnerApp: - def __init__(self, master): - self.master = master - self.master.title("API Runner - 未加载配置") - - self.current_directory = os.getcwd() - self.scanned_assets = {'image': [], 'video': [], 'json_config': []} - self.request_payloads = [] - - self.config_filepath_history = {} - self.last_loaded_config_path = None - self.prompts = [] - - self.API_DATA = {} - self.INTERFACE_INFO = [] - self.BASE_HEADERS = {"Content-Type": "application/json"} - - self.value_vars = {} - self.file_vars = {} - self.api_info_labels = {} - - # 重试/超时设置变量 - self.upload_timeout = tk.IntVar(value=60) - self.retry_interval = tk.IntVar(value=60) - self.max_retries = tk.IntVar(value=6) - - # 新增:批次模式变量 (包含所有可能性) - self.BATCH_MODE_OPTIONS = [ - "M0: 默认单请求模式", - "M1: 多图单提示词/视频", - "M2: 多视频单提示词/图片", - "M3: 纯多提示词批量", - "M4: 多图多提示词 1:1 顺序匹配", - "M6: 单图多提示词", - "M7a: 多图滑窗 (2图/1步, [001,002],[002,003]...)", - "M7b: 多图滑窗 (3图/2步, [001,002,003],[003,004,005]...)", - "M5: (危险) 笛卡尔积/全组合" - ] - self.batch_mode_var = tk.StringVar(value=self.BATCH_MODE_OPTIONS[0]) - - self.create_widgets() - - self.update_log_display("请点击 '导入新配置' 或从下拉菜单选择文件来启动应用。", level='WARNING') - - def create_widgets(self): - main_frame = ttk.Frame(self.master) - main_frame.pack(pady=10, padx=10, expand=True, fill="both") - - self.notebook = ttk.Notebook(main_frame) - self.notebook.pack(expand=True, fill="both") - - self.config_frame = ttk.Frame(self.notebook); self.notebook.add(self.config_frame, text="配置与扫描") - self.editor_frame = ttk.Frame(self.notebook); self.notebook.add(self.editor_frame, text="接口值编辑") - - self._build_config_tab() - self._build_editor_tab() - - # 运行按钮和设置区域 - run_control_frame = ttk.Frame(main_frame) - run_control_frame.pack(fill='x', pady=10) - - self.run_btn = ttk.Button(run_control_frame, text="🚀 运行 API 请求", command=self.run_api_requests, state='disabled') - self.run_btn.pack(side='left', padx=(0, 10)) - - settings_frame = ttk.LabelFrame(run_control_frame, text="运行/重试设置") - settings_frame.pack(side='left', fill='x', expand=True) - self.build_settings_widgets(settings_frame) - - # 日志区域放置在最下方 - self.log_frame = ttk.LabelFrame(main_frame, text="运行日志 (Run Log)") - self.log_frame.pack(fill="x", pady=(0, 5)) - self._build_log_display() - - def build_settings_widgets(self, parent_frame): - """构建重试/超时设置控件.""" - ttk.Label(parent_frame, text="上传超时(s):").pack(side='left', padx=(5, 2)) - ttk.Entry(parent_frame, textvariable=self.upload_timeout, width=5).pack(side='left', padx=(0, 5)) - - ttk.Label(parent_frame, text="重试频率(s):").pack(side='left', padx=(5, 2)) - ttk.Entry(parent_frame, textvariable=self.retry_interval, width=5).pack(side='left', padx=(0, 5)) - - ttk.Label(parent_frame, text="最大重试次数:").pack(side='left', padx=(5, 2)) - ttk.Entry(parent_frame, textvariable=self.max_retries, width=5).pack(side='left', padx=(0, 5)) - - - def _build_config_tab(self): - load_frame = ttk.LabelFrame(self.config_frame, text="API 配置加载") - load_frame.pack(fill="x", padx=5, pady=5) - - self.config_combobox = ttk.Combobox(load_frame, values=list(self.config_filepath_history.keys()), state='readonly', width=30) - self.config_combobox.pack(side='left', padx=5, pady=5) - self.config_combobox.bind("<>", self.load_config_from_combobox) - - ttk.Button(load_frame, text="📂 导入新配置", command=self.select_and_load_config).pack(side='left', padx=5, pady=5) - self.config_file_label = ttk.Label(load_frame, text="当前文件: 无") - self.config_file_label.pack(side='left', padx=10) - - info_frame = ttk.LabelFrame(self.config_frame, text="当前 API 信息") - info_frame.pack(fill="x", padx=5, pady=5) - self.api_info_labels['url'] = ttk.Label(info_frame, text="URL: N/A"); self.api_info_labels['url'].pack(anchor="w", padx=5) - self.api_info_labels['webappId'] = ttk.Label(info_frame, text="Webapp ID: N/A"); self.api_info_labels['webappId'].pack(anchor="w", padx=5) - self.api_info_labels['apiKey'] = ttk.Label(info_frame, text="API Key: N/A"); self.api_info_labels['apiKey'].pack(anchor="w", padx=5) - - scan_frame = ttk.LabelFrame(self.config_frame, text="本地文件管理") - scan_frame.pack(fill="x", padx=5, pady=10) - - self.dir_label_var = tk.StringVar(value=self.current_directory) - ttk.Label(scan_frame, text="当前目录:").pack(anchor="w", padx=5, pady=2) - ttk.Label(scan_frame, textvariable=self.dir_label_var, foreground="blue").pack(anchor="w", padx=5) - - btn_frame = ttk.Frame(scan_frame) - btn_frame.pack(fill="x", pady=5) - ttk.Button(btn_frame, text="更改目录", command=self.change_directory).pack(side="left", padx=5) - ttk.Button(btn_frame, text="重新扫描文件", command=self.scan_files_and_update_status).pack(side="left", padx=5) - - self.scan_status_label = ttk.Label(scan_frame, text="文件扫描状态: 未运行") - self.scan_status_label.pack(anchor="w", padx=5, pady=5) - - self.match_status_label = ttk.Label(scan_frame, text="匹配模式: 未生成请求") - self.match_status_label.pack(anchor="w", padx=5, pady=5) - - ttk.Button(self.config_frame, text="📝 生成请求负载 (查看匹配模式)", command=self.generate_payloads).pack(pady=10) - - - def _build_editor_tab(self): - """动态构建或更新接口值编辑面板,新增文件选择区域.""" - for widget in self.editor_frame.winfo_children(): - widget.destroy() - - if not self.API_DATA: - ttk.Label(self.editor_frame, text="请先加载 API 配置。").pack(padx=20, pady=20) - return - - # 顶部:单个请求参数配置 - top_frame = ttk.LabelFrame(self.editor_frame, text="单个请求参数配置 (作为批处理的默认值)") - top_frame.pack(fill="x", padx=5, pady=5) - - canvas = tk.Canvas(top_frame, height=150) - scrollbar = ttk.Scrollbar(top_frame, orient="vertical", command=canvas.yview) - self.interface_list_frame = ttk.Frame(canvas) - - canvas.create_window((0, 0), window=self.interface_list_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - canvas.pack(side="left", fill="x", expand=True) - scrollbar.pack(side="right", fill="y") - - self.interface_list_frame.bind("", - lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - - self.value_vars = {} - self.file_vars = {} - for info in self.INTERFACE_INFO: - row_frame = ttk.Frame(self.interface_list_frame) - row_frame.pack(fill="x", pady=3, padx=5) - - ttk.Label(row_frame, text=f"[{info['code']}] {info['name']}:", width=15, anchor="w").pack(side="left", padx=5) - ttk.Label(row_frame, text=f"({info['type']})", width=8).pack(side="left") - - if info['type'] in ("value", "text"): - var = tk.StringVar(value=info['default_value']) - self.value_vars[info['code']] = var - editor = ttk.Entry(row_frame, textvariable=var, width=50) - editor.pack(side="right", expand=True, fill="x", padx=5) - - elif info['type'] in ("image", "video"): - var = tk.StringVar(value=info['default_value']) - self.file_vars[info['code']] = var - ttk.Entry(row_frame, textvariable=var, state='readonly', width=45).pack(side="right", expand=True, fill="x", padx=5) - ttk.Label(row_frame, text="(下方选择文件)").pack(side="right", padx=5) - - - # 底部:批量文件选择区域 - batch_frame = ttk.LabelFrame(self.editor_frame, text="批量文件与模式选择(Ctrl/Shift 多选)") - batch_frame.pack(fill="both", expand=True, padx=5, pady=5) - - # 批次模式选择 - mode_config_frame = ttk.LabelFrame(batch_frame, text="批量模式选择") - mode_config_frame.pack(fill='x', padx=5, pady=5) - - ttk.Label(mode_config_frame, text="批量模式:").pack(side='left', padx=5) - self.mode_combobox = ttk.Combobox(mode_config_frame, textvariable=self.batch_mode_var, values=self.BATCH_MODE_OPTIONS, state='readonly', width=70) - self.mode_combobox.pack(side='left', padx=5, fill='x', expand=True) - - # 文件列表 - listbox_container = ttk.Frame(batch_frame) - listbox_container.pack(fill="both", expand=True) - - self.image_listbox, _ = self._create_file_listbox(listbox_container, "图片文件", self.scanned_assets['image'], 'extended') - self.video_listbox, _ = self._create_file_listbox(listbox_container, "视频文件", self.scanned_assets['video'], 'extended') - self.json_listbox, _ = self._create_file_listbox(listbox_container, "JSON 提示词/配置", self.scanned_assets['json_config'], 'extended') - - def _create_file_listbox(self, parent, title, file_list, selectmode): - """创建带有滚动条的文件列表框.""" - frame = ttk.LabelFrame(parent, text=f"{title} ({len(file_list)}个)") - frame.pack(side='left', padx=5, pady=5, fill='both', expand=True) - - listbox_frame = ttk.Frame(frame) - listbox_frame.pack(fill='both', expand=True) - - scrollbar = ttk.Scrollbar(listbox_frame, orient=tk.VERTICAL) - listbox = tk.Listbox(listbox_frame, selectmode=selectmode, height=8, yscrollcommand=scrollbar.set, exportselection=False) - scrollbar.config(command=listbox.yview) - - scrollbar.pack(side='right', fill='y') - listbox.pack(side='left', fill='both', expand=True) - - for filename in file_list: - listbox.insert(tk.END, filename) - - return listbox, None - - def _build_log_display(self): - """构建日志输出区域 (现在放在主面板下方)""" - self.log_text = tk.Text(self.log_frame, height=8, state='disabled', wrap='word', font=("Consolas", 10)) - self.log_text.pack(expand=True, fill="both", padx=5, pady=5) - - self.log_text.tag_config('INFO', foreground='black') - self.log_text.tag_config('WARNING', foreground='orange') - self.log_text.tag_config('ERROR', foreground='red') - self.log_text.tag_config('SUCCESS', foreground='green') - - def load_config_from_combobox(self, event): - """从下拉菜单选择文件时触发加载.""" - selected_file = self.config_combobox.get() - if selected_file and selected_file in self.config_filepath_history: - filepath = self.config_filepath_history[selected_file] - self.load_config_from_file(filepath, add_to_history=False) - - def select_and_load_config(self): - filepath = filedialog.askopenfilename( - defaultextension=".txt", - filetypes=[("API Config Files", "*.txt *.json"), ("All Files", "*.*")] - ) - if filepath: - self.load_config_from_file(filepath, add_to_history=True) - - def update_config_history_gui(self): - """更新下拉菜单的内容和当前显示值.""" - self.config_combobox['values'] = list(self.config_filepath_history.keys()) - if self.last_loaded_config_path: - filename = os.path.basename(self.last_loaded_config_path) - self.config_combobox.set(filename) - - def load_config_from_file(self, filepath, add_to_history=True): - """加载 API 配置,支持 curl 文件解析,并更新历史记录。 (逻辑不变)""" - filename = os.path.basename(filepath) - self.update_log_display(f"尝试从文件加载配置: {filename}", level='INFO') - - try: - with open(filepath, 'r', encoding='utf-8') as f: - file_content = f.read() - - config = None - try: - config = json.loads(file_content) - except json.JSONDecodeError: - url_match = re.search(r'(?:POST|GET|PUT)\s+[\'"](https?:\/\/[^\'"]+)[\'"]', file_content) - api_url = url_match.group(1) if url_match else None - json_body_match = re.search(r'(?:--data-raw|--data)\s+[\'"]\s*(\{.*\})\s*[\'"]', file_content, re.DOTALL) - if not api_url or not json_body_match: - raise ValueError("未在文件中找到有效的 API URL 和/或 JSON 请求主体。") - json_string = json_body_match.group(1) - body_data = json.loads(json_string) - - config = { - "url": api_url, "webappId": body_data.get('webappId'), "apiKey": body_data.get('apiKey'), "nodeInfoList": body_data.get('nodeInfoList') - } - - required_keys = ['url', 'webappId', 'apiKey', 'nodeInfoList'] - if not all(key in config and config[key] for key in required_keys): - raise ValueError("解析后的配置信息中缺少必要的字段。") - - self.API_DATA = config - self.INTERFACE_INFO = [ - { - "code": node['nodeId'], - "name": node['description'], - "type": node['fieldName'], - "default_value": node.get('fieldValue', '') - } - for node in config['nodeInfoList'] - ] - - if add_to_history: - self.config_filepath_history[filename] = filepath - self.last_loaded_config_path = filepath - self.update_config_history_gui() - - self.master.title(f"API Runner - {filename}") - self.config_file_label.config(text=f"当前文件: {filename}") - self.api_info_labels['url'].config(text=f"URL: {self.API_DATA['url']}") - self.api_info_labels['webappId'].config(text=f"Webapp ID: {self.API_DATA['webappId']}") - key_display = f"{self.API_DATA['apiKey'][:4]}...{self.API_DATA['apiKey'][-4:]}" if self.API_DATA.get('apiKey') else 'N/A' - self.api_info_labels['apiKey'].config(text=f"API Key: {key_display}") - - self._build_editor_tab() - self.run_btn.config(state='normal') - - self.update_log_display(f"成功加载并解析配置:{filename}", level='SUCCESS') - self.scan_files_and_update_status() - - except Exception as e: - msg = f"加载文件时发生错误: {e}" - messagebox.showerror("加载错误", msg) - self.update_log_display(msg, level='ERROR') - - def scan_files_and_update_status(self): - """扫描当前目录下的文件并更新状态,重建 Listbox. (逻辑不变)""" - self.update_log_display("开始扫描当前目录下的文件...") - - try: - files = os.listdir(self.current_directory) - except FileNotFoundError: - self.update_log_display("错误: 当前目录不存在。", level='ERROR') - return - - self.scanned_assets['image'] = [f for f in files if f.lower().endswith(('.png', '.jpg', '.jpeg'))] - self.scanned_assets['video'] = [f for f in files if f.lower().endswith(('.mp4', '.mov', '.avi', '.webm'))] - self.scanned_assets['json_config'] = [f for f in files if f.lower().endswith('.json')] - - if hasattr(self, 'editor_frame'): - self._build_editor_tab() - - status_msg = (f"图片: {len(self.scanned_assets['image'])}, " - f"视频: {len(self.scanned_assets['video'])}, " - f"JSON配置: {len(self.scanned_assets['json_config'])}") - self.scan_status_label.config(text=f"文件扫描状态: {status_msg}") - self.update_log_display("文件扫描完成。", level='INFO') - - def extract_prompts_from_json(self, json_filenames): - """从选定的 JSON 文件中提取提示词列表,支持多种格式. (逻辑不变)""" - self.prompts = [] - text_id = next((info['code'] for info in self.INTERFACE_INFO if info['type'] == 'text'), None) - if not text_id: return - - for filename in json_filenames: - filepath = os.path.join(self.current_directory, filename) - try: - with open(filepath, 'r', encoding='utf-8') as f: - data = json.load(f) - - # 1. [{"prompt": "..."}] 格式 - if isinstance(data, list) and all(isinstance(item, dict) and 'prompt' in item for item in data): - self.prompts.extend([item['prompt'] for item in data if item.get('prompt')]) - continue - - # 2. nodeInfoList 格式 (如 API 配置) - if isinstance(data, dict): - data = [data] - - if isinstance(data, list): - for payload in data: - for node in payload.get('nodeInfoList', []): - if node.get('nodeId') == text_id and node.get('fieldValue'): - self.prompts.append(node['fieldValue']) - - # 3. 纯字符串列表格式 - if isinstance(data, list) and all(isinstance(item, str) for item in data): - self.prompts.extend(data) - - except Exception as e: - self.update_log_display(f"错误: 解析 JSON 文件 {filename} 失败: {e}", level='ERROR') - - self.prompts = list(filter(None, self.prompts)) - - def _get_base_payload_nodes(self, image_id, video_id, text_id): - """获取所有字段的默认值,排除将被批量替换的字段。 (逻辑不变)""" - base_nodes = [] - for info in self.INTERFACE_INFO: - node_id = info['code'] - field_value = self.value_vars.get(node_id).get() if self.value_vars.get(node_id) and self.value_vars.get(node_id).get() != '' else info['default_value'] - - if node_id not in [image_id, video_id, text_id] and field_value is not None: - base_nodes.append({ - "nodeId": node_id, - "fieldName": info['type'], - "fieldValue": field_value, - "description": info['name'] - }) - return base_nodes - - def _create_payload(self, base_nodes, text_id=None, text_val=None, image_id=None, image_val=None, video_id=None, video_val=None): - """创建一个单独的请求负载. (逻辑不变)""" - final_nodes = list(base_nodes) - - default_text_val = self.value_vars.get(text_id).get() if text_id and self.value_vars.get(text_id) else next((info['default_value'] for info in self.INTERFACE_INFO if info['code'] == text_id), None) - default_image_val = self.file_vars.get(image_id).get() if image_id and self.file_vars.get(image_id) else next((info['default_value'] for info in self.INTERFACE_INFO if info['code'] == image_id), None) - default_video_val = self.file_vars.get(video_id).get() if video_id and self.file_vars.get(video_id) else next((info['default_value'] for info in self.INTERFACE_INFO if info['code'] == video_id), None) - - def append_node(node_id, node_type, description, value, default_value): - if node_id: - final_nodes.append({ - "nodeId": node_id, - "fieldName": node_type, - "fieldValue": value if value is not None else default_value, - "description": description - }) - - text_info = next((info for info in self.INTERFACE_INFO if info['code'] == text_id), None) - if text_info: append_node(text_id, text_info['type'], text_info['name'], text_val, default_text_val) - - image_info = next((info for info in self.INTERFACE_INFO if info['code'] == image_id), None) - if image_info: append_node(image_id, image_info['type'], image_info['name'], image_val, default_image_val) - - video_info = next((info for info in self.INTERFACE_INFO if info['code'] == video_id), None) - if video_info: append_node(video_id, video_info['type'], video_info['name'], video_val, default_video_val) - - return { - "webappId": self.API_DATA['webappId'], - "apiKey": self.API_DATA['apiKey'], - "nodeInfoList": final_nodes - } - - def _create_single_payload(self): - """创建单个请求负载,使用顶部面板的用户输入。 (逻辑不变)""" - node_info_list = [] - for info in self.INTERFACE_INFO: - node_id = info['code'] - field_value = self.value_vars.get(node_id).get() if self.value_vars.get(node_id) else info['default_value'] - - node_info_list.append({ - "nodeId": node_id, - "fieldName": info['type'], - "fieldValue": field_value, - "description": info['name'] - }) - - return { - "webappId": self.API_DATA['webappId'], - "apiKey": self.API_DATA['apiKey'], - "nodeInfoList": node_info_list - } - - - def generate_payloads(self): - """根据用户在 Editor Tab 中的选择和当前模式,生成请求负载列表.""" - if not self.API_DATA: - messagebox.showerror("错误", "请先加载 API 配置。") - return - - # 1. 获取选中的文件 (已排序) - selected_images = sorted([self.image_listbox.get(i) for i in self.image_listbox.curselection()]) - selected_videos = sorted([self.video_listbox.get(i) for i in self.video_listbox.curselection()]) - selected_jsons = [self.json_listbox.get(i) for i in self.json_listbox.curselection()] - - # 2. 提取提示词 - self.extract_prompts_from_json(selected_jsons) - prompts = self.prompts - - N_img, N_vid, N_prompt = len(selected_images), len(selected_videos), len(prompts) - - # 3. 确定输入字段的 Node ID - text_id = next((info['code'] for info in self.INTERFACE_INFO if info['type'] == 'text'), None) - image_id = next((info['code'] for info in self.INTERFACE_INFO if info['type'] == 'image'), None) - video_id = next((info['code'] for info in self.INTERFACE_INFO if info['type'] == 'video'), None) - - # 4. 自动推荐最合理的模式 (仅在生成时更新 Combobox,让用户覆盖) - current_mode = self.batch_mode_var.get() - - if N_img > 1 and N_prompt > 1 and N_img == N_prompt: - self.batch_mode_var.set("M4: 多图多提示词 1:1 顺序匹配") - elif N_img == 1 and N_prompt > 1: - self.batch_mode_var.set("M6: 单图多提示词") - elif N_img > 1 and N_prompt <= 1 and N_vid <= 1: - # 如果用户之前选择滑窗,则保留滑窗模式 - if "滑窗" not in current_mode: - self.batch_mode_var.set("M1: 多图单提示词/视频") - elif N_vid > 1 and N_prompt <= 1 and N_img <= 1: - self.batch_mode_var.set("M2: 多视频单提示词/图片") - elif N_prompt > 1 and N_img <= 1 and N_vid <= 1: - self.batch_mode_var.set("M3: 纯多提示词批量") - else: - self.batch_mode_var.set("M0: 默认单请求模式") - - final_mode = self.batch_mode_var.get() - self.update_log_display(f"已根据输入自动推荐模式,当前执行模式: {final_mode}", level='INFO') - - # 5. 根据最终模式执行请求生成 - self.request_payloads = [] - base_payload_nodes = self._get_base_payload_nodes(image_id, video_id, text_id) - - # 获取用于批处理的文本/图片/视频默认值 (如果未被批处理文件覆盖) - prompt_default = prompts[0] if N_prompt == 1 else (self.value_vars.get(text_id).get() if text_id and self.value_vars.get(text_id) else None) - image_default = selected_images[0] if N_img == 1 else (self.file_vars.get(image_id).get() if image_id and self.file_vars.get(image_id) else None) - video_default = selected_videos[0] if N_vid == 1 else (self.file_vars.get(video_id).get() if video_id and self.file_vars.get(video_id) else None) - - # M0/M3/M6 纯文本或单图+多文本 - if final_mode.startswith("M0") or final_mode.startswith("M3") or final_mode.startswith("M6"): - items = prompts if N_prompt > 1 else [prompt_default] - - for prompt in items: - self.request_payloads.append(self._create_payload( - base_payload_nodes, text_id, prompt, image_id, image_default, video_id, video_default - )) - if final_mode.startswith("M0"): # M0 模式只取第一个 - self.request_payloads = self.request_payloads[:1] - - # M1/M4/M7 多图模式 (需要处理滑窗) - elif final_mode.startswith("M1") or final_mode.startswith("M4") or final_mode.startswith("M7"): - - # M4: 多图多提示词 1:1 - if final_mode.startswith("M4"): - for img, prompt in zip(selected_images, prompts): - self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, prompt, image_id, img, video_id, video_default)) - - # M7a/M7b: 滑窗 - elif final_mode.startswith("M7a") or final_mode.startswith("M7b"): - window_size, step_size = (2, 1) if "M7a" in final_mode else (3, 2) - - i = 0 - while i + window_size <= N_img: - window_files = selected_images[i : i + window_size] - image_value = ",".join(window_files) - - self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, prompt_default, image_id, image_value, video_id, video_default)) - i += step_size - - # M1: 多图单提示词 - elif final_mode.startswith("M1"): - for img in selected_images: - self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, prompt_default, image_id, img, video_id, video_default)) - - # M2 多视频模式 - elif final_mode.startswith("M2"): - for vid in selected_videos: - self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, prompt_default, image_id, image_default, video_id, vid)) - - # M5 笛卡尔积 - elif final_mode.startswith("M5"): - if N_img == 0 or N_prompt == 0: - messagebox.showwarning("警告", "笛卡尔积模式要求同时选中多个图片和多个提示词。已回退到单请求模式。") - self.request_payloads = [self._create_single_payload()] - else: - for img in selected_images: - for prompt in prompts: - self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, prompt, image_id, img, video_id, video_default)) - - # 兜底或错误处理 - if not self.request_payloads: - self.request_payloads = [self._create_single_payload()] - final_mode = "M0: 默认单请求模式 (兜底)" - - num_payloads = len(self.request_payloads) - self.match_status_label.config(text=f"匹配模式: **{final_mode}** ({num_payloads} 个负载)") - self.update_log_display(f"成功生成 {num_payloads} 个 API 请求负载。模式: {final_mode}", level='SUCCESS') - - - # --- API 运行与重试逻辑 (保持不变) --- - - def run_api_requests(self): - """执行 API 调用,实现重试机制.""" - if not self.request_payloads or not self.API_DATA: - messagebox.showerror("错误", "请先加载配置并生成请求负载。") - return - - try: - max_retries = int(self.max_retries.get()) - retry_interval = int(self.retry_interval.get()) - timeout = int(self.upload_timeout.get()) - except ValueError: - messagebox.showerror("错误", "重试/超时设置必须是整数。") - return - - self.update_log_display(f"--- 开始执行 {len(self.request_payloads)} 个 API 请求 ---", level='INFO') - self.update_log_display(f"设置: 超时={timeout}s, 频率={retry_interval}s, 最大重试={max_retries}次", level='INFO') - self.run_btn.config(state='disabled') - - api_url = self.API_DATA['url'] - - for i, payload in enumerate(self.request_payloads): - batch_id = i + 1 - - for attempt in range(max_retries + 1): - try: - self.update_log_display(f"批次 {batch_id}/{len(self.request_payloads)}: 尝试第 {attempt + 1}/{max_retries + 1} 次...", level='INFO') - - response = requests.post(api_url, headers=self.BASE_HEADERS, json=payload, timeout=timeout) - response.raise_for_status() - - response_json = response.json() - - if response_json.get('success', True) or response.status_code == 200: - task_id = response_json.get('taskId', 'N/A') - self.update_log_display(f"批次 {batch_id} 成功!Task ID: {task_id}", level='SUCCESS') - break - else: - error_msg = response_json.get('message', '未知业务错误') - raise Exception(f"API 返回业务错误: {error_msg}") - - except requests.exceptions.Timeout: - if attempt < max_retries: - self.update_log_display(f"批次 {batch_id} 超时,将在 {retry_interval} 秒后重试。", level='WARNING') - time.sleep(retry_interval) - else: - raise - except requests.exceptions.RequestException as err: - if attempt < max_retries: - self.update_log_display(f"批次 {batch_id} 连接错误 ({err}),将在 {retry_interval} 秒后重试。", level='WARNING') - time.sleep(retry_interval) - else: - raise - except Exception as e: - if attempt < max_retries: - self.update_log_display(f"批次 {batch_id} 错误 ({e}),将在 {retry_interval} 秒后重试。", level='WARNING') - time.sleep(retry_interval) - else: - raise - else: - msg = log_error_report(f"执行批次 {batch_id} 失败,已达到最大重试次数 ({max_retries} 次)", self.API_DATA) - self.update_log_display(msg, level='ERROR') - - - self.update_log_display("--- 所有请求执行完毕 ---", level='INFO') - self.run_btn.config(state='normal') - - def update_log_display(self, message, level='INFO'): - """更新 GUI 日志文本框和主日志文件.""" - log_method = getattr(logging, level.lower(), logging.info) - log_method(message) - - self.log_text.config(state='normal') - self.log_text.insert(tk.END, f"{datetime.now().strftime('%H:%M:%S')} [{level}]: {message}\n", level) - self.log_text.config(state='disabled') - self.log_text.see(tk.END) - - def change_directory(self): - new_dir = filedialog.askdirectory(initialdir=self.current_directory) - if new_dir: - self.current_directory = new_dir - self.dir_label_var.set(self.current_directory) - self.update_log_display(f"工作目录已更改为: {new_dir}") - self.scan_files_and_update_status() - -# --- 应用程序启动 --- - -if __name__ == "__main__": - root = tk.Tk() - app = APIRunnerApp(root) +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +import requests +import json +import os +import logging +from datetime import datetime +import re +import time +import threading + +# --- 日志与错误报告功能 --- + +LOG_FILENAME = 'api_runner_log.txt' +logging.basicConfig(filename=LOG_FILENAME, level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s') + +def log_error_report(message, api_data=None): + """记录错误并生成详细的错误报告文件.""" + error_time = datetime.now().strftime("%Y%m%d_%H%M%S") + report_filename = f'ERROR_REPORT_{error_time}.txt' + + logging.error(message) + + with open(report_filename, 'w', encoding='utf-8') as f: + f.write(f"--- API Runner 错误报告 ---\n") + f.write(f"时间戳: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + f.write(f"错误信息: {message}\n") + f.write(f"--- 当前 API 配置 ---\n") + if api_data: + f.write(f"URL: {api_data.get('url', 'N/A')}\n") + f.write(f"Webapp ID: {api_data.get('webappId', 'N/A')}\n") + f.write(f"API Key: {api_data.get('apiKey', 'N/A')[:4]}...\n") + f.write(f"------------------------------\n") + + return f"操作失败。已生成错误报告文件:{report_filename}" + +# --- Tkinter GUI 应用类 --- + +class APIRunnerApp: + def __init__(self, master): + self.master = master + self.master.title("API Runner - 未加载配置") + + self.current_directory = os.getcwd() + self.scanned_assets = {'image': [], 'video': [], 'json_config': []} + self.request_payloads = [] + + self.config_filepath_history = {} + self.last_loaded_config_path = None + self.prompts = [] + + self.API_DATA = {} + self.INTERFACE_INFO = [] + self.BASE_HEADERS = {"Content-Type": "application/json"} + + self.value_vars = {} + self.file_vars = {} + self.api_info_labels = {} + + self.upload_timeout = tk.IntVar(value=60) + self.retry_interval = tk.IntVar(value=60) + self.max_retries = tk.IntVar(value=6) + self.upload_delay_on_success = tk.IntVar(value=0) + + # New settings for task polling + self.task_polling_interval = tk.IntVar(value=5) + self.task_timeout = tk.IntVar(value=300) + + self.BATCH_MODE_OPTIONS = [ + "M0: 默认单请求模式", + "M1: 多图单提示词/视频", + "M2: 多视频单提示词/图片", + "M3: 纯多提示词批量", + "M4: 多图多提示词 1:1 顺序匹配", + "M6: 单图多提示词", + "M7a: 多图滑窗 (2图/1步, [001,002],[002,003]...)", + "M7b: 多图滑窗 (3图/2步, [001,002,003],[003,004,005]...)", + "M8: 纯多图批量", + "M9: 纯多视频批量", + "M10: 固定单图+多图组合", + "M11: 固定双图+多图组合", + "M5: (危险) 笛卡尔积/全组合" + ] + self.batch_mode_var = tk.StringVar(value=self.BATCH_MODE_OPTIONS[0]) + + self.create_widgets() + self.update_log_display("请点击 '导入新配置' 或从下拉菜单选择文件来启动应用。", level='WARNING') + + def create_widgets(self): + main_frame = ttk.Frame(self.master) + main_frame.pack(pady=10, padx=10, expand=True, fill="both") + + control_area = ttk.Frame(main_frame) + control_area.pack(side='bottom', fill='x', pady=(10, 0)) + + self.log_frame = ttk.LabelFrame(main_frame, text="运行日志 (Run Log)") + self.log_frame.pack(side='bottom', fill="x", expand=True, pady=(5, 0), ipady=5) + self._build_log_display() + + canvas = tk.Canvas(main_frame) + scrollbar = ttk.Scrollbar(main_frame, orient="vertical", command=canvas.yview) + self.scrollable_frame = ttk.Frame(canvas) + self.scrollable_frame.bind( + "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + self._build_unified_ui(self.scrollable_frame) + + self.run_btn = ttk.Button(control_area, text="🚀 运行 API 请求", command=self.start_run_api_requests_thread, state='disabled') + self.run_btn.pack(side='left', padx=(0, 10)) + + settings_frame = ttk.LabelFrame(control_area, text="运行/重试设置") + settings_frame.pack(side='left', fill='x', expand=True) + self.build_settings_widgets(settings_frame) + + def build_settings_widgets(self, parent_frame): + ttk.Label(parent_frame, text="连接超时(s):").pack(side='left', padx=(5, 2)) + ttk.Entry(parent_frame, textvariable=self.upload_timeout, width=5).pack(side='left', padx=(0, 10)) + + ttk.Label(parent_frame, text="失败重试间隔(s):").pack(side='left', padx=(5, 2)) + ttk.Entry(parent_frame, textvariable=self.retry_interval, width=5).pack(side='left', padx=(0, 10)) + + ttk.Label(parent_frame, text="最大重试次数:").pack(side='left', padx=(5, 2)) + ttk.Entry(parent_frame, textvariable=self.max_retries, width=5).pack(side='left', padx=(0, 10)) + + ttk.Label(parent_frame, text="成功上传间隔(s):").pack(side='left', padx=(5, 2)) + ttk.Entry(parent_frame, textvariable=self.upload_delay_on_success, width=5).pack(side='left', padx=(0, 10)) + + ttk.Label(parent_frame, text="任务轮询间隔(s):").pack(side='left', padx=(5, 2)) + ttk.Entry(parent_frame, textvariable=self.task_polling_interval, width=5).pack(side='left', padx=(0, 10)) + + ttk.Label(parent_frame, text="任务超时(s):").pack(side='left', padx=(5, 2)) + ttk.Entry(parent_frame, textvariable=self.task_timeout, width=5).pack(side='left', padx=(0, 5)) + + def _build_unified_ui(self, parent_frame): + load_frame = ttk.LabelFrame(parent_frame, text="API 配置加载") + load_frame.pack(fill="x", padx=5, pady=5) + + self.config_combobox = ttk.Combobox(load_frame, values=list(self.config_filepath_history.keys()), state='readonly', width=30) + self.config_combobox.pack(side='left', padx=5, pady=5) + self.config_combobox.bind("<>", self.load_config_from_combobox) + + ttk.Button(load_frame, text="📂 导入新配置", command=self.select_and_load_config).pack(side='left', padx=5, pady=5) + self.config_file_label = ttk.Label(load_frame, text="当前文件: 无") + self.config_file_label.pack(side='left', padx=10) + + info_frame = ttk.LabelFrame(parent_frame, text="当前 API 信息") + info_frame.pack(fill="x", padx=5, pady=5) + self.api_info_labels['url'] = ttk.Label(info_frame, text="URL: N/A"); self.api_info_labels['url'].pack(anchor="w", padx=5) + self.api_info_labels['webappId'] = ttk.Label(info_frame, text="Webapp ID: N/A"); self.api_info_labels['webappId'].pack(anchor="w", padx=5) + self.api_info_labels['apiKey'] = ttk.Label(info_frame, text="API Key: N/A"); self.api_info_labels['apiKey'].pack(anchor="w", padx=5) + + scan_frame = ttk.LabelFrame(parent_frame, text="本地文件管理") + scan_frame.pack(fill="x", padx=5, pady=10) + + self.dir_label_var = tk.StringVar(value=self.current_directory) + ttk.Label(scan_frame, text="当前目录:").pack(anchor="w", padx=5, pady=2) + ttk.Label(scan_frame, textvariable=self.dir_label_var, foreground="blue").pack(anchor="w", padx=5) + + btn_frame = ttk.Frame(scan_frame) + btn_frame.pack(fill="x", pady=5) + ttk.Button(btn_frame, text="更改目录", command=self.change_directory).pack(side="left", padx=5) + ttk.Button(btn_frame, text="重新扫描文件", command=self.scan_files_and_update_status).pack(side="left", padx=5) + ttk.Button(btn_frame, text="📝 生成请求负载", command=self.generate_payloads).pack(side='left', padx=(20, 5)) + + self.scan_status_label = ttk.Label(scan_frame, text="文件扫描状态: 未运行") + self.scan_status_label.pack(anchor="w", padx=5, pady=5) + + self.match_status_label = ttk.Label(scan_frame, text="匹配模式: 未生成请求") + self.match_status_label.pack(anchor="w", padx=5, pady=5) + + self.editor_container_frame = ttk.Frame(parent_frame) + self.editor_container_frame.pack(fill="x", expand=True, pady=(10,0)) + self._build_editor_ui() + + def _build_editor_ui(self): + for widget in self.editor_container_frame.winfo_children(): + widget.destroy() + + if not self.API_DATA: + ttk.Label(self.editor_container_frame, text="请先加载 API 配置以编辑接口值。").pack(padx=20, pady=20) + return + + top_frame = ttk.LabelFrame(self.editor_container_frame, text="单个请求参数配置 (作为批处理的默认值)") + top_frame.pack(fill="x", padx=5, pady=5) + + editor_canvas = tk.Canvas(top_frame, height=150) + editor_scrollbar = ttk.Scrollbar(top_frame, orient="vertical", command=editor_canvas.yview) + self.interface_list_frame = ttk.Frame(editor_canvas) + + editor_canvas.create_window((0, 0), window=self.interface_list_frame, anchor="nw") + editor_canvas.configure(yscrollcommand=editor_scrollbar.set) + + editor_canvas.pack(side="left", fill="x", expand=True) + editor_scrollbar.pack(side="right", fill="y") + + self.interface_list_frame.bind("", + lambda e: editor_canvas.configure(scrollregion=editor_canvas.bbox("all"))) + + self.value_vars = {} + self.file_vars = {} + for info in self.INTERFACE_INFO: + row_frame = ttk.Frame(self.interface_list_frame) + row_frame.pack(fill="x", pady=3, padx=5) + + ttk.Label(row_frame, text=f"[{info['code']}] {info['name']}:", width=15, anchor="w").pack(side="left", padx=5) + ttk.Label(row_frame, text=f"({info['type']})", width=8).pack(side="left") + + if info['type'] in ("value", "text", "select"): + var = tk.StringVar(value=info['default_value']) + self.value_vars[info['code']] = var + editor = ttk.Entry(row_frame, textvariable=var, width=50) + editor.pack(side="right", expand=True, fill="x", padx=5) + + elif info['type'] in ("image", "video"): + var = tk.StringVar(value=info['default_value']) + self.file_vars[info['code']] = var + entry = ttk.Entry(row_frame, textvariable=var, state='readonly', width=40) + entry.pack(side="right", expand=True, fill="x", padx=(0, 5)) + browse_btn = ttk.Button(row_frame, text="浏览...", command=lambda v=var, t=info['type']: self._browse_file_for_var(v, t)) + browse_btn.pack(side="right") + + batch_frame = ttk.LabelFrame(self.editor_container_frame, text="批量文件与模式选择(Ctrl/Shift 多选)") + batch_frame.pack(fill="both", expand=True, padx=5, pady=5) + + mode_config_frame = ttk.LabelFrame(batch_frame, text="批量模式选择") + mode_config_frame.pack(fill='x', padx=5, pady=5) + + ttk.Label(mode_config_frame, text="批量模式:").pack(side='left', padx=5) + self.mode_combobox = ttk.Combobox(mode_config_frame, textvariable=self.batch_mode_var, values=self.BATCH_MODE_OPTIONS, state='readonly', width=70) + self.mode_combobox.pack(side='left', padx=5, fill='x', expand=True) + + listbox_container = ttk.Frame(batch_frame) + listbox_container.pack(fill="both", expand=True) + + self.image_listbox, _ = self._create_file_listbox(listbox_container, "图片文件", self.scanned_assets['image'], 'extended') + self.video_listbox, _ = self._create_file_listbox(listbox_container, "视频文件", self.scanned_assets['video'], 'extended') + self.json_listbox, _ = self._create_file_listbox(listbox_container, "JSON 提示词/配置", self.scanned_assets['json_config'], 'extended') + + def _create_file_listbox(self, parent, title, file_list, selectmode): + frame = ttk.LabelFrame(parent, text=f"{title} ({len(file_list)}个)") + frame.pack(side='left', padx=5, pady=5, fill='both', expand=True) + + listbox_frame = ttk.Frame(frame) + listbox_frame.pack(fill='both', expand=True) + + scrollbar = ttk.Scrollbar(listbox_frame, orient=tk.VERTICAL) + listbox = tk.Listbox(listbox_frame, selectmode=selectmode, height=8, yscrollcommand=scrollbar.set, exportselection=False) + scrollbar.config(command=listbox.yview) + + scrollbar.pack(side='right', fill='y') + listbox.pack(side='left', fill='both', expand=True) + + for filename in file_list: + listbox.insert(tk.END, filename) + + if file_list: + listbox.select_set(0, tk.END) + + return listbox, None + + def _build_log_display(self): + self.log_text = tk.Text(self.log_frame, height=6, state='disabled', wrap='word', font=("Consolas", 10)) + self.log_text.pack(expand=True, fill="both", padx=5, pady=5) + + self.log_text.tag_config('INFO', foreground='black') + self.log_text.tag_config('WARNING', foreground='orange') + self.log_text.tag_config('ERROR', foreground='red') + self.log_text.tag_config('SUCCESS', foreground='green') + + def load_config_from_combobox(self, event): + selected_file = self.config_combobox.get() + if selected_file and selected_file in self.config_filepath_history: + filepath = self.config_filepath_history[selected_file] + self.load_config_from_file(filepath, add_to_history=False) + + def select_and_load_config(self): + filepath = filedialog.askopenfilename( + defaultextension=".txt", + filetypes=[("API Config Files", "*.txt *.json"), ("All Files", "*.*")] + ) + if filepath: + self.load_config_from_file(filepath, add_to_history=True) + + def update_config_history_gui(self): + self.config_combobox['values'] = list(self.config_filepath_history.keys()) + if self.last_loaded_config_path: + filename = os.path.basename(self.last_loaded_config_path) + self.config_combobox.set(filename) + + def load_config_from_file(self, filepath, add_to_history=True): + filename = os.path.basename(filepath) + self.update_log_display(f"尝试从文件加载配置: {filename}", level='INFO') + + try: + with open(filepath, 'r', encoding='utf-8') as f: + file_content = f.read() + + config = None + try: + config = json.loads(file_content) + except json.JSONDecodeError: + url_match = re.search(r'(?:POST|GET|PUT)\s+[\'"](https?:\/\/[^\'"]+)[\'"]', file_content) + api_url = url_match.group(1) if url_match else None + json_body_match = re.search(r'(?:--data-raw|--data)\s+[\'"]\s*(\{.*\})\s*[\'"]', file_content, re.DOTALL) + if not api_url or not json_body_match: + raise ValueError("未在文件中找到有效的 API URL 和/或 JSON 请求主体。") + json_string = json_body_match.group(1) + body_data = json.loads(json_string) + + config = { + "url": api_url, "webappId": body_data.get('webappId'), "apiKey": body_data.get('apiKey'), "nodeInfoList": body_data.get('nodeInfoList') + } + + required_keys = ['url', 'webappId', 'apiKey', 'nodeInfoList'] + if not all(key in config and config[key] for key in required_keys): + raise ValueError("解析后的配置信息中缺少必要的字段。") + + self.API_DATA = config + self.INTERFACE_INFO = [ + { + "code": node['nodeId'], + "name": node['description'], + "type": node['fieldName'], + "default_value": node.get('fieldValue', '') + } + for node in config['nodeInfoList'] + ] + + if add_to_history: + self.config_filepath_history[filename] = filepath + self.last_loaded_config_path = filepath + self.update_config_history_gui() + + self.master.title(f"API Runner - {filename}") + self.config_file_label.config(text=f"当前文件: {filename}") + self.api_info_labels['url'].config(text=f"URL: {self.API_DATA['url']}") + self.api_info_labels['webappId'].config(text=f"Webapp ID: {self.API_DATA['webappId']}") + key_display = f"{self.API_DATA['apiKey'][:4]}...{self.API_DATA['apiKey'][-4:]}" if self.API_DATA.get('apiKey') else 'N/A' + self.api_info_labels['apiKey'].config(text=f"API Key: {key_display}") + + self._build_editor_ui() + self.run_btn.config(state='normal') + + self.update_log_display(f"成功加载并解析配置:{filename}", level='SUCCESS') + self.scan_files_and_update_status() + + except Exception as e: + msg = f"加载文件时发生错误: {e}" + messagebox.showerror("加载错误", msg) + self.update_log_display(msg, level='ERROR') + + def scan_files_and_update_status(self): + self.update_log_display("开始扫描当前目录下的文件...") + + try: + files = os.listdir(self.current_directory) + except FileNotFoundError: + self.update_log_display("错误: 当前目录不存在。", level='ERROR') + return + + self.scanned_assets['image'] = sorted([f for f in files if f.lower().endswith(('.png', '.jpg', '.jpeg'))]) + self.scanned_assets['video'] = sorted([f for f in files if f.lower().endswith(('.mp4', '.mov', '.avi', '.webm'))]) + self.scanned_assets['json_config'] = sorted([f for f in files if f.lower().endswith('.json')]) + + if hasattr(self, 'editor_container_frame'): + self._build_editor_ui() + + status_msg = (f"图片: {len(self.scanned_assets['image'])}, " + f"视频: {len(self.scanned_assets['video'])}, " + f"JSON配置: {len(self.scanned_assets['json_config'])}") + self.scan_status_label.config(text=f"文件扫描状态: {status_msg}") + self.update_log_display("文件扫描完成。", level='INFO') + + def extract_prompts_from_json(self, json_filenames): + self.prompts = [] + text_id = next((info['code'] for info in self.INTERFACE_INFO if info['type'] == 'text'), None) + if not text_id: return + + for filename in json_filenames: + filepath = os.path.join(self.current_directory, filename) + try: + with open(filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + + if isinstance(data, list) and all(isinstance(item, dict) and 'prompt' in item for item in data): + self.prompts.extend([item['prompt'] for item in data if item.get('prompt')]) + continue + + if isinstance(data, dict): data = [data] + + if isinstance(data, list): + for payload in data: + for node in payload.get('nodeInfoList', []): + if node.get('nodeId') == text_id and node.get('fieldValue'): + self.prompts.append(node['fieldValue']) + + if isinstance(data, list) and all(isinstance(item, str) for item in data): + self.prompts.extend(data) + + except Exception as e: + self.update_log_display(f"错误: 解析 JSON 文件 {filename} 失败: {e}", level='ERROR') + + self.prompts = list(filter(None, self.prompts)) + if json_filenames: + self.update_log_display(f"从JSON文件中成功提取了 {len(self.prompts)} 个提示词。", level='INFO') + + def _get_base_payload_nodes(self, image_id, video_id, text_id): + base_nodes = [] + for info in self.INTERFACE_INFO: + node_id = info['code'] + field_value = self.value_vars.get(node_id).get() if self.value_vars.get(node_id) and self.value_vars.get(node_id).get() != '' else info['default_value'] + + if node_id not in [image_id, video_id, text_id] and field_value is not None: + base_nodes.append({ + "nodeId": node_id, "fieldName": info['type'], "fieldValue": field_value, "description": info['name'] + }) + return base_nodes + + def _create_payload(self, base_nodes, text_id=None, text_val=None, image_id=None, image_val=None, video_id=None, video_val=None): + final_nodes = list(base_nodes) + + default_text_val = self.value_vars.get(text_id).get() if text_id and self.value_vars.get(text_id) else next((info['default_value'] for info in self.INTERFACE_INFO if info['code'] == text_id), None) + default_image_val = self.file_vars.get(image_id).get() if image_id and self.file_vars.get(image_id) else next((info['default_value'] for info in self.INTERFACE_INFO if info['code'] == image_id), None) + default_video_val = self.file_vars.get(video_id).get() if video_id and self.file_vars.get(video_id) else next((info['default_value'] for info in self.INTERFACE_INFO if info['code'] == video_id), None) + + def append_node(node_id, node_type, description, value, default_value): + if node_id: + final_nodes.append({ + "nodeId": node_id, "fieldName": node_type, "fieldValue": value if value is not None else default_value, "description": description + }) + + text_info = next((info for info in self.INTERFACE_INFO if info['code'] == text_id), None) + if text_info: append_node(text_id, text_info['type'], text_info['name'], text_val, default_text_val) + + image_info = next((info for info in self.INTERFACE_INFO if info['code'] == image_id), None) + if image_info: append_node(image_id, image_info['type'], image_info['name'], image_val, default_image_val) + + video_info = next((info for info in self.INTERFACE_INFO if info['code'] == video_id), None) + if video_info: append_node(video_id, video_info['type'], video_info['name'], video_val, default_video_val) + + return { + "webappId": self.API_DATA['webappId'], "apiKey": self.API_DATA['apiKey'], "nodeInfoList": final_nodes + } + + def _create_single_payload(self): + node_info_list = [] + for info in self.INTERFACE_INFO: + node_id = info['code'] + field_value = self.value_vars.get(node_id).get() if self.value_vars.get(node_id) else info['default_value'] + node_info_list.append({ + "nodeId": node_id, "fieldName": info['type'], "fieldValue": field_value, "description": info['name'] + }) + return { + "webappId": self.API_DATA['webappId'], "apiKey": self.API_DATA['apiKey'], "nodeInfoList": node_info_list + } + + def _browse_file_for_var(self, target_var, file_type='image'): + """Opens a file dialog to select a single file and updates the target StringVar.""" + if file_type == 'image': + filetypes = [("Image Files", "*.png *.jpg *.jpeg"), ("All Files", "*.*")] + elif file_type == 'video': + filetypes = [("Video Files", "*.mp4 *.mov *.avi *.webm"), ("All Files", "*.*")] + else: + filetypes = [("All Files", "*.*")] + + filepath = filedialog.askopenfilename( + initialdir=self.current_directory, + filetypes=filetypes + ) + if filepath: + filename = os.path.basename(filepath) + target_var.set(filename) + self.update_log_display(f"已为单个参数选择文件: {filename}", level='INFO') + + def generate_payloads(self): + if not self.API_DATA: + messagebox.showerror("错误", "请先加载 API 配置。") + return + + selected_images = sorted([self.image_listbox.get(i) for i in self.image_listbox.curselection()]) + selected_videos = sorted([self.video_listbox.get(i) for i in self.video_listbox.curselection()]) + selected_jsons = [self.json_listbox.get(i) for i in self.json_listbox.curselection()] + + self.extract_prompts_from_json(selected_jsons) + prompts = self.prompts + + N_img, N_vid, N_prompt = len(selected_images), len(selected_videos), len(prompts) + + text_id = next((info['code'] for info in self.INTERFACE_INFO if info['type'] == 'text'), None) + image_id = next((info['code'] for info in self.INTERFACE_INFO if info['type'] == 'image'), None) + video_id = next((info['code'] for info in self.INTERFACE_INFO if info['type'] == 'video'), None) + + current_mode = self.batch_mode_var.get() + + if N_img > 1 and N_prompt > 1 and N_img == N_prompt: self.batch_mode_var.set("M4: 多图多提示词 1:1 顺序匹配") + elif N_img == 1 and N_prompt > 1: self.batch_mode_var.set("M6: 单图多提示词") + elif N_img > 1 and N_prompt == 0 and N_vid == 0: self.batch_mode_var.set("M8: 纯多图批量") + elif N_vid > 1 and N_prompt == 0 and N_img == 0: self.batch_mode_var.set("M9: 纯多视频批量") + elif N_img > 1 and N_prompt <= 1: + if "滑窗" not in current_mode and "组合" not in current_mode: self.batch_mode_var.set("M1: 多图单提示词/视频") + elif N_vid > 1 and N_prompt <= 1: self.batch_mode_var.set("M2: 多视频单提示词/图片") + elif N_prompt > 1 and N_img < 2 and N_vid < 2: self.batch_mode_var.set("M3: 纯多提示词批量") + elif "组合" not in current_mode: self.batch_mode_var.set("M0: 默认单请求模式") + + final_mode = self.batch_mode_var.get() + self.update_log_display(f"已根据输入自动推荐模式,当前执行模式: {final_mode}", level='INFO') + + self.request_payloads = [] + base_payload_nodes = self._get_base_payload_nodes(image_id, video_id, text_id) + + prompt_default = prompts[0] if N_prompt == 1 else (self.value_vars.get(text_id).get() if text_id and self.value_vars.get(text_id) else None) + image_default = self.file_vars.get(image_id).get() if image_id and self.file_vars.get(image_id) else None + video_default = self.file_vars.get(video_id).get() if video_id and self.file_vars.get(video_id) else None + + if final_mode.startswith(("M0", "M3", "M6")): + items = prompts if N_prompt > 1 else [prompt_default] + img_val = selected_images[0] if N_img == 1 else image_default + for prompt in items: + self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, prompt, image_id, img_val, video_id, video_default)) + if final_mode.startswith("M0"): self.request_payloads = self.request_payloads[:1] + + elif final_mode.startswith(("M1", "M4", "M7", "M8", "M10", "M11")): + if final_mode.startswith("M4"): + for img, prompt in zip(selected_images, prompts): + self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, prompt, image_id, img, video_id, video_default)) + elif "M7" in final_mode: + window_size, step_size = (2, 1) if "M7a" in final_mode else (3, 2) + i = 0 + while i + window_size <= N_img: + image_value = ",".join(selected_images[i : i + window_size]) + self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, prompt_default, image_id, image_value, video_id, video_default)) + i += step_size + elif final_mode.startswith("M8"): + for img in selected_images: + self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, None, image_id, img, video_id, None)) + + elif final_mode.startswith("M10"): # Fixed-single-image + multi-image + if not image_default: + messagebox.showwarning("模式错误", "M10 模式要求在'单个请求参数'中选择一个固定的图片。") + else: + for img in selected_images: + combined_images = f"{image_default},{img}" + self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, prompt_default, image_id, combined_images, video_id, video_default)) + + elif final_mode.startswith("M11"): # Fixed-double-image + multi-image + if not image_default or len(image_default.split(',')) != 2: + messagebox.showwarning("模式错误", "M11 模式要求在'单个请求参数'的图片栏中填入两个固定的图片文件名,并用逗号分隔。") + else: + for img in selected_images: + combined_images = f"{image_default},{img}" + self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, prompt_default, image_id, combined_images, video_id, video_default)) + + else: # M1 + for img in selected_images: + self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, prompt_default, image_id, img, video_id, video_default)) + + elif final_mode.startswith(("M2", "M9")): + if final_mode.startswith("M9"): + for vid in selected_videos: + self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, None, image_id, None, video_id, vid)) + else: + for vid in selected_videos: + self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, prompt_default, image_id, image_default, video_id, vid)) + + elif final_mode.startswith("M5"): + if N_img == 0 or N_prompt == 0: + messagebox.showwarning("警告", "笛卡尔积模式要求同时选中多个图片和多个提示词。") + self.request_payloads = [self._create_single_payload()] + else: + for img in selected_images: + for prompt in prompts: + self.request_payloads.append(self._create_payload(base_payload_nodes, text_id, prompt, image_id, img, video_id, video_default)) + + if not self.request_payloads: + self.request_payloads = [self._create_single_payload()] + final_mode = "M0: 默认单请求模式 (兜底)" + + num_payloads = len(self.request_payloads) + self.match_status_label.config(text=f"匹配模式: **{final_mode}** ({num_payloads} 个负载)") + self.update_log_display(f"成功生成 {num_payloads} 个 API 请求负载。模式: {final_mode}", level='SUCCESS') + + def _handle_single_task(self, payload, batch_id): + """ + Handles the complete lifecycle of a single task: create, poll for status, and get results. + Returns True if successful, False otherwise. + """ + api_url = self.API_DATA['url'] + api_key = self.API_DATA['apiKey'] + + connect_timeout = int(self.upload_timeout.get()) + polling_interval = int(self.task_polling_interval.get()) + task_timeout = int(self.task_timeout.get()) + + try: + # 1. Create the task + self.update_log_display(f"批次 {batch_id}: 正在创建任务...", level='INFO') + create_response = requests.post(api_url, headers=self.BASE_HEADERS, json=payload, timeout=connect_timeout) + create_response.raise_for_status() + create_data = create_response.json() + + if create_data.get('code') != 0 or 'data' not in create_data or 'taskId' not in create_data['data']: + error_msg = create_data.get('msg', '创建任务时返回了未知错误') + self.update_log_display(f"批次 {batch_id}: 创建任务失败: {error_msg}", level='ERROR') + return False + + task_id = create_data['data']['taskId'] + self.update_log_display(f"批次 {batch_id}: 任务创建成功, Task ID: {task_id}", level='INFO') + + # 2. Poll for task completion by checking the outputs endpoint + start_time = time.time() + outputs_url = "https://www.runninghub.cn/task/openapi/outputs" + + while True: + if time.time() - start_time > task_timeout: + self.update_log_display(f"批次 {batch_id}: 任务超时 ({task_timeout}s)", level='ERROR') + return False + + time.sleep(polling_interval) + + self.update_log_display(f"批次 {batch_id}: 正在查询任务结果 (Task ID: {task_id})...", level='INFO') + outputs_payload = {"apiKey": api_key, "taskId": task_id} + + try: + outputs_response = requests.post(outputs_url, headers=self.BASE_HEADERS, json=outputs_payload, timeout=connect_timeout) + outputs_response.raise_for_status() + outputs_data = outputs_response.json() + except requests.exceptions.RequestException as poll_e: + self.update_log_display(f"批次 {batch_id}: 查询结果时网络错误: {poll_e}, 将在稍后重试查询。", level='WARNING') + continue + + if outputs_data.get('code') == 0: + if isinstance(outputs_data.get('data'), list) and outputs_data['data']: + self.update_log_display(f"批次 {batch_id}: 任务成功完成!", level='SUCCESS') + for i, result in enumerate(outputs_data['data']): + file_url = result.get('fileUrl', 'N/A') + self.update_log_display(f" 结果 {i+1}: {file_url}", level='SUCCESS') + return True + else: + error_msg = outputs_data.get('msg', '任务完成但未返回任何结果或已失败。') + self.update_log_display(f"批次 {batch_id}: {error_msg}", level='ERROR') + return False + else: + status_msg = outputs_data.get('msg', '任务仍在处理中...') + self.update_log_display(f"批次 {batch_id}: {status_msg}", level='INFO') + + except requests.exceptions.RequestException as e: + self.update_log_display(f"批次 {batch_id}: 初始网络请求失败: {e}", level='ERROR') + return False + except Exception as e: + log_error_report(f"未知错误在 _handle_single_task: {e}", self.API_DATA) + self.update_log_display(f"批次 {batch_id}: 处理时发生未知错误: {e}", level='ERROR') + return False + + def start_run_api_requests_thread(self): + """Starts the API request process in a separate thread to keep the UI responsive.""" + thread = threading.Thread(target=self.run_api_requests, daemon=True) + thread.start() + + def run_api_requests(self): + if not self.request_payloads or not self.API_DATA: + messagebox.showerror("错误", "请先加载配置并生成请求负载。") + return + try: + max_retries = int(self.max_retries.get()) + retry_interval = int(self.retry_interval.get()) + success_delay = int(self.upload_delay_on_success.get()) + task_polling_interval = int(self.task_polling_interval.get()) + task_timeout = int(self.task_timeout.get()) + connect_timeout = int(self.upload_timeout.get()) + except ValueError: + messagebox.showerror("错误", "运行设置必须是有效的整数。") + return + + self.update_log_display(f"--- 开始执行 {len(self.request_payloads)} 个 API 请求 ---", level='INFO') + settings_log = (f"设置: 连接超时={connect_timeout}s, 失败重试间隔={retry_interval}s, " + f"最大重试={max_retries}次, 成功间隔={success_delay}s, " + f"任务轮询={task_polling_interval}s, 任务超时={task_timeout}s") + self.update_log_display(settings_log, level='INFO') + self.run_btn.config(state='disabled') + + for i, payload in enumerate(self.request_payloads): + batch_id = i + 1 + + for attempt in range(max_retries + 1): + self.update_log_display(f"批次 {batch_id}/{len(self.request_payloads)}: 开始第 {attempt + 1}/{max_retries + 1} 次尝试...", level='INFO') + + task_successful = self._handle_single_task(payload, batch_id) + + if task_successful: + is_last_request = (i == len(self.request_payloads) - 1) + if success_delay > 0 and not is_last_request: + self.update_log_display(f"等待 {success_delay} 秒后继续下一个批次...", level='INFO') + time.sleep(success_delay) + break + else: + if attempt < max_retries: + self.update_log_display(f"批次 {batch_id} 任务失败,将在 {retry_interval} 秒后重试。", level='WARNING') + time.sleep(retry_interval) + else: + msg = log_error_report(f"执行批次 {batch_id} 失败,已达到最大重试次数 ({max_retries} 次)。", self.API_DATA) + self.update_log_display(msg, level='ERROR') + + self.update_log_display("--- 所有请求执行完毕 ---", level='INFO') + self.run_btn.config(state='normal') + + def update_log_display(self, message, level='INFO'): + log_method = getattr(logging, level.lower(), logging.info) + log_method(message) + + self.log_text.config(state='normal') + self.log_text.insert(tk.END, f"{datetime.now().strftime('%H:%M:%S')} [{level}]: {message}\n", level) + self.log_text.config(state='disabled') + self.log_text.see(tk.END) + + def change_directory(self): + new_dir = filedialog.askdirectory(initialdir=self.current_directory) + if new_dir: + self.current_directory = new_dir + self.dir_label_var.set(self.current_directory) + self.update_log_display(f"工作目录已更改为: {new_dir}") + self.scan_files_and_update_status() + +# --- 应用程序启动 --- +if __name__ == "__main__": + root = tk.Tk() + app = APIRunnerApp(root) root.mainloop() \ No newline at end of file diff --git a/runninghub file downloader/runninghub file downloader.js b/runninghub file downloader/runninghub file downloader.js index d424e9f..d3e8d01 100644 --- a/runninghub file downloader/runninghub file downloader.js +++ b/runninghub file downloader/runninghub file downloader.js @@ -1,9 +1,9 @@ // ==UserScript== -// @name RunningHub 视频批量下载助手 (v4.3 最终精简版) +// @name RunningHub 批量下载助手 (v6.0 - 最终正确版) // @namespace http://tampermonkey.net/ -// @version 4.3 -// @description 批量下载RunningHub视频,无动画过渡,无确认弹窗,支持滚轮调整和详细失败报告。 -// @author Gemini +// @version 6.0 +// @description 批量下载RunningHub中的所有文件。能够正确识别所有下载按钮,并智能处理有无时间戳的情况。 +// @author Gemini & Jules // @match https://www.runninghub.cn/ai-detail/* // @grant GM_addStyle // @grant unsafeWindow @@ -14,153 +14,59 @@ 'use strict'; // --- 配置 --- - const MIN_DOWNLOAD_DELAY_MS = 3000; // 最小延迟 3秒 - const MAX_DOWNLOAD_DELAY_MS = 4000; // 最大延迟 4秒 + const MIN_DOWNLOAD_DELAY_MS = 3000; + const MAX_DOWNLOAD_DELAY_MS = 4000; const MENU_TRIGGER_WAIT_MS = 500; // --------------------------------- let initialized = false; - let currentAllFiles = []; // 存储文件数据,内部使用 0-based 索引 + let currentAllFiles = []; // 存储文件数据 - // 1. 样式注入 (移除 transition 动画) + // 1. 样式注入 (保持不变) GM_addStyle(` - #batch-download-control-panel { - position: fixed; - top: 60px; - right: 20px; - z-index: 10000; - background-color: #364d79; - color: white; - padding: 15px; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; - width: 330px; - /* 移除动画过渡效果 */ - /* transition: width 0.1s linear, height 0.1s linear, padding 0.1s linear, border-radius 0.1s linear; */ - pointer-events: auto; - } - #batch-download-control-panel.minimized { - width: 50px; - height: 50px; - padding: 0; - overflow: hidden; - border-radius: 50%; - } - #panel-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; - cursor: pointer; - } - #panel-header h4 { - margin: 0; - font-size: 16px; - white-space: nowrap; - } - #collapse-button { - background: none; - border: 1px solid white; - color: white; - font-size: 16px; - width: 25px; - height: 25px; - line-height: 22px; - text-align: center; - border-radius: 50%; - cursor: pointer; - /* 移除按钮过渡效果 */ - /* transition: background-color 0.2s; */ - } - #collapse-button:hover { - background-color: rgba(255, 255, 255, 0.1); - } + #batch-download-control-panel { position: fixed; top: 60px; right: 20px; z-index: 10000; background-color: #364d79; color: white; padding: 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; width: 330px; pointer-events: auto; } + #batch-download-control-panel.minimized { width: 50px; height: 50px; padding: 0; overflow: hidden; border-radius: 50%; } + #panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; cursor: pointer; } + #panel-header h4 { margin: 0; font-size: 16px; white-space: nowrap; } + #collapse-button { background: none; border: 1px solid white; color: white; font-size: 16px; width: 25px; height: 25px; line-height: 22px; text-align: center; border-radius: 50%; cursor: pointer; } + #collapse-button:hover { background-color: rgba(255, 255, 255, 0.1); } #batch-download-control-panel.minimized #panel-content { display: none; } #batch-download-control-panel.minimized #panel-header { padding: 12px; } #batch-download-control-panel.minimized #panel-header h4 { display: none; } - - #time-display { - margin-bottom: 15px; - padding: 5px; - border: 1px dashed rgba(255, 255, 255, 0.5); - border-radius: 4px; - font-size: 12px; - line-height: 1.5; - } - - #file-selection-input { - display: flex; - justify-content: space-between; - margin-bottom: 10px; - gap: 10px; - } - #file-selection-input > div { - flex: 1; - display: flex; - flex-direction: column; - } - #file-selection-input label { - font-size: 12px; - margin-bottom: 5px; - font-weight: bold; - } - #file-selection-input input[type="number"] { - width: 100%; - padding: 5px; - border: none; - border-radius: 4px; - color: #333; - background-color: white; - text-align: center; - font-size: 16px; - } - - #batch-download-button, #refresh-count-button { - width: 100%; - margin-top: 5px; - } + #time-display { margin-bottom: 15px; padding: 5px; border: 1px dashed rgba(255, 255, 255, 0.5); border-radius: 4px; font-size: 12px; line-height: 1.5; } + #file-selection-input { display: flex; justify-content: space-between; margin-bottom: 10px; gap: 10px; } + #file-selection-input > div { flex: 1; display: flex; flex-direction: column; } + #file-selection-input label { font-size: 12px; margin-bottom: 5px; font-weight: bold; } + #file-selection-input input[type="number"] { width: 100%; padding: 5px; border: none; border-radius: 4px; color: #333; background-color: white; text-align: center; font-size: 16px; } + #batch-download-button, #refresh-count-button { width: 100%; margin-top: 5px; } #refresh-count-button { background-color: #5bc0de; } #refresh-count-button:hover { background-color: #31b0d5; } `); - // 2. 核心工具函数 (保持不变) - function findTimeStr(element) { + // 2. 核心工具函数 (已重构) + function findOriginalTimeStr(triggerElement) { const timeRegex = /(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})/; - let current = element; - for (let i = 0; i < 5; i++) { - if (!current) break; - const textContent = current.textContent; - const match = textContent.match(timeRegex); - if (match) return match[1]; - current = current.parentElement; - } - if (element.parentElement) { - for (const child of element.parentElement.children) { - if (child === element) continue; - const match = child.textContent.match(timeRegex); - if (match) return match[1]; + const historyItem = triggerElement.closest('.history-item'); + if (historyItem) { + const timeElement = historyItem.querySelector('.history-create-time'); + if (timeElement) { + const match = timeElement.textContent.match(timeRegex); + if (match) return match[1]; } } return null; } function simulateMouseAction(element, eventType) { - const event = new MouseEvent(eventType, { - view: unsafeWindow, - bubbles: true, - cancelable: true, - composed: true - }); + const event = new MouseEvent(eventType, { view: unsafeWindow, bubbles: true, cancelable: true, composed: true }); element.dispatchEvent(event); } const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); - function getRandomDelay() { return Math.floor(Math.random() * (MAX_DOWNLOAD_DELAY_MS - MIN_DOWNLOAD_DELAY_MS + 1)) + MIN_DOWNLOAD_DELAY_MS; } - // 3. 下载文件核心逻辑 (保持不变) + // 3. 下载文件核心逻辑 (稳定) async function downloadFiles(indicesToProcess, isRetry) { let successfulCount = 0; let failedList = []; @@ -168,18 +74,17 @@ for (const index of indicesToProcess) { const fileData = currentAllFiles[index]; - const icon = fileData.icon; + const element = fileData.element; let downloadClicked = false; - if (!icon) { - console.error(`${isRetryText} 索引 ${index+1} 对应的文件未找到。`); + if (!element) { + console.error(`${isRetryText} 索引 ${index+1} 对应的文件元素未找到。`); failedList.push(index); continue; } console.log(`${isRetryText} [${index+1} / ${currentAllFiles.length}] 正在处理文件,时间: ${fileData.timeStr}...`); - - simulateMouseAction(icon, 'mouseenter'); + simulateMouseAction(element, 'mouseenter'); await sleep(MENU_TRIGGER_WAIT_MS); try { @@ -202,13 +107,9 @@ const activeDropdown = document.querySelector('.ant-dropdown'); if (activeDropdown) { activeDropdown.remove(); - console.log(`[CleanUp] 已强制移除激活的下载菜单。`); } - const delay = getRandomDelay(); - console.log(`等待随机延迟: ${delay}ms...`); await sleep(delay); - if (!downloadClicked) { failedList.push(index); } @@ -216,136 +117,121 @@ return { successfulCount, failedList }; } - - // 4. 批量下载主逻辑 (保持不变) + // 4. 批量下载主逻辑 (稳定) async function startBatchDownload(startIndex, endIndex) { const downloadButton = document.getElementById('batch-download-button'); downloadButton.disabled = true; downloadButton.textContent = '下载中... 请勿关闭页面'; const indicesToProcess = Array.from({ length: endIndex - startIndex + 1 }, (_, i) => startIndex + i); + let { successfulCount, failedList } = await downloadFiles(indicesToProcess, false); - // --- 第一轮下载 --- - let firstResult = await downloadFiles(indicesToProcess, false); - let successfulDownloads = firstResult.successfulCount; - let failedList = firstResult.failedList; - - // --- 第二轮重试 --- if (failedList.length > 0) { console.warn(`[重试] 首次下载有 ${failedList.length} 个文件失败,等待 5 秒后开始重试...`); alert(`首次下载有 ${failedList.length} 个文件失败,等待 5 秒后开始重试...`); await sleep(5000); - const retryResult = await downloadFiles(failedList, true); - successfulDownloads += retryResult.successfulCount; - failedList = retryResult.failedList; // 最终失败列表 + successfulCount += retryResult.successfulCount; + failedList = retryResult.failedList; } - // --- 最终结果统计和报告 --- const totalSelected = endIndex - startIndex + 1; const finalFailedCount = failedList.length; - let failedFilesReport = ''; if (finalFailedCount > 0) { - const failedTimes = failedList.map(index => { - const fileData = currentAllFiles[index]; - const fileNumber = index + 1; // 1-based index for user - return ` - [序号 ${fileNumber}] ${fileData.timeStr}`; - }); + const failedTimes = failedList.map(index => ` - [序号 ${index + 1}] ${currentAllFiles[index].timeStr}`); failedFilesReport = `\n\n最终失败文件 (序号 / 生成时间):\n${failedTimes.join('\n')}`; } - - alert(`批量下载完成!\n总共尝试 ${totalSelected} 个文件 (含一次重试)。\n最终成功触发下载: ${successfulDownloads} 个\n最终处理失败/跳过: ${finalFailedCount} 个${failedFilesReport}`); - + alert(`批量下载完成!\n总共尝试 ${totalSelected} 个文件 (含一次重试)。\n最终成功触发下载: ${successfulCount} 个\n最终处理失败/跳过: ${finalFailedCount} 个${failedFilesReport}`); downloadButton.textContent = '一键批量下载'; downloadButton.disabled = false; } - - // 5. UI 状态更新 (保持不变) + // 5. UI 状态更新 (已重构:最终正确逻辑 v6.0) function updateUIState() { - const allIcons = document.querySelectorAll('span.anticon-ellipsis.ant-dropdown-trigger'); - const newFiles = []; - - Array.from(allIcons).forEach(icon => { - const timeStr = findTimeStr(icon); - newFiles.push({ - icon: icon, - timeStr: timeStr || '时间未知', - time: timeStr ? new Date(timeStr.replace(/-/g, "/")) : null - }); + let allFilesData = []; + const allEllipsisIcons = document.querySelectorAll('span.anticon-ellipsis'); + + allEllipsisIcons.forEach(icon => { + const triggerElement = icon.closest('.ant-dropdown-trigger'); + if (triggerElement) { + allFilesData.push({ + element: triggerElement, + originalTimeStr: findOriginalTimeStr(triggerElement) + }); + } + }); + + let lastSeenTimeStr = '--'; + allFilesData.forEach(file => { + if (file.originalTimeStr) { + lastSeenTimeStr = file.originalTimeStr; + } + file.timeStr = lastSeenTimeStr; + file.time = file.timeStr !== '--' ? new Date(file.timeStr.replace(/-/g, "/")) : null; + }); + + allFilesData.sort((a, b) => { + if (a.time === b.time) return 0; + if (a.time === null) return 1; + if (b.time === null) return -1; + return a.time - b.time; }); - currentAllFiles = newFiles; + currentAllFiles = allFilesData; const totalFiles = currentAllFiles.length; const totalCountP = document.getElementById('total-file-count'); const startInput = document.getElementById('start-file-index'); const endInput = document.getElementById('end-file-index'); - if (totalCountP) { - totalCountP.innerHTML = `共找到 **${totalFiles}** 个文件`; - } - + if (totalCountP) totalCountP.innerHTML = `共找到 **${totalFiles}** 个文件`; if (startInput) { startInput.max = totalFiles; const currentStart = parseInt(startInput.value) || 1; startInput.value = Math.max(1, Math.min(currentStart, totalFiles || 1)); } - if (endInput) { endInput.max = totalFiles; endInput.value = totalFiles || 1; } - if (startInput) startInput.dispatchEvent(new Event('input')); - return totalFiles; } + function findLastAvailableTimeStr(index) { + if (index < 0 || index >= currentAllFiles.length) return '--'; + for (let i = index; i >= 0; i--) { + if (currentAllFiles[i].originalTimeStr) { + return currentAllFiles[i].originalTimeStr; + } + } + return '--'; + } - // 6. UI 初始化 (保持不变) - function initializeUI(initialIcons) { + // 6. UI 初始化 (已重构) + function initializeUI() { if (initialized) return; initialized = true; const initialTotalFiles = updateUIState(); - const panel = document.createElement('div'); panel.id = 'batch-download-control-panel'; panel.innerHTML = ` -
-

批量下载助手 (v4.3)

- -
- +

批量下载助手 (v6.0)

共找到 ${initialTotalFiles} 个文件


- -
- 开始时间: --
- 结束时间: -- -
- +
开始时间: --
结束时间: --
-
- - -
-
- - -
+
+
- -
- `; + `; document.body.appendChild(panel); - // 获取元素 const panelEl = document.getElementById('batch-download-control-panel'); const collapseButton = document.getElementById('collapse-button'); const startInput = document.getElementById('start-file-index'); @@ -355,12 +241,10 @@ const downloadButton = document.getElementById('batch-download-button'); const refreshButton = document.getElementById('refresh-count-button'); - // 通用事件:收缩和刷新 collapseButton.addEventListener('click', () => { const isMinimized = panelEl.classList.toggle('minimized'); collapseButton.textContent = isMinimized ? '+' : '-'; }); - refreshButton.addEventListener('click', () => { refreshButton.textContent = '正在刷新...'; refreshButton.disabled = true; @@ -369,113 +253,70 @@ refreshButton.disabled = false; }); - // 核心功能:数字输入更新逻辑 (约束、时间、计数) const updateRangeDisplay = () => { const totalFiles = currentAllFiles.length; let startIdx = parseInt(startInput.value) || 1; let endIdx = parseInt(endInput.value) || totalFiles; - - // 1. 约束:确保 start >= 1, end <= totalFiles - startIdx = Math.max(1, startIdx); - endIdx = Math.min(totalFiles, endIdx); - - // 2. 约束:确保 start <= end (推拉逻辑) + startIdx = Math.max(1, Math.min(startIdx, totalFiles || 1)); + endIdx = Math.max(1, Math.min(endIdx, totalFiles || 1)); if (startIdx > endIdx) { - if (startInput === document.activeElement) { - endIdx = startIdx; - } else if (endInput === document.activeElement) { - startIdx = endIdx; - } else { - endIdx = startIdx; - } + if (startInput === document.activeElement) endIdx = startIdx; + else if (endInput === document.activeElement) startIdx = endIdx; + else endIdx = startIdx; } - - // 3. 更新输入框显示 (应用约束后的值) startInput.value = startIdx; endInput.value = endIdx; - - // 4. 映射到时间 (注意:这里使用 0-based 索引) - const startIndex0 = startIdx - 1; - const endIndex0 = endIdx - 1; - - const startTimeStr = currentAllFiles[startIndex0] ? currentAllFiles[startIndex0].timeStr : '--'; - const endTimeStr = currentAllFiles[endIndex0] ? currentAllFiles[endIndex0].timeStr : '--'; - - startTimeSpan.textContent = startTimeStr; - endTimeSpan.textContent = endTimeStr; - - const count = endIdx - startIdx + 1; + startTimeSpan.textContent = findLastAvailableTimeStr(startIdx - 1); + endTimeSpan.textContent = findLastAvailableTimeStr(endIdx - 1); + const count = (totalFiles > 0) ? (endIdx - startIdx + 1) : 0; downloadButton.textContent = `一键批量下载 (${count} 个文件)`; }; - // 鼠标滚轮处理逻辑 const handleWheel = (e, inputElement) => { e.preventDefault(); - let currentValue = parseInt(inputElement.value); - const direction = e.deltaY < 0 ? 1 : -1; - - let newValue = currentValue + direction; - - newValue = Math.max(1, newValue); - newValue = Math.min(currentAllFiles.length || 1, newValue); - + let newValue = currentValue + (e.deltaY < 0 ? 1 : -1); + newValue = Math.max(1, Math.min(currentAllFiles.length || 1, newValue)); if (newValue !== currentValue) { inputElement.value = newValue; inputElement.dispatchEvent(new Event('input', { bubbles: true })); } }; - // 绑定事件 startInput.addEventListener('input', updateRangeDisplay); endInput.addEventListener('input', updateRangeDisplay); - startInput.addEventListener('wheel', (e) => handleWheel(e, startInput)); endInput.addEventListener('wheel', (e) => handleWheel(e, endInput)); + updateRangeDisplay(); - updateRangeDisplay(); // 初始调用 - - // 去除确认弹窗,直接启动下载 downloadButton.addEventListener('click', () => { - updateRangeDisplay(); // 确保使用最新的约束值 + updateRangeDisplay(); const startIdx = parseInt(startInput.value); const endIdx = parseInt(endInput.value); - if (startIdx > endIdx || startIdx < 1 || endIdx > currentAllFiles.length || currentAllFiles.length === 0) { - alert('文件序号选择无效,请检查! (1-based, 且开始序号 <= 结束序号)'); + alert('文件序号选择无效,请检查!'); return; } - - // 转换为 0-based 索引 - const startIndex0 = startIdx - 1; - const endIndex0 = endIdx - 1; - - const confirmMsg = `下载任务已启动:序号 ${startIdx} 到 ${endIdx},共 ${endIdx - startIdx + 1} 个文件 (含一次重试)。`; - console.log(`[下载启动] ${confirmMsg}`); - - // 直接启动下载 - startBatchDownload(startIndex0, endIndex0); + startBatchDownload(startIdx - 1, endIdx - 1); }); } - // 7. 持续检查元素是否加载完毕 (保持不变) + // 7. 持续检查元素是否加载完毕 (已重构) function checkAndInitialize() { if (initialized) { clearInterval(intervalId); return; } - const allIcons = document.querySelectorAll('span.anticon-ellipsis.ant-dropdown-trigger'); - if (allIcons.length > 0) { - initializeUI(allIcons); + if (document.querySelector('span.anticon-ellipsis')) { + initializeUI(); } } const intervalId = setInterval(checkAndInitialize, 500); - setTimeout(() => { if (!initialized) { clearInterval(intervalId); - console.warn("RunningHub 批量下载助手:超时,未找到下载图标。"); + console.warn("RunningHub 批量下载助手:超时,未找到下载项目。"); } }, 20000); })(); \ No newline at end of file