diff --git a/app/run.py b/app/run.py index 15eee65..f5c0edf 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 @@ -511,6 +515,14 @@ def get_data(): } } + # 初始化搜索来源默认结构 + 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"], @@ -929,7 +941,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") @@ -941,35 +960,76 @@ 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 - } - ) - 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() - } - ) + 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)}") + + # 去重(按shareurl优先,其次taskname) + dedup = [] + seen = set() + for item in merged: + if not isinstance(item, dict): + continue + key = item.get("shareurl") or item.get("taskname") + if not key: + continue + if key in seen: + continue + seen.add(key) + dedup.append(item) + + # 全局时间排序:所有来源的结果混合排序,按时间倒序(最新的在前) + if dedup: + def parse_datetime_for_sort(item): + """解析时间字段,返回可比较的时间戳""" + # 兼容两个字段名:publish_date 和 datetime + datetime_str = item.get("publish_date") or item.get("datetime") + if not datetime_str: + return 0 # 没有时间的排在最后 + try: + from datetime import datetime + # 尝试解析格式: 2025-01-01 12:00:00 + dt = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S") + return dt.timestamp() + except: + 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)}"}) diff --git a/app/sdk/cloudsaver.py b/app/sdk/cloudsaver.py index a095a59..8509118 100644 --- a/app/sdk/cloudsaver.py +++ b/app/sdk/cloudsaver.py @@ -124,6 +124,10 @@ class CloudSaver: content = content.replace('', "") content = content.replace("", "") content = content.strip() + # 获取发布时间 - 采用与原始实现一致的方式 + pubdate = item.get("pubDate", "") # 使用 pubDate 字段 + if pubdate: + pubdate = self._iso_to_cst(pubdate) # 转换为中国标准时间 # 链接去重 if link.get("link") not in link_array: link_array.append(link.get("link")) @@ -132,12 +136,33 @@ class CloudSaver: "shareurl": link.get("link"), "taskname": title, "content": content, + "datetime": pubdate, # 使用 datetime 字段名,与原始实现一致 "tags": item.get("tags", []), - "channel": item.get("channel", ""), - "channel_id": item.get("channelId", ""), + "channel": item.get("channelId", ""), + "source": "CloudSaver" } ) + + # 注意:排序逻辑已移至全局,这里不再进行内部排序 + # 返回原始顺序的结果,由全局排序函数统一处理 return clean_results + + def _iso_to_cst(self, iso_time_str: str) -> str: + """将 ISO 格式的时间字符串转换为 CST(China Standard Time) 时间并格式化为 %Y-%m-%d %H:%M:%S 格式 + + Args: + iso_time_str (str): ISO 格式时间字符串 + + Returns: + str: CST(China Standard Time) 时间字符串 + """ + try: + from datetime import datetime, timezone, timedelta + dt = datetime.fromisoformat(iso_time_str) + dt_cst = dt.astimezone(timezone(timedelta(hours=8))) + return dt_cst.strftime("%Y-%m-%d %H:%M:%S") if dt_cst.year >= 1970 else "" + except: + return iso_time_str # 转换失败时返回原始字符串 # 测试示例 diff --git a/app/sdk/pansou.py b/app/sdk/pansou.py new file mode 100644 index 0000000..726c3f3 --- /dev/null +++ b/app/sdk/pansou.py @@ -0,0 +1,188 @@ +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 = [] + + 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", "") + 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, # 添加发布日期字段 + "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 字段作为标题 + 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 去重 + seen_urls = set() + unique_results = [] + for item in cleaned: + url = item.get("shareurl", "") + if url and url not in seen_urls: + seen_urls.add(url) + unique_results.append(item) + + # 按发布日期排序:最新的在前 + def parse_datetime(datetime_str): + """解析日期时间字符串,返回可比较的时间戳""" + if not datetime_str: + return 0 # 没有日期的排在最后 + try: + from datetime import datetime, timezone, timedelta + # 尝试解析 ISO 8601 格式: 2025-07-28T20:43:27Z + dt = datetime.fromisoformat(datetime_str.replace('Z', '+00:00')) + return dt.timestamp() + except: + return 0 # 解析失败排在最后 + + def convert_to_cst(datetime_str): + """将 ISO 时间转换为中国标准时间 (CST)""" + if not datetime_str: + return "" + try: + from datetime import datetime, timezone, timedelta + dt = datetime.fromisoformat(datetime_str.replace('Z', '+00:00')) + dt_cst = dt.astimezone(timezone(timedelta(hours=8))) + return dt_cst.strftime("%Y-%m-%d %H:%M:%S") + except: + return datetime_str # 转换失败时返回原始字符串 + + # 转换时间为中国标准时间格式 + for item in unique_results: + if item.get("publish_date"): + item["publish_date"] = convert_to_cst(item["publish_date"]) + + # 注意:排序逻辑已移至全局,这里不再进行内部排序 + # 返回原始顺序的结果,由全局排序函数统一处理 + return {"success": True, "data": unique_results} diff --git a/app/static/css/main.css b/app/static/css/main.css index 6494d01..7ba300c 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: 0px; + color: var(--light-text-color); +} + +.source-badge::after { + content: attr(data-publish-date); + margin-left: 2px; + 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 846c703..f8067ae 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -509,7 +509,7 @@
-

通知

+

通知设置

@@ -531,7 +531,7 @@
-

插件

+

插件设置

@@ -541,7 +541,7 @@
- +
@@ -582,6 +582,72 @@
+ +
+
+

搜索来源

+ + + +
+
+ +
+ +
+
+
+
+ CloudSaver +
+
+
+
+
+
+ 服务器 +
+ +
+
+
+ 用户名 +
+ +
+
+
+ 密码 +
+ +
+ +
+
+
+
+ + +
+
+ +
+
+
+
+ 服务器 +
+ +
+
+
+
@@ -718,59 +784,6 @@
-
-
-

API

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

CloudSaver

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

显示设置

@@ -910,6 +923,20 @@
+
+
+

API

+ + + +
+
+
+
+ Token +
+ +
@@ -999,6 +1026,7 @@ {{ suggestion.taskname }} {{ suggestion.shareurl }} + {{ suggestion.source }} @@ -1926,6 +1954,7 @@ {{ suggestion.taskname }} {{ suggestion.shareurl }} + {{ suggestion.source }} @@ -2094,6 +2123,14 @@ 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: {}, @@ -2118,6 +2155,9 @@ username: "", password: "", token: "" + }, + pansou: { + server: "https://so.252035.xyz" } }, webui: { @@ -2718,6 +2758,10 @@ document.removeEventListener('click', this.handleOutsideClick); }, methods: { + // 获取插件展示名称(支持别名,仅用于WebUI显示) + getPluginDisplayName(pluginName) { + return this.pluginDisplayAliases[pluginName] || pluginName; + }, // 设置移动端任务列表展开/收起状态监听 setupMobileTaskListToggle() { // 监听所有collapse事件 @@ -7632,10 +7676,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); } @@ -7644,13 +7688,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) { // 智能填充任务数据