Merge pull request #53 from x1ao4/dev

新增高级过滤、插件配置模式设置、推送通知类型选择功能和 PanSou 资源搜索支持及其他修复和优化
This commit is contained in:
x1ao4 2025-08-28 00:03:28 +08:00 committed by GitHub
commit 5e5acb8d0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1384 additions and 262 deletions

View File

@ -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[]`)自动切换为 `剧集命名` 模式,该模式将从原始文件名中提取剧集编号,然后把提取的编号代入对应文件名的 `[]` 中,实现自动按剧集编号命名。
- **自动切换命名模式**:默认的命名模式依然为 `正则命名` 模式,现在会通过用户输入的 `匹配表达式` 自动实时判断和切换对应的模式。

View File

@ -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)mkvnfo" # 只有过滤词
- "期|加更,企划,超前,(1)mkvnfo" # 保留词|过滤词
- "2160P|加更,企划,超前,(1)mkvnfo" # 多个保留词(或关系)|过滤词
- "期|2160P|加更,企划,超前,(1)mkvnfo" # 多个保留词(并关系)|过滤词
- "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>[?...]
# 同时支持直接传入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 = []

View File

@ -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("&amp;", "&").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('<mark class="highlight">', "")
content = content.replace("</mark>", "")
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:
# 比较 datetimeCloudSaver清洗阶段时间字段名为 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__":

199
app/sdk/pansou.py Normal file
View File

@ -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}

View File

@ -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;
}

View File

@ -509,12 +509,22 @@
<div class="row title" title="通知推送支持多个渠道查阅Wiki了解详情">
<div class="col">
<h2 style="display: inline-block; font-size: 1.5rem;">通知</h2>
<h2 style="display: inline-block; font-size: 1.5rem;">通知设置</h2>
<span class="badge badge-pill badge-light">
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/通知推送服务配置" target="_blank"><i class="bi bi-question-circle"></i></a>
</span>
</div>
</div>
<div class="input-group mb-2">
<div class="input-group-prepend">
<span class="input-group-text">推送通知</span>
</div>
<select v-model="formData.push_notify_type" class="form-control">
<option value="full">推送完整信息(转存成功、转存失败、资源失效)</option>
<option value="success_only">推送成功信息(转存成功)</option>
<option value="exclude_invalid">排除失效信息(转存成功、转存失败)</option>
</select>
</div>
<div v-for="(value, key, index) in formData.push_config" :key="key" class="input-group mb-2">
<div class="input-group-prepend">
<span class="input-group-text" v-html="key"></span>
@ -531,7 +541,7 @@
<div class="row title" v-if="Object.keys(getAvailablePlugins(formData.plugins)).length" title="插件配置具体键值由插件定义查阅Wiki了解详情">
<div class="col">
<h2 style="display: inline-block; font-size: 1.5rem;">插件</h2>
<h2 style="display: inline-block; font-size: 1.5rem;">插件设置</h2>
<span class="badge badge-pill badge-light">
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/插件配置" target="_blank"><i class="bi bi-question-circle"></i></a>
</span>
@ -541,7 +551,7 @@
<div class="form-group row mb-0" style="display:flex; align-items:center;">
<div data-toggle="collapse" :data-target="'#collapse_'+pluginName" aria-expanded="true" :aria-controls="'collapse_'+pluginName">
<div class="btn btn-block text-left">
<i class="bi bi-caret-right-fill"></i> <span v-html="`${pluginName}`"></span>
<i class="bi bi-caret-right-fill"></i> <span v-html="getPluginDisplayName(pluginName)"></span>
</div>
</div>
</div>
@ -554,6 +564,98 @@
:placeholder="getPluginConfigPlaceholder(pluginName, key)"
:title="getPluginConfigHelp(pluginName, key)">
</div>
<!-- 为特定插件添加全局配置选项 -->
<div v-if="['aria2', 'alist_strm_gen', 'emby'].includes(pluginName)" class="input-group mb-2">
<div class="input-group-prepend">
<span class="input-group-text" :title="getPluginConfigModeHelp(pluginName)">插件配置模式</span>
</div>
<select class="form-control" v-model="formData.plugin_config_mode[pluginName]" @change="onPluginConfigModeChange(pluginName)" :title="getPluginConfigModeHelp(pluginName)">
<option value="independent">独立配置</option>
<option value="global">全局配置</option>
</select>
</div>
<!-- 全局插件配置选项 -->
<div v-if="['aria2', 'alist_strm_gen', 'emby'].includes(pluginName) && formData.plugin_config_mode[pluginName] === 'global'">
<div v-for="(taskConfig, taskKey) in getPluginTaskConfig(pluginName)" :key="taskKey" class="input-group mb-2">
<div class="input-group-prepend">
<span class="input-group-text" v-html="taskKey" :title="getPluginTaskConfigHelp(pluginName, taskKey)"></span>
</div>
<select v-if="typeof formData.global_plugin_config[pluginName][taskKey] === 'boolean'" class="form-control" v-model="formData.global_plugin_config[pluginName][taskKey]" @change="onGlobalPluginConfigChange()" :title="getPluginTaskConfigHelp(pluginName, taskKey)">
<option :value="true">true</option>
<option :value="false">false</option>
</select>
<input v-else type="text" class="form-control" v-model="formData.global_plugin_config[pluginName][taskKey]"
:placeholder="getPluginTaskConfigPlaceholder(pluginName, taskKey)"
:title="getPluginTaskConfigHelp(pluginName, taskKey)"
@input="onGlobalPluginConfigChange()">
</div>
</div>
</div>
</div>
<div class="row title" title="资源搜索服务配置用于任务名称智能搜索查阅Wiki了解详情">
<div class="col">
<h2 style="display: inline-block; font-size: 1.5rem;">搜索来源</h2>
<span class="badge badge-pill badge-light">
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/资源搜索" target="_blank"><i class="bi bi-question-circle"></i></a>
</span>
</div>
</div>
<!-- 按插件风格显示为可展开项 -->
<div style="margin-bottom: -8px;">
<!-- CloudSaver -->
<div style="margin-bottom: -8px; margin-top: -8px;">
<div class="form-group row mb-0" style="display:flex; align-items:center;">
<div data-toggle="collapse" data-target="#collapse_source_cloudsaver" aria-expanded="true" aria-controls="collapse_source_cloudsaver">
<div class="btn btn-block text-left">
<i class="bi bi-caret-right-fill"></i> CloudSaver
</div>
</div>
</div>
<div class="collapse" id="collapse_source_cloudsaver" style="margin-left: 26px;">
<div class="input-group mb-2">
<div class="input-group-prepend">
<span class="input-group-text">服务器</span>
</div>
<input type="text" v-model="formData.source.cloudsaver.server" class="form-control" placeholder="CloudSaver 服务器地址http://192.168.1.100:8008">
</div>
<div class="input-group mb-2">
<div class="input-group-prepend">
<span class="input-group-text">用户名</span>
</div>
<input type="text" v-model="formData.source.cloudsaver.username" class="form-control" placeholder="CloudSaver 用户名">
</div>
<div class="input-group mb-2">
<div class="input-group-prepend">
<span class="input-group-text">密码</span>
</div>
<input :type="showCloudSaverPassword ? 'text' : 'password'" v-model="formData.source.cloudsaver.password" class="form-control" placeholder="CloudSaver 密码">
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" @click="toggleCloudSaverPassword">
<i :class="['bi', showCloudSaverPassword ? 'bi-eye' : 'bi-eye-slash']"></i>
</button>
</div>
</div>
</div>
</div>
<!-- PanSou -->
<div style="margin-bottom: -9.5px;">
<div class="form-group row mb-0" style="display:flex; align-items:center;">
<div data-toggle="collapse" data-target="#collapse_source_pansou" aria-expanded="false" aria-controls="collapse_source_pansou">
<div class="btn btn-block text-left">
<i class="bi bi-caret-right-fill"></i> PanSou
</div>
</div>
</div>
<div class="collapse" id="collapse_source_pansou" style="margin-left: 26px;">
<div class="input-group mb-2" style="margin-bottom: 10.5px !important;">
<div class="input-group-prepend">
<span class="input-group-text">服务器</span>
</div>
<input type="text" v-model="formData.source.pansou.server" class="form-control" placeholder="PanSou 服务器地址http://192.168.1.100:80默认地址 https://so.252035.xyz 为 PanSou 官方地址,建议自部署以获得更稳定的体验">
</div>
</div>
</div>
</div>
@ -692,59 +794,6 @@
</div>
</div>
<div class="row title" title="API接口用于第三方添加任务等操作查阅Wiki了解详情">
<div class="col">
<h2 style="display: inline-block; font-size: 1.5rem;">API</h2>
<span class="badge badge-pill badge-light">
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/API接口" target="_blank"><i class="bi bi-question-circle"></i></a>
</span>
</div>
</div>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">Token</span>
</div>
<input type="text" v-model="formData.api_token" class="form-control" style="background-color:white;" disabled>
</div>
<div class="row title" title="资源搜索服务配置用于任务名称智能搜索查阅Wiki了解详情">
<div class="col">
<h2 style="display: inline-block; font-size: 1.5rem;">CloudSaver</h2>
<span class="badge badge-pill badge-light">
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/CloudSaver搜索源" target="_blank"><i class="bi bi-question-circle"></i></a>
</span>
</div>
</div>
<div class="input-group mb-2">
<div class="input-group-prepend">
<span class="input-group-text">服务器</span>
</div>
<input type="text" v-model="formData.source.cloudsaver.server" class="form-control" placeholder="资源搜索服务器地址,如 http://172.17.0.1:8008">
</div>
<div class="row mb-2">
<div class="col cloudsaver-username-col">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">用户名</span>
</div>
<input type="text" v-model="formData.source.cloudsaver.username" class="form-control" placeholder="用户名">
</div>
</div>
<div class="col cloudsaver-password-col">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">密码</span>
</div>
<input :type="showCloudSaverPassword ? 'text' : 'password'" v-model="formData.source.cloudsaver.password" class="form-control" placeholder="密码">
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" @click="toggleCloudSaverPassword">
<i :class="['bi', showCloudSaverPassword ? 'bi-eye' : 'bi-eye-slash']"></i>
</button>
</div>
</div>
</div>
</div>
<div class="row title" title="设置任务列表页面的任务按钮的显示方式刷新Plex媒体库和刷新AList目录按钮仅在配置了对应插件的前提下才支持显示">
<div class="col">
<h2 style="display: inline-block; font-size: 1.5rem;">显示设置</h2>
@ -884,6 +933,20 @@
</div>
</div>
</div>
<div class="row title" title="API接口用于第三方添加任务等操作查阅Wiki了解详情">
<div class="col">
<h2 style="display: inline-block; font-size: 1.5rem;">API</h2>
<span class="badge badge-pill badge-light">
<a href="https://github.com/x1ao4/quark-auto-save-x/wiki/API接口" target="_blank"><i class="bi bi-question-circle"></i></a>
</span>
</div>
</div>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">Token</span>
</div>
<input type="text" v-model="formData.api_token" class="form-control" style="background-color:white;" disabled>
</div>
</div>
@ -964,15 +1027,16 @@
<i class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></i>
正在验证链接有效性...{{ smart_param.validateProgress.current }}/{{ smart_param.validateProgress.total }}<span v-if="smart_param.validateProgress.valid > 0">已找到 {{ smart_param.validateProgress.valid }} 个有效链接</span>
</span>
<span v-else>正在搜索...</span>
<span v-else>正在搜索资源...</span>
</div>
<div class="dropdown-item text-muted" v-else style="font-size:14px; padding-left: 8px; text-align: left;">
{{ smart_param.taskSuggestions.message ? smart_param.taskSuggestions.message : smart_param.taskSuggestions.data && smart_param.taskSuggestions.data.length ? `以下资源由 ${smart_param.taskSuggestions.source} 搜索提供(仅显示有效链接),如有侵权请联系资源发布方` : "未搜索到有效资源" }}
{{ smart_param.taskSuggestions.message ? smart_param.taskSuggestions.message : smart_param.taskSuggestions.data && smart_param.taskSuggestions.data.length ? `以下资源由 ${(smart_param.taskSuggestions.source || '').replace(/,\s*/g, '、')} 搜索提供(仅显示有效链接,共 ${(smart_param.taskSuggestions.data || []).length} 个),如有侵权请联系资源发布方` : "未搜索到有效资源" }}
</div>
<div v-for="suggestion in smart_param.taskSuggestions.data || []" :key="suggestion.taskname" class="dropdown-item cursor-pointer" @click.prevent="selectSuggestion(index, suggestion)" style="font-size: 14px;" :title="suggestion.content">
<div v-for="suggestion in smart_param.taskSuggestions.data || []" :key="(suggestion.shareurl || '') + '_' + (suggestion.taskname || '') + '_' + (suggestion.publish_date || '')" class="dropdown-item cursor-pointer" @click.prevent="selectSuggestion(index, suggestion)" style="font-size: 14px;" :title="getSuggestionHoverTitle(suggestion)">
<span v-html="suggestion.verify ? '✅': ''"></span> {{ suggestion.taskname }}
<small class="text-muted">
<a :href="suggestion.shareurl" target="_blank" @click.stop>{{ suggestion.shareurl }}</a>
<a :href="suggestion.shareurl" target="_blank" @click.stop> · {{ suggestion.shareurl.replace(/^https?:\/\/pan\.quark\.cn\/s\//, '') }}</a>
<template v-if="suggestion.source"><span class="source-badge" :class="suggestion.source.toLowerCase()" :data-publish-date="suggestion.publish_date ? ' · ' + formatPublishDate(suggestion.publish_date) : ''">{{ suggestion.source }}</span></template>
</small>
</div>
</div>
@ -1044,11 +1108,11 @@
</div>
</div>
<div class="form-group row" title="名称包含过滤词汇的项目不会被转存,多个词用逗号分隔,支持通过文件名和扩展名过滤文件和文件夹">
<div class="form-group row" title="名称包含过滤词汇的项目不会被转存,多个词用逗号分隔,支持通过文件名和扩展名过滤文件和文件夹,支持使用保留词|过滤词的格式实现高级过滤查阅Wiki了解详情">
<label class="col-sm-2 col-form-label">过滤规则</label>
<div class="col-sm-10">
<div class="input-group">
<input type="text" name="filterwords[]" class="form-control" v-model="task.filterwords" placeholder="可选输入过滤词汇用逗号分隔纯享txt超前企划名称包含过滤词汇的项目不会被转存">
<input type="text" name="filterwords[]" class="form-control" v-model="task.filterwords" placeholder="可选输入过滤词汇用逗号分隔纯享txt超前企划名称包含过滤词汇的项目不会被转存,支持使用保留词|过滤词的格式实现高级过滤">
</div>
</div>
</div>
@ -1096,10 +1160,10 @@
</div>
</div>
</div>
<div class="form-group row" v-if="Object.keys(getAvailablePlugins(formData.plugins)).length" title="单个任务的插件配置具体键值由插件定义查阅Wiki了解详情">
<div class="form-group row" v-if="Object.keys(getAvailablePlugins(formData.plugins)).length" :title="getPluginConfigTitle(task)">
<label class="col-sm-2 col-form-label">插件配置</label>
<div class="col-sm-10">
<v-jsoneditor v-model="task.addition" :options="{mode:'tree'}" :plus="false" height="162px" style="margin-bottom: -8px;"></v-jsoneditor>
<v-jsoneditor v-model="task.addition" :options="{mode:'tree'}" :plus="false" height="162px" style="margin-bottom: -8px;" :disabled="isPluginConfigDisabled(task)"></v-jsoneditor>
</div>
</div>
</div>
@ -1359,7 +1423,7 @@
@input="detectFileManagerNamingMode"
:title="fileManager.use_sequence_naming ? '输入带{}占位符的重命名格式,如:剧名 - S01E{}、剧名.S03E{}等,{}将被替换为集序号(按文件名和修改日期智能排序)' : (fileManager.use_episode_naming ? '输入带[]占位符的重命名格式,如:剧名 - S01E[]、剧名.S03E[]等,[]将被替换为从文件名中提取的集编号' : '只重命名匹配到文件名的文件,留空不会进行重命名')">
<input v-if="!fileManager.use_sequence_naming && !fileManager.use_episode_naming" type="text" class="form-control file-manager-input" v-model="fileManager.replace" placeholder="替换表达式" title="替换表达式">
<input type="text" class="form-control file-manager-input" v-model="fileManager.filterwords" placeholder="可选输入过滤词汇用逗号分隔纯享txt超前企划" title="名称包含过滤词汇的项目不会被重命名,多个词用逗号分隔,支持通过文件名和扩展名过滤文件和文件夹">
<input type="text" class="form-control file-manager-input" v-model="fileManager.filterwords" placeholder="可选输入过滤词汇用逗号分隔纯享txt超前企划" title="名称包含过滤词汇的项目不会被重命名,多个词用逗号分隔,支持通过文件名和扩展名过滤文件和文件夹,支持使用保留词|过滤词的格式实现高级过滤查阅Wiki了解详情">
<div class="input-group-append">
<div class="input-group-text" title="勾选后,重命名和过滤规则也将应用于文件夹">
<input type="checkbox" v-model="fileManager.include_folders">&nbsp;含文件夹
@ -1390,7 +1454,7 @@
<div class="input-group-prepend">
<span class="input-group-text">过滤规则</span>
</div>
<input type="text" class="form-control" v-model="fileManager.filterwords" placeholder="可选输入过滤词汇用逗号分隔纯享txt超前企划" title="名称包含过滤词汇的项目不会被重命名,多个词用逗号分隔,支持通过文件名和扩展名过滤文件和文件夹">
<input type="text" class="form-control" v-model="fileManager.filterwords" placeholder="可选输入过滤词汇用逗号分隔纯享txt超前企划" title="名称包含过滤词汇的项目不会被重命名,多个词用逗号分隔,支持通过文件名和扩展名过滤文件和文件夹,支持使用保留词|过滤词的格式实现高级过滤查阅Wiki了解详情">
<span class="input-group-text file-folder-rounded">
<input type="checkbox" v-model="fileManager.include_folders">&nbsp;含文件夹
</span>
@ -1891,15 +1955,16 @@
<i class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></i>
正在验证链接有效性...{{ smart_param.validateProgress.current }}/{{ smart_param.validateProgress.total }}<span v-if="smart_param.validateProgress.valid > 0">已找到 {{ smart_param.validateProgress.valid }} 个有效链接</span>
</span>
<span v-else>正在搜索...</span>
<span v-else>正在搜索资源...</span>
</div>
<div class="dropdown-item text-muted" v-else style="font-size:14px; padding-left: 8px; text-align: left;">
{{ smart_param.taskSuggestions.message ? smart_param.taskSuggestions.message : smart_param.taskSuggestions.data && smart_param.taskSuggestions.data.length ? `以下资源由 ${smart_param.taskSuggestions.source} 搜索提供(仅显示有效链接),如有侵权请联系资源发布方` : "未搜索到有效资源" }}
{{ smart_param.taskSuggestions.message ? smart_param.taskSuggestions.message : smart_param.taskSuggestions.data && smart_param.taskSuggestions.data.length ? `以下资源由 ${(smart_param.taskSuggestions.source || '').replace(/,\s*/g, '、')} 搜索提供(仅显示有效链接,共 ${(smart_param.taskSuggestions.data || []).length} 个),如有侵权请联系资源发布方` : "未搜索到有效资源" }}
</div>
<div v-for="suggestion in smart_param.taskSuggestions.data || []" :key="suggestion.taskname" class="dropdown-item cursor-pointer" @click.prevent="selectSuggestion(-1, suggestion)" style="font-size: 14px;" :title="suggestion.content">
<div v-for="suggestion in smart_param.taskSuggestions.data || []" :key="(suggestion.shareurl || '') + '_' + (suggestion.taskname || '') + '_' + (suggestion.publish_date || '')" class="dropdown-item cursor-pointer" @click.prevent="selectSuggestion(-1, suggestion)" style="font-size: 14px;" :title="getSuggestionHoverTitle(suggestion)">
<span v-html="suggestion.verify ? '✅': ''"></span> {{ suggestion.taskname }}
<small class="text-muted">
<a :href="suggestion.shareurl" target="_blank" @click.stop>{{ suggestion.shareurl }}</a>
<a :href="suggestion.shareurl" target="_blank" @click.stop> · {{ suggestion.shareurl.replace(/^https?:\/\/pan\.quark\.cn\/s\//, '') }}</a>
<template v-if="suggestion.source"><span class="source-badge" :class="suggestion.source.toLowerCase()" :data-publish-date="suggestion.publish_date ? ' · ' + formatPublishDate(suggestion.publish_date) : ''">{{ suggestion.source }}</span></template>
</small>
</div>
</div>
@ -1971,11 +2036,11 @@
</div>
</div>
<div class="form-group row" title="名称包含过滤词汇的项目不会被转存,多个词用逗号分隔,支持通过文件名和扩展名过滤文件和文件夹">
<div class="form-group row" title="名称包含过滤词汇的项目不会被转存,多个词用逗号分隔,支持通过文件名和扩展名过滤文件和文件夹,支持使用保留词|过滤词的格式实现高级过滤查阅Wiki了解详情">
<label class="col-sm-2 col-form-label">过滤规则</label>
<div class="col-sm-10">
<div class="input-group">
<input type="text" name="filterwords[]" class="form-control" v-model="createTask.taskData.filterwords" placeholder="可选,输入过滤词汇,用逗号分隔,纯享txt超前企划名称包含过滤词汇的项目不会被转存">
<input type="text" name="filterwords[]" class="form-control" v-model="createTask.taskData.filterwords" placeholder="可选,输入过滤词汇,用逗号分隔,名称包含过滤词汇的项目不会被转存,支持使用保留词|过滤词的格式实现高级过滤">
</div>
</div>
</div>
@ -2023,10 +2088,10 @@
</div>
</div>
</div>
<div class="form-group row" v-if="Object.keys(getAvailablePlugins(formData.plugins)).length" title="单个任务的插件配置具体键值由插件定义查阅Wiki了解详情">
<div class="form-group row" v-if="Object.keys(getAvailablePlugins(formData.plugins)).length" :title="getCreateTaskPluginConfigTitle()">
<label class="col-sm-2 col-form-label">插件配置</label>
<div class="col-sm-10">
<v-jsoneditor v-model="createTask.taskData.addition" :options="{mode:'tree'}" :plus="false" height="162px" style="margin-bottom: -8px;"></v-jsoneditor>
<v-jsoneditor v-model="createTask.taskData.addition" :options="{mode:'tree'}" :plus="false" height="162px" style="margin-bottom: -8px;" :disabled="isPluginConfigDisabled(createTask.taskData)"></v-jsoneditor>
</div>
</div>
</div>
@ -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;

View File

@ -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, # 是否在添加下载任务后自动删除夸克网盘文件
}

View File

@ -36,6 +36,106 @@ except ImportError:
def close(self):
pass
def advanced_filter_files(file_list, filterwords):
"""
高级过滤函数支持保留词和过滤词
Args:
file_list: 文件列表
filterwords: 过滤规则字符串支持以下格式
- "加更,企划,超前,(1)mkvnfo" # 只有过滤词
- "期|加更,企划,超前,(1)mkvnfo" # 保留词|过滤词
- "2160P|加更,企划,超前,(1)mkvnfo" # 多个保留词(或关系)|过滤词
- "期|2160P|加更,企划,超前,(1)mkvnfo" # 多个保留词(并关系)|过滤词
- "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:
# 更新配置

View File

@ -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+)_?"
}
]
}