Compare commits

...

48 Commits
v0.7.1 ... main

Author SHA1 Message Date
Cp0204
579c35fadc feat(plugins): 新增 飞牛影视刷新v2 插件并调整优先级
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2026-01-07 23:22:34 +08:00
Cp0204
72506d6b5f feat(plugins): 支持插件 task_after 方法,并可更新自身配置 2026-01-07 22:20:55 +08:00
Cp0204
f3a6d665cf refactor(plugins): 支持编译插件的加载 2026-01-07 19:44:56 +08:00
Cp0204
41201653f1 fix: 修复浏览目录时违规文件名变 X*** 2026-01-07 17:28:03 +08:00
Cp0204
39cac1bacb fix: 移除尝试终止超时子进程的冗余代码块
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2026-01-05 16:04:58 +08:00
Cp0204
55e338f35c fix: 修复任务导入参数缺失导致的错误
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-12-28 12:23:20 +08:00
Cp0204
f7fe5d68e7 feat: 导入任务后自动展开,提升体验
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
2025-12-28 02:49:15 +08:00
Cp0204
6fc0915117 fix: 剪贴板导入任务环境兼容,不支持读取时提供手动粘贴框 2025-12-28 02:49:15 +08:00
Cp0204
000618ac5e feat: 添加 toast 通知替代 alert 提示 2025-12-28 01:30:25 +08:00
Cp0204
66f39ea9e2 feat(ui): 添加任务分享和剪贴板导入功能 2025-12-28 01:30:25 +08:00
xiaoQQya
ef5c6e4644
feat: 任务保存规则双击魔法匹配可释放填入原始表达式 (#136)
* perf: 任务保存规则支持以魔法匹配为模板调整正则表达式

* feat(ui): 调整魔法正则表达式交互逻辑

- 将 `@change` 事件调整为 `@dblclick`
- 添加 `title` 提示用户“双击可将魔法匹配释放为填入原始正则表达式”

---------

Co-authored-by: Cp0204 <Cp0204@qq.com>
2025-12-28 00:10:56 +08:00
xiaoQQya
9fe3863c31
fix: 修复任务没有新的转存记录时报错的问题 (#135) 2025-12-27 23:27:08 +08:00
Cp0204
7679bbab38 🐛 修复容量限制 (capacity limit) 时的无报错无限转圈
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-12-10 18:40:00 +08:00
Cp0204
365f3de136 🔧 更新 $TV_MAGIC 匹配常见视频格式 2025-12-10 15:20:24 +08:00
tellbin
dbc965c6fe
飞牛插件添加媒体库文件夹路径列表支持,优化刷新指令输出 (#131)
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
Co-authored-by: changguobin <changguobin@kostech.com.cn>
2025-10-30 13:21:08 +08:00
Cp0204
75ccf228cd 📝 更新功能描述与生态项目
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-10-28 19:56:36 +08:00
Cp0204
e2a6238ab9 🔧 调整默认综艺魔法命名配置 2025-10-28 19:53:25 +08:00
Cp0204
98e53b38db QAS一键推送助手:优化错误提示 #127
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
- 设置按钮改为利用 '.pc-member-entrance'
- 增强任务推送接口的错误提示
2025-10-16 12:49:51 +08:00
Cp0204
846bf0345a 🔧 增强代码可读性与优化日志
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-10-14 14:12:30 +08:00
ypq123456789
95ddc95c79
🐛 补充修复:添加 APScheduler 调度器参数,彻底解决任务堆积问题 (#126) 2025-10-14 13:31:02 +08:00
ypq123456789
956105c16e
🐛 修复定时任务调度器卡死导致后续任务无法执行的问题 (#125)
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-10-10 22:41:05 +08:00
Cp0204
3b9ee5eb96 📝 添加 QAS 生态项目推荐
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-09-15 12:26:47 +08:00
Cp0204
a03b57cbb0 🐛 修复 {II} 时反复存相同的内容 #123 2025-09-15 11:14:06 +08:00
Cp0204
2c2aa50a88 🐛 修复一次性转存>100个时的报错
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-09-06 22:17:11 +08:00
Cp0204
5cc955f590 适配官方新的分享子目录链接格式
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-09-05 16:18:30 +08:00
Cp0204
33215957bf 📦 优化版本信息设置和获取方式 2025-09-05 12:18:06 +08:00
Cp0204
473ac0d468 优化 SmartStrm 插件初始化逻辑 2025-09-05 12:17:30 +08:00
Cp0204
0f6b6839c4 🐛 修复切换分享链接时闪现的问题 #117
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-09-03 15:46:58 +08:00
xiaoQQya
e67d95a82b
资源搜索功能优化 (#117)
* perf: 优化资源发布时间解析逻辑
* perf: PanSou 源支持前端深度搜索
* feat: 网络公开搜索源支持启用或关闭
* feat: 文件选择窗口支持切换分享链接
* perf: 优化文件选择窗口资源简介展示
* perf: 优化文件选择窗口资源信息样式
* fix: 修复 net.enable=None 时 lower() 报错
* style: 优化资源简介和切换样式
* style: 优化资源搜索配置样式
---------
Co-authored-by: Cp0204 <Cp0204@qq.com>
2025-09-03 14:37:06 +08:00
Cp0204
edbc4c50c9 📝 更新文档说明
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-08-26 22:58:53 +08:00
Cp0204
119bd3a516 🔧 优化 SmartStrm 插件的错误处理和提示 2025-08-26 19:11:08 +08:00
xiaoQQya
1fad4d7137
🐛 修复资源时间格式解析错误导致搜索失败的问题 (#115)
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
* fix: 修复资源时间格式解析错误导致搜索失败的问题
* feat: 资源搜索结果显示来源通道
2025-08-23 16:26:11 +08:00
xiaoQQya
6f9b009194
🐛 修复资源发布时间时区错误问题 (#114)
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
2025-08-22 21:08:50 +08:00
Cp0204
de37c26423 前端搜索过滤如 (2025) 的年份字符 2025-08-22 19:43:07 +08:00
Cp0204
e975b2822b CS 搜索增加发布时间信息,并统一格式 2025-08-22 19:28:38 +08:00
xiaoQQya
0a361e974d
添加 PanSou 资源搜索功能 (#113)
* feat: 添加 PanSou 资源搜索功能
* fix: 修复 PanSou 未配置时搜索报错问题
* perf: 资源搜索结果按时间倒序排序
* fix: 修复缺失 PanSou 配置前端报错问题
* perf: 资源多源搜索结果合并去重
2025-08-22 18:53:06 +08:00
Cp0204
70176a46a1 🐛 修复油猴脚本任务名称获取逻辑
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-08-20 22:48:28 +08:00
Cp0204
36e4b3273d 🔧 fnv 未激活默认不提示
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-08-19 18:20:53 +08:00
Cp0204
282cb70cf5 📝 更新工具搭配方案
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
- 将 Alist 和 rclone 替换为 OpenList 和 SmartStrm
2025-08-18 11:37:10 +08:00
tellbin
195524f2ee
添加飞牛影视媒体库刷新插件 (#106)
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
- 用于与飞牛影视服务器进行对接:登录、获取媒体库列表、按名称查找目标媒体库并触发扫描刷新
- 支持自动处理 API 鉴权签名(cse 签名机制)与 Token 管理
- 支持重复任务检测,若遇到重复任务会尝试停止旧任务并重新触发
2025-08-15 15:45:55 +08:00
Cp0204
759e6a451b 添加 SmartStrm 插件
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-08-11 20:23:37 +08:00
Cp0204
d0c9a78067 🎨 优化点击样式
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-08-02 16:59:35 +08:00
jenfonro
518037cee8
♻️ 插件 alist_sync 修改为不同步子目录 (#99)
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
* ♻️ 修改为不同步子目录:先暂时修改为不同步子目录,原因是目前不清楚通过调用API创建任务时,是否会将原有的文件进行覆盖,后续测试修改完毕后再将此项迁移至TV模式下启用

* ♻️ 增加获取文件列表失败提示:有2个原因会导致代码报错:1.api刷新的为最底层目录,如果保存的目录被删除且上层目录未刷新时,获取的是假的文件列表,可能会为空,则报错2.网络不好获取目录失败。增加提示告诉用户原因
2025-07-18 18:25:59 +08:00
Cp0204
b153b2aaf6 🐛 修复转存目录下全为文件夹时越界报错 2025-07-18 18:17:48 +08:00
Cp0204
46ec89d201 📝 Add Sponsor
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-07-01 16:25:04 +08:00
Cp0204
b06fc18062 ♻️ 调整默认配置
- 将 $TV 重命名为 $TV_REGEX,用于剧集的正则匹配
- 将 $SHOW_PRO 重命名为 $SHOW_MAGIC,用于节目的魔法匹配
- 将 $TV_PRO 重命名为 $TV_MAGIC,用于剧集的魔法匹配
2025-07-01 16:03:33 +08:00
Cp0204
5809871cf1 优化自定义排序逻辑
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
- 自定义排序键一次替换改为完整替换
- 在优先级列表中添加"百"、"千"、"万"
- 排序因素加入间隔符,避免影响相邻数字自然排序
2025-06-26 18:56:34 +08:00
Cp0204
f6b7ecdc83 🐛 修复保存规则不对子目录生效 #98
- 改进逻辑:当更新目录输入为空时,沿用保存规则
2025-06-26 17:09:47 +08:00
16 changed files with 1164 additions and 145 deletions

View File

@ -1,6 +1,12 @@
# 使用官方 Python 镜像作为基础镜像 # 使用官方 Python 镜像作为基础镜像
FROM python:3.13-alpine FROM python:3.13-alpine
#构建版本
ARG BUILD_SHA
ARG BUILD_TAG
ENV BUILD_SHA=$BUILD_SHA
ENV BUILD_TAG=$BUILD_TAG
# 设置工作目录 # 设置工作目录
WORKDIR /app WORKDIR /app
@ -8,17 +14,12 @@ WORKDIR /app
COPY . /app COPY . /app
# 安装依赖 # 安装依赖
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt && \
echo "{\"BUILD_SHA\":\"$BUILD_SHA\", \"BUILD_TAG\":\"$BUILD_TAG\"}" > build.json
# 时区 # 时区
ENV TZ="Asia/Shanghai" ENV TZ="Asia/Shanghai"
#构建版本
ARG BUILD_SHA
ARG BUILD_TAG
ENV BUILD_SHA=$BUILD_SHA
ENV BUILD_TAG=$BUILD_TAG
# 端口 # 端口
EXPOSE 5005 EXPOSE 5005

View File

@ -8,7 +8,7 @@
对于一些持续更新的资源,隔段时间去转存十分麻烦。 对于一些持续更新的资源,隔段时间去转存十分麻烦。
定期执行本脚本自动转存、文件名整理,配合 Alist, rclone, Emby 可达到自动追更的效果。🥳 定期执行本脚本自动转存、文件名整理,配合 [SmartStrm](https://github.com/Cp0204/SmartStrm) / [OpenList](https://github.com/OpenListTeam/OpenList) , Emby 可达到自动追更的效果。🥳
[![wiki][wiki-image]][wiki-url] [![github releases][gitHub-releases-image]][github-url] [![docker pulls][docker-pulls-image]][docker-url] [![docker image size][docker-image-size-image]][docker-url] [![wiki][wiki-image]][wiki-url] [![github releases][gitHub-releases-image]][github-url] [![docker pulls][docker-pulls-image]][docker-url] [![docker image size][docker-image-size-image]][docker-url]
@ -29,7 +29,7 @@
> ⛔️⛔️⛔️ 注意!资源不会每时每刻更新,**严禁设定过高的定时运行频率!** 以免账号风控和给夸克服务器造成不必要的压力。雪山崩塌,每一片雪花都有责任! > ⛔️⛔️⛔️ 注意!资源不会每时每刻更新,**严禁设定过高的定时运行频率!** 以免账号风控和给夸克服务器造成不必要的压力。雪山崩塌,每一片雪花都有责任!
> [!NOTE] > [!NOTE]
> 开发者≠客服,开源免费≠帮你解决使用问题;本项目Wiki和已经相对完善,遇到问题请先翻阅 Issues 和 Wiki ,请勿盲目发问。 > 开发者≠客服,开源免费≠帮你解决使用问题;本项目 Wiki 已经相对完善,遇到问题请先翻阅 Issues 和 Wiki ,请勿盲目发问。
## 功能 ## 功能
@ -58,7 +58,7 @@
- 媒体库整合 - 媒体库整合
- [x] 根据任务名搜索 Emby 媒体库 - [x] 根据任务名搜索 Emby 媒体库
- [x] 追更或整理后自动刷新 Emby 媒体库 - [x] 追更或整理后自动刷新 Emby 媒体库
- [x] 媒体库模块化,用户可很方便地[开发自己的媒体库hook模块](./plugins) - [x] 插件模块化,允许自行开发和挂载[插件](./plugins)
- 其它 - 其它
- [x] 每日签到领空间 <sup>[?](https://github.com/Cp0204/quark-auto-save/wiki/使用技巧集锦#每日签到领空间)</sup> - [x] 每日签到领空间 <sup>[?](https://github.com/Cp0204/quark-auto-save/wiki/使用技巧集锦#每日签到领空间)</sup>
@ -69,7 +69,7 @@
### Docker 部署 ### Docker 部署
Docker 部署提供 WebUI 管理配置,图形化配置已能满足绝大多数需求。部署命令: Docker 部署提供 WebUI 进行管理配置,部署命令:
```shell ```shell
docker run -d \ docker run -d \
@ -107,11 +107,13 @@ services:
管理地址http://yourhost:5005 管理地址http://yourhost:5005
| 环境变量 | 默认 | 备注 | | 环境变量 | 默认 | 备注 |
| ---------------- | ---------- | -------- | | ---------------- | ---------- | ---------------------------------------- |
| `WEBUI_USERNAME` | `admin` | 管理账号 | | `WEBUI_USERNAME` | `admin` | 管理账号 |
| `WEBUI_PASSWORD` | `admin123` | 管理密码 | | `WEBUI_PASSWORD` | `admin123` | 管理密码 |
| `PORT` | `5005` | 管理后台端口 |
| `PLUGIN_FLAGS` | | 插件标志,如 `-emby,-aria2` 禁用某些插件 | | `PLUGIN_FLAGS` | | 插件标志,如 `-emby,-aria2` 禁用某些插件 |
| `TASK_TIMEOUT` | `1800` | 任务执行超时时间(秒),超时则任务结束 |
#### 一键更新 #### 一键更新
@ -128,23 +130,17 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
</details> </details>
### 青龙部署
程序也支持以青龙定时任务的方式运行,但该方式无法使用 WebUI 管理任务,需手动修改配置文件。
青龙部署说明已转移到 Wiki [青龙部署教程](https://github.com/Cp0204/quark-auto-save/wiki/部署教程#青龙部署)
## 使用说明 ## 使用说明
### 正则处理示例 ### 正则处理示例
| pattern | replace | 效果 | | pattern | replace | 效果 |
| -------------------------------------- | ------------ | ---------------------------------------------------------------------- | | -------------------------------------- | ----------------------- | ---------------------------------------------------------------------- |
| `.*` | | 无脑转存所有文件,不整理 | | `.*` | | 无脑转存所有文件,不整理 |
| `\.mp4$` | | 转存所有 `.mp4` 后缀的文件 | | `\.mp4$` | | 转存所有 `.mp4` 后缀的文件 |
| `^【电影TT】花好月圆(\d+)\.(mp4\|mkv)` | `\1.\2` | 【电影TT】花好月圆01.mp4 → 01.mp4<br>【电影TT】花好月圆02.mkv → 02.mkv | | `^【电影TT】花好月圆(\d+)\.(mp4\|mkv)` | `\1.\2` | 【电影TT】花好月圆01.mp4 → 01.mp4<br>【电影TT】花好月圆02.mkv → 02.mkv |
| `^(\d+)\.mp4` | `S02E\1.mp4` | 01.mp4 → S02E01.mp4<br>02.mp4 → S02E02.mp4 | | `^(\d+)\.mp4` | `S02E\1.mp4` | 01.mp4 → S02E01.mp4<br>02.mp4 → S02E02.mp4 |
| `$TV` | | [魔法匹配](#魔法匹配)剧集文件 | | `$TV` | | [魔法匹配](#魔法匹配)剧集文件 |
| `^(\d+)\.mp4` | `{TASKNAME}.S02E\1.mp4` | 01.mp4 → 任务名.S02E01.mp4 | | `^(\d+)\.mp4` | `{TASKNAME}.S02E\1.mp4` | 01.mp4 → 任务名.S02E01.mp4 |
更多正则使用说明:[正则处理教程](https://github.com/Cp0204/quark-auto-save/wiki/正则处理教程) 更多正则使用说明:[正则处理教程](https://github.com/Cp0204/quark-auto-save/wiki/正则处理教程)
@ -167,6 +163,40 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
请参考 Wiki [使用技巧集锦](https://github.com/Cp0204/quark-auto-save/wiki/使用技巧集锦) 请参考 Wiki [使用技巧集锦](https://github.com/Cp0204/quark-auto-save/wiki/使用技巧集锦)
## 生态项目
以下展示 QAS 生态项目,包括官方项目和第三方项目。
### 官方项目
* [QAS一键推送助手](https://greasyfork.org/zh-CN/scripts/533201-qas一键推送助手)
油猴脚本,在夸克网盘分享页面添加推送到 QAS 的按钮
* [SmartStrm](https://github.com/Cp0204/SmartStrm)
STRM 文件生成工具,用于转存后处理,媒体免下载入库播放。
### 第三方开源项目
> [!TIP]
>
> 以下第三方开源项目均由社区开发并保持开源,与 QAS 作者无直接关联。在部署到生产环境前,请自行评估相关风险。
>
> 如果您有新的项目没有在此列出,可以通过 Issues 提交。
* [nonebot-plugin-quark-autosave](https://github.com/fllesser/nonebot-plugin-quark-autosave)
QAS Telegram 机器人,快速管理自动转存任务
* [Astrbot_plugin_quarksave](https://github.com/lm379/astrbot_plugin_quarksave)
AstrBot 插件,调用 quark_auto_save 实现自动转存资源到夸克网盘
* [Telegram 媒体资源管理机器人](https://github.com/2beetle/tgbot)
一个功能丰富的 Telegram 机器人专注于媒体资源管理、Emby 集成、自动下载和夸克网盘资源管理。
## 打赏 ## 打赏
如果这个项目让你受益你可以无偿赠与我1块钱让我知道开源有价值。谢谢 如果这个项目让你受益你可以无偿赠与我1块钱让我知道开源有价值。谢谢
@ -179,4 +209,10 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
程序没有任何破解行为只是对于夸克已有的API进行封装所有数据来自于夸克官方API本人不对网盘内容负责、不对夸克官方API未来可能的变动导致的影响负责请自行斟酌使用。 程序没有任何破解行为只是对于夸克已有的API进行封装所有数据来自于夸克官方API本人不对网盘内容负责、不对夸克官方API未来可能的变动导致的影响负责请自行斟酌使用。
开源仅供学习与交流使用,未盈利也未授权商业使用,严禁用于非法用途。 开源仅供学习与交流使用,未盈利也未授权商业使用,严禁用于非法用途。
## Sponsor
CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne.
<a href="https://edgeone.ai/?from=github" target="_blank"><img title="Best Asian CDN, Edge, and Secure Solutions - Tencent EdgeOne" src="https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png" width="300"></a>

View File

@ -15,12 +15,15 @@ from flask import (
) )
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from concurrent.futures import ThreadPoolExecutor, as_completed
from sdk.cloudsaver import CloudSaver from sdk.cloudsaver import CloudSaver
from sdk.pansou import PanSou
from datetime import timedelta from datetime import timedelta
import subprocess import subprocess
import requests import requests
import hashlib import hashlib
import logging import logging
import traceback
import base64 import base64
import sys import sys
import os import os
@ -30,10 +33,30 @@ parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, parent_dir) sys.path.insert(0, parent_dir)
from quark_auto_save import Quark, Config, MagicRename from quark_auto_save import Quark, Config, MagicRename
print(
r"""
____ ___ _____
/ __ \ / | / ___/
/ / / / / /| | \__ \
/ /_/ / / ___ |___/ /
\___\_\/_/ |_/____/
-- Quark-Auto-Save --
"""
)
sys.stdout.flush()
def get_app_ver(): def get_app_ver():
BUILD_SHA = os.environ.get("BUILD_SHA", "") """获取应用版本"""
BUILD_TAG = os.environ.get("BUILD_TAG", "") try:
with open("build.json", "r") as f:
build_info = json.loads(f.read())
BUILD_SHA = build_info["BUILD_SHA"]
BUILD_TAG = build_info["BUILD_TAG"]
except Exception as e:
BUILD_SHA = os.getenv("BUILD_SHA", "")
BUILD_TAG = os.getenv("BUILD_TAG", "")
if BUILD_TAG[:1] == "v": if BUILD_TAG[:1] == "v":
return BUILD_TAG return BUILD_TAG
elif BUILD_SHA: elif BUILD_SHA:
@ -50,6 +73,7 @@ PLUGIN_FLAGS = os.environ.get("PLUGIN_FLAGS", "")
DEBUG = os.environ.get("DEBUG", "false").lower() == "true" DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
HOST = os.environ.get("HOST", "0.0.0.0") HOST = os.environ.get("HOST", "0.0.0.0")
PORT = os.environ.get("PORT", 5005) PORT = os.environ.get("PORT", 5005)
TASK_TIMEOUT = int(os.environ.get("TASK_TIMEOUT", 1800))
config_data = {} config_data = {}
task_plugins_config_default = {} task_plugins_config_default = {}
@ -73,6 +97,8 @@ logging.basicConfig(
# 过滤werkzeug日志输出 # 过滤werkzeug日志输出
if not DEBUG: if not DEBUG:
logging.getLogger("werkzeug").setLevel(logging.ERROR) logging.getLogger("werkzeug").setLevel(logging.ERROR)
logging.getLogger("apscheduler").setLevel(logging.ERROR)
sys.modules["flask.cli"].show_server_banner = lambda *x: None
def gen_md5(string): def gen_md5(string):
@ -233,8 +259,19 @@ def get_task_suggestions():
return jsonify({"success": False, "message": "未登录"}) return jsonify({"success": False, "message": "未登录"})
query = request.args.get("q", "").lower() query = request.args.get("q", "").lower()
deep = request.args.get("d", "").lower() deep = request.args.get("d", "").lower()
try: net_data = config_data.get("source", {}).get("net", {})
cs_data = config_data.get("source", {}).get("cloudsaver", {}) cs_data = config_data.get("source", {}).get("cloudsaver", {})
ps_data = config_data.get("source", {}).get("pansou", {})
def net_search():
if str(net_data.get("enable", "true")).lower() != "false":
base_url = base64.b64decode("aHR0cHM6Ly9zLjkxNzc4OC54eXo=").decode()
url = f"{base_url}/task_suggestions?q={query}&d={deep}"
response = requests.get(url)
return response.json()
return []
def cs_search():
if ( if (
cs_data.get("server") cs_data.get("server")
and cs_data.get("username") and cs_data.get("username")
@ -252,18 +289,37 @@ def get_task_suggestions():
cs_data["token"] = search.get("new_token") cs_data["token"] = search.get("new_token")
Config.write_json(CONFIG_PATH, config_data) Config.write_json(CONFIG_PATH, config_data)
search_results = cs.clean_search_results(search.get("data")) search_results = cs.clean_search_results(search.get("data"))
return jsonify( return search_results
{"success": True, "source": "CloudSaver", "data": search_results} return []
)
else: def ps_search():
return jsonify({"success": True, "message": search.get("message")}) if ps_data.get("server"):
else: ps = PanSou(ps_data.get("server"))
base_url = base64.b64decode("aHR0cHM6Ly9zLjkxNzc4OC54eXo=").decode() return ps.search(query, deep == "1")
url = f"{base_url}/task_suggestions?q={query}&d={deep}" return []
response = requests.get(url)
return jsonify( try:
{"success": True, "source": "网络公开", "data": response.json()} search_results = []
) with ThreadPoolExecutor(max_workers=3) as executor:
features = []
features.append(executor.submit(net_search))
features.append(executor.submit(cs_search))
features.append(executor.submit(ps_search))
for future in as_completed(features):
result = future.result()
search_results.extend(result)
# 按时间排序并去重
results = []
link_array = []
search_results.sort(key=lambda x: x.get("datetime", ""), reverse=True)
for item in search_results:
url = item.get("shareurl", "")
if url != "" and url not in link_array:
link_array.append(url)
results.append(item)
return jsonify({"success": True, "data": results})
except Exception as e: except Exception as e:
return jsonify({"success": True, "message": f"error: {str(e)}"}) return jsonify({"success": True, "message": f"error: {str(e)}"})
@ -284,7 +340,9 @@ def get_share_detail():
return jsonify( return jsonify(
{"success": False, "data": {"error": get_stoken.get("message")}} {"success": False, "data": {"error": get_stoken.get("message")}}
) )
share_detail = account.get_detail(pwd_id, stoken, pdir_fid, _fetch_share=1) share_detail = account.get_detail(
pwd_id, stoken, pdir_fid, _fetch_share=1, fetch_share_full_path=1
)
if share_detail.get("code") != 0: if share_detail.get("code") != 0:
return jsonify( return jsonify(
@ -292,7 +350,10 @@ def get_share_detail():
) )
data = share_detail["data"] data = share_detail["data"]
data["paths"] = paths data["paths"] = [
{"fid": i["fid"], "name": i["file_name"]}
for i in share_detail["data"].get("full_path", [])
] or paths
data["stoken"] = stoken data["stoken"] = stoken
# 正则处理预览 # 正则处理预览
@ -315,7 +376,9 @@ def get_share_detail():
) )
for share_file in data["list"]: for share_file in data["list"]:
search_pattern = ( search_pattern = (
task.get("update_subdir", "") if share_file["dir"] else pattern task["update_subdir"]
if share_file["dir"] and task.get("update_subdir")
else pattern
) )
if re.search(search_pattern, share_file["file_name"]): if re.search(search_pattern, share_file["file_name"]):
# 文件名重命名,目录不重命名 # 文件名重命名,目录不重命名
@ -424,7 +487,36 @@ def add_task():
# 定时任务执行的函数 # 定时任务执行的函数
def run_python(args): def run_python(args):
logging.info(f">>> 定时运行任务") logging.info(f">>> 定时运行任务")
os.system(f"{PYTHON_PATH} {args}") try:
result = subprocess.run(
f"{PYTHON_PATH} {args}",
shell=True,
timeout=TASK_TIMEOUT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
# 输出执行日志
if result.stdout:
for line in result.stdout.strip().split("\n"):
if line.strip():
logging.info(line)
if result.returncode == 0:
logging.info(f">>> 任务执行成功")
else:
logging.error(f">>> 任务执行失败,返回码: {result.returncode}")
if result.stderr:
logging.error(f"错误信息: {result.stderr[:500]}")
except subprocess.TimeoutExpired as e:
logging.error(f">>> 任务执行超时(>{TASK_TIMEOUT}s),强制终止")
except Exception as e:
logging.error(f">>> 任务执行异常: {str(e)}")
logging.error(traceback.format_exc())
finally:
# 确保函数能够正常返回
logging.debug(f">>> run_python 函数执行完成")
# 重新加载任务 # 重新加载任务
@ -440,6 +532,10 @@ def reload_tasks():
trigger=trigger, trigger=trigger,
args=[f"{SCRIPT_PATH} {CONFIG_PATH}"], args=[f"{SCRIPT_PATH} {CONFIG_PATH}"],
id=SCRIPT_PATH, id=SCRIPT_PATH,
max_instances=1, # 最多允许1个实例运行
coalesce=True, # 合并错过的任务,避免堆积
misfire_grace_time=300, # 错过任务的宽限期(秒),超过则跳过
replace_existing=True, # 替换已存在的同ID任务
) )
if scheduler.state == 0: if scheduler.state == 0:
scheduler.start() scheduler.start()
@ -458,7 +554,7 @@ def reload_tasks():
def init(): def init():
global config_data, task_plugins_config_default global config_data, task_plugins_config_default
logging.info(f">>> 初始化配置") logging.info(">>> 初始化配置")
# 检查配置文件是否存在 # 检查配置文件是否存在
if not os.path.exists(CONFIG_PATH): if not os.path.exists(CONFIG_PATH):
if not os.path.exists(os.path.dirname(CONFIG_PATH)): if not os.path.exists(os.path.dirname(CONFIG_PATH)):
@ -496,6 +592,8 @@ def init():
if __name__ == "__main__": if __name__ == "__main__":
init() init()
reload_tasks() reload_tasks()
logging.info(">>> 启动Web服务")
logging.info(f"运行在: http://{HOST}:{PORT}")
app.run( app.run(
debug=DEBUG, debug=DEBUG,
host=HOST, host=HOST,

View File

@ -1,5 +1,6 @@
import re import re
import requests import requests
from sdk.common import iso_to_cst
class CloudSaver: class CloudSaver:
@ -124,6 +125,10 @@ class CloudSaver:
content = content.replace('<mark class="highlight">', "") content = content.replace('<mark class="highlight">', "")
content = content.replace("</mark>", "") content = content.replace("</mark>", "")
content = content.strip() content = content.strip()
# 统一发布时间格式
pubdate = item.get("pubDate", "")
if pubdate:
pubdate = iso_to_cst(pubdate)
# 链接去重 # 链接去重
if link.get("link") not in link_array: if link.get("link") not in link_array:
link_array.append(link.get("link")) link_array.append(link.get("link"))
@ -132,9 +137,10 @@ class CloudSaver:
"shareurl": link.get("link"), "shareurl": link.get("link"),
"taskname": title, "taskname": title,
"content": content, "content": content,
"datetime": pubdate,
"tags": item.get("tags", []), "tags": item.get("tags", []),
"channel": item.get("channel", ""), "channel": item.get("channelId", ""),
"channel_id": item.get("channelId", ""), "source": "CloudSaver"
} }
) )
return clean_results return clean_results

16
app/sdk/common.py Normal file
View File

@ -0,0 +1,16 @@
from datetime import datetime, timezone, timedelta
def iso_to_cst(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) 时间字符串
"""
dt = datetime.fromisoformat(iso_time_str)
tz = timezone(timedelta(hours=8))
dt_cst = dt if dt.astimezone(tz) > datetime.now(tz) else dt.astimezone(tz)
return dt_cst.strftime("%Y-%m-%d %H:%M:%S") if dt_cst.year >= 1970 else ""

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

@ -0,0 +1,97 @@
import re
import requests
from sdk.common import iso_to_cst
class PanSou:
"""
PanSou 用于获取云盘资源
"""
def __init__(self, server):
self.server = server
self.session = requests.Session()
def search(self, keyword: str, refresh: bool = False) -> list:
"""搜索资源
Args:
keyword (str): 搜索关键字
Returns:
list: 资源列表
"""
try:
url = f"{self.server.rstrip('/')}/api/search"
params = {"kw": keyword, "cloud_types": ["quark"], "res": "merge", "refresh": refresh}
response = self.session.get(url, params=params)
result = response.json()
if result.get("code") == 0:
data = result.get("data", {}).get("merged_by_type", {}).get("quark", [])
return self.format_search_results(data)
return []
except Exception as _:
return []
def format_search_results(self, search_results: list) -> list:
"""格式化搜索结果
Args:
search_results (list): 搜索结果列表
Returns:
list: 夸克网盘资源列表
"""
pattern = (
r'^(.*?)'
r'(?:'
r'[【\[]?'
r'(?:简介|介绍|描述)'
r'[】\]]?'
r'[:]?'
r')'
r'(.*)$'
)
format_results = []
link_array = []
for item in search_results:
url = item.get("url", "")
note = item.get("note", "")
tm = item.get("datetime", "")
if tm:
tm = iso_to_cst(tm)
match = re.search(pattern, note)
if match:
title = match.group(1)
content = match.group(2)
else:
title = note
content = ""
if url != "" and url not in link_array:
link_array.append(url)
format_results.append({
"shareurl": url,
"taskname": title,
"content": content,
"datetime": tm,
"channel": item.get("source", ""),
"source": "PanSou"
})
return format_results
if __name__ == "__main__":
server: str = "https://so.252035.xyz"
pansou = PanSou(server)
results = pansou.search("哪吒")
for item in results:
print(f"标题: {item['taskname']}")
print(f"描述: {item['content']}")
print(f"链接: {item['shareurl']}")
print(f"时间: {item['datetime']}")
print("-" * 50)

View File

@ -45,7 +45,7 @@ body {
margin-bottom: 10px; margin-bottom: 10px;
} }
table.jsoneditor-tree > tbody > tr.jsoneditor-expandable:first-child { table.jsoneditor-tree>tbody>tr.jsoneditor-expandable:first-child {
display: none; display: none;
} }
@ -196,3 +196,48 @@ table.jsoneditor-tree > tbody > tr.jsoneditor-expandable:first-child {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
} }
/* Toast */
.toast-container {
position: fixed;
top: 80px;
right: 20px;
z-index: 9999;
width: 300px;
}
.toast {
background-color: rgba(255, 255, 255, 0.95);
border-radius: 8px;
border-width: 0 0 0 5px !important;
margin-bottom: 10px;
animation: slideIn 0.3s ease forwards;
}
.toast.success {
border-left-color: #28a745 !important;
}
.toast.error {
border-left-color: #dc3545 !important;
}
.toast.warning {
border-left-color: #ffc107 !important;
}
.toast.info {
border-left-color: #17a2b8 !important;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}

View File

@ -2,7 +2,7 @@
// @name QAS一键推送助手 // @name QAS一键推送助手
// @namespace https://github.com/Cp0204/quark-auto-save // @namespace https://github.com/Cp0204/quark-auto-save
// @license AGPL // @license AGPL
// @version 0.4 // @version 0.6
// @description 在夸克网盘分享页面添加推送到 QAS 的按钮 // @description 在夸克网盘分享页面添加推送到 QAS 的按钮
// @icon https://pan.quark.cn/favicon.ico // @icon https://pan.quark.cn/favicon.ico
// @author Cp0204 // @author Cp0204
@ -76,16 +76,16 @@
} }
} }
waitForElement('.DetailLayout--client-download--FpyCkdW.ant-dropdown-trigger', (clientDownloadButton) => { waitForElement('.pc-member-entrance', (PcMemberButton) => {
const qasSettingButton = document.createElement('div'); const qasSettingButton = document.createElement('div');
qasSettingButton.className = 'DetailLayout--client-download--FpyCkdW ant-dropdown-trigger'; qasSettingButton.className = 'pc-member-entrance';
qasSettingButton.innerHTML = 'QAS设置'; qasSettingButton.innerHTML = 'QAS设置';
qasSettingButton.addEventListener('click', () => { qasSettingButton.addEventListener('click', () => {
showQASSettingDialog(); showQASSettingDialog();
}); });
clientDownloadButton.parentNode.insertBefore(qasSettingButton, clientDownloadButton.nextSibling); PcMemberButton.parentNode.insertBefore(qasSettingButton, PcMemberButton.nextSibling);
}); });
} }
@ -112,7 +112,8 @@
// 获取数据函数 // 获取数据函数
function getData() { function getData() {
const currentUrl = window.location.href; const currentUrl = window.location.href;
taskname = currentUrl.lastIndexOf('-') > 0 ? decodeURIComponent(currentUrl.match(/.*\/[^-]+-(.+)$/)[1]).replace('*101', '-') : document.querySelector('.author-name').textContent; const lastTitle = document.querySelector('.primary .bcrumb-filename:last-child')?.getAttribute('title') || null;
taskname = (lastTitle && lastTitle != "全部文件") ? lastTitle : document.querySelector('.author-name').textContent;
shareurl = currentUrl; shareurl = currentUrl;
let pathElement = document.querySelector('.path-name'); let pathElement = document.querySelector('.path-name');
savepath = pathElement ? pathElement.title.replace('全部文件', '').trim() : ""; savepath = pathElement ? pathElement.title.replace('全部文件', '').trim() : "";
@ -154,6 +155,63 @@
}, },
data: JSON.stringify(data), data: JSON.stringify(data),
onload: function (response) { onload: function (response) {
// 检查 HTTP 状态码
if (response.status === 401) {
Swal.fire({
title: '认证失败',
text: 'Token 无效或已过期,请重新配置 QAS Token',
icon: 'error',
confirmButtonText: '重新配置',
showCancelButton: true,
cancelButtonText: '取消'
}).then((result) => {
if (result.isConfirmed) {
showQASSettingDialog();
}
});
return;
}
if (response.status === 503) {
Swal.fire({
title: '服务器不可用',
html: `服务器暂时无法处理请求 (503)<br><br>
<small>可能原因<br>
QAS 服务未运行<br>
服务器过载<br>
网络连接问题</small>`,
icon: 'error',
confirmButtonText: '重新配置',
showCancelButton: true,
cancelButtonText: '取消'
}).then((result) => {
if (result.isConfirmed) {
showQASSettingDialog();
}
});
return;
}
// 检查响应内容类型
const contentType = response.responseHeaders.match(/content-type:\s*([^;\s]+)/i);
if (contentType && !contentType[1].includes('application/json')) {
Swal.fire({
title: '认证失败',
html: `服务器返回了非 JSON 响应,可能是 Token 错误<br><br>
<small>响应类型: ${contentType[1]}</small><br>
<small>响应状态: ${response.status}</small>`,
icon: 'error',
confirmButtonText: '重新配置',
showCancelButton: true,
cancelButtonText: '取消'
}).then((result) => {
if (result.isConfirmed) {
showQASSettingDialog();
}
});
return;
}
try { try {
const jsonResponse = JSON.parse(response.responseText); const jsonResponse = JSON.parse(response.responseText);
if (jsonResponse.success) { if (jsonResponse.success) {
@ -176,16 +234,34 @@
} catch (e) { } catch (e) {
Swal.fire({ Swal.fire({
title: '解析响应失败', title: '解析响应失败',
text: `无法解析 JSON 响应: ${response.responseText}`, html: `<small>
icon: 'error' 响应状态: ${response.status}<br>
响应内容: ${response.responseText.substring(0, 200)}...<br><br>
错误详情: ${e.message}
</small>`,
icon: 'error',
confirmButtonText: '重新配置',
showCancelButton: true,
cancelButtonText: '取消'
}).then((result) => {
if (result.isConfirmed) {
showQASSettingDialog();
}
}); });
} }
}, },
onerror: function (error) { onerror: function (error) {
Swal.fire({ Swal.fire({
title: '任务创建失败', title: '网络请求失败',
text: error, text: '无法连接到 QAS 服务器,请检查网络连接和服务器地址',
icon: 'error' icon: 'error',
confirmButtonText: '重新配置',
showCancelButton: true,
cancelButtonText: '取消'
}).then((result) => {
if (result.isConfirmed) {
showQASSettingDialog();
}
}); });
} }
}); });

View File

@ -198,28 +198,70 @@
<div class="row title" title="资源搜索服务配置,用于任务名称智能搜索"> <div class="row title" title="资源搜索服务配置,用于任务名称智能搜索">
<div class="col-10"> <div class="col-10">
<h2 style="display: inline-block;"><i class="bi bi-search"></i> CloudSaver</h2> <h2 style="display: inline-block;"><i class="bi bi-search"></i> 资源搜索</h2>
<span class="badge badge-pill badge-light">
<a href="https://github.com/Cp0204/quark-auto-save/wiki/CloudSaver搜索源" target="_blank">?</a>
</span>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row mb-0" style="display:flex; align-items:center;">
<label class="col-sm-2 col-form-label">服务器</label> <div data-toggle="collapse" data-target="#collapse_net" aria-expanded="true" aria-controls="collapse_net">
<div class="col-sm-10"> <div class="btn btn-block text-left">
<input type="text" v-model="formData.source.cloudsaver.server" class="form-control" placeholder="资源搜索服务器地址,如 http://172.17.0.1:8008"> <i class="bi bi-caret-right-fill"></i> 网络公开搜索
</div>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="collapse show ml-3" id="collapse_net">
<label class="col-sm-2 col-form-label">用户名</label> <div class="form-group row">
<div class="col-sm-10"> <label class="col-sm-2 col-form-label">启用</label>
<input type="text" v-model="formData.source.cloudsaver.username" class="form-control" placeholder="用户名"> <div class="col-sm-10 d-flex align-items-center">
<input type="checkbox" class="form-check-input" v-model="formData.source.net.enable" placeholder="是否启用网络公开搜索,默认启用">
</div>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row mb-0" style="display:flex; align-items:center;">
<label class="col-sm-2 col-form-label">密码</label> <div data-toggle="collapse" data-target="#collapse_cloudsaver" aria-expanded="true" aria-controls="collapse_cloudsaver">
<div class="col-sm-10"> <div class="btn btn-block text-left">
<input type="password" v-model="formData.source.cloudsaver.password" class="form-control" placeholder="密码"> <i class="bi bi-caret-right-fill"></i> CloudSaver
<span class="badge badge-pill badge-light">
<a href="https://github.com/Cp0204/quark-auto-save/wiki/CloudSaver搜索源" target="_blank">?</a>
</span>
</div>
</div>
</div>
<div class="collapse show ml-3" id="collapse_cloudsaver">
<div class="form-group row">
<label class="col-sm-2 col-form-label">服务器</label>
<div class="col-sm-10">
<input type="text" v-model="formData.source.cloudsaver.server" class="form-control" placeholder="资源搜索服务器地址,如 http://172.17.0.1:8008">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label">用户名</label>
<div class="col-sm-10">
<input type="text" v-model="formData.source.cloudsaver.username" class="form-control" placeholder="用户名">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label">密码</label>
<div class="col-sm-10">
<input type="password" v-model="formData.source.cloudsaver.password" class="form-control" placeholder="密码">
</div>
</div>
</div>
<div class="form-group row mb-0" style="display:flex; align-items:center;">
<div data-toggle="collapse" data-target="#collapse_pansou" aria-expanded="true" aria-controls="collapse_pansou">
<div class="btn btn-block text-left">
<i class="bi bi-caret-right-fill"></i> PanSou
<span class="badge badge-pill badge-light">
<a href="https://github.com/fish2018/pansou" target="_blank">?</a>
</span>
</div>
</div>
</div>
<div class="collapse show ml-3" id="collapse_pansou">
<div class="form-group row">
<label class="col-sm-2 col-form-label">服务器</label>
<div class="col-sm-10">
<input type="text" v-model="formData.source.pansou.server" class="form-control" placeholder="资源搜索服务器地址,如 https://so.252035.xyz">
</div>
</div> </div>
</div> </div>
@ -268,9 +310,10 @@
</div> </div>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button class="btn btn-warning" v-if="task.shareurl_ban" :title="task.shareurl_ban" disabled><i class="bi bi-exclamation-triangle-fill"></i></button> <button type="button" class="btn btn-outline-primary btn-sm" @click="copyTaskToClipboard(index)" title="复制任务参数到粘贴板"><i class=" bi bi-clipboard-check-fill"></i></button>
<button type="button" class="btn btn-outline-primary" @click="runScriptNow(index)" title="运行此任务" v-else><i class="bi bi-play-fill"></i></button> <button class="btn btn-warning btn-sm" v-if="task.shareurl_ban" :title="task.shareurl_ban" disabled><i class="bi bi-exclamation-triangle-fill"></i></button>
<button type="button" class="btn btn-outline-danger" @click="removeTask(index)" title="删除此任务"><i class="bi bi-trash3-fill"></i></button> <button type="button" class="btn btn-outline-primary btn-sm" @click="runScriptNow(index)" title="运行此任务" v-else><i class="bi bi-play-fill"></i></button>
<button type="button" class="btn btn-outline-danger btn-sm" @click="removeTask(index)" title="删除此任务"><i class="bi bi-trash3-fill"></i></button>
</div> </div>
</div> </div>
<div class="collapse ml-3" :id="'collapse_'+index"> <div class="collapse ml-3" :id="'collapse_'+index">
@ -281,12 +324,15 @@
<div class="input-group"> <div class="input-group">
<input type="text" name="taskname[]" class="form-control" v-model="task.taskname" placeholder="必填" @focus="smart_param.showSuggestions=true;focusTaskname(index, task)" @input="changeTaskname(index, task)"> <input type="text" name="taskname[]" class="form-control" v-model="task.taskname" placeholder="必填" @focus="smart_param.showSuggestions=true;focusTaskname(index, task)" @input="changeTaskname(index, task)">
<div class="dropdown-menu show task-suggestions" v-if="smart_param.showSuggestions && smart_param.taskSuggestions.success && smart_param.index === index"> <div class="dropdown-menu show task-suggestions" v-if="smart_param.showSuggestions && smart_param.taskSuggestions.success && smart_param.index === index">
<div class="dropdown-item text-muted text-center" style="font-size:12px;">{{ smart_param.taskSuggestions.message ? smart_param.taskSuggestions.message : smart_param.taskSuggestions.data.length ? `以下资源来自 ${smart_param.taskSuggestions.source} 搜索,请自行辨识,如有侵权请联系资源方` : "未搜索到资源" }}</div> <div class="dropdown-item text-muted text-center" style="font-size:12px;">{{ smart_param.taskSuggestions.message ? smart_param.taskSuggestions.message : 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: 12px;" :title="suggestion.content"> <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: 12px;" :title="suggestion.content">
<span v-html="suggestion.verify ? '✅': '❔'"></span> {{ suggestion.taskname }} <span v-html="suggestion.verify ? '✅': '❔'"></span> {{ suggestion.taskname }}
<small class="text-muted"> <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 }}</a>
</small> </small>
<span class="badge bg-transparent border border-success text-success">{{ suggestion.source || "网络公开" }}</span>
<span class="badge bg-transparent border border-info text-info">{{ suggestion.channel }}</span>
<span v-if="suggestion.datetime" class="badge bg-transparent border border-dark text-dark">{{ suggestion.datetime }}</span>
</div> </div>
</div> </div>
<div class="input-group-append" title="深度搜索"> <div class="input-group-append" title="深度搜索">
@ -307,7 +353,7 @@
<div class="input-group"> <div class="input-group">
<input type="text" name="shareurl[]" class="form-control" v-model="task.shareurl" placeholder="必填" @blur="changeShareurl(task)"> <input type="text" name="shareurl[]" class="form-control" v-model="task.shareurl" placeholder="必填" @blur="changeShareurl(task)">
<div class="input-group-append" v-if="task.shareurl"> <div class="input-group-append" v-if="task.shareurl">
<button type="button" class="btn btn-outline-secondary" @click="fileSelect.selectDir=true;fileSelect.previewRegex=false;fileSelect.sortBy='file_name';fileSelect.sortOrder='desc';showShareSelect(index)" title="选择文件夹"><i class="bi bi-folder"></i></button> <button type="button" class="btn btn-outline-secondary" @click="fileSelect.selectDir=true;fileSelect.switchShare=false;fileSelect.previewRegex=false;fileSelect.sortBy='file_name';fileSelect.sortOrder='desc';showShareSelect(index)" title="选择文件夹"><i class="bi bi-folder"></i></button>
<div class="input-group-text"> <div class="input-group-text">
<a target="_blank" :href="task.shareurl"><i class="bi bi-box-arrow-up-right"></i></a> <a target="_blank" :href="task.shareurl"><i class="bi bi-box-arrow-up-right"></i></a>
</div> </div>
@ -332,9 +378,9 @@
<div class="col-sm-10"> <div class="col-sm-10">
<div class="input-group"> <div class="input-group">
<div class="input-group-prepend"> <div class="input-group-prepend">
<button class="btn btn-outline-secondary" type="button" @click="fileSelect.selectDir=true;fileSelect.previewRegex=true;fileSelect.sortBy='file_name';fileSelect.sortOrder='asc';showShareSelect(index)" title="预览正则处理效果">正则处理</button> <button class="btn btn-outline-secondary" type="button" @click="fileSelect.selectDir=true;fileSelect.switchShare=false;fileSelect.previewRegex=true;fileSelect.sortBy='file_name';fileSelect.sortOrder='asc';showShareSelect(index)" title="预览正则处理效果">正则处理</button>
</div> </div>
<input type="text" name="pattern[]" class="form-control" v-model="task.pattern" placeholder="匹配表达式" list="magicRegex"> <input type="text" name="pattern[]" class="form-control" v-model="task.pattern" placeholder="匹配表达式" list="magicRegex" @dblclick="inputRawMagicRegex(task)" title="双击可将魔法匹配释放为填入原始正则表达式">
<input type="text" name="replace[]" class="form-control" v-model="task.replace" placeholder="替换表达式"> <input type="text" name="replace[]" class="form-control" v-model="task.replace" placeholder="替换表达式">
<div class="input-group-append" title="保存时只比较文件名的部分01.mp4 和 01.mkv 视同为同一文件,不重复转存"> <div class="input-group-append" title="保存时只比较文件名的部分01.mp4 和 01.mkv 视同为同一文件,不重复转存">
<div class="input-group-text"> <div class="input-group-text">
@ -353,7 +399,7 @@
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" placeholder="可选,只转存修改日期>此文件的文件" name="startfid[]" v-model="task.startfid"> <input type="text" class="form-control" placeholder="可选,只转存修改日期>此文件的文件" name="startfid[]" v-model="task.startfid">
<div class="input-group-append" v-if="task.shareurl"> <div class="input-group-append" v-if="task.shareurl">
<button class="btn btn-outline-secondary" type="button" @click="fileSelect.selectDir=false;fileSelect.previewRegex=false;fileSelect.sortBy='updated_at';fileSelect.sortOrder='desc';showShareSelect(index)">选择</button> <button class="btn btn-outline-secondary" type="button" @click="fileSelect.selectDir=false;fileSelect.switchShare=false;fileSelect.previewRegex=false;fileSelect.sortBy='updated_at';fileSelect.sortOrder='desc';showShareSelect(index)">选择</button>
</div> </div>
</div> </div>
</div> </div>
@ -401,7 +447,10 @@
</div> </div>
<div class="row mt-5"> <div class="row mt-5">
<div class="col-sm-12 text-center"> <div class="col-sm-12 text-center">
<button type="button" class="btn btn-primary" @click="addTask()"><i class="bi bi-plus"></i> 增加任务</button> <div class="btn-group" role="group" aria-label="任务操作">
<button type="button" class="btn btn-primary" @click="addTask()"><i class="bi bi-plus"></i> 增加任务</button>
<button type="button" class="btn btn-primary" @click="addTaskForClipboard()" title="从粘贴板导入"><i class="bi bi-clipboard-plus"></i></button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -450,6 +499,34 @@
</button> </button>
</div> </div>
<div class="modal-body small"> <div class="modal-body small">
<!-- 分享链接来源 -->
<div class="mb-3 row" v-if="fileSelect.switchShare">
<div class="col-sm-8">
<div>
<b>名称:</b>
<span :title="fileSelect.share.content">{{ fileSelect.share.taskname }}</span>
</div>
<div>
<b>链接:</b>
<a :href="fileSelect.share.shareurl" target="_blank" @click.stop>{{ fileSelect.share.shareurl }}</a>
</div>
<div>
<b>来源:</b>
<span class="badge bg-transparent border border-success text-success">{{ fileSelect.share.source || "网络公开" }}</span>
<span class="badge bg-transparent border border-info text-info" v-if="fileSelect.share.channel">{{ fileSelect.share.channel }}</span>
</div>
<div v-if="fileSelect.share.datetime">
<b>时间:</b>
<span>{{ fileSelect.share.datetime }}</span>
</div>
</div>
<div class="col-sm-4 text-right">
<div class="btn-group" title="资源搜索结果切换">
<button type="button" class="btn btn-sm btn-outline-primary" @click="switchShare(-1)">上一个</button>
<button type="button" class="btn btn-sm btn-outline-primary" @click="switchShare(1)">下一个</button>
</div>
</div>
</div>
<div class="alert alert-warning" v-if="fileSelect.error" v-html="fileSelect.error"></div> <div class="alert alert-warning" v-if="fileSelect.error" v-html="fileSelect.error"></div>
<div v-else> <div v-else>
<!-- 正则处理表达式 --> <!-- 正则处理表达式 -->
@ -504,7 +581,7 @@
<td v-if="file.dir">{{ file.include_items }}项</td> <td v-if="file.dir">{{ file.include_items }}项</td>
<td v-else>{{file.size | size}}</td> <td v-else>{{file.size | size}}</td>
<td>{{file.updated_at | ts2date}}</td> <td>{{file.updated_at | ts2date}}</td>
<td v-if="!fileSelect.selectShare"><a @click.stop.prevent="deleteFile(file.fid, file.file_name, file.dir)">删除</a></td> <td v-if="!fileSelect.selectShare"><a class="cursor-pointer text-muted" @click.stop.prevent="deleteFile(file.fid, file.file_name, file.dir)">删除</a></td>
</template> </template>
</tr> </tr>
</tbody> </tbody>
@ -519,6 +596,19 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Toast 提示 -->
<div class="toast-container">
<div v-for="toast in toasts" :key="toast.id" class="toast show shadow-sm" :class="toast.type" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-body d-flex align-items-center">
<i class="bi mr-2" :class="getToastIcon(toast.type)"></i>
<span>{{ toast.message }}</span>
<button type="button" class="ml-auto close" @click="removeToast(toast.id)" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
</div>
</div> </div>
@ -537,14 +627,21 @@
tasklist: [], tasklist: [],
magic_regex: {}, magic_regex: {},
source: { source: {
net: {
enable: ""
},
cloudsaver: { cloudsaver: {
server: "", server: "",
username: "", username: "",
password: "", password: "",
token: "" token: ""
},
pansou: {
server: ""
} }
}, },
}, },
toasts: [],
newTask: { newTask: {
taskname: "", taskname: "",
shareurl: "", shareurl: "",
@ -574,12 +671,14 @@
configModified: false, configModified: false,
fileSelect: { fileSelect: {
index: null, index: null,
share: {},
shareurl: "", shareurl: "",
stoken: "", stoken: "",
fileList: [], fileList: [],
paths: [], paths: [],
selectDir: true, selectDir: true,
selectShare: true, selectShare: true,
switchShare: false,
previewRegex: false, previewRegex: false,
sortBy: "updated_at", sortBy: "updated_at",
sortOrder: "desc" sortOrder: "desc"
@ -676,6 +775,16 @@
token: "" token: ""
}; };
} }
if (!config_data.source.pansou) {
config_data.source.pansou = {
server: ""
};
}
if (!config_data.source.net) {
config_data.source.net = {
enable: ""
};
}
this.formData = config_data; this.formData = config_data;
setTimeout(() => { setTimeout(() => {
this.configModified = false; this.configModified = false;
@ -708,8 +817,10 @@
.then(response => { .then(response => {
if (response.data.success) { if (response.data.success) {
this.configModified = false; this.configModified = false;
this.showToast(response.data.message, 'success');
} else {
this.showToast(response.data.message, 'error');
} }
alert(response.data.message);
console.log('Config saved result:', response.data); console.log('Config saved result:', response.data);
}) })
.catch(error => { .catch(error => {
@ -930,6 +1041,7 @@
} }
}, },
searchSuggestions(index, taskname, deep = 1) { searchSuggestions(index, taskname, deep = 1) {
taskname = taskname.replace(/\((19|20)\d{2}\)/g, '').trim();
if (taskname.length < 2) { if (taskname.length < 2) {
console.log(`任务名[${taskname}]过短${taskname.length} 不进行搜索`); console.log(`任务名[${taskname}]过短${taskname.length} 不进行搜索`);
return; return;
@ -959,7 +1071,9 @@
selectSuggestion(index, suggestion) { selectSuggestion(index, suggestion) {
this.smart_param.showSuggestions = false; this.smart_param.showSuggestions = false;
this.fileSelect.selectDir = true; this.fileSelect.selectDir = true;
this.fileSelect.switchShare = true;
this.fileSelect.previewRegex = false; this.fileSelect.previewRegex = false;
this.fileSelect.share = suggestion;
this.showShareSelect(index, suggestion.shareurl); this.showShareSelect(index, suggestion.shareurl);
}, },
addMagicRegex() { addMagicRegex() {
@ -969,7 +1083,7 @@
updateMagicRegexKey(oldKey, newKey) { updateMagicRegexKey(oldKey, newKey) {
if (oldKey !== newKey) { if (oldKey !== newKey) {
if (this.formData.magic_regex[newKey]) { if (this.formData.magic_regex[newKey]) {
alert(`魔法名 [${newKey}] 已存在,请使用其他名称`); this.showToast(`魔法名 [${newKey}] 已存在,请使用其他名称`, 'warning');
return; return;
} }
this.$set(this.formData.magic_regex, newKey, this.formData.magic_regex[oldKey]); this.$set(this.formData.magic_regex, newKey, this.formData.magic_regex[oldKey]);
@ -989,7 +1103,7 @@
if (response.data.code == 0) { if (response.data.code == 0) {
this.fileSelect.fileList = this.fileSelect.fileList.filter(item => item.fid != fid); this.fileSelect.fileList = this.fileSelect.fileList.filter(item => item.fid != fid);
} else { } else {
alert('删除失败:' + response.data.message); this.showToast('删除失败:' + response.data.message, 'error');
} }
}).catch(error => { }).catch(error => {
console.error('Error /delete_file:', error); console.error('Error /delete_file:', error);
@ -1020,6 +1134,7 @@
showSavepathSelect(index) { showSavepathSelect(index) {
this.fileSelect.selectShare = false; this.fileSelect.selectShare = false;
this.fileSelect.selectDir = true; this.fileSelect.selectDir = true;
this.fileSelect.switchShare = false;
this.fileSelect.previewRegex = false; this.fileSelect.previewRegex = false;
this.fileSelect.error = undefined; this.fileSelect.error = undefined;
this.fileSelect.fileList = []; this.fileSelect.fileList = [];
@ -1067,6 +1182,23 @@
$('#fileSelectModal').modal('toggle'); $('#fileSelectModal').modal('toggle');
this.getShareDetail(); this.getShareDetail();
}, },
switchShare(index) {
currentIndex = this.smart_param.taskSuggestions.data.indexOf(this.fileSelect.share);
nextIndex = currentIndex + index;
if (nextIndex < 0) {
this.showToast("没有上一个啦", "info");
} else if (nextIndex >= this.smart_param.taskSuggestions.data.length) {
this.showToast("没有下一个啦", "info");
} else {
this.fileSelect.error = "";
this.fileSelect.stoken = "";
this.fileSelect.share = this.smart_param.taskSuggestions.data[nextIndex];
this.fileSelect.shareurl = this.smart_param.taskSuggestions.data[nextIndex].shareurl;
this.fileSelect.paths = [];
this.fileSelect.fileList = [];
this.getShareDetail();
}
},
navigateTo(fid, name) { navigateTo(fid, name) {
dir = { fid: fid, name: name } dir = { fid: fid, name: name }
if (this.fileSelect.selectShare) { if (this.fileSelect.selectShare) {
@ -1108,9 +1240,9 @@
} else if (shareurl.includes(dir.fid)) { } else if (shareurl.includes(dir.fid)) {
shareurl = shareurl.match(`.*/${dir.fid}[^/]*`)[0] shareurl = shareurl.match(`.*/${dir.fid}[^/]*`)[0]
} else if (shareurl.includes('#/list/share')) { } else if (shareurl.includes('#/list/share')) {
shareurl = `${shareurl}/${dir.fid}-${dir.name?.replace('-', '*101')}` shareurl = `${shareurl.split('#')[0]}#/list/share/${dir.fid}`
} else { } else {
shareurl = `${shareurl}#/list/share/${dir.fid}-${dir.name?.replace('-', '*101')}` shareurl = `${shareurl.split('#')[0]}#/list/share/${dir.fid}`
} }
return shareurl; return shareurl;
}, },
@ -1133,7 +1265,86 @@
if (valA > valB) return this.fileSelect.sortOrder === "asc" ? 1 : -1; if (valA > valB) return this.fileSelect.sortOrder === "asc" ? 1 : -1;
return 0; return 0;
}); });
} },
inputRawMagicRegex(task) {
const item = this.formData.magic_regex[task.pattern];
if (item) {
task.pattern = item.pattern;
task.replace = item.replace;
}
},
copyText(text, callback = () => { }) {
if (!text) {
console.error('No text to copy');
return;
}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.top = '0';
textarea.style.left = '0';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, 99999);
document.execCommand("copy");
document.body.removeChild(textarea);
}
callback()
},
copyTaskToClipboard(index) {
const task = { ...this.formData.tasklist[index] };
delete task.addition;
const _this = this;
this.copyText(JSON.stringify(task), function () {
_this.showToast("任务参数已复制到剪贴板", "success");
});
},
async addTaskForClipboard() {
text = null
try {
text = await navigator.clipboard.readText();
} catch (error) {
text = prompt("当前环境不支持自动读取粘贴板,请手动粘贴任务参数", "");
}
if (text) {
try {
let task = JSON.parse(text);
task = { ...this.newTask, ...task };
this.formData.tasklist.push(task);
this.showToast("剪贴板参数已成功导入任务", "success");
// 滚到最下
setTimeout(() => {
$('#collapse_' + (this.formData.tasklist.length - 1)).collapse('show').on('shown.bs.collapse', () => {
this.scrollToX();
});
}, 1);
} catch (error) {
this.showToast("解析剪贴板内容失败", "error");
}
}
},
showToast(message, type = 'info', duration = 3000) {
const id = Date.now();
this.toasts.push({ id, message, type });
setTimeout(() => {
this.removeToast(id);
}, duration);
},
removeToast(id) {
this.toasts = this.toasts.filter(t => t.id !== id);
},
getToastIcon(type) {
switch (type) {
case 'success': return 'bi-check-circle-fill text-success';
case 'error': return 'bi-exclamation-circle-fill text-danger';
case 'warning': return 'bi-exclamation-triangle-fill text-warning';
default: return 'bi-info-circle-fill text-info';
}
},
} }
}); });
</script> </script>

View File

@ -1,9 +1,12 @@
[ [
"smartstrm",
"fnv_refresh_v2",
"alist", "alist",
"alist_strm", "alist_strm",
"alist_strm_gen", "alist_strm_gen",
"alist_sync", "alist_sync",
"aria2", "aria2",
"emby", "emby",
"plex" "plex",
"fnv"
] ]

View File

@ -164,7 +164,9 @@ class Alist_sync:
# 获取网盘已有文件 # 获取网盘已有文件
source_dir_list = self.get_path_list(self.source_path) source_dir_list = self.get_path_list(self.source_path)
if not source_dir_list:
print("获取夸克文件列表失败请检查网络或手动刷新alist中的夸克目录")
return 0
if self.tv_mode == 0 or self.tv_mode == "": if self.tv_mode == 0 or self.tv_mode == "":
self.tv_mode = False self.tv_mode = False
else: else:
@ -228,6 +230,10 @@ class Alist_sync:
.lower() .lower()
) )
for target_list in target_dir_list: for target_list in target_dir_list:
if source_list["is_dir"]:
# print(f"跳过目录同步")
skip = True
break
if self.tv_mode: if self.tv_mode:
target_list_filename = ( target_list_filename = (
target_list["name"] target_list["name"]

312
plugins/fnv.py Normal file
View File

@ -0,0 +1,312 @@
import requests
import json
import hashlib
import random
import time
from urllib.parse import urlencode
from typing import Any, Optional
# 飞牛影视插件
# 该插件用于与飞牛影视服务器API交互支持自动刷新媒体库
# 通过配置用户名、密码和密钥字符串进行认证,并提供媒体库扫描功能
class Fnv:
# --- 配置信息 ---
default_config = {
"base_url": "http://10.0.0.6:5666", # 飞牛影视服务器URL
"app_name": "trimemedia-web", # 飞牛影视应用名称
"username": "", # 飞牛影视用户名
"password": "", # 飞牛影视密码
"secret_string": "", # 飞牛影视密钥字符串
"api_key": "", # 飞牛影视API密钥
"token": None, # 飞牛影视认证Token (可选)
}
default_task_config = {
"auto_refresh": False, # 是否自动刷新媒体库
"mdb_name": "", # 飞牛影视目标媒体库名称
"mdb_dir_list": "", # 飞牛影视目标媒体库文件夹路径列表,多个用逗号分隔
}
# 定义一个可选键的集合
OPTIONAL_KEYS = {"token"}
# --- API 端点常量 ---
API_LOGIN = "/v/api/v1/login" # 登录端点
API_MDB_LIST = "/v/api/v1/mdb/list" # 获取媒体库列表
API_MDB_SCAN = "/v/api/v1/mdb/scan/{}" # 刷新媒体库端点 ({}为媒体库ID)
API_TASK_STOP = "/v/api/v1/task/stop" # 停止任务端点
# --- 实例状态 ---
is_active = False
session = requests.Session()
token = None
# =====================================================================
# Public Methods / Entry Points (公共方法/入口)
# =====================================================================
def __init__(self, **kwargs):
"""
初始化 Fnv 客户端
"""
self.plugin_name = self.__class__.__name__.lower()
if kwargs:
for key, _ in self.default_config.items():
if key in kwargs:
setattr(self, key, kwargs[key])
# 检查配置并尝试登录,以确定插件是否激活
if self._check_config():
if self.token is None or self.token == "":
self._login()
self.is_active = self.token is not None or self.token != ""
if self.is_active:
print(f"{self.plugin_name}: 插件已激活 ✅")
else:
print(f"{self.plugin_name}: 插件未激活 ❌")
def run(self, task, **kwargs):
"""
插件运行主入口
根据任务配置执行媒体库刷新操作
"""
if not self.is_active:
print(f"飞牛影视: 插件未激活,跳过任务。")
return
task_config = task.get("addition", {}).get(
self.plugin_name, self.default_task_config
)
if not task_config.get("auto_refresh"):
print("飞牛影视: 自动刷新未启用,跳过处理。")
return
target_library_name = task_config.get("mdb_name")
if not target_library_name:
print("飞牛影视: 未指定媒体库名称,跳过处理。")
return
target_library_mdb_dir_list = task_config.get("mdb_dir_list")
dir_list = []
if target_library_mdb_dir_list:
dir_list = [dir_path.strip() for dir_path in target_library_mdb_dir_list.split(",") if dir_path.strip()]
# 获取媒体库ID
library_id = self._get_library_id(target_library_name)
if library_id:
# 获取ID成功后刷新该媒体库
self._refresh_library(library_id, dir_list=dir_list)
# =====================================================================
# Internal Methods (内部实现方法)
# =====================================================================
def _check_config(self):
"""检查配置是否完整"""
missing_keys = [
key for key in self.default_config
if key not in self.OPTIONAL_KEYS and not getattr(self, key, None)
]
if missing_keys:
# print(f"{self.plugin_name} 模块缺少必要参数: {', '.join(missing_keys)}")
return False
return True
def _make_request(self, method: str, rel_url: str, params: dict = None, data: dict = None) -> Optional[Any]:
"""
一个统一的私有方法用于发送所有API请求
它会自动处理签名请求头错误和响应解析
当认证失败时会自动尝试重新登录并重试最多3次
"""
max_retries = 3
for attempt in range(max_retries):
url = f"{self.base_url.rstrip('/')}{rel_url}"
authx = self._cse_sign(method, rel_url, params, data)
if not authx:
print(f"飞牛影视: 为 {rel_url} 生成签名失败,请求中止。")
return None
headers = {
"Content-Type": "application/json",
"authx": authx,
}
if self.token:
headers["Authorization"] = self.token
try:
response = self.session.request(
method, url, headers=headers, params=params,
data=self._serialize_data(data if data is not None else {})
)
response.raise_for_status()
response_data = response.json()
except requests.exceptions.RequestException as e:
print(f"飞牛影视: 请求 {url} 时出错: {e}")
return None
except json.JSONDecodeError:
print(f"飞牛影视: 解析来自 {url} 的响应失败内容非JSON格式。")
return None
response_code = response_data.get("code")
if response_code is None:
print(f"飞牛影视: 响应格式错误,未找到 'code' 字段。")
return None
if response_code == 0:
return response_data
if response_code == -2:
print(f"飞牛影视: 认证失败 (尝试 {attempt + 1}/{max_retries}),尝试重新登录...")
if rel_url == self.API_LOGIN:
print("飞牛影视: 登录接口认证失败,请检查用户名和密码。")
return response_data
if not self._login():
print("飞牛影视: 重新登录失败,无法继续请求。")
return None
continue
else:
msg = response_data.get('msg', '未知错误')
print(f"飞牛影视: API调用失败 ({rel_url}): {msg}")
return response_data
print(f"飞牛影视: 请求 {rel_url} 在尝试 {max_retries} 次后仍然失败。")
return None
def _login(self) -> bool:
"""
登录到飞牛影视服务器并获取认证token
"""
app_name = self.app_name or self.default_config["app_name"]
username = self.username or self.default_config["username"]
password = self.password or self.default_config["password"]
print("飞牛影视: 正在尝试登录...")
payload = {"username": username, "password": password, "app_name": app_name}
response_json = self._make_request('post', self.API_LOGIN, data=payload)
if response_json and response_json.get("data", {}).get("token"):
self.token = response_json["data"]["token"]
print("飞牛影视: 登录成功 ✅")
return True
else:
print("飞牛影视: 登录失败 ❌")
return False
def _get_library_id(self, library_name: str) -> Optional[str]:
"""
根据媒体库的名称获取其唯一ID (guid)
"""
if not self.token:
print("飞牛影视: 必须先登录才能获取媒体库列表。")
return None
print(f"飞牛影视: 正在查找媒体库 '{library_name}'...")
response_json = self._make_request('get', self.API_MDB_LIST)
if response_json and response_json.get("data"):
for library in response_json.get("data", []):
if library.get("name") == library_name:
print(f"飞牛影视: 找到目标媒体库 ✅ID: {library.get('guid')}")
return library.get("guid")
print(f"飞牛影视: 未在媒体库列表中找到名为 '{library_name}' 的媒体库 ❌")
return None
def _refresh_library(self, library_id: str, dir_list: list[str] = None) -> bool:
"""
根据给定的媒体库ID触发一次媒体库扫描/刷新
"""
if not self.token:
print("飞牛影视: 必须先登录才能刷新媒体库。")
return False
if dir_list:
print(f"飞牛影视: 正在为媒体库 {library_id} 发送部分目录{dir_list}刷新指令...")
else:
print(f"飞牛影视: 正在为媒体库 {library_id} 发送刷新指令...")
rel_url = self.API_MDB_SCAN.format(library_id)
request_body = {"dir_list": dir_list} if dir_list else {}
response_json = self._make_request('post', rel_url, data=request_body)
if not response_json: return False
response_code = response_json.get("code")
if response_code == 0:
print(f"飞牛影视: 发送刷新指令成功 ✅")
return True
elif response_code == -14:
if self._stop_refresh_task(library_id):
print(f"飞牛影视: 发现重复任务,已停止旧任务,重新发送刷新指令...")
response_json = self._make_request('post', rel_url, data={})
if response_json and response_json.get("code") == 0:
print(f"飞牛影视: 发送刷新指令成功 ✅")
return True
else:
print(f"飞牛影视: 重新发送刷新指令失败 ❌")
else:
print(f"飞牛影视: 停止旧任务失败,无法继续刷新操作 ❌")
return False
def _stop_refresh_task(self, library_id: str) -> bool:
"""
停止指定的媒体库刷新任务
"""
if not self.token:
print("飞牛影视: 必须先登录才能停止刷新任务。")
return False
print(f"飞牛影视: 正在停止媒体库刷新任务 {library_id}...")
payload = {"guid": library_id, "type": "TaskItemScrap"}
response_json = self._make_request('post', self.API_TASK_STOP, data=payload)
if response_json and response_json.get("code") == 0:
print(f"飞牛影视: 停止刷新任务成功 ✅")
return True
else:
print(f"飞牛影视: 停止刷新任务失败 ❌")
return False
def _cse_sign(self, method: str, path: str, params: dict = None, data: dict = None) -> str:
"""
为API请求生成 cse 签名参数字符串
"""
nonce = str(random.randint(100000, 999999))
timestamp = str(int(time.time() * 1000))
serialized_str = ""
if method.lower() == 'get':
if params:
serialized_str = urlencode(sorted(params.items()))
else:
serialized_str = self._serialize_data(data)
body_hash = self._md5_hash(serialized_str)
string_to_sign_parts = [
self.secret_string, path, nonce, timestamp, body_hash, self.api_key
]
string_to_sign = "_".join(string_to_sign_parts)
final_sign = self._md5_hash(string_to_sign)
return f"nonce={nonce}&timestamp={timestamp}&sign={final_sign}"
# =====================================================================
# Static Utility Methods (静态工具方法)
# =====================================================================
@staticmethod
def _md5_hash(s: str) -> str:
"""计算并返回字符串的小写 MD5 哈希值。"""
return hashlib.md5(s.encode('utf-8')).hexdigest()
@staticmethod
def _serialize_data(data: Any) -> str:
"""
将请求体数据序列化为紧凑的JSON字符串
"""
if isinstance(data, dict):
return json.dumps(data, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
if isinstance(data, str):
return data
if not data:
return ""
return ""

BIN
plugins/fnv_refresh_v2.so Normal file

Binary file not shown.

75
plugins/smartstrm.py Normal file
View File

@ -0,0 +1,75 @@
import requests
class Smartstrm:
default_config = {
"webhook": "", # SmartStrm Webhook 地址
"strmtask": "", # SmartStrm 任务名,支持多个如 `tv,movie`
"xlist_path_fix": "", # 路径映射, SmartStrm 任务使用 quark 驱动时无须填写;使用 openlist 驱动时需填写 `/storage_mount_path:/quark_root_dir` ,例如把夸克根目录挂载在 OpenList 的 /quark 下,则填写 `/quark:/` ;以及 SmartStrm 会使 OpenList 强制刷新目录,无需再用 alist 插件刷新。
}
is_active = False
def __init__(self, **kwargs):
self.plugin_name = self.__class__.__name__.lower()
if kwargs:
for key, _ in self.default_config.items():
if key in kwargs:
setattr(self, key, kwargs[key])
else:
print(f"{self.plugin_name} 模块缺少必要参数: {key}")
if self.webhook and self.strmtask:
if self.get_info():
self.is_active = True
def get_info(self):
"""获取 SmartStrm 信息"""
try:
response = requests.request(
"GET",
self.webhook,
timeout=5,
)
response = response.json()
if response.get("success"):
print(f"SmartStrm 触发任务: 连接成功 {response.get('version','')}")
return response
print(f"SmartStrm 触发任务:连接失败 {response.get('message','')}")
return None
except Exception as e:
print(f"SmartStrm 触发任务:连接出错 {str(e)}")
return None
def run(self, task, **kwargs):
"""
插件主入口函数
:param task: 任务配置
:param kwargs: 其他参数
"""
try:
# 准备发送的数据
headers = {"Content-Type": "application/json"}
payload = {
"event": "qas_strm",
"data": {
"strmtask": self.strmtask,
"savepath": task["savepath"],
"xlist_path_fix": self.xlist_path_fix,
},
}
# 发送 POST 请求
response = requests.request(
"POST",
self.webhook,
headers=headers,
json=payload,
timeout=5,
)
response = response.json()
if response.get("success"):
print(
f"SmartStrm 触发任务: [{response['task']['name']}] {response['task']['storage_path']} 成功✅"
)
else:
print(f"SmartStrm 触发任务: {response['message']}")
except Exception as e:
print(f"SmartStrm 触发任务:出错 {str(e)}")

View File

@ -1,6 +1,6 @@
# !/usr/bin/env python3 # !/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Modify: 2024-11-13 # Modify: 2025-09-05
# Repo: https://github.com/Cp0204/quark_auto_save # Repo: https://github.com/Cp0204/quark_auto_save
# ConfigFile: quark_config.json # ConfigFile: quark_config.json
""" """
@ -96,20 +96,26 @@ class Config:
PLUGIN_FLAGS = os.environ.get("PLUGIN_FLAGS", "").split(",") PLUGIN_FLAGS = os.environ.get("PLUGIN_FLAGS", "").split(",")
plugins_available = {} plugins_available = {}
task_plugins_config = {} task_plugins_config = {}
# 获取所有模块
py_ext = [".py", ".pyd"] if sys.platform == "win32" else [".py", ".so"]
all_modules = [ all_modules = [
f.replace(".py", "") for f in os.listdir(plugins_dir) if f.endswith(".py") f.replace(ext, "")
for f in os.listdir(plugins_dir)
for ext in py_ext
if f.endswith(ext)
] ]
# 调整模块优先级 # 调整模块优先级
priority_path = os.path.join(plugins_dir, "_priority.json") priority_path = os.path.join(plugins_dir, "_priority.json")
try: try:
with open(priority_path, encoding="utf-8") as f: with open(priority_path, encoding="utf-8") as f:
priority_modules = json.load(f) priority_modules = json.load(f)
if priority_modules:
all_modules = [
module for module in priority_modules if module in all_modules
] + [module for module in all_modules if module not in priority_modules]
except (FileNotFoundError, json.JSONDecodeError): except (FileNotFoundError, json.JSONDecodeError):
priority_modules = [] priority_modules = []
if priority_modules:
all_modules = [
module for module in priority_modules if module in all_modules
] + [module for module in all_modules if module not in priority_modules]
# 加载模块
for module_name in all_modules: for module_name in all_modules:
if f"-{module_name}" in PLUGIN_FLAGS: if f"-{module_name}" in PLUGIN_FLAGS:
continue continue
@ -128,7 +134,6 @@ class Config:
task_plugins_config[module_name] = plugin.default_task_config task_plugins_config[module_name] = plugin.default_task_config
except (ImportError, AttributeError) as e: except (ImportError, AttributeError) as e:
print(f"载入模块 {module_name} 失败: {e}") print(f"载入模块 {module_name} 失败: {e}")
print()
return plugins_available, plugins_config, task_plugins_config return plugins_available, plugins_config, task_plugins_config
def breaking_change_update(config_data): def breaking_change_update(config_data):
@ -195,6 +200,9 @@ class MagicRename:
"", "",
"", "",
"", "",
"",
"",
"",
] ]
def __init__(self, magic_regex={}, magic_variable={}): def __init__(self, magic_regex={}, magic_variable={}):
@ -259,14 +267,14 @@ class MagicRename:
"""自定义排序键""" """自定义排序键"""
for i, keyword in enumerate(self.priority_list): for i, keyword in enumerate(self.priority_list):
if keyword in name: if keyword in name:
return name.replace(keyword, f"{i:02d}") # 替换为数字,方便排序 name = name.replace(keyword, f"_{i:02d}_") # 替换为数字,方便排序
return name return name
def sort_file_list(self, file_list, dir_filename_dict={}): def sort_file_list(self, file_list, dir_filename_dict={}):
"""文件列表统一排序,给{I+}赋值""" """文件列表统一排序,给{I+}赋值"""
filename_list = [ filename_list = [
# 强制加入`文件修改时间`字段供排序效果1无可排序字符时则按修改时间排序2和目录已有文件重名时始终在其后 # 强制加入`文件修改时间`字段供排序效果1无可排序字符时则按修改时间排序2和目录已有文件重名时始终在其后
f"{f['file_name_re']}{f['updated_at']}" f"{f['file_name_re']}_{f['updated_at']}"
for f in file_list for f in file_list
if f.get("file_name_re") and not f["dir"] if f.get("file_name_re") and not f["dir"]
] ]
@ -289,7 +297,7 @@ class MagicRename:
if file.get("file_name_re"): if file.get("file_name_re"):
if match := re.search(r"\{I+\}", file["file_name_re"]): if match := re.search(r"\{I+\}", file["file_name_re"]):
i = filename_index.get( i = filename_index.get(
f"{file['file_name_re']}{file['updated_at']}", 0 f"{file['file_name_re']}_{file['updated_at']}", 0
) )
file["file_name_re"] = re.sub( file["file_name_re"] = re.sub(
match.group(), match.group(),
@ -299,11 +307,11 @@ class MagicRename:
def set_dir_file_list(self, file_list, replace): def set_dir_file_list(self, file_list, replace):
"""设置目录文件列表""" """设置目录文件列表"""
if not file_list:
return
self.dir_filename_dict = {} self.dir_filename_dict = {}
filename_list = [f["file_name"] for f in file_list if not f["dir"]] filename_list = [f["file_name"] for f in file_list if not f["dir"]]
filename_list.sort() filename_list.sort()
if not filename_list:
return
if match := re.search(r"\{I+\}", replace): if match := re.search(r"\{I+\}", replace):
# 由替换式转换匹配式 # 由替换式转换匹配式
magic_i = match.group() magic_i = match.group()
@ -336,7 +344,7 @@ class MagicRename:
if match := re.search(r"\{I+\}", filename): if match := re.search(r"\{I+\}", filename):
magic_i = match.group() magic_i = match.group()
pattern_i = r"\d" * magic_i.count("I") pattern_i = r"\d" * magic_i.count("I")
pattern = filename.replace(magic_i, pattern_i) pattern = re.escape(filename).replace(re.escape(magic_i), pattern_i)
for filename in filename_list: for filename in filename_list:
if re.match(pattern, filename): if re.match(pattern, filename):
return filename return filename
@ -494,7 +502,9 @@ class Quark:
).json() ).json()
return response return response
def get_detail(self, pwd_id, stoken, pdir_fid, _fetch_share=0): def get_detail(
self, pwd_id, stoken, pdir_fid, _fetch_share=0, fetch_share_full_path=0
):
list_merge = [] list_merge = []
page = 1 page = 1
while True: while True:
@ -512,6 +522,8 @@ class Quark:
"_fetch_share": _fetch_share, "_fetch_share": _fetch_share,
"_fetch_total": "1", "_fetch_total": "1",
"_sort": "file_type:asc,updated_at:desc", "_sort": "file_type:asc,updated_at:desc",
"ver": "2",
"fetch_share_full_path": fetch_share_full_path,
} }
response = self._send_request("GET", url, params=querystring).json() response = self._send_request("GET", url, params=querystring).json()
if response["code"] != 0: if response["code"] != 0:
@ -561,6 +573,8 @@ class Quark:
"_fetch_sub_dirs": "0", "_fetch_sub_dirs": "0",
"_sort": "file_type:asc,updated_at:desc", "_sort": "file_type:asc,updated_at:desc",
"_fetch_full_path": kwargs.get("fetch_full_path", 0), "_fetch_full_path": kwargs.get("fetch_full_path", 0),
"fetch_all_file": 1, # 跟随Web端作用未知
"fetch_risk_file_name": 1, # 如无此参数,违规文件名会被变 ***
} }
response = self._send_request("GET", url, params=querystring).json() response = self._send_request("GET", url, params=querystring).json()
if response["code"] != 0: if response["code"] != 0:
@ -613,6 +627,8 @@ class Quark:
"__t": datetime.now().timestamp(), "__t": datetime.now().timestamp(),
} }
response = self._send_request("GET", url, params=querystring).json() response = self._send_request("GET", url, params=querystring).json()
if response["status"] != 200:
return response
if response["data"]["status"] == 2: if response["data"]["status"] == 2:
if retry_index > 0: if retry_index > 0:
print() print()
@ -706,6 +722,7 @@ class Quark:
match_pwd = re.search(r"pwd=(\w+)", url) match_pwd = re.search(r"pwd=(\w+)", url)
passcode = match_pwd.group(1) if match_pwd else "" passcode = match_pwd.group(1) if match_pwd else ""
# path: fid-name # path: fid-name
# Legacy 20250905
paths = [] paths = []
matches = re.findall(r"/(\w{32})-?([^/]+)?", url) matches = re.findall(r"/(\w{32})-?([^/]+)?", url)
for match in matches: for match in matches:
@ -875,7 +892,9 @@ class Quark:
# 添加符合的 # 添加符合的
for share_file in share_file_list: for share_file in share_file_list:
search_pattern = ( search_pattern = (
task.get("update_subdir", "") if share_file["dir"] else pattern task["update_subdir"]
if share_file["dir"] and task.get("update_subdir")
else pattern
) )
# 正则文件名匹配 # 正则文件名匹配
if re.search(search_pattern, share_file["file_name"]): if re.search(search_pattern, share_file["file_name"]):
@ -962,36 +981,46 @@ class Quark:
fid_list = [item["fid"] for item in need_save_list] fid_list = [item["fid"] for item in need_save_list]
fid_token_list = [item["share_fid_token"] for item in need_save_list] fid_token_list = [item["share_fid_token"] for item in need_save_list]
if fid_list: if fid_list:
save_file_return = self.save_file(
fid_list, fid_token_list, to_pdir_fid, pwd_id, stoken
)
err_msg = None err_msg = None
if save_file_return["code"] == 0: save_as_top_fids = []
task_id = save_file_return["data"]["task_id"] while fid_list:
query_task_return = self.query_task(task_id) # 分次转存100个/次因query_task返回save_as_top_fids最多100
if query_task_return["code"] == 0: save_file_return = self.save_file(
# 建立目录树 fid_list[:100], fid_token_list[:100], to_pdir_fid, pwd_id, stoken
for index, item in enumerate(need_save_list): )
icon = self._get_file_icon(item) fid_list = fid_list[100:]
tree.create_node( fid_token_list = fid_token_list[100:]
f"{icon}{item['file_name_re']}", if save_file_return["code"] == 0:
item["fid"], # 转存成功,查询转存结果
parent=pdir_fid, task_id = save_file_return["data"]["task_id"]
data={ query_task_return = self.query_task(task_id)
"file_name": item["file_name"], if query_task_return["code"] == 0:
"file_name_re": item["file_name_re"], save_as_top_fids.extend(
"fid": f"{query_task_return['data']['save_as']['save_as_top_fids'][index]}", query_task_return["data"]["save_as"]["save_as_top_fids"]
"path": f"{savepath}/{item['file_name_re']}",
"is_dir": item["dir"],
"obj_category": item.get("obj_category", ""),
},
) )
else:
err_msg = query_task_return["message"]
else: else:
err_msg = query_task_return["message"] err_msg = save_file_return["message"]
else: if err_msg:
err_msg = save_file_return["message"] add_notify(f"❌《{task['taskname']}》转存失败:{err_msg}\n")
if err_msg: # 建立目录树
add_notify(f"❌《{task['taskname']}》转存失败:{err_msg}\n") if len(need_save_list) == len(save_as_top_fids):
for index, item in enumerate(need_save_list):
icon = self._get_file_icon(item)
tree.create_node(
f"{icon}{item['file_name_re']}",
item["fid"],
parent=pdir_fid,
data={
"file_name": item["file_name"],
"file_name_re": item["file_name_re"],
"fid": f"{save_as_top_fids[index]}",
"path": f"{savepath}/{item['file_name_re']}",
"is_dir": item["dir"],
"obj_category": item.get("obj_category", ""),
},
)
return tree return tree
def do_rename(self, tree, node_id=None): def do_rename(self, tree, node_id=None):
@ -1086,6 +1115,7 @@ def do_save(account, tasklist=[]):
plugins, CONFIG_DATA["plugins"], task_plugins_config = Config.load_plugins( plugins, CONFIG_DATA["plugins"], task_plugins_config = Config.load_plugins(
CONFIG_DATA.get("plugins", {}) CONFIG_DATA.get("plugins", {})
) )
print()
print(f"转存账号: {account.nickname}") print(f"转存账号: {account.nickname}")
# 获取全部保存目录fid # 获取全部保存目录fid
account.update_savepath_fid(tasklist) account.update_savepath_fid(tasklist)
@ -1153,6 +1183,13 @@ def do_save(account, tasklist=[]):
plugin.run(task, account=account, tree=is_new_tree) or task plugin.run(task, account=account, tree=is_new_tree) or task
) )
print() print()
print(f"===============插件收尾===============")
for plugin_name, plugin in plugins.items():
if plugin.is_active and hasattr(plugin, "task_after"):
data = plugin.task_after()
if data.get("config"):
CONFIG_DATA["plugins"][plugin_name] = data["config"]
print()
def main(): def main():

View File

@ -13,7 +13,7 @@
} }
}, },
"magic_regex": { "magic_regex": {
"$TV": { "$TV_REGEX": {
"pattern": ".*?([Ss]\\d{1,2})?(?:[第EePpXx\\.\\-\\_\\( ]{1,2}|^)(\\d{1,3})(?!\\d).*?\\.(mp4|mkv)", "pattern": ".*?([Ss]\\d{1,2})?(?:[第EePpXx\\.\\-\\_\\( ]{1,2}|^)(\\d{1,3})(?!\\d).*?\\.(mp4|mkv)",
"replace": "\\1E\\2.\\3" "replace": "\\1E\\2.\\3"
}, },
@ -21,12 +21,12 @@
"pattern": "^(?!.*纯享)(?!.*加更)(?!.*超前企划)(?!.*训练室)(?!.*蒸蒸日上).*", "pattern": "^(?!.*纯享)(?!.*加更)(?!.*超前企划)(?!.*训练室)(?!.*蒸蒸日上).*",
"replace": "" "replace": ""
}, },
"$SHOW_PRO": { "$SHOW_MAGIC": {
"pattern": "^(?!.*纯享)(?!.*加更)(?!.*抢先)(?!.*预告).*?第\\d+期.*", "pattern": "^(?!.*纯享)(?!.*加更)(?!.*抢先)(?!.*预告).*?第\\d+期.*",
"replace": "{II}.{TASKNAME}.{DATE}.第{E}期{PART}.{EXT}" "replace": "{TASKNAME}.{SXX}E{II}.第{E}期{PART}.{EXT}"
}, },
"$TV_PRO": { "$TV_MAGIC": {
"pattern": "", "pattern": ".*\\.(mp4|mkv|mov|m4v|avi|mpeg|ts)$",
"replace": "{TASKNAME}.{SXX}E{E}.{EXT}" "replace": "{TASKNAME}.{SXX}E{E}.{EXT}"
} }
}, },
@ -35,7 +35,7 @@
"taskname": "测试-魔法匹配剧集这是一组有效分享配置CK后可测试任务是否正常", "taskname": "测试-魔法匹配剧集这是一组有效分享配置CK后可测试任务是否正常",
"shareurl": "https://pan.quark.cn/s/d07a34a9c695#/list/share/7e25ddd87cf64443b637125478733295-夸克自动转存测试", "shareurl": "https://pan.quark.cn/s/d07a34a9c695#/list/share/7e25ddd87cf64443b637125478733295-夸克自动转存测试",
"savepath": "/夸克自动转存测试/剧集", "savepath": "/夸克自动转存测试/剧集",
"pattern": "$TV", "pattern": "$TV_REGEX",
"replace": "", "replace": "",
"enddate": "2099-01-30", "enddate": "2099-01-30",
"update_subdir": "4k|1080p" "update_subdir": "4k|1080p"