diff --git a/README.md b/README.md index 8c8e547..ee5b7e9 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # 夸克自动转存 本项目是在 [Cp0204/quark-auto-save:0.5.3.1](https://github.com/Cp0204/quark-auto-save) 的基础上修改而来的(感谢 [Cp0204](https://github.com/Cp0204)),我对整个 WebUI 进行了重塑,增加了更多实用功能,新增功能的代码都是通过 AI 完成的,不保证功能的稳定性。主要的新增功能如下([详见](https://github.com/x1ao4/quark-auto-save-x/wiki)): -- **过滤项目**:通过在 `过滤规则` 里设置过滤词来过滤不需要转存的文件或文件夹。 +- **过滤项目**:通过在 `过滤规则` 里设置过滤词来过滤不需要转存的文件或文件夹。支持高级过滤功能,使用保留词和过滤词可实现复杂的过滤逻辑。 - **顺序命名**:通过使用包含 `{}` 的表达式(如 `乘风2025 - S06E{}`)自动切换为 `顺序命名` 模式,该模式将通过文件名与上传时间等信息对文件进行智能排序,然后按顺序对每个文件的 `{}` 赋予序号,实现顺序命名。 - **剧集命名**:通过使用包含 `[]` 的表达式(如 `黑镜 - S06E[]`)自动切换为 `剧集命名` 模式,该模式将从原始文件名中提取剧集编号,然后把提取的编号代入对应文件名的 `[]` 中,实现自动按剧集编号命名。 - **自动切换命名模式**:默认的命名模式依然为 `正则命名` 模式,现在会通过用户输入的 `匹配表达式` 自动实时判断和切换对应的模式。 diff --git a/app/run.py b/app/run.py index 2c28a65..abe6ed3 100644 --- a/app/run.py +++ b/app/run.py @@ -16,6 +16,10 @@ from flask import ( from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from sdk.cloudsaver import CloudSaver +try: + from sdk.pansou import PanSou +except Exception: + PanSou = None from datetime import timedelta, datetime import subprocess import requests @@ -43,6 +47,106 @@ from quark_auto_save import extract_episode_number, sort_file_by_name, chinese_t # 导入豆瓣服务 from sdk.douban_service import douban_service +def advanced_filter_files(file_list, filterwords): + """ + 高级过滤函数,支持保留词和过滤词 + + Args: + file_list: 文件列表 + filterwords: 过滤规则字符串,支持以下格式: + - "加更,企划,超前,(1),mkv,nfo" # 只有过滤词 + - "期|加更,企划,超前,(1),mkv,nfo" # 保留词|过滤词 + - "期,2160P|加更,企划,超前,(1),mkv,nfo" # 多个保留词(或关系)|过滤词 + - "期|2160P|加更,企划,超前,(1),mkv,nfo" # 多个保留词(并关系)|过滤词 + - "期,2160P|" # 只有保留词,无过滤词 + + Returns: + 过滤后的文件列表 + """ + if not filterwords or not filterwords.strip(): + return file_list + + # 检查是否包含分隔符 | + if '|' not in filterwords: + # 只有过滤词的情况 + filterwords = filterwords.replace(",", ",") + filterwords_list = [word.strip().lower() for word in filterwords.split(',') if word.strip()] + + filtered_files = [] + for file in file_list: + file_name = file['file_name'].lower() + file_ext = os.path.splitext(file_name)[1].lower().lstrip('.') + + # 检查过滤词是否存在于文件名中,或者过滤词等于扩展名 + if not any(word in file_name for word in filterwords_list) and not any(word == file_ext for word in filterwords_list): + filtered_files.append(file) + + return filtered_files + + # 包含分隔符的情况,需要解析保留词和过滤词 + parts = filterwords.split('|') + if len(parts) < 2: + # 格式错误,返回原列表 + return file_list + + # 最后一个|后面的是过滤词 + filter_part = parts[-1].strip() + # 前面的都是保留词 + keep_parts = [part.strip() for part in parts[:-1] if part.strip()] + + # 解析过滤词 + filterwords_list = [] + if filter_part: + filter_part = filter_part.replace(",", ",") + filterwords_list = [word.strip().lower() for word in filter_part.split(',') if word.strip()] + + # 解析保留词:每个|分隔的部分都是一个独立的筛选条件 + # 这些条件需要按顺序依次应用,形成链式筛选 + keep_conditions = [] + for part in keep_parts: + if part.strip(): + if ',' in part or ',' in part: + # 包含逗号,表示或关系 + part = part.replace(",", ",") + or_words = [word.strip().lower() for word in part.split(',') if word.strip()] + keep_conditions.append(("or", or_words)) + else: + # 不包含逗号,表示单个词 + keep_conditions.append(("single", [part.strip().lower()])) + + # 第一步:应用保留词筛选(链式筛选) + if keep_conditions: + for condition_type, words in keep_conditions: + filtered_by_keep = [] + for file in file_list: + file_name = file['file_name'].lower() + + if condition_type == "or": + # 或关系:包含任意一个词即可 + if any(word in file_name for word in words): + filtered_by_keep.append(file) + elif condition_type == "single": + # 单个词:必须包含 + if words[0] in file_name: + filtered_by_keep.append(file) + + file_list = filtered_by_keep + + # 第二步:应用过滤词过滤 + if filterwords_list: + filtered_files = [] + for file in file_list: + file_name = file['file_name'].lower() + file_ext = os.path.splitext(file_name)[1].lower().lstrip('.') + + # 检查过滤词是否存在于文件名中,或者过滤词等于扩展名 + if not any(word in file_name for word in filterwords_list) and not any(word == file_ext for word in filterwords_list): + filtered_files.append(file) + + return filtered_files + + return file_list + def process_season_episode_info(filename, task_name=None): """ @@ -386,6 +490,43 @@ def get_data(): data["plugins"]["alist"]["storage_id"] ) + # 初始化插件配置模式(如果不存在) + if "plugin_config_mode" not in data: + data["plugin_config_mode"] = { + "aria2": "independent", + "alist_strm_gen": "independent", + "emby": "independent" + } + + # 初始化全局插件配置(如果不存在) + if "global_plugin_config" not in data: + data["global_plugin_config"] = { + "aria2": { + "auto_download": True, + "pause": False, + "auto_delete_quark_files": False + }, + "alist_strm_gen": { + "auto_gen": True + }, + "emby": { + "try_match": True, + "media_id": "" + } + } + + # 初始化推送通知类型配置(如果不存在) + if "push_notify_type" not in data: + data["push_notify_type"] = "full" + + # 初始化搜索来源默认结构 + if "source" not in data or not isinstance(data.get("source"), dict): + data["source"] = {} + # CloudSaver 默认字段 + data["source"].setdefault("cloudsaver", {"server": "", "username": "", "password": "", "token": ""}) + # PanSou 默认字段 + data["source"].setdefault("pansou", {"server": "https://so.252035.xyz"}) + # 发送webui信息,但不发送密码原文 data["webui"] = { "username": config_data["webui"]["username"], @@ -405,6 +546,7 @@ def sync_task_plugins_config(): 4. 保留原有的自定义配置 5. 只处理已启用的插件(通过PLUGIN_FLAGS检查) 6. 清理被禁用插件的配置 + 7. 应用全局插件配置(如果启用) """ global config_data, task_plugins_config_default @@ -416,6 +558,10 @@ def sync_task_plugins_config(): disabled_plugins = set() if PLUGIN_FLAGS: disabled_plugins = {name.lstrip('-') for name in PLUGIN_FLAGS.split(',')} + + # 获取插件配置模式 + plugin_config_mode = config_data.get("plugin_config_mode", {}) + global_plugin_config = config_data.get("global_plugin_config", {}) # 遍历所有任务 for task in config_data["tasklist"]: @@ -433,23 +579,31 @@ def sync_task_plugins_config(): # 跳过被禁用的插件 if plugin_name in disabled_plugins: continue - - # 如果任务中没有该插件的配置,添加默认配置 - if plugin_name not in task["addition"]: - task["addition"][plugin_name] = default_config.copy() - else: - # 如果任务中有该插件的配置,检查是否有新的配置项 - current_config = task["addition"][plugin_name] - # 确保current_config是字典类型 - if not isinstance(current_config, dict): - # 如果不是字典类型,使用默认配置 + + # 检查是否使用全局配置模式 + if plugin_name in plugin_config_mode and plugin_config_mode[plugin_name] == "global": + # 使用全局配置 + if plugin_name in global_plugin_config: + task["addition"][plugin_name] = global_plugin_config[plugin_name].copy() + else: task["addition"][plugin_name] = default_config.copy() - continue - - # 遍历默认配置的每个键值对 - for key, default_value in default_config.items(): - if key not in current_config: - current_config[key] = default_value + else: + # 使用独立配置 + if plugin_name not in task["addition"]: + task["addition"][plugin_name] = default_config.copy() + else: + # 如果任务中有该插件的配置,检查是否有新的配置项 + current_config = task["addition"][plugin_name] + # 确保current_config是字典类型 + if not isinstance(current_config, dict): + # 如果不是字典类型,使用默认配置 + task["addition"][plugin_name] = default_config.copy() + continue + + # 遍历默认配置的每个键值对 + for key, default_value in default_config.items(): + if key not in current_config: + current_config[key] = default_value def parse_comma_separated_config(value): @@ -791,7 +945,14 @@ def get_task_suggestions(): search_query = extract_show_name(query) try: - cs_data = config_data.get("source", {}).get("cloudsaver", {}) + sources_cfg = config_data.get("source", {}) or {} + cs_data = sources_cfg.get("cloudsaver", {}) + ps_data = sources_cfg.get("pansou", {}) + + merged = [] + providers = [] + + # CloudSaver if ( cs_data.get("server") and cs_data.get("username") @@ -803,35 +964,214 @@ def get_task_suggestions(): cs_data.get("password", ""), cs_data.get("token", ""), ) - # 使用处理后的搜索关键词 search = cs.auto_login_search(search_query) if search.get("success"): if search.get("new_token"): cs_data["token"] = search.get("new_token") Config.write_json(CONFIG_PATH, config_data) search_results = cs.clean_search_results(search.get("data")) - # 在返回结果中添加实际使用的搜索关键词 - return jsonify( - { - "success": True, - "source": "CloudSaver", - "data": search_results - } - ) + if isinstance(search_results, list): + merged.extend(search_results) + providers.append("CloudSaver") + + # PanSou + if ps_data and ps_data.get("server") and PanSou is not None: + try: + ps = PanSou(ps_data.get("server")) + result = ps.search(search_query) + if result.get("success") and isinstance(result.get("data"), list): + merged.extend(result.get("data")) + providers.append("PanSou") + except Exception as e: + logging.warning(f"PanSou 搜索失败: {str(e)}") + + # 去重并统一时间字段为 publish_date + # 规则: + # 1) 首轮仅按 shareurl 归并:同一链接保留发布时间最新的一条(展示以该条为准) + # 2) 兜底(极少):无链接时按完整指纹(shareurl|title|date|source)归并 + # 3) 二次归并:对所有候选结果再按 标题+发布时间 做一次归并(无论 shareurl 是否相同),取最新 + # 注意:当发生归并冲突时,始终保留发布时间最新的记录 + dedup_map = {} # 按 shareurl 归并 + fingerprint_map = {} # 兜底:完整指纹归并(仅当缺失链接时) + # 规范化工具 + def normalize_shareurl(url: str) -> str: + try: + if not url: + return "" + u = url.strip() + # 仅取夸克分享ID: pan.quark.cn/s/[?...] + # 同时支持直接传入ID的情况 + match = re.search(r"/s/([^\?/#\s]+)", u) + if match: + return match.group(1) + # 如果没有域名路径,尝试去掉查询参数 + return u.split('?')[0] + except Exception: + return url or "" + def normalize_title(title: str) -> str: + try: + if not title: + return "" + import unicodedata + t = unicodedata.normalize('NFKC', title) + t = t.replace('\u3000', ' ').replace('\t', ' ') + t = re.sub(r"\s+", " ", t).strip() + return t + except Exception: + return title or "" + def normalize_date(date_str: str) -> str: + try: + if not date_str: + return "" + import unicodedata + ds = unicodedata.normalize('NFKC', date_str).strip() + return ds + except Exception: + return (date_str or "").strip() + # 解析时间供比较 + def to_ts(datetime_str): + if not datetime_str: + return 0 + try: + s = str(datetime_str).strip() + from datetime import datetime + try: + return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").timestamp() + except Exception: + pass + try: + return datetime.strptime(s, "%Y-%m-%d").timestamp() + except Exception: + pass + try: + s2 = s.replace('Z', '+00:00') + return datetime.fromisoformat(s2).timestamp() + except Exception: + return 0 + except Exception: + return 0 + + for item in merged: + if not isinstance(item, dict): + continue + # 统一时间字段:优先使用已存在的 publish_date,否则使用 datetime,并写回 publish_date + try: + if not item.get("publish_date") and item.get("datetime"): + item["publish_date"] = item.get("datetime") + except Exception: + pass + + shareurl = normalize_shareurl(item.get("shareurl") or "") + title = normalize_title(item.get("taskname") or "") + pubdate = normalize_date(item.get("publish_date") or "") + source = (item.get("source") or "").strip() + + timestamp = to_ts(pubdate) + + # 条件1:按 shareurl 归并,取最新 + if shareurl: + existed = dedup_map.get(shareurl) + if not existed or to_ts(existed.get("publish_date")) < timestamp: + dedup_map[shareurl] = item else: - return jsonify({"success": True, "message": search.get("message")}) - else: - base_url = base64.b64decode("aHR0cHM6Ly9zLjkxNzc4OC54eXo=").decode() - # 使用处理后的搜索关键词 - url = f"{base_url}/task_suggestions?q={search_query}&d={deep}" - response = requests.get(url) - return jsonify( - { - "success": True, - "source": "网络公开", - "data": response.json() - } - ) + # 条件2(兜底):完整指纹归并(极少发生),依然取最新 + fingerprint = f"{shareurl}|{title}|{pubdate}|{source}" + existed = fingerprint_map.get(fingerprint) + if not existed or to_ts(existed.get("publish_date")) < timestamp: + fingerprint_map[fingerprint] = item + + # 第一轮:汇总归并后的候选结果 + candidates = list(dedup_map.values()) + list(fingerprint_map.values()) + + # 第二轮:无论 shareurl 是否相同,再按 标题+发布时间 归并一次(使用时间戳作为键,兼容不同时间格式),保留最新 + final_map = {} + for item in candidates: + try: + t = normalize_title(item.get("taskname") or "") + d = normalize_date(item.get("publish_date") or "") + s = normalize_shareurl(item.get("shareurl") or "") + src = (item.get("source") or "").strip() + # 优先采用 标题+时间 作为归并键 + ts_val = to_ts(d) + if t and ts_val: + key = f"TD::{t}||{int(ts_val)}" + elif s: + key = f"URL::{s}" + else: + key = f"FP::{s}|{t}|{d}|{src}" + existed = final_map.get(key) + current_ts = to_ts(item.get("publish_date")) + if not existed: + final_map[key] = item + else: + existed_ts = to_ts(existed.get("publish_date")) + if current_ts > existed_ts: + final_map[key] = item + elif current_ts == existed_ts: + # 时间完全相同,使用确定性优先级打破平手 + source_priority = {"CloudSaver": 2, "PanSou": 1} + existed_pri = source_priority.get((existed.get("source") or "").strip(), 0) + current_pri = source_priority.get(src, 0) + if current_pri > existed_pri: + final_map[key] = item + elif current_pri == existed_pri: + # 进一步比较信息丰富度(content 长度) + if len(str(item.get("content") or "")) > len(str(existed.get("content") or "")): + final_map[key] = item + except Exception: + # 出现异常则跳过该项 + continue + + dedup = list(final_map.values()) + + # 仅在排序时对多种格式进行解析(优先解析 YYYY-MM-DD HH:mm:ss,其次 ISO) + if dedup: + def parse_datetime_for_sort(item): + """解析时间字段,返回可比较的时间戳(统一以 publish_date 为准)""" + datetime_str = item.get("publish_date") + if not datetime_str: + return 0 # 没有时间的排在最后 + from datetime import datetime + s = str(datetime_str).strip() + # 优先解析标准显示格式 + try: + dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S") + return dt.timestamp() + except Exception: + pass + # 补充解析仅日期格式 + try: + dt = datetime.strptime(s, "%Y-%m-%d") + return dt.timestamp() + except Exception: + pass + # 其次尝试 ISO(支持 Z/偏移) + try: + s2 = s.replace('Z', '+00:00') + dt = datetime.fromisoformat(s2) + return dt.timestamp() + except Exception: + return 0 # 解析失败排在最后 + + # 按时间倒序排序(最新的在前) + dedup.sort(key=parse_datetime_for_sort, reverse=True) + + return jsonify({ + "success": True, + "source": ", ".join(providers) if providers else "聚合", + "data": dedup + }) + + # 若无本地可用来源,回退到公开网络 + base_url = base64.b64decode("aHR0cHM6Ly9zLjkxNzc4OC54eXo=").decode() + url = f"{base_url}/task_suggestions?q={search_query}&d={deep}" + response = requests.get(url) + return jsonify({ + "success": True, + "source": "网络公开", + "data": response.json() + }) + except Exception as e: return jsonify({"success": True, "message": f"error: {str(e)}"}) @@ -860,6 +1200,10 @@ def get_share_detail(): if not is_sharing: return jsonify({"success": False, "data": {"error": stoken}}) share_detail = account.get_detail(pwd_id, stoken, pdir_fid, _fetch_share=1) + # 统一错误返回,避免前端崩溃 + if isinstance(share_detail, dict) and share_detail.get("error"): + return jsonify({"success": False, "data": {"error": share_detail.get("error")}}) + share_detail["paths"] = paths share_detail["stoken"] = stoken @@ -924,15 +1268,14 @@ def get_share_detail(): # 根据提取的排序值进行排序 sorted_files = sorted(files_to_process, key=extract_sort_value) - # 应用过滤词过滤 + # 应用高级过滤词过滤 filterwords = regex.get("filterwords", "") if filterwords: - # 同时支持中英文逗号分隔 - filterwords = filterwords.replace(",", ",") - filterwords_list = [word.strip() for word in filterwords.split(',')] + # 使用高级过滤函数 + filtered_files = advanced_filter_files(sorted_files, filterwords) + # 标记被过滤的文件 for item in sorted_files: - # 被过滤的文件不会有file_name_re,与不匹配正则的文件显示一致 - if any(word in item['file_name'] for word in filterwords_list): + if item not in filtered_files: item["filtered"] = True # 为每个文件分配序号 @@ -982,15 +1325,14 @@ def get_share_detail(): ] episode_patterns.extend(chinese_patterns) - # 应用过滤词过滤 + # 应用高级过滤词过滤 filterwords = regex.get("filterwords", "") if filterwords: - # 同时支持中英文逗号分隔 - filterwords = filterwords.replace(",", ",") - filterwords_list = [word.strip() for word in filterwords.split(',')] + # 使用高级过滤函数 + filtered_files = advanced_filter_files(share_detail["list"], filterwords) + # 标记被过滤的文件 for item in share_detail["list"]: - # 被过滤的文件显示一个 × - if any(word in item['file_name'] for word in filterwords_list): + if item not in filtered_files: item["filtered"] = True item["file_name_re"] = "×" @@ -1019,15 +1361,14 @@ def get_share_detail(): regex.get("magic_regex", {}), ) - # 应用过滤词过滤 + # 应用高级过滤词过滤 filterwords = regex.get("filterwords", "") if filterwords: - # 同时支持中英文逗号分隔 - filterwords = filterwords.replace(",", ",") - filterwords_list = [word.strip() for word in filterwords.split(',')] + # 使用高级过滤函数 + filtered_files = advanced_filter_files(share_detail["list"], filterwords) + # 标记被过滤的文件 for item in share_detail["list"]: - # 被过滤的文件不会有file_name_re,与不匹配正则的文件显示一致 - if any(word in item['file_name'] for word in filterwords_list): + if item not in filtered_files: item["filtered"] = True # 应用正则命名 @@ -1431,6 +1772,35 @@ def init(): if plugin_name in disabled_plugins: del task["addition"][plugin_name] + # 初始化插件配置模式(如果不存在) + if "plugin_config_mode" not in config_data: + config_data["plugin_config_mode"] = { + "aria2": "independent", + "alist_strm_gen": "independent", + "emby": "independent" + } + + # 初始化全局插件配置(如果不存在) + if "global_plugin_config" not in config_data: + config_data["global_plugin_config"] = { + "aria2": { + "auto_download": True, + "pause": False, + "auto_delete_quark_files": False + }, + "alist_strm_gen": { + "auto_gen": True + }, + "emby": { + "try_match": True, + "media_id": "" + } + } + + # 初始化推送通知类型配置(如果不存在) + if "push_notify_type" not in config_data: + config_data["push_notify_type"] = "full" + # 同步更新任务的插件配置 sync_task_plugins_config() @@ -2066,25 +2436,12 @@ def preview_rename(): if isinstance(files, dict) and files.get("error"): return jsonify({"success": False, "message": f"获取文件列表失败: {files.get('error', '未知错误')}"}) - # 过滤要排除的文件 - # 替换中文逗号为英文逗号 - filterwords = filterwords.replace(",", ",") - filter_list = [keyword.strip() for keyword in filterwords.split(",") if keyword.strip()] - filtered_files = [] - for file in files: - # 如果不包含文件夹且当前项是文件夹,跳过 - if not include_folders and file["dir"]: - continue - - # 检查是否包含过滤关键词 - should_filter = False - for keyword in filter_list: - if keyword and keyword in file["file_name"]: - should_filter = True - break - - if not should_filter: - filtered_files.append(file) + # 使用高级过滤函数过滤文件 + filtered_files = advanced_filter_files(files, filterwords) + + # 如果不包含文件夹,进一步过滤掉文件夹 + if not include_folders: + filtered_files = [file for file in filtered_files if not file["dir"]] # 按不同命名模式处理 preview_results = [] diff --git a/app/sdk/cloudsaver.py b/app/sdk/cloudsaver.py index a095a59..05907b1 100644 --- a/app/sdk/cloudsaver.py +++ b/app/sdk/cloudsaver.py @@ -106,6 +106,19 @@ class CloudSaver: pattern_title = r"(名称|标题)[::]?(.*)" pattern_content = r"(描述|简介)[::]?(.*)(链接|标签)" clean_results = [] + # 工具:移除标题中的链接(http/https 以及常见裸域名的夸克分享) + def strip_links(text: str) -> str: + if not isinstance(text, str): + return text + s = text + import re + # 去除 http/https 链接 + s = re.sub(r"https?://\S+", "", s) + # 去除裸域夸克分享链接(不带协议的 pan.quark.cn/...) + s = re.sub(r"\bpan\.quark\.cn/\S+", "", s) + # 收尾多余空白和分隔符 + s = re.sub(r"\s+", " ", s).strip(" -|·,,::;;" + " ") + return s.strip() link_array = [] for channel in search_results: for item in channel.get("list", []): @@ -117,6 +130,8 @@ class CloudSaver: if match := re.search(pattern_title, title, re.DOTALL): title = match.group(2) title = title.replace("&", "&").strip() + # 标题去除链接 + title = strip_links(title) # 清洗内容 content = item.get("content", "") if match := re.search(pattern_content, content, re.DOTALL): @@ -124,21 +139,66 @@ class CloudSaver: content = content.replace('', "") content = content.replace("", "") content = content.strip() - # 链接去重 - if link.get("link") not in link_array: - link_array.append(link.get("link")) - clean_results.append( - { - "shareurl": link.get("link"), - "taskname": title, - "content": content, - "tags": item.get("tags", []), - "channel": item.get("channel", ""), - "channel_id": item.get("channelId", ""), - } - ) - return clean_results + # 获取发布时间 - 采用与原始实现一致的方式 + pubdate_iso = item.get("pubDate", "") # 原始时间字符串(可能为 ISO 或已是北京时间) + pubdate = pubdate_iso # 不做时区转换,保留来源原始时间 + # 收集结果(不在此处去重,统一在末尾按最新归并) + clean_results.append( + { + "shareurl": link.get("link"), + "taskname": title, + "content": content, + "datetime": pubdate, # 显示用时间 + "tags": item.get("tags", []), + "channel": item.get("channelId", ""), + "source": "CloudSaver" + } + ) + + # 去重:按 shareurl 归并,保留发布时间最新的记录 + def to_ts(date_str: str) -> float: + if not date_str: + return 0 + try: + s = str(date_str).strip() + from datetime import datetime + try: + return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").timestamp() + except Exception: + pass + try: + return datetime.strptime(s, "%Y-%m-%d").timestamp() + except Exception: + pass + try: + s2 = s.replace('Z', '+00:00') + return datetime.fromisoformat(s2).timestamp() + except Exception: + return 0 + except Exception: + return 0 + by_url = {} + for item in clean_results: + try: + url = item.get("shareurl", "") + if not url: + continue + existed = by_url.get(url) + if not existed: + by_url[url] = item + else: + # 比较 datetime(CloudSaver清洗阶段时间字段名为 datetime) + if to_ts(item.get("datetime")) > to_ts(existed.get("datetime")): + by_url[url] = item + except Exception: + continue + + unique_results = list(by_url.values()) + + # 注意:排序逻辑已移至全局,这里不再进行内部排序 + # 返回归并后的结果,由全局排序函数统一处理 + return unique_results # 测试示例 if __name__ == "__main__": diff --git a/app/sdk/pansou.py b/app/sdk/pansou.py new file mode 100644 index 0000000..2adf95d --- /dev/null +++ b/app/sdk/pansou.py @@ -0,0 +1,199 @@ +import requests +import json +from typing import List, Dict, Any + + +class PanSou: + """PanSou 资源搜索客户端""" + + def __init__(self, server: str): + self.server = server.rstrip("/") if server else "" + self.session = requests.Session() + # 使用标准请求头 + self.session.headers.update({ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "QASX-PanSouClient/1.0" + }) + + def _request_json(self, url: str, params: dict): + """发送 GET 请求并解析 JSON 响应""" + try: + resp = self.session.get(url, params=params, timeout=15) + return resp.json() + except Exception as e: + return {"success": False, "message": str(e)} + + def search(self, keyword: str): + """ + 搜索资源(仅返回夸克网盘结果) + 返回:{"success": True, "data": [{taskname, content, shareurl, tags[]}]} + """ + if not self.server: + return {"success": False, "message": "PanSou未配置服务器"} + + # 使用已验证的参数:kw + cloud_types=quark + res=all + params = { + "kw": keyword, + "cloud_types": "quark", # 单个类型用字符串,多个类型用逗号分隔 + "res": "all" + } + + # 优先使用 /api/search 路径 + url = f"{self.server}/api/search" + result = self._request_json(url, params) + + if not result: + return {"success": False, "message": "PanSou请求失败"} + + # 解析响应:兼容 {code, message, data: {results, merged_by_type}} 格式 + payload = result + if isinstance(result.get("data"), dict): + payload = result["data"] + + # 检查错误码 + if "code" in result and result.get("code") != 0: + return {"success": False, "message": result.get("message") or "PanSou搜索失败"} + + # 解析结果:优先 results,然后 merged_by_type + cleaned = [] + # 工具:移除标题中的链接 + def strip_links(text: str) -> str: + if not isinstance(text, str): + return text + s = text + import re + s = re.sub(r"https?://\S+", "", s) + s = re.sub(r"\bpan\.quark\.cn/\S+", "", s) + s = re.sub(r"\s+", " ", s).strip(" -|·,,::;;" + " ") + return s.strip() + + try: + # 1) results: 主要结果数组,每个结果包含 title 和 links + results = payload.get("results", []) + if isinstance(results, list): + for result_item in results: + if not isinstance(result_item, dict): + continue + + # 从 result_item 获取标题、内容和发布日期 + title = result_item.get("title", "") + title = strip_links(title) + content = result_item.get("content", "") + datetime_str = result_item.get("datetime", "") # 获取发布日期 + + # 从 links 获取具体链接 + links = result_item.get("links", []) + if isinstance(links, list): + for link in links: + if isinstance(link, dict): + url = link.get("url", "") + link_type = link.get("type", "") + if url: # 确保有有效链接 + cleaned.append({ + "taskname": title, + "content": content, + "shareurl": url, + "tags": [link_type] if link_type else (result_item.get("tags", []) or []), + "publish_date": datetime_str, # 原始时间(可能是 ISO) + "source": "PanSou" # 添加来源标识 + }) + + # 2) merged_by_type: 兜底解析,使用 note 字段作为标题 + if not cleaned: + merged = payload.get("merged_by_type") + if isinstance(merged, dict): + for cloud_type, links in merged.items(): + if isinstance(links, list): + for link in links: + if isinstance(link, dict): + # 从 merged_by_type 获取链接信息 + url = link.get("url", "") + note = link.get("note", "") # 使用 note 字段作为标题 + note = strip_links(note) + datetime_str = link.get("datetime", "") # 获取发布日期 + if url: + cleaned.append({ + "taskname": note, + "content": note, # 如果没有 content,使用 note + "shareurl": url, + "tags": [cloud_type] if cloud_type else [], + "publish_date": datetime_str, # 原始时间 + "source": "PanSou" # 添加来源标识 + }) + + # 3) 直接 data 数组兜底 + if not cleaned and isinstance(payload, list): + for item in payload: + if isinstance(item, dict): + cleaned.append({ + "taskname": item.get("title", ""), + "content": item.get("content", ""), + "shareurl": item.get("url", ""), + "tags": item.get("tags", []) or [], + "publish_date": item.get("datetime", ""), # 原始时间 + "source": "PanSou" # 添加来源标识 + }) + + except Exception as e: + return {"success": False, "message": f"解析PanSou结果失败: {str(e)}"} + + # 二次过滤:确保只返回夸克网盘链接 + if cleaned: + filtered = [] + for item in cleaned: + try: + url = item.get("shareurl", "") + tags = item.get("tags", []) or [] + # 检查是否为夸克网盘 + is_quark = ("quark" in tags) or ("pan.quark.cn" in url) + if is_quark: + filtered.append(item) + except Exception: + continue + cleaned = filtered + + if not cleaned: + return {"success": False, "message": "PanSou搜索无夸克网盘结果"} + + # 去重:按 shareurl 归并,保留发布时间最新的记录 + def to_ts(date_str: str) -> float: + if not date_str: + return 0 + try: + s = str(date_str).strip() + from datetime import datetime + try: + return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").timestamp() + except Exception: + pass + try: + return datetime.strptime(s, "%Y-%m-%d").timestamp() + except Exception: + pass + try: + s2 = s.replace('Z', '+00:00') + return datetime.fromisoformat(s2).timestamp() + except Exception: + return 0 + except Exception: + return 0 + + by_url = {} + for item in cleaned: + try: + url = item.get("shareurl", "") + if not url: + continue + existed = by_url.get(url) + if not existed: + by_url[url] = item + else: + # 比较 publish_date(若不存在则视为0) + if to_ts(item.get("publish_date")) > to_ts(existed.get("publish_date")): + by_url[url] = item + except Exception: + continue + + unique_results = list(by_url.values()) + return {"success": True, "data": unique_results} diff --git a/app/static/css/main.css b/app/static/css/main.css index 6494d01..b2b4cbe 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -3218,7 +3218,7 @@ div[data-toggle="collapse"] .btn.text-left i.bi-caret-right-fill { color: inherit; transition: transform 0.2s; position: relative; - top: 0.5px; /* 调整箭头垂直对齐,使其与文本居中 */ + top: 0; /* 调整箭头垂直对齐,使其与文本居中 */ font-size: 0.95rem; /* 调整箭头大小与文本比例协调 */ margin-right: 4px; /* 添加右侧间距使与文字有适当间距 */ } @@ -6399,3 +6399,42 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil font-family: inherit; letter-spacing: normal; } + +/* 仅在“搜索来源”前的最后一个插件折叠时,将间距减少 2px */ +div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) { + margin-bottom: -10px !important; /* override inline -8px only for collapsed state */ +} + +/* 修复系统配置页面性能设置与API接口模块间距问题 */ +.row.mb-2.performance-setting-row + .row.title[title^="API接口"] { + margin-top: 0 !important; /* prevent unexpected collapse stacking */ + padding-top: 4px !important; /* adds effective +4px spacing */ +} + +/* --------------- 来源标识样式 --------------- */ +.source-badge { + display: inline-block; + margin-left: 1px; + font-size: 14px; + line-height: 1.2; + white-space: nowrap; + vertical-align: baseline; + color: var(--light-text-color); + background-color: transparent; +} + +.source-badge::before { + content: " · "; + margin-right: 0; + color: var(--light-text-color); +} + +.source-badge::after { + content: attr(data-publish-date); + margin-left: 0; + color: var(--light-text-color); + font-size: 14px; + line-height: 1.2; + white-space: nowrap; + vertical-align: baseline; +} \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index 27c0d02..98acf0f 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -509,12 +509,22 @@
-

通知

+

通知设置

+
+
+ 推送通知 +
+ +
@@ -531,7 +541,7 @@
-

插件

+

插件设置

@@ -541,7 +551,7 @@
- +
@@ -554,6 +564,98 @@ :placeholder="getPluginConfigPlaceholder(pluginName, key)" :title="getPluginConfigHelp(pluginName, key)">
+ +
+
+ 插件配置模式 +
+ +
+ +
+
+
+ +
+ + +
+
+
+
+ +
+
+

搜索来源

+ + + +
+
+ +
+ +
+
+
+
+ CloudSaver +
+
+
+
+
+
+ 服务器 +
+ +
+
+
+ 用户名 +
+ +
+
+
+ 密码 +
+ +
+ +
+
+
+
+ + +
+
+ +
+
+
+
+ 服务器 +
+ +
+
@@ -692,59 +794,6 @@
-
-
-

API

- - - -
-
-
-
- Token -
- -
- -
-
-

CloudSaver

- - - -
-
-
-
- 服务器 -
- -
-
-
-
-
- 用户名 -
- -
-
-
-
-
- 密码 -
- -
- -
-
-
-
-

显示设置

@@ -884,6 +933,20 @@
+
+
+

API

+ + + +
+
+
+
+ Token +
+ +
@@ -964,15 +1027,16 @@ 正在验证链接有效性...({{ smart_param.validateProgress.current }}/{{ smart_param.validateProgress.total }})已找到 {{ smart_param.validateProgress.valid }} 个有效链接 - 正在搜索中... + 正在搜索资源... - @@ -1044,11 +1108,11 @@ -
+
- +
@@ -1096,10 +1160,10 @@
-
+
- +
@@ -1359,7 +1423,7 @@ @input="detectFileManagerNamingMode" :title="fileManager.use_sequence_naming ? '输入带{}占位符的重命名格式,如:剧名 - S01E{}、剧名.S03E{}等,{}将被替换为集序号(按文件名和修改日期智能排序)' : (fileManager.use_episode_naming ? '输入带[]占位符的重命名格式,如:剧名 - S01E[]、剧名.S03E[]等,[]将被替换为从文件名中提取的集编号' : '只重命名匹配到文件名的文件,留空不会进行重命名')"> - +
 含文件夹 @@ -1390,7 +1454,7 @@
过滤规则
- +  含文件夹 @@ -1891,15 +1955,16 @@ 正在验证链接有效性...({{ smart_param.validateProgress.current }}/{{ smart_param.validateProgress.total }})已找到 {{ smart_param.validateProgress.valid }} 个有效链接 - 正在搜索中... + 正在搜索资源...
- @@ -1971,11 +2036,11 @@
-
+
- +
@@ -2023,10 +2088,10 @@
-
+
- +
@@ -2068,9 +2133,18 @@ showCloudSaverPassword: false, showWebuiPassword: false, pageWidthMode: 'medium', // 页面宽度模式:narrow, medium, wide + pluginDisplayAliases: { + alist: 'AList', + alist_strm: 'AList Strm', + alist_strm_gen: 'AList Strm Gen', + aria2: 'Aria2', + emby: 'Emby', + plex: 'Plex' + }, formData: { cookie: [], push_config: {}, + push_notify_type: 'full', media_servers: {}, tasklist: [], magic_regex: {}, @@ -2092,6 +2166,9 @@ username: "", password: "", token: "" + }, + pansou: { + server: "https://so.252035.xyz" } }, webui: { @@ -2111,6 +2188,25 @@ api_page_size: 200, cache_expire_time: 30, discovery_items_count: 30 + }, + plugin_config_mode: { + aria2: "independent", + alist_strm_gen: "independent", + emby: "independent" + }, + global_plugin_config: { + aria2: { + auto_download: true, + pause: false, + auto_delete_quark_files: false + }, + alist_strm_gen: { + auto_gen: true + }, + emby: { + try_match: true, + media_id: "" + } } }, userInfoList: [], // 用户信息列表 @@ -2155,6 +2251,8 @@ valid: 0 }, searchTimer: null, + // 新增:搜索会话号用于取消上一次验证/渲染,避免卡死和重复 + searchSessionId: 0 }, activeTab: 'config', configModified: false, @@ -2673,6 +2771,28 @@ document.removeEventListener('click', this.handleOutsideClick); }, methods: { + // 仅当有有效信息时返回悬停提示,否则返回null以不显示 + getSuggestionHoverTitle(suggestion) { + if (!suggestion) return null; + let content = (suggestion.content || '').trim(); + if (!content) return null; + // 统一标点为英文冒号,统一逗号 + const normalized = content + .replace(/:/g, ':') + .replace(/,/g, ',') + .replace(/\s+/g, ' ') + .trim(); + // 仅在明确的占位文本时隐藏: + // 1) 全文就是“大小:-” + if (/^大小\s*:\s*-$/i.test(normalized)) return null; + // 2) 完全匹配“类别:xx, 文件类型:yy, 大小:-”这类占位 + if (/^类别\s*:[^,]*,\s*文件类型\s*:[^,]*,\s*大小\s*:\s*-$/i.test(normalized)) return null; + return content; + }, + // 获取插件展示名称(支持别名,仅用于WebUI显示) + getPluginDisplayName(pluginName) { + return this.pluginDisplayAliases[pluginName] || pluginName; + }, // 设置移动端任务列表展开/收起状态监听 setupMobileTaskListToggle() { // 监听所有collapse事件 @@ -2743,6 +2863,139 @@ return ''; }, + // 获取插件任务配置 + getPluginTaskConfig(pluginName) { + const taskConfigs = { + aria2: { + auto_download: true, + pause: false, + auto_delete_quark_files: false + }, + alist_strm_gen: { + auto_gen: true + }, + emby: { + try_match: true, + media_id: "" + } + }; + return taskConfigs[pluginName] || {}; + }, + + // 获取插件配置模式的帮助文本 + getPluginConfigModeHelp(pluginName) { + return "选择插件的配置模式:独立配置允许每个任务单独设置,全局配置则所有任务共享同一套设置,且只能在系统配置页面修改"; + }, + + // 获取插件任务配置的帮助文本 + getPluginTaskConfigHelp(pluginName, key) { + const helpTexts = { + aria2: { + auto_download: "是否自动添加下载任务", + pause: "添加任务后为暂停状态,不自动开始(手动下载)", + auto_delete_quark_files: "是否在添加下载任务后自动删除夸克网盘文件" + }, + alist_strm_gen: { + auto_gen: "是否自动生成 strm 文件" + }, + emby: { + try_match: "是否尝试匹配", + media_id: "媒体ID,当为0时不刷新" + } + }; + return helpTexts[pluginName]?.[key] || ''; + }, + + // 获取插件任务配置的占位符文本 + getPluginTaskConfigPlaceholder(pluginName, key) { + const placeholders = { + aria2: { + auto_download: "", + pause: "", + auto_delete_quark_files: "" + }, + alist_strm_gen: { + auto_gen: "" + }, + emby: { + try_match: "", + media_id: "输入媒体ID,留空则自动匹配" + } + }; + return placeholders[pluginName]?.[key] || ''; + }, + + // 检查插件配置是否被禁用 + isPluginConfigDisabled(task) { + for (const pluginName of ['aria2', 'alist_strm_gen', 'emby']) { + if (this.formData.plugin_config_mode[pluginName] === 'global') { + return true; + } + } + return false; + }, + + // 插件配置模式改变时的处理 + onPluginConfigModeChange(pluginName) { + if (this.formData.plugin_config_mode[pluginName] === 'global') { + // 切换到全局模式时,初始化全局配置 + if (!this.formData.global_plugin_config[pluginName]) { + this.formData.global_plugin_config[pluginName] = { ...this.getPluginTaskConfig(pluginName) }; + } + } + + // 更新新任务的配置,应用全局配置 + this.applyGlobalPluginConfig(this.newTask); + + // 更新影视发现页面创建任务的配置,应用全局配置 + if (this.createTask && this.createTask.taskData) { + this.applyGlobalPluginConfig(this.createTask.taskData); + } + }, + + // 全局插件配置改变时的处理 + onGlobalPluginConfigChange() { + // 更新新任务的配置,应用全局配置 + this.applyGlobalPluginConfig(this.newTask); + + // 更新影视发现页面创建任务的配置,应用全局配置 + if (this.createTask && this.createTask.taskData) { + this.applyGlobalPluginConfig(this.createTask.taskData); + } + }, + + // 应用全局插件配置到任务 + applyGlobalPluginConfig(task) { + if (!task.addition) { + task.addition = {}; + } + + for (const pluginName of ['aria2', 'alist_strm_gen', 'emby']) { + if (this.formData.plugin_config_mode[pluginName] === 'global') { + // 应用全局配置到任务 + task.addition[pluginName] = { ...this.formData.global_plugin_config[pluginName] }; + } + } + }, + + + + // 获取插件配置的悬停提示文本 + getPluginConfigTitle(task) { + if (this.isPluginConfigDisabled(task)) { + return `单个任务的插件配置,具体键值由插件定义,当前有部分插件使用了全局配置模式,在该模式下对应的配置选项将被锁定,若要修改配置,请前往系统配置页面进行操作,查阅Wiki了解详情`; + } + return "单个任务的插件配置,具体键值由插件定义,查阅Wiki了解详情"; + }, + + // 获取创建任务时的插件配置悬停提示文本 + getCreateTaskPluginConfigTitle() { + if (this.isPluginConfigDisabled(this.createTask.taskData)) { + return `单个任务的插件配置,具体键值由插件定义,当前有部分插件使用了全局配置模式,在该模式下对应的配置选项将被锁定,若要修改配置,请前往系统配置页面进行操作,查阅Wiki了解详情`; + } + return "单个任务的插件配置,具体键值由插件定义,查阅Wiki了解详情"; + }, + fetchUserInfo() { // 获取所有cookie对应的用户信息 axios.get('/get_user_info') @@ -3153,7 +3406,9 @@ if (!this.taskDirs.includes(parentDir)) this.taskDirs.push(parentDir); }); - this.newTask.addition = config_data.task_plugins_config_default; + // 初始化新任务的插件配置,应用全局配置 + this.newTask.addition = { ...config_data.task_plugins_config_default }; + this.applyGlobalPluginConfig(this.newTask); // 确保source配置存在 if (!config_data.source) { config_data.source = {}; @@ -3297,6 +3552,9 @@ task.episode_naming = task.pattern; } } + + // 应用全局插件配置 + this.applyGlobalPluginConfig(task); }); } @@ -3368,6 +3626,9 @@ newTask.replace = lastTask.replace || ""; } + // 应用全局插件配置到新任务 + this.applyGlobalPluginConfig(newTask); + this.formData.tasklist.push(newTask) const index = this.formData.tasklist.length - 1; @@ -4015,6 +4276,8 @@ // 确保显示下拉菜单 this.smart_param.showSuggestions = true; try { + // 启动新的搜索会话,后续增量结果仅在会话一致时才渲染 + const sessionId = ++this.smart_param.searchSessionId; axios.get('/task_suggestions', { params: { q: taskname, @@ -4022,9 +4285,10 @@ } }).then(response => { // 接收到数据后,过滤无效链接 + if (sessionId !== this.smart_param.searchSessionId) return; // 旧会话结果忽略 if (response.data.success && response.data.data && response.data.data.length > 0) { // 使用新增的方法验证链接有效性 - this.validateSearchResults(response.data); + this.validateSearchResults(response.data, sessionId); } else { this.smart_param.taskSuggestions = response.data; // 重新确认设置为true @@ -4035,6 +4299,7 @@ }).catch(error => { this.smart_param.isSearching = false; this.smart_param.validating = false; // 重置验证状态 + }); } catch (e) { this.smart_param.taskSuggestions = { @@ -4042,10 +4307,11 @@ }; this.smart_param.isSearching = false; this.smart_param.validating = false; // 重置验证状态 + } }, // 添加新方法来验证搜索结果 - validateSearchResults(searchData) { + validateSearchResults(searchData, sessionId) { const invalidTerms = [ "分享者用户封禁链接查看受限", "好友已取消了分享", @@ -4080,11 +4346,15 @@ // 批量处理的大小,每批次最多处理5个链接 const batchSize = 5; + // 解析时间用于排序(降序:最新在前) + const getItemTs = (item) => this.parsePublishTs(item && item.publish_date); + // 处理单个链接的函数 const processLink = (link) => { return new Promise((resolve) => { if (!link.shareurl) { // 没有分享链接,直接跳过 + resolve(null); return; } @@ -4096,6 +4366,10 @@ .then(response => { // 更新进度 this.smart_param.validateProgress.current++; + if (sessionId !== this.smart_param.searchSessionId) { + resolve(null); + return; + } if (response.data.success) { // 检查文件列表是否为空 @@ -4103,6 +4377,7 @@ if (shareDetail.list && shareDetail.list.length > 0) { // 链接有效,添加到有效结果列表 this.smart_param.validateProgress.valid++; + resolve(link); return; } @@ -4128,18 +4403,21 @@ // 如果不是已知的失效原因,保留该结果 if (!isInvalid) { this.smart_param.validateProgress.valid++; + resolve(link); return; } } // 链接无效 + resolve(null); }) .catch(error => { // 验证出错,保守处理为有效 this.smart_param.validateProgress.current++; this.smart_param.validateProgress.valid++; + resolve(link); }); }); @@ -4147,6 +4425,8 @@ // 修改processBatch函数,增加快速显示功能 const processBatch = async () => { + // 新会话已开始或已被取消(validating=false)则停止当前批次 + if (sessionId !== this.smart_param.searchSessionId || !this.smart_param.validating) return; // 取下一批处理 const batch = toProcess.splice(0, batchSize); if (batch.length === 0) { @@ -4165,26 +4445,17 @@ } }); - // 快速显示:如果已经找到至少3个有效链接,并且还没有显示过结果 - // 同时已验证的链接数量超过总数的30%或者已经找到5个有效链接 - const hasEnoughValidLinks = validResults.length >= 3; - const hasProcessedEnough = this.smart_param.validateProgress.current >= this.smart_param.validateProgress.total * 0.3 || validResults.length >= 5; + // 动态排序(按发布时间/日期降序) + validResults.sort((a, b) => getItemTs(b) - getItemTs(a)); - if (hasEnoughValidLinks && hasProcessedEnough && !this.smart_param._hasShownInterimResults) { - // 标记已显示过快速结果 - this.smart_param._hasShownInterimResults = true; - - // 创建一个中间结果显示,同时保持验证状态 - const interimResult = { - success: searchData.success, - source: searchData.source, - data: [...validResults], // 复制当前有效结果 - message: `已找到${validResults.length}个有效链接,验证继续进行中...` - }; - - // 更新显示但保持验证状态 - this.smart_param.taskSuggestions = interimResult; - } + // 每批次都增量更新到界面,显示当前有效数量并保持正在验证状态 + if (sessionId !== this.smart_param.searchSessionId || !this.smart_param.validating) return; // 渲染前再次检查会话 + this.smart_param.taskSuggestions = { + success: searchData.success, + source: searchData.source, + data: [...validResults], + message: `已找到${validResults.length}个有效链接,验证继续进行中...` + }; // 继续处理下一批 processBatch(); @@ -4197,7 +4468,10 @@ // 设置超时,避免永久等待 setTimeout(() => { // 如果验证还在进行中,强制完成 - if (this.smart_param.validating) { + if (this.smart_param.validating && sessionId === this.smart_param.searchSessionId) { + // 在收尾前立即取消会话,避免后续批次继续追加导致重复 + this.smart_param.validating = false; + this.smart_param.searchSessionId++; // 将剩余未验证的链接添加到结果中 const remaining = toProcess.filter(item => item.shareurl); validResults.push(...remaining); @@ -4208,6 +4482,7 @@ // 完成验证 this.finishValidation(searchData, validResults); + } }, 30000); // 30秒超时 }, @@ -4216,6 +4491,10 @@ // 清除快速显示标记 this.smart_param._hasShownInterimResults = false; + // 结束前做一次排序,确保最终顺序正确 + const getItemTs = (item) => this.parsePublishTs(item && item.publish_date); + validResults.sort((a, b) => getItemTs(b) - getItemTs(a)); + // 更新搜索结果 const result = { success: searchData.success, @@ -4224,6 +4503,7 @@ message: validResults.length === 0 ? "未找到有效的分享链接" : searchData.message }; + this.smart_param.taskSuggestions = result; this.smart_param.isSearching = false; this.smart_param.validating = false; @@ -6282,6 +6562,44 @@ const seconds = String(d.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; }, + // 统一解析资源发布日期为时间戳 + parsePublishTs(raw) { + if (!raw) return 0; + const s = String(raw).trim(); + // YYYY-MM-DD HH:mm:ss + let m = /^\s*(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s*$/.exec(s); + if (m) { + const [, y, mo, d, h, mi, se] = m; + return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se)).getTime(); + } + // YYYY-MM-DD + m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(s); + if (m) { + const [, y, mo, d] = m; + return new Date(Number(y), Number(mo) - 1, Number(d), 0, 0, 0).getTime(); + } + // ISO 回退 + const ts = Date.parse(s); + return isNaN(ts) ? 0 : ts; + }, + // 规范化资源发布日期展示:将 ISO 格式(含 T/Z/偏移)转为 "YYYY-MM-DD HH:mm:ss" + formatPublishDate(value) { + if (!value) return ''; + const s = String(value).trim(); + // 已是标准格式则直接返回 + if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}$/.test(s)) return s; + // 优先匹配 ISO 主体部分 + const m = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/.exec(s); + if (m) { + const [, y, mo, d, h, mi, se] = m; + return `${y}-${mo}-${d} ${h}:${mi}:${se}`; + } + // 回退:简单替换T为空格并去除尾部Z/时区偏移 + let out = s.replace('T', ' '); + out = out.replace(/Z$/i, ''); + out = out.replace(/([+-]\d{2}:?\d{2})$/i, ''); + return out; + }, changeFolderPage(page) { if (page < 1) page = 1; if (page > this.fileManager.totalPages) page = this.fileManager.totalPages; @@ -7446,10 +7764,10 @@ // 打开创建任务模态框 $('#createTaskModal').modal('show'); - // 如果启用了自动搜索资源且配置了有效的CloudSaver信息,自动触发资源搜索 + // 如果启用了自动搜索资源且配置了有效的搜索来源,自动触发资源搜索 this.$nextTick(() => { if (this.formData.task_settings.auto_search_resources === 'enabled' && - this.isCloudSaverConfigValid() && + this.hasAnyValidSearchSource() && this.createTask.taskData.taskname) { this.searchSuggestions(-1, this.createTask.taskData.taskname); } @@ -7458,13 +7776,13 @@ console.error('创建任务时出错:', error); } }, - isCloudSaverConfigValid() { - // 检查CloudSaver配置是否有效 - const csData = this.formData.source && this.formData.source.cloudsaver; - return csData && - csData.server && - csData.username && - csData.password; + hasAnyValidSearchSource() { + const src = this.formData.source || {}; + const cs = src.cloudsaver || {}; + const ps = src.pansou || {}; + const csValid = cs.server && cs.username && cs.password; + const psValid = ps.server; + return !!(csValid || psValid); }, smartFillTaskData(item, movieData) { // 智能填充任务数据 @@ -7548,9 +7866,10 @@ this.createTask.taskData.startfid = ""; this.createTask.taskData.update_subdir = ""; - // 设置默认的插件配置 + // 设置默认的插件配置,并应用全局配置 if (this.formData.task_plugins_config_default) { this.createTask.taskData.addition = { ...this.formData.task_plugins_config_default }; + this.applyGlobalPluginConfig(this.createTask.taskData); } return; } @@ -7644,9 +7963,10 @@ this.createTask.taskData.startfid = ""; this.createTask.taskData.update_subdir = ""; - // 设置默认的插件配置 + // 设置默认的插件配置,并应用全局配置 if (this.formData.task_plugins_config_default) { this.createTask.taskData.addition = { ...this.formData.task_plugins_config_default }; + this.applyGlobalPluginConfig(this.createTask.taskData); } }, isUsingCustomTaskSettingsForType(taskSettings, contentType) { @@ -7974,6 +8294,9 @@ // 创建新任务 const newTask = { ...this.createTask.taskData }; + // 应用全局插件配置 + this.applyGlobalPluginConfig(newTask); + // 处理命名模式 if (newTask.use_sequence_naming) { newTask.pattern = newTask.sequence_naming; @@ -8034,6 +8357,9 @@ // 创建新任务 const newTask = { ...this.createTask.taskData }; + // 应用全局插件配置 + this.applyGlobalPluginConfig(newTask); + // 处理命名模式 if (newTask.use_sequence_naming) { newTask.pattern = newTask.sequence_naming; @@ -8103,6 +8429,9 @@ // 创建新任务 const newTask = { ...this.createTask.taskData }; + // 应用全局插件配置 + this.applyGlobalPluginConfig(newTask); + // 处理命名模式 if (newTask.use_sequence_naming) { newTask.pattern = newTask.sequence_naming; diff --git a/plugins/aria2.py b/plugins/aria2.py index a1b3b7e..7646a16 100644 --- a/plugins/aria2.py +++ b/plugins/aria2.py @@ -45,7 +45,7 @@ class Aria2: "dir": "/downloads", # 下载目录,需要Aria2有权限访问 } default_task_config = { - "auto_download": False, # 是否自动添加下载任务 + "auto_download": True, # 是否自动添加下载任务 "pause": False, # 添加任务后为暂停状态,不自动开始(手动下载) "auto_delete_quark_files": False, # 是否在添加下载任务后自动删除夸克网盘文件 } diff --git a/quark_auto_save.py b/quark_auto_save.py index c9f0f70..8ad04a2 100644 --- a/quark_auto_save.py +++ b/quark_auto_save.py @@ -36,6 +36,106 @@ except ImportError: def close(self): pass +def advanced_filter_files(file_list, filterwords): + """ + 高级过滤函数,支持保留词和过滤词 + + Args: + file_list: 文件列表 + filterwords: 过滤规则字符串,支持以下格式: + - "加更,企划,超前,(1),mkv,nfo" # 只有过滤词 + - "期|加更,企划,超前,(1),mkv,nfo" # 保留词|过滤词 + - "期,2160P|加更,企划,超前,(1),mkv,nfo" # 多个保留词(或关系)|过滤词 + - "期|2160P|加更,企划,超前,(1),mkv,nfo" # 多个保留词(并关系)|过滤词 + - "期,2160P|" # 只有保留词,无过滤词 + + Returns: + 过滤后的文件列表 + """ + if not filterwords or not filterwords.strip(): + return file_list + + # 检查是否包含分隔符 | + if '|' not in filterwords: + # 只有过滤词的情况 + filterwords = filterwords.replace(",", ",") + filterwords_list = [word.strip().lower() for word in filterwords.split(',') if word.strip()] + + filtered_files = [] + for file in file_list: + file_name = file['file_name'].lower() + file_ext = os.path.splitext(file_name)[1].lower().lstrip('.') + + # 检查过滤词是否存在于文件名中,或者过滤词等于扩展名 + if not any(word in file_name for word in filterwords_list) and not any(word == file_ext for word in filterwords_list): + filtered_files.append(file) + + return filtered_files + + # 包含分隔符的情况,需要解析保留词和过滤词 + parts = filterwords.split('|') + if len(parts) < 2: + # 格式错误,返回原列表 + return file_list + + # 最后一个|后面的是过滤词 + filter_part = parts[-1].strip() + # 前面的都是保留词 + keep_parts = [part.strip() for part in parts[:-1] if part.strip()] + + # 解析过滤词 + filterwords_list = [] + if filter_part: + filter_part = filter_part.replace(",", ",") + filterwords_list = [word.strip().lower() for word in filter_part.split(',') if word.strip()] + + # 解析保留词:每个|分隔的部分都是一个独立的筛选条件 + # 这些条件需要按顺序依次应用,形成链式筛选 + keep_conditions = [] + for part in keep_parts: + if part.strip(): + if ',' in part or ',' in part: + # 包含逗号,表示或关系 + part = part.replace(",", ",") + or_words = [word.strip().lower() for word in part.split(',') if word.strip()] + keep_conditions.append(("or", or_words)) + else: + # 不包含逗号,表示单个词 + keep_conditions.append(("single", [part.strip().lower()])) + + # 第一步:应用保留词筛选(链式筛选) + if keep_conditions: + for condition_type, words in keep_conditions: + filtered_by_keep = [] + for file in file_list: + file_name = file['file_name'].lower() + + if condition_type == "or": + # 或关系:包含任意一个词即可 + if any(word in file_name for word in words): + filtered_by_keep.append(file) + elif condition_type == "single": + # 单个词:必须包含 + if words[0] in file_name: + filtered_by_keep.append(file) + + file_list = filtered_by_keep + + # 第二步:应用过滤词过滤 + if filterwords_list: + filtered_files = [] + for file in file_list: + file_name = file['file_name'].lower() + file_ext = os.path.splitext(file_name)[1].lower().lstrip('.') + + # 检查过滤词是否存在于文件名中,或者过滤词等于扩展名 + if not any(word in file_name for word in filterwords_list) and not any(word == file_ext for word in filterwords_list): + filtered_files.append(file) + + return filtered_files + + return file_list + # 全局的文件排序函数 def sort_file_by_name(file): """ @@ -729,6 +829,28 @@ def add_notify(text): # 防止重复添加相同的通知 if text in NOTIFYS: return text + + # 检查推送通知类型配置 + push_notify_type = CONFIG_DATA.get("push_notify_type", "full") + + # 如果设置为仅推送成功信息,则过滤掉失败和错误信息 + if push_notify_type == "success_only": + # 检查是否包含失败或错误相关的关键词 + failure_keywords = ["❌", "❗", "失败", "失效", "错误", "异常", "无效", "登录失败"] + if any(keyword in text for keyword in failure_keywords): + # 只打印到控制台,不添加到通知列表 + print(text) + return text + + # 如果设置为排除失效信息,则过滤掉资源失效信息,但保留转存失败信息 + elif push_notify_type == "exclude_invalid": + # 检查是否包含资源失效相关的关键词(主要是分享资源失效) + invalid_keywords = ["分享资源已失效", "分享详情获取失败", "分享为空", "文件已被分享者删除"] + if any(keyword in text for keyword in invalid_keywords): + # 只打印到控制台,不添加到通知列表 + print(text) + return text + NOTIFYS.append(text) print(text) return text @@ -1103,18 +1225,39 @@ class Quark: "_fetch_total": "1", "_sort": "file_type:asc,updated_at:desc", } - response = self._send_request("GET", url, params=querystring).json() - if response["code"] != 0: - return {"error": response["message"]} - if response["data"]["list"]: - list_merge += response["data"]["list"] + # 兼容网络错误或服务端异常 + try: + response = self._send_request("GET", url, params=querystring).json() + except Exception: + return {"error": "request error"} + + # 统一判错:某些情况下返回没有 code 字段 + code = response.get("code") + status = response.get("status") + if code not in (0, None): + return {"error": response.get("message", "unknown error")} + if status not in (None, 200): + return {"error": response.get("message", "request error")} + + data = response.get("data") or {} + metadata = response.get("metadata") or {} + + if data.get("list"): + list_merge += data["list"] page += 1 else: break - if len(list_merge) >= response["metadata"]["_total"]: + # 防御性:metadata 或 _total 缺失时不再访问嵌套键 + total = metadata.get("_total") if isinstance(metadata, dict) else None + if isinstance(total, int) and len(list_merge) >= total: break - response["data"]["list"] = list_merge - return response["data"] + # 统一输出结构,缺失字段时提供默认值 + if not isinstance(data, dict): + return {"error": response.get("message", "request error")} + data["list"] = list_merge + if "paths" not in data: + data["paths"] = [] + return data def get_fids(self, file_paths): fids = [] @@ -1978,22 +2121,8 @@ class Quark: # 记录过滤前的文件总数(包括文件夹) original_total_count = len(share_file_list) - # 同时支持中英文逗号分隔 - filterwords = task["filterwords"].replace(",", ",") - filterwords_list = [word.strip().lower() for word in filterwords.split(',')] - - # 改进过滤逻辑,同时检查文件名和扩展名 - filtered_files = [] - for file in share_file_list: - file_name = file['file_name'].lower() - # 提取文件扩展名(不带点) - file_ext = os.path.splitext(file_name)[1].lower().lstrip('.') - - # 检查过滤词是否存在于文件名中,或者过滤词等于扩展名 - if not any(word in file_name for word in filterwords_list) and not any(word == file_ext for word in filterwords_list): - filtered_files.append(file) - - share_file_list = filtered_files + # 使用高级过滤函数处理保留词和过滤词 + share_file_list = advanced_filter_files(share_file_list, task["filterwords"]) # 打印过滤信息(格式保持不变) # 计算剩余文件数 @@ -3003,7 +3132,14 @@ class Quark: non_dir_files = [f for f in dir_file_list if not f.get("dir", False)] is_empty_dir = len(non_dir_files) == 0 - + # 应用过滤词过滤(修复bug:为本地文件重命名添加过滤规则) + if task.get("filterwords"): + # 记录过滤前的文件总数 + original_total_count = len(dir_file_list) + + # 使用高级过滤函数处理保留词和过滤词 + dir_file_list = advanced_filter_files(dir_file_list, task["filterwords"]) + dir_file_name_list = [item["file_name"] for item in dir_file_list] # 找出当前最大序号 max_sequence = 0 @@ -3405,23 +3541,14 @@ class Quark: # 检查过滤词 should_filter = False if task.get("filterwords"): - # 同时支持中英文逗号分隔 - filterwords = task["filterwords"].replace(",", ",") - filterwords_list = [word.strip().lower() for word in filterwords.split(',')] - - # 检查原始文件名 - original_name_lower = share_file["file_name"].lower() - if any(word in original_name_lower for word in filterwords_list): - should_filter = True - - # 检查目标文件名 - save_name_lower = save_name.lower() - if any(word in save_name_lower for word in filterwords_list): - should_filter = True - - # 检查文件扩展名 - file_ext_lower = file_ext.lower().lstrip('.') - if any(word == file_ext_lower for word in filterwords_list): + # 使用高级过滤函数检查文件名 + temp_file_list = [{"file_name": share_file["file_name"]}] + if advanced_filter_files(temp_file_list, task["filterwords"]): + # 检查目标文件名 + temp_save_list = [{"file_name": save_name}] + if not advanced_filter_files(temp_save_list, task["filterwords"]): + should_filter = True + else: should_filter = True # 只处理不需要过滤的文件 @@ -3435,19 +3562,9 @@ class Quark: # 检查过滤词 should_filter = False if task.get("filterwords"): - # 同时支持中英文逗号分隔 - filterwords = task["filterwords"].replace(",", ",") - filterwords_list = [word.strip().lower() for word in filterwords.split(',')] - - # 检查原始文件名 - original_name_lower = share_file["file_name"].lower() - if any(word in original_name_lower for word in filterwords_list): - should_filter = True - - # 检查文件扩展名 - file_ext = os.path.splitext(share_file["file_name"])[1].lower() - file_ext_lower = file_ext.lstrip('.') - if any(word == file_ext_lower for word in filterwords_list): + # 使用高级过滤函数检查文件名 + temp_file_list = [{"file_name": share_file["file_name"]}] + if not advanced_filter_files(temp_file_list, task["filterwords"]): should_filter = True # 只处理不需要过滤的文件 @@ -3609,6 +3726,14 @@ class Quark: is_rename_count = 0 renamed_files = {} + # 应用过滤词过滤(修复bug:为本地文件重命名添加过滤规则) + if task.get("filterwords"): + # 记录过滤前的文件总数 + original_total_count = len(dir_file_list) + + # 使用高级过滤函数处理保留词和过滤词 + dir_file_list = advanced_filter_files(dir_file_list, task["filterwords"]) + # 使用一个列表收集所有需要重命名的操作 rename_operations = [] rename_logs = [] # 收集重命名日志 @@ -3754,6 +3879,14 @@ class Quark: # 获取目录中的文件列表 dir_file_list = self.ls_dir(self.savepath_fid[savepath]) + # 应用过滤词过滤(修复bug:为本地文件重命名添加过滤规则) + if task.get("filterwords"): + # 记录过滤前的文件总数 + original_total_count = len(dir_file_list) + + # 使用高级过滤函数处理保留词和过滤词 + dir_file_list = advanced_filter_files(dir_file_list, task["filterwords"]) + # 使用一个列表收集所有需要重命名的操作 rename_operations = [] rename_logs = [] # 收集重命名日志 @@ -4395,7 +4528,7 @@ def do_save(account, tasklist=[]): # 添加成功通知,带文件数量图标 # 这个通知会在下面的新逻辑中添加,这里注释掉 - # add_notify(f"✅《{task['taskname']}》添加追更:") + # add_notify(f"✅《{task['taskname']}》新增文件:") # add_notify(f"/{task['savepath']}") # 移除调试信息 @@ -4677,7 +4810,7 @@ def do_save(account, tasklist=[]): pass else: # 添加基本通知 - add_notify(f"✅《{task['taskname']}》添加追更:") + add_notify(f"✅《{task['taskname']}》新增文件:") add_notify(f"{re.sub(r'/{2,}', '/', f'/{task['savepath']}')}") # 修正首次运行时对子目录的处理 - 只有在首次运行且有新增的子目录时才显示子目录内容 @@ -5008,7 +5141,7 @@ def do_save(account, tasklist=[]): # 添加成功通知 - 修复问题:确保在有文件时添加通知 if display_files: - add_notify(f"✅《{task['taskname']}》添加追更:") + add_notify(f"✅《{task['taskname']}》新增文件:") add_notify(f"{re.sub(r'/{2,}', '/', f'/{task['savepath']}')}") @@ -5098,7 +5231,7 @@ def do_save(account, tasklist=[]): display_files = [file["file_name"] for file in file_nodes] # 添加成功通知 - add_notify(f"✅《{task['taskname']}》添加追更:") + add_notify(f"✅《{task['taskname']}》新增文件:") add_notify(f"{re.sub(r'/{2,}', '/', f'/{task['savepath']}')}") # 打印文件列表 @@ -5249,7 +5382,12 @@ def main(): if NOTIFYS: notify_body = "\n".join(NOTIFYS) print(f"===============推送通知===============") - send_ql_notify("【夸克自动追更】", notify_body) + send_ql_notify("【夸克自动转存】", notify_body) + print() + else: + # 如果没有通知内容,显示统一提示 + print(f"===============推送通知===============") + print("📭 本次运行没有新的转存,未推送通知") print() if cookie_form_file: # 更新配置 diff --git a/quark_config.json b/quark_config.json index 683844c..77f590d 100644 --- a/quark_config.json +++ b/quark_config.json @@ -67,7 +67,7 @@ ], "episode_patterns": [ { - "regex": "第(\\d+)集|第(\\d+)期|第(\\d+)话|(\\d+)集|(\\d+)期|(\\d+)话|[Ee][Pp]?(\\d+)|(\\d+)[-_\\\\s]*4[Kk]|\\[(\\d+)\\]|【(\\d+)】|_?(\\d+)_?" + "regex": "第(\\d+)集|第(\\d+)期|第(\\d+)话|(\\d+)集|(\\d+)期|(\\d+)话|[Ee][Pp]?(\\d+)|(\\d+)[-_\\s]*4[Kk]|\\[(\\d+)\\]|【(\\d+)】|_?(\\d+)_?" } ] }