Compare commits

...

97 Commits

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
jenfonro
53a2b04e60
♻️修复 alist_sync.py 变量命名 (#97)
* ♻️ 修复变量重命名后问题

* ♻️ 修复TV模式语法大小写问题
2025-06-26 16:15:37 +08:00
Cp0204
8e27444f0e 新增重存模式处理子目录更新 #67
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
- 重存模式下,删除整个子目录并重新转存
- 新增提示信息,帮助用户选择合适的处理模式
2025-06-26 01:00:23 +08:00
Cp0204
59e024fd40 ♻️ 插件 alist_sync 更清晰的变量命名 2025-06-25 20:25:47 +08:00
Cp0204
ba9d3c7826 🔧 插件 alist_sync 加入优先级列表 2025-06-25 20:17:11 +08:00
Cp0204
44b3920055 改进魔法变量 {II} 排序加入文件修改时间因素
- 无可排序字符时则按修改时间排序
- 和目录已有文件重名时始终在其后
- 确保稳定排序和正确赋值
2025-06-25 20:16:13 +08:00
xiaoQQya
a939c233dc
改进魔法变量 {II} 使用自然排序
* fix: 修复 II 魔法变量排序时不是自然排序的问题

* chore: del empty line

---------

Co-authored-by: Cp0204 <Cp0204@qq.com>
2025-06-25 19:46:48 +08:00
xiaoQQya
cd3964a8f8
🐛 修复 alist_sync 语法错误 (#95)
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-06-20 20:14:53 +08:00
Cp0204
cbd00b2fcf 📝 更新正则处理和魔法匹配说明
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
- 更新插件贡献者
- 杂项修改
2025-06-19 10:03:36 +08:00
Cp0204
2fe35a4ebb ntfy推送增加可选的认证与用户动作
- whyour/qinglong#2741
2025-06-19 09:59:14 +08:00
jenfonro
5b3fca3fdc
插件:调用 alist 跨网盘转存 (#94)
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
2025-06-18 17:39:04 +08:00
Cp0204
e6b8963069 ️ 优化正则转换逻辑,修复空分享报错
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-05-20 11:57:47 +08:00
Cp0204
e55433c3f8 Revert "🐛 修复浏览空分享文件夹时报错"
This reverts commit a45663b1f1.
2025-05-20 11:54:46 +08:00
Cp0204
a45663b1f1 🐛 修复浏览空分享文件夹时报错 2025-05-20 11:37:45 +08:00
xiaoQQya
4646e7db78
新增文件选择列表排序功能 (#88)
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-05-18 15:26:12 +08:00
Cp0204
6d924efba2 🐛 修复特定场景下 {II} 取值重复 #89
- 新增 filename_index 字典,用于存储文件名和索引
- 使用 get 方法获取 filename_index 中的值,简化逻辑
2025-05-18 14:13:44 +08:00
Cp0204
0efded719f 插件 alist_strm_gen 支持 alist 签名
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-05-12 13:57:26 +08:00
xiaoQQya
b20a29ab8c
🐛 修复 {II} 不在开头时重复保存的问题 (#85)
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
2025-05-11 22:03:42 +08:00
Hinata
aa848bf63f
🐛 Fix SyntaxError (#83)
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
2025-05-11 12:53:56 +08:00
Cp0204
6e79107070 🐛 修复最后一个任务正则预览后删除报错
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
- 修复 formData.tasklist[fileSelect.index] 越界访问
2025-05-10 22:41:17 +08:00
Cp0204
c18f544c26 🔧 移除 dateutil 依赖
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
2025-05-10 20:23:42 +08:00
Cp0204
52d4216727 更新配置结构、移除老旧版本升级代码 2025-05-10 19:37:21 +08:00
Cp0204
4ece1ec80a ♻️ 调整 Quark 类初始化方法 2025-05-10 19:37:21 +08:00
Cp0204
f692ce57ee 增加通知推送测试的按钮和功能
- 在 run.py 中添加了处理测试请求的逻辑,设置 QUARK_TEST 环境变量
- 更新了 index.html,增加推送测试按钮
- 修改了 quark_auto_save.py,增加了转存测试和通知测试的功能
- 转存测试增加错误处理和日志输出
2025-05-10 19:37:20 +08:00
Cp0204
4669935200 🔧 优化通知配置读取 2025-05-10 17:11:17 +08:00
Cp0204
f005d4f614 WEBUI 端口可用环境变量配置
- 新增 HOST 和 PORT 环境变量,默认值分别为 0.0.0.0 和 5005
- 更新 app.run() 方法,使用新的环境变量配置
2025-05-10 12:45:31 +08:00
Cp0204
a7e61cd937 ♻️ 调整重命名逻辑:子目录文件不重命名
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-05-08 22:08:16 +08:00
Cp0204
202349b3a9 增加多种类型的文件图标显示
- 新增 _get_file_icon 方法,用于获取文件图标
- 扩展图标类型,支持视频、图片、音频、文档和压缩文件等类型
2025-05-08 20:44:26 +08:00
Cp0204
54a1c222c7 改进 {I} 排序:如相同序号已存在则递增 2025-05-08 19:34:30 +08:00
Cp0204
4c245d2c34 🐛 修复油猴添加的任务无插件配置
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
- 如果请求数据中没有提供"addition"字段,则使用默认的插件配置
2025-05-07 17:09:06 +08:00
Cp0204
d2e63b96eb 🐛 Aria2插件:修复下载文件为空时报错 #80 2025-05-07 17:00:05 +08:00
Cp0204
cc7603f92b 🐛 修正排序策略为:目录不参与排序 2025-05-07 00:52:46 +08:00
Cp0204
c80c73d3cc 增强 magic_variable 的识别
- {DATE} 增加对两位数年份的匹配
- 改进 {E} 的正则,健壮匹配
- 新增 {VER} 变量以识别版本
- 优化 {DATE} 处理,补全不完整的年份信息
2025-05-05 01:03:59 +08:00
Cp0204
c2f674cef0 优化保存目录中文件是否存在的比较逻辑
- 在 run.py:get_share_detail() 中增加对目录的处理,不进行重命名
- dir_check_and_save() 优化对比逻辑,提高效率,增强可读性
2025-05-05 00:55:50 +08:00
Cp0204
3c193dcd74 重构重命名功能,引入魔法变量
- 引入 MagicRename 类,支持文件名的正则匹配和替换
- 引入魔法变量,支持提取文件名部分信息
- 引入 {I} 模式,支持文件编序 (WIP)
- 预览正则时,区别显示目录已有文件
- 移除 do_rename_task() 新增 do_rename(),调整逻辑为只对新转存的文件重命名
- 更新相关处理逻辑,修改 /get_share_detail 前后端传参
2025-05-03 16:39:58 +08:00
Cp0204
7b4fd666b4 🐛 预防运行日志显示异常
- 使用 eventData.replace('<', '<\u200B') 防止 HTML 解析错误
2025-05-03 16:16:57 +08:00
Cp0204
eea76d3aa3 🔧 修改逻辑:运行单个任务后不刷新配置
- 以免测试任务时参数被覆盖
2025-05-03 11:46:46 +08:00
Mr.King
69e4baee87 通知模块:增加 DoDo机器人推送 (#76)
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
🐛 通知模块:修复DoDo机器人配置参数大小写错误 (#77)
2025-04-28 20:30:00 +08:00
Cp0204
ef5f71b4db 同步通知模块代码:增加 wxpusher 推送
- 修改通知文件未设置变量提示
2025-04-28 16:50:02 +08:00
Hinata
2bdf315f4b
🐛 Fix SyntaxError (#74)
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
2025-04-27 13:51:54 +08:00
Cp0204
71b4fca6c2 📝 更新文档
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
2025-04-26 18:08:29 +08:00
Cp0204
b6aca2e2e9 油猴脚本:一键推送助手更新至 0.4
- QAS 地址支持填入 https
- 变更更新链接为 GitHub 地址
- 添加默认正则和替换设置
2025-04-26 17:04:35 +08:00
Cp0204
996210f8c7 🐛 修复正则预览后搜资源 界面未复位 #73
- 同时修复切换资源提示 非法token
2025-04-26 17:04:35 +08:00
Cp0204
de6e4356a4 🎨 优化样式和布局,显示油猴脚本链接
- 添加教程二维码
- 增强按钮的工具提示功能
2025-04-26 17:04:35 +08:00
Cp0204
da7a5e93c8 🐛 修复网络异常时记录为分享失效 #71
- 调整分享详情和文件列表的返回结构
- 增强网络异常时的错误处理
2025-04-26 17:04:35 +08:00
Cp0204
546f4300a1 🎨 增强正则预览使用魔法匹配时的表达式提示
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-04-25 12:28:52 +08:00
Cp0204
b724fbb98a 增强路径名称中的特殊字符处理
- 修复选择转存目录时带提取码的链接处理
- 修复返回空路径时的错误提示
2025-04-25 12:26:50 +08:00
Cp0204
9222a703b4 🐛 修复手动填写链接时自动检查失效 #72 2025-04-25 12:21:13 +08:00
Cp0204
f62ca7a057 🐛 修正获取保存路径的多余斜杠 /
Some checks failed
Docker Publish / build-and-push (push) Has been cancelled
2025-04-22 13:24:39 +08:00
Hinata
c3ff5a49bd
🐛 Fix SyntaxError 2025-04-22 09:34:22 +08:00
25 changed files with 2355 additions and 509 deletions

View File

@ -1,6 +1,12 @@
# 使用官方 Python 镜像作为基础镜像
FROM python:3.13-alpine
#构建版本
ARG BUILD_SHA
ARG BUILD_TAG
ENV BUILD_SHA=$BUILD_SHA
ENV BUILD_TAG=$BUILD_TAG
# 设置工作目录
WORKDIR /app
@ -8,17 +14,12 @@ WORKDIR /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"
#构建版本
ARG BUILD_SHA
ARG BUILD_TAG
ENV BUILD_SHA=$BUILD_SHA
ENV BUILD_TAG=$BUILD_TAG
# 端口
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]
@ -29,13 +29,13 @@
> ⛔️⛔️⛔️ 注意!资源不会每时每刻更新,**严禁设定过高的定时运行频率!** 以免账号风控和给夸克服务器造成不必要的压力。雪山崩塌,每一片雪花都有责任!
> [!NOTE]
> 因不想当客服处理各种使用咨询,即日起 Issues 关闭,如果你发现了 bug 、有好的想法或功能建议,欢迎通过 PR 和我对话,谢谢!
> 开发者≠客服,开源免费≠帮你解决使用问题;本项目 Wiki 已经相对完善,遇到问题请先翻阅 Issues 和 Wiki ,请勿盲目发问。
## 功能
- 部署方式
- [x] 兼容青龙
- [x] 支持 Docker 独立部署WebUI 配置
- [x] 可能~~兼容青龙~~
- [x] Docker 部署WebUI 配置
- 分享链接
- [x] 支持分享链接的子目录
@ -58,7 +58,7 @@
- 媒体库整合
- [x] 根据任务名搜索 Emby 媒体库
- [x] 追更或整理后自动刷新 Emby 媒体库
- [x] **媒体库模块化,用户可很方便地[开发自己的媒体库hook模块](./plugins)**
- [x] 插件模块化,允许自行开发和挂载[插件](./plugins)
- 其它
- [x] 每日签到领空间 <sup>[?](https://github.com/Cp0204/quark-auto-save/wiki/使用技巧集锦#每日签到领空间)</sup>
@ -69,15 +69,15 @@
### Docker 部署
Docker 部署提供 WebUI 管理配置,图形化配置已能满足绝大多数需求。部署命令:
Docker 部署提供 WebUI 进行管理配置,部署命令:
```shell
docker run -d \
--name quark-auto-save \
-p 5005:5005 \
-p 5005:5005 \ # 映射端口,:前的可以改,即部署后访问的端口,:后的不可改
-e WEBUI_USERNAME=admin \
-e WEBUI_PASSWORD=admin123 \
-v ./quark-auto-save/config:/app/config \
-v ./quark-auto-save/config:/app/config \ # 必须,配置持久化
-v ./quark-auto-save/media:/media \ # 可选模块alist_strm_gen生成strm使用
--network bridge \
--restart unless-stopped \
@ -108,10 +108,12 @@ services:
管理地址http://yourhost:5005
| 环境变量 | 默认 | 备注 |
| ---------------- | ---------- | -------- |
| ---------------- | ---------- | ---------------------------------------- |
| `WEBUI_USERNAME` | `admin` | 管理账号 |
| `WEBUI_PASSWORD` | `admin123` | 管理密码 |
| `PORT` | `5005` | 管理后台端口 |
| `PLUGIN_FLAGS` | | 插件标志,如 `-emby,-aria2` 禁用某些插件 |
| `TASK_TIMEOUT` | `1800` | 任务执行超时时间(秒),超时则任务结束 |
#### 一键更新
@ -128,32 +130,28 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
</details>
### 青龙部署
程序也支持以青龙定时任务的方式运行,但该方式无法使用 WebUI 管理任务,需手动修改配置文件。
青龙部署说明已转移到 Wiki [青龙部署教程](https://github.com/Cp0204/quark-auto-save/wiki/部署教程#青龙部署)
## 使用说明
### 正则理示例
### 正则处理示例
| pattern | replace | 效果 |
| -------------------------------------- | ------------ | ---------------------------------------------------------------------- |
| -------------------------------------- | ----------------------- | ---------------------------------------------------------------------- |
| `.*` | | 无脑转存所有文件,不整理 |
| `\.mp4$` | | 转存所有 `.mp4` 后缀的文件 |
| `^【电影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 |
| `$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/正则处理教程)
> [!TIP]
>
> **魔法匹配**:当任务 `pattern` 值为 `$开头``replace` 留空时,实际将调用程序预设的正则表达式
> **魔法匹配和魔法变量**:在正则处理中,我们定义了一些“魔法匹配”模式,如果 表达式 的值以 $ 开头且 替换式 留空,程序将自动使用预设的正则表达式进行匹配和替换
>
> `$TV` 可适配和自动整理市面上90%分享剧集的文件名格式,具体实现见代码,欢迎贡献规则
更多正则使用说明:[正则处理教程](https://github.com/Cp0204/quark-auto-save/wiki/正则处理教程)
> 自 v0.6.0 开始,支持更多以 {} 包裹的我称之为“魔法变量”,可以更灵活地进行重命名
>
> 更多说明请看[魔法匹配和魔法变量](https://github.com/Cp0204/quark-auto-save/wiki/魔法匹配和魔法变量)
### 刷新媒体库
@ -165,14 +163,56 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
请参考 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块钱让我知道开源有价值。谢谢
![WeChatPay](https://cdn.jsdelivr.net/gh/Cp0204/Cp0204@main/img/wechat_pay_qrcode.png)
## 声明
本程序为个人兴趣开发,开源仅供学习与交流使用。
项目为个人兴趣开发,旨在通过程序自动化提高网盘使用效率
程序没有任何破解行为只是对于夸克已有的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.triggers.cron import CronTrigger
from concurrent.futures import ThreadPoolExecutor, as_completed
from sdk.cloudsaver import CloudSaver
from sdk.pansou import PanSou
from datetime import timedelta
import subprocess
import requests
import hashlib
import logging
import traceback
import base64
import sys
import os
@ -28,13 +31,32 @@ import re
parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, parent_dir)
from quark_auto_save import Quark
from quark_auto_save import Config
from quark_auto_save import Quark, Config, MagicRename
print(
r"""
____ ___ _____
/ __ \ / | / ___/
/ / / / / /| | \__ \
/ /_/ / / ___ |___/ /
\___\_\/_/ |_/____/
-- Quark-Auto-Save --
"""
)
sys.stdout.flush()
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":
return BUILD_TAG
elif BUILD_SHA:
@ -49,6 +71,9 @@ SCRIPT_PATH = os.environ.get("SCRIPT_PATH", "./quark_auto_save.py")
CONFIG_PATH = os.environ.get("CONFIG_PATH", "./config/quark_config.json")
PLUGIN_FLAGS = os.environ.get("PLUGIN_FLAGS", "")
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
HOST = os.environ.get("HOST", "0.0.0.0")
PORT = os.environ.get("PORT", 5005)
TASK_TIMEOUT = int(os.environ.get("TASK_TIMEOUT", 1800))
config_data = {}
task_plugins_config_default = {}
@ -72,6 +97,8 @@ logging.basicConfig(
# 过滤werkzeug日志输出
if not DEBUG:
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):
@ -191,6 +218,14 @@ def run_script_now():
# 设置环境变量
process_env = os.environ.copy()
process_env["PYTHONIOENCODING"] = "utf-8"
if request.json.get("quark_test"):
process_env["QUARK_TEST"] = "true"
process_env["COOKIE"] = json.dumps(
request.json.get("cookie", []), ensure_ascii=False
)
process_env["PUSH_CONFIG"] = json.dumps(
request.json.get("push_config", {}), ensure_ascii=False
)
if tasklist:
process_env["TASKLIST"] = json.dumps(tasklist, ensure_ascii=False)
process = subprocess.Popen(
@ -224,8 +259,19 @@ def get_task_suggestions():
return jsonify({"success": False, "message": "未登录"})
query = request.args.get("q", "").lower()
deep = request.args.get("d", "").lower()
try:
net_data = config_data.get("source", {}).get("net", {})
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 (
cs_data.get("server")
and cs_data.get("username")
@ -243,18 +289,37 @@ def get_task_suggestions():
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={query}&d={deep}"
response = requests.get(url)
return jsonify(
{"success": True, "source": "网络公开", "data": response.json()}
)
return search_results
return []
def ps_search():
if ps_data.get("server"):
ps = PanSou(ps_data.get("server"))
return ps.search(query, deep == "1")
return []
try:
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:
return jsonify({"success": True, "message": f"error: {str(e)}"})
@ -265,45 +330,91 @@ def get_share_detail():
return jsonify({"success": False, "message": "未登录"})
shareurl = request.json.get("shareurl", "")
stoken = request.json.get("stoken", "")
account = Quark("", 0)
account = Quark()
pwd_id, passcode, pdir_fid, paths = account.extract_url(shareurl)
if not stoken:
is_sharing, stoken = account.get_stoken(pwd_id, passcode)
if not is_sharing:
return jsonify({"success": False, "data": {"error": stoken}})
share_detail = account.get_detail(pwd_id, stoken, pdir_fid, _fetch_share=1)
share_detail["paths"] = paths
share_detail["stoken"] = stoken
get_stoken = account.get_stoken(pwd_id, passcode)
if get_stoken.get("status") == 200:
stoken = get_stoken["data"]["stoken"]
else:
return jsonify(
{"success": False, "data": {"error": get_stoken.get("message")}}
)
share_detail = account.get_detail(
pwd_id, stoken, pdir_fid, _fetch_share=1, fetch_share_full_path=1
)
if share_detail.get("code") != 0:
return jsonify(
{"success": False, "data": {"error": share_detail.get("message")}}
)
data = share_detail["data"]
data["paths"] = [
{"fid": i["fid"], "name": i["file_name"]}
for i in share_detail["data"].get("full_path", [])
] or paths
data["stoken"] = stoken
# 正则处理预览
def preview_regex(share_detail):
regex = request.json.get("regex")
pattern, replace = account.magic_regex_func(
regex.get("pattern", ""),
regex.get("replace", ""),
regex.get("taskname", ""),
regex.get("magic_regex", {}),
)
for item in share_detail["list"]:
file_name = item["file_name"]
if re.search(pattern, item["file_name"]):
item["file_name_re"] = (
re.sub(pattern, replace, file_name) if replace != "" else file_name
)
return share_detail
def preview_regex(data):
task = request.json.get("task", {})
magic_regex = request.json.get("magic_regex", {})
mr = MagicRename(magic_regex)
mr.set_taskname(task.get("taskname", ""))
account = Quark(config_data["cookie"][0])
get_fids = account.get_fids([task.get("savepath", "")])
if get_fids:
dir_file_list = account.ls_dir(get_fids[0]["fid"])["data"]["list"]
dir_filename_list = [dir_file["file_name"] for dir_file in dir_file_list]
else:
dir_file_list = []
dir_filename_list = []
share_detail = preview_regex(share_detail)
pattern, replace = mr.magic_regex_conv(
task.get("pattern", ""), task.get("replace", "")
)
for share_file in data["list"]:
search_pattern = (
task["update_subdir"]
if share_file["dir"] and task.get("update_subdir")
else pattern
)
if re.search(search_pattern, share_file["file_name"]):
# 文件名重命名,目录不重命名
file_name_re = (
share_file["file_name"]
if share_file["dir"]
else mr.sub(pattern, replace, share_file["file_name"])
)
if file_name_saved := mr.is_exists(
file_name_re,
dir_filename_list,
(task.get("ignore_extension") and not share_file["dir"]),
):
share_file["file_name_saved"] = file_name_saved
else:
share_file["file_name_re"] = file_name_re
return jsonify({"success": True, "data": share_detail})
# 文件列表排序
if re.search(r"\{I+\}", replace):
mr.set_dir_file_list(dir_file_list, replace)
mr.sort_file_list(data["list"])
if request.json.get("task"):
preview_regex(data)
return jsonify({"success": True, "data": data})
@app.route("/get_savepath_detail")
def get_savepath_detail():
if not is_login():
return jsonify({"success": False, "message": "未登录"})
account = Quark(config_data["cookie"][0], 0)
account = Quark(config_data["cookie"][0])
paths = []
if path := request.args.get("path"):
path = re.sub(r"/+", "/", path)
if path == "/":
fid = 0
else:
@ -326,7 +437,7 @@ def get_savepath_detail():
else:
fid = request.args.get("fid", "0")
file_list = {
"list": account.ls_dir(fid),
"list": account.ls_dir(fid)["data"]["list"],
"paths": paths,
}
return jsonify({"success": True, "data": file_list})
@ -336,7 +447,7 @@ def get_savepath_detail():
def delete_file():
if not is_login():
return jsonify({"success": False, "message": "未登录"})
account = Quark(config_data["cookie"][0], 0)
account = Quark(config_data["cookie"][0])
if fid := request.json.get("fid"):
response = account.delete([fid])
else:
@ -362,6 +473,8 @@ def add_task():
),
400,
)
if not request_data.get("addition"):
request_data["addition"] = task_plugins_config_default
# 添加任务
config_data["tasklist"].append(request_data)
Config.write_json(CONFIG_PATH, config_data)
@ -374,7 +487,36 @@ def add_task():
# 定时任务执行的函数
def run_python(args):
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 函数执行完成")
# 重新加载任务
@ -390,6 +532,10 @@ def reload_tasks():
trigger=trigger,
args=[f"{SCRIPT_PATH} {CONFIG_PATH}"],
id=SCRIPT_PATH,
max_instances=1, # 最多允许1个实例运行
coalesce=True, # 合并错过的任务,避免堆积
misfire_grace_time=300, # 错过任务的宽限期(秒),超过则跳过
replace_existing=True, # 替换已存在的同ID任务
)
if scheduler.state == 0:
scheduler.start()
@ -408,7 +554,7 @@ def reload_tasks():
def init():
global config_data, task_plugins_config_default
logging.info(f">>> 初始化配置")
logging.info(">>> 初始化配置")
# 检查配置文件是否存在
if not os.path.exists(CONFIG_PATH):
if not os.path.exists(os.path.dirname(CONFIG_PATH)):
@ -419,6 +565,8 @@ def init():
# 读取配置
config_data = Config.read_json(CONFIG_PATH)
Config.breaking_change_update(config_data)
if not config_data.get("magic_regex"):
config_data["magic_regex"] = MagicRename().magic_regex
# 默认管理账号
config_data["webui"] = {
@ -444,4 +592,10 @@ def init():
if __name__ == "__main__":
init()
reload_tasks()
app.run(debug=DEBUG, host="0.0.0.0", port=5005)
logging.info(">>> 启动Web服务")
logging.info(f"运行在: http://{HOST}:{PORT}")
app.run(
debug=DEBUG,
host=HOST,
port=PORT,
)

View File

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

@ -82,14 +82,14 @@ table.jsoneditor-tree>tbody>tr.jsoneditor-expandable:first-child {
/* Behind the navbar */
padding: 54px 0 0;
/* Height of navbar */
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.1);
}
.sidebar-sticky {
position: relative;
top: 0;
height: calc(100vh - 54px);
padding-top: .5rem;
padding-top: 0.5rem;
overflow-x: hidden;
overflow-y: auto;
/* Scrollable contents if viewport is shorter than content. */
@ -125,9 +125,8 @@ table.jsoneditor-tree>tbody>tr.jsoneditor-expandable:first-child {
color: white !important;
}
.sidebar-heading {
font-size: .75rem;
font-size: 0.75rem;
text-transform: uppercase;
}
@ -136,10 +135,10 @@ table.jsoneditor-tree>tbody>tr.jsoneditor-expandable:first-child {
*/
.navbar-brand {
padding-top: .75rem;
padding-bottom: .75rem;
background-color: rgba(0, 0, 0, .25);
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
padding-top: 0.75rem;
padding-bottom: 0.75rem;
background-color: rgba(0, 0, 0, 0.25);
box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.25);
}
.navbar .navbar-toggler {
@ -147,22 +146,98 @@ table.jsoneditor-tree>tbody>tr.jsoneditor-expandable:first-child {
}
.navbar .form-control {
padding: .75rem 1rem;
padding: 0.75rem 1rem;
border-width: 0;
border-radius: 0;
}
.form-control-dark {
color: #fff;
background-color: rgba(255, 255, 255, .1);
border-color: rgba(255, 255, 255, .1);
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.1);
}
.form-control-dark:focus {
border-color: transparent;
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.25);
}
.cursor-pointer {
cursor: pointer
cursor: pointer;
}
.nav-bottom {
position: absolute;
bottom: 32px;
width: 100%;
font-size: small;
}
.position-relative:hover .position-absolute {
display: block !important;
}
.qrcode-tutorial {
display: none;
left: 50%;
transform: translateX(-50%);
bottom: 100%;
margin-bottom: 10px;
z-index: 1000;
border: 1px solid #ddd;
background-color: #fff;
padding: 10px;
border-radius: 5px;
max-width: 100%;
text-align: center;
}
.qrcode-tutorial img {
max-width: 100%;
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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -2,7 +2,7 @@
// @name QAS一键推送助手
// @namespace https://github.com/Cp0204/quark-auto-save
// @license AGPL
// @version 0.3
// @version 0.6
// @description 在夸克网盘分享页面添加推送到 QAS 的按钮
// @icon https://pan.quark.cn/favicon.ico
// @author Cp0204
@ -11,41 +11,53 @@
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @require https://cdn.jsdelivr.net/npm/sweetalert2@11
// @downloadURL https://update.greasyfork.org/scripts/533201/QAS%E4%B8%80%E9%94%AE%E6%8E%A8%E9%80%81%E5%8A%A9%E6%89%8B.user.js
// @updateURL https://update.greasyfork.org/scripts/533201/QAS%E4%B8%80%E9%94%AE%E6%8E%A8%E9%80%81%E5%8A%A9%E6%89%8B.meta.js
// @downloadURL https://cdn.jsdelivr.net/gh/Cp0204/quark-auto-save@refs/heads/main/app/static/js/qas.addtask.user.js
// @updateURL https://cdn.jsdelivr.net/gh/Cp0204/quark-auto-save@refs/heads/main/app/static/js/qas.addtask.user.js
// ==/UserScript==
(function() {
(function () {
'use strict';
let qas_base = GM_getValue('qas_base', '');
let qas_token = GM_getValue('qas_token', '');
let default_pattern = GM_getValue('default_pattern', '');
let default_replace = GM_getValue('default_replace', '');
// QAS 设置弹窗函数
function showQASSettingDialog(callback) {
Swal.fire({
title: 'QAS 设置',
showCancelButton: true,
html: `
<label for="qas_base">QAS 服务器</label>
<input id="qas_base" class="swal2-input" placeholder="如: 192.168.1.8:5005" value="${qas_base}">
<label for="qas_base">QAS 地址</label>
<input id="qas_base" class="swal2-input" placeholder="如: http://192.168.1.8:5005" value="${qas_base}"><br>
<label for="qas_token">QAS Token</label>
<input id="qas_token" class="swal2-input" placeholder="v0.5+ 系统配置中查找" value="${qas_token}">
<input id="qas_token" class="swal2-input" placeholder="v0.5+ 系统配置中查找" value="${qas_token}"><br>
<label for="qas_token">默认正则</label>
<input id="default_pattern" class="swal2-input" placeholder="如 $TV" value="${default_pattern}"><br>
<label for="qas_token">默认替换</label><input id="default_replace" class="swal2-input" value="${default_replace}">
`,
focusConfirm: false,
preConfirm: () => {
qas_base = document.getElementById('qas_base').value;
qas_token = document.getElementById('qas_token').value;
default_pattern = document.getElementById('default_pattern').value;
default_replace = document.getElementById('default_replace').value;
if (!qas_base || !qas_token) {
Swal.showValidationMessage('请填写 QAS 服务器和 Token');
Swal.showValidationMessage('请填写 QAS 地址和 Token');
}
return { qas_base: qas_base, qas_token: qas_token }
return { qas_base: qas_base, qas_token: qas_token, default_pattern: default_pattern, default_replace: default_replace }
}
}).then((result) => {
if (result.isConfirmed) {
GM_setValue('qas_base', result.value.qas_base);
GM_setValue('qas_token', result.value.qas_token);
GM_setValue('default_pattern', result.value.default_pattern);
GM_setValue('default_replace', result.value.default_replace);
qas_base = result.value.qas_base;
qas_token = result.value.qas_token;
default_pattern = result.value.default_pattern;
default_replace = result.value.default_replace;
if (callback) {
callback(); // 执行回调函数
}
@ -64,16 +76,16 @@
}
}
waitForElement('.DetailLayout--client-download--FpyCkdW.ant-dropdown-trigger', (clientDownloadButton) => {
waitForElement('.pc-member-entrance', (PcMemberButton) => {
const qasSettingButton = document.createElement('div');
qasSettingButton.className = 'DetailLayout--client-download--FpyCkdW ant-dropdown-trigger';
qasSettingButton.className = 'pc-member-entrance';
qasSettingButton.innerHTML = 'QAS设置';
qasSettingButton.addEventListener('click', () => {
showQASSettingDialog();
});
clientDownloadButton.parentNode.insertBefore(qasSettingButton, clientDownloadButton.nextSibling);
PcMemberButton.parentNode.insertBefore(qasSettingButton, PcMemberButton.nextSibling);
});
}
@ -93,18 +105,19 @@
qasButton.type = 'button';
qasButton.className = 'ant-btn share-save';
qasButton.style.marginLeft = '10px';
qasButton.innerHTML = '<span class="share-save-ico"></span><span>推送到QAS</span>';
qasButton.innerHTML = '<span class="share-save-ico"></span><span>创建QAS任务</span>';
let taskname, shareurl, savepath; // 声明变量
// 获取数据函数
function getData() {
const currentUrl = window.location.href;
taskname = currentUrl.lastIndexOf('-') > 0 ? decodeURIComponent(currentUrl.match(/.*\/[^-]+-(.+)$/)[1]) : 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;
let pathElement = document.querySelector('.path-name')
let pathElement = document.querySelector('.path-name');
savepath = pathElement ? pathElement.title.replace('全部文件', '').trim() : "";
savepath += "/" + taskname
savepath += "/" + taskname;
qasButton.title = `任务名称: ${taskname}\n分享链接: ${shareurl}\n保存路径: ${savepath}`;
}
@ -119,11 +132,19 @@
qasButton.addEventListener('click', () => {
getData(); // 点击时重新获取数据,确保最新
const apiUrl = `http://${qas_base}/api/add_task?token=${qas_token}`;
// 检查 qas_base 是否包含 http 或 https如果没有则添加 http://
let qasApiBase = qas_base;
if (!qasApiBase.startsWith('http')) {
qasApiBase = 'http://' + qasApiBase;
}
const apiUrl = `${qasApiBase}/api/add_task?token=${qas_token}`;
const data = {
"taskname": taskname,
"shareurl": shareurl,
"savepath": savepath,
"pattern": default_pattern,
"replace": default_replace,
};
GM_xmlhttpRequest({
@ -133,7 +154,64 @@
'Content-Type': 'application/json'
},
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 {
const jsonResponse = JSON.parse(response.responseText);
if (jsonResponse.success) {
@ -142,7 +220,7 @@
html: `<small>
<b>任务名称:</b> ${taskname}<br><br>
<b>保存路径:</b> ${savepath}<br><br>
<a href="http://${qas_base}" target="_blank"> QAS 查看</a>
<a href="${qasApiBase}" target="_blank"> QAS 查看</a>
<small>`,
icon: 'success'
});
@ -156,16 +234,34 @@
} catch (e) {
Swal.fire({
title: '解析响应失败',
text: `无法解析 JSON 响应: ${response.responseText}`,
icon: 'error'
html: `<small>
响应状态: ${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({
title: '任务创建失败',
text: error,
icon: 'error'
title: '网络请求失败',
text: '无法连接到 QAS 服务器,请检查网络连接和服务器地址',
icon: 'error',
confirmButtonText: '重新配置',
showCancelButton: true,
cancelButtonText: '取消'
}).then((result) => {
if (result.isConfirmed) {
showQASSettingDialog();
}
});
}
});

View File

@ -49,9 +49,17 @@
</a>
</li>
</ul>
<div class="text-center" style="position: absolute; bottom: 32px; width: 100%; font-size: small;">
<p><a class="" target="_blank" href="https://github.com/Cp0204/quark-auto-save/wiki"><i class="bi bi-wechat"></i> 使用交流</a></p>
<p><a target="_blank" href="https://github.com/Cp0204/quark-auto-save"><i class="bi bi-github"></i> quark-auto-save</a></p>
<div class="nav-bottom text-center">
<p class="position-relative" hidden>
<b class="text-success"><i class="bi bi-record-circle mr-1"></i>视频教程</b>
<span class="position-absolute qrcode-tutorial">
使用夸克扫码查看<br>
<img src="./static/img/qrcode_tutorial.png">
</span>
</p>
<p><a target="_blank" href="https://github.com/Cp0204/quark-auto-save/wiki"><i class="bi bi-wechat mr-1"></i>使用交流</a></p>
<p><a href="./static/js/qas.addtask.user.js"><i class="bi bi-cloud-plus-fill mr-1"></i>推送任务油猴脚本</a></p>
<p><a target="_blank" href="https://github.com/Cp0204/quark-auto-save"><i class="bi bi-github mr-1"></i>quark-auto-save</a></p>
<p><span v-html="versionTips"></span></p>
</div>
</div>
@ -69,7 +77,7 @@
<button type="button" class="btn btn-outline-primary" @click="addCookie()">+</button>
</div>
</div>
<p>1. 所有账号执行签到,纯签到只需移动端参数即可!</p>
<p>1. 所有账号执行签到,纯<a class="" href="https://github.com/Cp0204/quark-auto-save/wiki/使用技巧集锦#每日签到领空间">签到</a>只需移动端参数即可!</p>
<p>2. 仅第一个账号执行转存,请自行确认顺序。<b>最好是手机验证码<a target="_blank" href="https://pan.quark.cn/">登录</a>CK比较完整</b>如需签到参数附在CK后面。</p>
<div v-for="(value, index) in formData.cookie" :key="index" class="input-group mb-2">
<input type="text" v-model="formData.cookie[index]" class="form-control" placeholder="打开 pan.quark.com 按 F12 抓取">
@ -94,13 +102,14 @@
</div>
<div class="row title" title="通知推送支持多个渠道见Wiki">
<div class="col-10">
<div class="col-8">
<h2 style="display: inline-block;"><i class="bi bi-bell"></i> 通知</h2>
<span class="badge badge-pill badge-light">
<a href="https://github.com/Cp0204/quark-auto-save/wiki/通知推送服务配置" target="_blank">?</a>
</span>
</div>
<div class="col-2 text-right">
<div class="col-4 text-right">
<button type="button" class="btn btn-success" title="通知推送测试" @click="testPush()"><i class="bi bi-lightning"></i></button>
<button type="button" class="btn btn-outline-primary" @click="addPush()">+</button>
</div>
</div>
@ -189,12 +198,35 @@
<div class="row title" title="资源搜索服务配置,用于任务名称智能搜索">
<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>
</div>
</div>
<div class="form-group row mb-0" style="display:flex; align-items:center;">
<div data-toggle="collapse" data-target="#collapse_net" aria-expanded="true" aria-controls="collapse_net">
<div class="btn btn-block text-left">
<i class="bi bi-caret-right-fill"></i> 网络公开搜索
</div>
</div>
</div>
<div class="collapse show ml-3" id="collapse_net">
<div class="form-group row">
<label class="col-sm-2 col-form-label">启用</label>
<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 class="form-group row mb-0" style="display:flex; align-items:center;">
<div data-toggle="collapse" data-target="#collapse_cloudsaver" aria-expanded="true" aria-controls="collapse_cloudsaver">
<div class="btn btn-block text-left">
<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">
@ -213,6 +245,25 @@
<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>
@ -259,9 +310,10 @@
</div>
</div>
<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" @click="runScriptNow(index)" title="运行此任务" v-else><i class="bi bi-play-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="copyTaskToClipboard(index)" title="复制任务参数到粘贴板"><i class=" bi bi-clipboard-check-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-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 class="collapse ml-3" :id="'collapse_'+index">
@ -272,12 +324,15 @@
<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)">
<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">
<span v-html="suggestion.verify ? '✅': '❔'"></span> {{ suggestion.taskname }}
<small class="text-muted">
<a :href="suggestion.shareurl" target="_blank" @click.stop>{{ suggestion.shareurl }}</a>
</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 class="input-group-append" title="深度搜索">
@ -298,7 +353,7 @@
<div class="input-group">
<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">
<button type="button" class="btn btn-outline-secondary" @click="fileSelect.selectDir=true;fileSelect.previewRegex=false;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">
<a target="_blank" :href="task.shareurl"><i class="bi bi-box-arrow-up-right"></i></a>
</div>
@ -313,7 +368,7 @@
<input type="text" name="savepath[]" class="form-control" v-model="task.savepath" placeholder="必填" @focus="focusTaskname(index, task)">
<div class="input-group-append">
<button class="btn btn-secondary" type="button" v-if="smart_param.savepath && smart_param.index == index && task.savepath != smart_param.origin_savepath" @click="task.savepath = smart_param.origin_savepath"><i class="bi bi-reply"></i></button>
<button class="btn btn-outline-secondary" type="button" @click="showSavepathSelect(index)">选择</button>
<button class="btn btn-outline-secondary" type="button" @click="fileSelect.sortBy='file_name';fileSelect.sortOrder='asc';showSavepathSelect(index)">选择</button>
</div>
</div>
</div>
@ -323,9 +378,9 @@
<div class="col-sm-10">
<div class="input-group">
<div class="input-group-prepend">
<button class="btn btn-outline-secondary" type="button" @click="fileSelect.selectDir=true;fileSelect.previewRegex=true;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>
<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="替换表达式">
<div class="input-group-append" title="保存时只比较文件名的部分01.mp4 和 01.mkv 视同为同一文件,不重复转存">
<div class="input-group-text">
@ -344,15 +399,22 @@
<div class="input-group">
<input type="text" class="form-control" placeholder="可选,只转存修改日期>此文件的文件" name="startfid[]" v-model="task.startfid">
<div class="input-group-append" v-if="task.shareurl">
<button class="btn btn-outline-secondary" type="button" @click="fileSelect.selectDir=false;fileSelect.previewRegex=false;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 class="form-group row" title="需匹配到各级嵌套目录名才会更新,否则子目录在第一次转存后不会更新。注意:原理是逐级索引,深层嵌套目录的场景下效率非常低,慎用 .*">
<div class="form-group row" title="需匹配到各级嵌套目录名才会更新,否则子目录在第一次转存后不会更新。注意:递归模式原理是逐级索引,深层嵌套目录的场景下效率非常低,慎用 .*">
<label class="col-sm-2 col-form-label">更新目录</label>
<div class="col-sm-10">
<div class="input-group">
<input type="text" name="update_subdir[]" class="form-control" v-model="task.update_subdir" placeholder="可选,匹配需更新子目录(含各级嵌套目录)的正则表达式,多项以|分割,如 4k|1080p">
<div class="input-group-append" title="重存模式:删除该目录下所有文件,重新转存,大资源包时推荐使用&#x0A;不勾选为递归模式:递归检查,逐级更新嵌套目录,效率低">
<div class="input-group-text">
<input type="checkbox" v-model="task.update_subdir_resave_mode">&nbsp;重存模式
</div>
</div>
</div>
</div>
</div>
<div class="form-group row">
@ -385,15 +447,18 @@
</div>
<div class="row mt-5">
<div class="col-sm-12 text-center">
<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 class="bottom-buttons">
<button class="btn btn-success" title="保存 CTRL+S"><i class="bi bi-floppy2-fill"></i></button>
<button type="button" class="btn btn-primary" title="运行 CTRL+R" @click="runScriptNow()"><i class="bi bi-play-fill"></i></button>
<button type="button" class="btn btn-info" @click="scrollToX(0)" @dblclick="scrollToX()" data-toggle="tooltip" data-placement="top" title="单击回顶,双击到底"><i class="bi bi-chevron-bar-up"></i></button>
<button class="btn btn-success" data-toggle="tooltip" data-placement="top" title="保存 CTRL+S"><i class="bi bi-floppy2-fill"></i></button>
<button type="button" class="btn btn-primary" data-toggle="tooltip" data-placement="top" title="运行 CTRL+R" @click="runScriptNow()"><i class="bi bi-play-fill"></i></button>
<button type="button" class="btn btn-info" data-toggle="tooltip" data-placement="top" title="单击回顶,双击到底" @click="scrollToX(0)" @dblclick="scrollToX()"><i class="bi bi-chevron-bar-up"></i></button>
</div>
</form>
</main>
@ -434,8 +499,45 @@
</button>
</div>
<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 v-else>
<!-- 正则处理表达式 -->
<div class="mb-3" v-if="fileSelect.previewRegex && fileSelect.index<this.formData.tasklist.length">
<div><b>匹配表达式:</b><span class="badge badge-info" v-html="formData.tasklist[fileSelect.index].pattern"></span>
<span class="badge badge-info" v-if="formData.tasklist[fileSelect.index].pattern in formData.magic_regex">{{ formData.magic_regex[formData.tasklist[fileSelect.index].pattern].pattern }}</span>
</div>
<div><b>替换表达式:</b><span class="badge badge-info" v-if="formData.tasklist[fileSelect.index].replace" v-html="formData.tasklist[fileSelect.index].replace"></span>
<span class="badge badge-info" v-else-if="formData.tasklist[fileSelect.index].pattern in formData.magic_regex">{{ formData.magic_regex[formData.tasklist[fileSelect.index].pattern].replace }}</span>
</div>
</div>
<!-- 面包屑导航 -->
<nav aria-label="breadcrumb" v-if="fileSelect.selectDir">
<ol class="breadcrumb">
@ -447,31 +549,39 @@
</ol>
</nav>
<!-- 文件列表 -->
<div class="mb-3" v-if="fileSelect.previewRegex">
<div><b>匹配表达式:</b><span class="badge badge-info" v-html="formData.tasklist[fileSelect.index].pattern"></span></div>
<div><b>替换表达式:</b><span class="badge badge-info" v-html="formData.tasklist[fileSelect.index].replace"></span></div>
</div>
<table class="table table-hover table-sm">
<thead>
<tr>
<th scope="col">文件名</th>
<th scope="col" v-if="fileSelect.selectShare">正则处理</th>
<th scope="col" class="cursor-pointer" @click="sortFileList('file_name')">
文件名
<span v-if="fileSelect.sortBy === 'file_name'">{{ fileSelect.sortOrder === "asc" ? "↑" : "↓" }}</span>
</th>
<th scope="col" v-if="fileSelect.selectShare">
正则处理
</th>
<template v-if="!fileSelect.previewRegex">
<th scope="col">大小</th>
<th scope="col">修改日期 ↓</th>
<th scope="col" v-if="!fileSelect.selectShare">操作</th>
<th scope="col">
大小
</th>
<th scope="col" class="cursor-pointer" @click="sortFileList('updated_at')">
修改日期
<span v-if="fileSelect.sortBy === 'updated_at'">{{ fileSelect.sortOrder === "asc" ? "↑" : "↓" }}</span>
</th>
<th scope="col" v-if="!fileSelect.selectShare">
操作
</th>
</template>
</tr>
</thead>
<tbody>
<tr v-for="(file, key) in fileSelect.fileList" :key="key" @click="fileSelect.selectDir ? (file.dir ? navigateTo(file.fid, file.file_name) : null) : selectStartFid(file.fid)" :class="{'cursor-pointer': fileSelect.selectDir ? file.dir : true}">
<td><i class="bi" :class="file.dir ? 'bi-folder-fill text-warning' : 'bi-file-earmark'"></i> {{file.file_name}}</td>
<td v-if="fileSelect.selectShare" :class="file.file_name_re ? 'text-success' : 'text-danger'">{{file.file_name_re || '&times;'}}</td>
<td><i class="bi mr-1" :class="file.dir ? 'bi-folder-fill text-warning' : 'bi-file-earmark'"></i>{{file.file_name}}</td>
<td v-if="fileSelect.selectShare" :class="file.file_name_re ? 'text-success' : file.file_name_saved ? 'text-muted' : 'text-danger'">{{file.file_name_re || file.file_name_saved || '&times;'}}</td>
<template v-if="!fileSelect.previewRegex">
<td v-if="file.dir">{{ file.include_items }}项</td>
<td v-else>{{file.size | size}}</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>
</tr>
</tbody>
@ -481,11 +591,24 @@
<div class="modal-footer" v-if="fileSelect.selectDir && !fileSelect.previewRegex">
<span v-html="fileSelect.selectShare ? '转存:' : '保存到:'"></span>
<button type="button" class="btn btn-primary btn-sm" @click="selectCurrentFolder()">当前文件夹</button>
<button type="button" class="btn btn-primary btn-sm" v-if="!fileSelect.selectShare" @click="selectCurrentFolder(true)">当前文件夹<span class="badge badge-light" v-html="'/'+formData.tasklist[fileSelect.index].taskname"></span></button>
<button type="button" class="btn btn-primary btn-sm" v-if="!fileSelect.selectShare" @click="selectCurrentFolder(true)">当前文件夹<span class="badge badge-light" v-if="fileSelect.index<this.formData.tasklist.length" v-html="'/'+formData.tasklist[fileSelect.index].taskname"></span></button>
</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>
@ -504,14 +627,21 @@
tasklist: [],
magic_regex: {},
source: {
net: {
enable: ""
},
cloudsaver: {
server: "",
username: "",
password: "",
token: ""
},
pansou: {
server: ""
}
},
},
toasts: [],
newTask: {
taskname: "",
shareurl: "",
@ -541,13 +671,17 @@
configModified: false,
fileSelect: {
index: null,
share: {},
shareurl: "",
stoken: "",
fileList: [],
paths: [],
selectDir: true,
selectShare: true,
switchShare: false,
previewRegex: false,
sortBy: "updated_at",
sortOrder: "desc"
},
},
filters: {
@ -601,7 +735,7 @@
latestVersion = response.data[0].name;
console.log(`检查版本:当前 ${this.version} 最新 ${latestVersion}`);
if (latestVersion != this.version) {
this.versionTips += ` <sup><span class="badge badge-pill badge-danger">${latestVersion}</span></sup>`;
this.versionTips += ` <sup><span class="position-absolute badge badge-pill badge-danger">${latestVersion}</span></sup>`;
}
})
.catch(error => {
@ -641,6 +775,16 @@
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;
setTimeout(() => {
this.configModified = false;
@ -673,9 +817,9 @@
.then(response => {
if (response.data.success) {
this.configModified = false;
alert(response.data.message);
this.showToast(response.data.message, 'success');
} else {
alert(response.data.message);
this.showToast(response.data.message, 'error');
}
console.log('Config saved result:', response.data);
})
@ -690,6 +834,9 @@
if (this.formData.cookie[index] == "" || confirm("确认删除吗?"))
this.formData.cookie.splice(index, 1);
},
testPush() {
this.runScriptNow(1, true);
},
addPush() {
key = prompt("增加的键名", "");
if (key != "" && key != null)
@ -763,8 +910,9 @@
console.error("Error decodeURIComponent:", e);
}
// 从分享中提取任务名
axios.get('/get_share_detail', { params: { shareurl: task.shareurl } })
.then(response => {
axios.post('/get_share_detail', {
shareurl: task.shareurl
}).then(response => {
share_detail = response.data.data
if (!response.data.success) {
if (share_detail.error.includes("提取码")) {
@ -781,17 +929,22 @@
task.savepath = task.savepath.replace(/TASKNAME/g, share_detail.share.title);
this.$set(task, "shareurl_ban", undefined);
}
})
.catch(error => {
}).catch(error => {
console.error('Error get_share_detail:', error);
});
},
clearData(target) {
this[target] = "";
},
async runScriptNow(task_index = null) {
async runScriptNow(task_index = null, test = false) {
body = {};
if (task_index != null) {
if (test) {
body = {
"quark_test": true,
"cookie": this.formData.cookie,
"push_config": this.formData.push_config
};
} else if (task_index != null) {
task = { ...this.formData.tasklist[task_index] };
delete task.runweek;
delete task.enddate;
@ -825,10 +978,6 @@
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('Stream complete.');
this.modalLoading = false;
// 运行后刷新数据
this.fetchData();
break;
}
partialData += decoder.decode(value);
@ -838,10 +987,12 @@
const eventData = line.substring(5).trim();
if (eventData === '[DONE]') {
this.modalLoading = false;
if (task_index == null) {
this.fetchData();
return;
}
this.run_log += eventData + '\n';
break;
}
this.run_log += eventData.replace('<', '<\u200B') + '\n';
// 在更新 run_log 后将滚动条滚动到底部
this.$nextTick(() => {
const modalBody = document.querySelector('.modal-body');
@ -890,6 +1041,7 @@
}
},
searchSuggestions(index, taskname, deep = 1) {
taskname = taskname.replace(/\((19|20)\d{2}\)/g, '').trim();
if (taskname.length < 2) {
console.log(`任务名[${taskname}]过短${taskname.length} 不进行搜索`);
return;
@ -918,6 +1070,10 @@
},
selectSuggestion(index, suggestion) {
this.smart_param.showSuggestions = false;
this.fileSelect.selectDir = true;
this.fileSelect.switchShare = true;
this.fileSelect.previewRegex = false;
this.fileSelect.share = suggestion;
this.showShareSelect(index, suggestion.shareurl);
},
addMagicRegex() {
@ -927,7 +1083,7 @@
updateMagicRegexKey(oldKey, newKey) {
if (oldKey !== newKey) {
if (this.formData.magic_regex[newKey]) {
alert(`魔法名 [${newKey}] 已存在,请使用其他名称`);
this.showToast(`魔法名 [${newKey}] 已存在,请使用其他名称`, 'warning');
return;
}
this.$set(this.formData.magic_regex, newKey, this.formData.magic_regex[oldKey]);
@ -947,7 +1103,7 @@
if (response.data.code == 0) {
this.fileSelect.fileList = this.fileSelect.fileList.filter(item => item.fid != fid);
} else {
alert('删除失败:' + response.data.message);
this.showToast('删除失败:' + response.data.message, 'error');
}
}).catch(error => {
console.error('Error /delete_file:', error);
@ -963,8 +1119,9 @@
axios.get('/get_savepath_detail', {
params: params
}).then(response => {
this.fileSelect.fileList = response.data.data.list
if (response.data.data.paths.length > 0) {
this.fileSelect.fileList = response.data.data.list;
this.sortFileList(this.fileSelect.sortBy, this.fileSelect.sortOrder);
if (response.data.data.paths?.length > 0) {
this.fileSelect.paths = response.data.data.paths
}
this.modalLoading = false;
@ -977,12 +1134,14 @@
showSavepathSelect(index) {
this.fileSelect.selectShare = false;
this.fileSelect.selectDir = true;
this.fileSelect.switchShare = false;
this.fileSelect.previewRegex = false;
this.fileSelect.error = undefined;
this.fileSelect.fileList = [];
this.fileSelect.paths = [];
this.fileSelect.index = index;
$('#fileSelectModal').modal('toggle');
this.formData.tasklist[index].savepath = this.formData.tasklist[index].savepath.replace(/\/+/g, "/");
this.getSavepathDetail(this.formData.tasklist[index].savepath);
},
getShareDetail() {
@ -990,15 +1149,12 @@
axios.post('/get_share_detail', {
shareurl: this.fileSelect.shareurl,
stoken: this.fileSelect.stoken,
regex: {
pattern: this.formData.tasklist[this.fileSelect.index].pattern,
replace: this.formData.tasklist[this.fileSelect.index].replace,
taskname: this.formData.tasklist[this.fileSelect.index].taskname,
task: this.formData.tasklist[this.fileSelect.index],
magic_regex: this.formData.magic_regex,
}
}).then(response => {
if (response.data.success) {
this.fileSelect.fileList = response.data.data.list;
this.sortFileList(this.fileSelect.sortBy, this.fileSelect.sortOrder);
this.fileSelect.paths = response.data.data.paths;
this.fileSelect.stoken = response.data.data.stoken;
} else {
@ -1016,18 +1172,37 @@
this.fileSelect.fileList = [];
this.fileSelect.paths = [];
this.fileSelect.error = undefined;
if (this.getShareurl(this.fileSelect.shareurl) != this.getShareurl(this.formData.tasklist[index].shareurl)) {
// 如果分享链接发生变化,则重置 stoken
const newShareurl = shareurl || this.formData.tasklist[index].shareurl
if (this.getShareurl(this.fileSelect.shareurl) != this.getShareurl(newShareurl)) {
this.fileSelect.stoken = "";
}
this.fileSelect.shareurl = shareurl || this.formData.tasklist[index].shareurl;
this.fileSelect.shareurl = newShareurl;
this.fileSelect.index = index;
$('#fileSelectModal').modal('toggle');
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) {
path = { fid: fid, name: name }
dir = { fid: fid, name: name }
if (this.fileSelect.selectShare) {
this.fileSelect.shareurl = this.getShareurl(this.fileSelect.shareurl, path);
this.fileSelect.shareurl = this.getShareurl(this.fileSelect.shareurl, dir);
this.getShareDetail();
} else {
if (fid == "0") {
@ -1059,18 +1234,117 @@
Vue.set(this.formData.tasklist[this.fileSelect.index], 'startfid', fid);
$('#fileSelectModal').modal('hide')
},
getShareurl(shareurl, path = {}) {
if (path == {} || path.fid == 0) {
shareurl = shareurl.match(`.*s/[a-z0-9]+`)[0]
} else if (shareurl.includes(path.fid)) {
shareurl = shareurl.match(`.*/${path.fid}[^\/]*`)[0]
getShareurl(shareurl, dir = {}) {
if (dir == {} || dir.fid == 0) {
shareurl = shareurl.match(`.*s/[a-z0-9]+(\\?pwd=[^#]+)?`)[0]
} else if (shareurl.includes(dir.fid)) {
shareurl = shareurl.match(`.*/${dir.fid}[^/]*`)[0]
} else if (shareurl.includes('#/list/share')) {
shareurl = `${shareurl}/${path.fid}-${path.name}`
shareurl = `${shareurl.split('#')[0]}#/list/share/${dir.fid}`
} else {
shareurl = `${shareurl}#/list/share/${path.fid}-${path.name}`
shareurl = `${shareurl.split('#')[0]}#/list/share/${dir.fid}`
}
return shareurl;
},
sortFileList(column, order) {
if (this.fileSelect.sortBy === column && !order) {
this.fileSelect.sortOrder = this.fileSelect.sortOrder === "asc" ? "desc" : "asc";
} else {
this.fileSelect.sortBy = column;
this.fileSelect.sortOrder = order || "asc";
}
this.fileSelect.fileList.sort((a, b) => {
let valA = a[this.fileSelect.sortBy];
let valB = b[this.fileSelect.sortBy];
if (typeof valA === "string") valA = valA.toLowerCase();
if (typeof valB === "string") valB = valB.toLowerCase();
if (valA < valB) return this.fileSelect.sortOrder === "asc" ? -1 : 1;
if (valA > valB) return this.fileSelect.sortOrder === "asc" ? 1 : -1;
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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 313 KiB

209
notify.py
View File

@ -72,8 +72,13 @@ push_config = {
'CHAT_URL': '', # synology chat url
'CHAT_TOKEN': '', # synology chat token
'PUSH_PLUS_TOKEN': '', # push+ 微信推送的用户令牌
'PUSH_PLUS_USER': '', # push+ 微信推送的群组编码
'PUSH_PLUS_TOKEN': '', # pushplus 推送的用户令牌
'PUSH_PLUS_USER': '', # pushplus 推送的群组编码
'PUSH_PLUS_TEMPLATE': 'html', # pushplus 发送模板支持html,txt,json,markdown,cloudMonitor,jenkins,route,pay
'PUSH_PLUS_CHANNEL': 'wechat', # pushplus 发送渠道支持wechat,webhook,cp,mail,sms
'PUSH_PLUS_WEBHOOK': '', # pushplus webhook编码可在pushplus公众号上扩展配置出更多渠道
'PUSH_PLUS_CALLBACKURL': '', # pushplus 发送结果回调地址,会把推送最终结果通知到这个地址上
'PUSH_PLUS_TO': '', # pushplus 好友令牌微信公众号渠道填写好友令牌企业微信渠道填写企业微信用户id
'WE_PLUS_BOT_TOKEN': '', # 微加机器人的用户令牌
'WE_PLUS_BOT_RECEIVER': '', # 微加机器人的消息接收者
@ -123,6 +128,19 @@ push_config = {
'NTFY_URL': '', # ntfy地址,如https://ntfy.sh
'NTFY_TOPIC': '', # ntfy的消息应用topic
'NTFY_PRIORITY':'3', # 推送消息优先级,默认为3
'NTFY_TOKEN': '', # 推送token,可选
'NTFY_USERNAME': '', # 推送用户名称,可选
'NTFY_PASSWORD': '', # 推送用户密码,可选
'NTFY_ACTIONS': '', # 推送用户动作,可选
'WXPUSHER_APP_TOKEN': '', # wxpusher 的 appToken 官方文档: https://wxpusher.zjiecode.com/docs/ 管理后台: https://wxpusher.zjiecode.com/admin/
'WXPUSHER_TOPIC_IDS': '', # wxpusher 的 主题ID多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
'WXPUSHER_UIDS': '', # wxpusher 的 用户ID多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
'DODO_BOTTOKEN': '', # DoDo机器人的token DoDo开发平台https://doker.imdodo.com/
'DODO_BOTID': '', # DoDo机器人的id
'DODO_LANDSOURCEID': '', # DoDo机器人所在的群ID
'DODO_SOURCEID': '', # DoDo机器人推送目标用户的ID
}
# fmt: on
@ -137,7 +155,6 @@ def bark(title: str, content: str) -> None:
使用 bark 推送消息
"""
if not push_config.get("BARK_PUSH"):
print("bark 服务的 BARK_PUSH 未设置!!\n取消推送")
return
print("bark 服务启动")
@ -190,7 +207,6 @@ def dingding_bot(title: str, content: str) -> None:
使用 钉钉机器人 推送消息
"""
if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"):
print("钉钉机器人 服务的 DD_BOT_SECRET 或者 DD_BOT_TOKEN 未设置!!\n取消推送")
return
print("钉钉机器人 服务启动")
@ -220,7 +236,6 @@ def feishu_bot(title: str, content: str) -> None:
使用 飞书机器人 推送消息
"""
if not push_config.get("FSKEY"):
print("飞书 服务的 FSKEY 未设置!!\n取消推送")
return
print("飞书 服务启动")
@ -239,7 +254,6 @@ def go_cqhttp(title: str, content: str) -> None:
使用 go_cqhttp 推送消息
"""
if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"):
print("go-cqhttp 服务的 GOBOT_URL 或 GOBOT_QQ 未设置!!\n取消推送")
return
print("go-cqhttp 服务启动")
@ -257,7 +271,6 @@ def gotify(title: str, content: str) -> None:
使用 gotify 推送消息
"""
if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"):
print("gotify 服务的 GOTIFY_URL 或 GOTIFY_TOKEN 未设置!!\n取消推送")
return
print("gotify 服务启动")
@ -280,7 +293,6 @@ def iGot(title: str, content: str) -> None:
使用 iGot 推送消息
"""
if not push_config.get("IGOT_PUSH_KEY"):
print("iGot 服务的 IGOT_PUSH_KEY 未设置!!\n取消推送")
return
print("iGot 服务启动")
@ -300,13 +312,12 @@ def serverJ(title: str, content: str) -> None:
通过 serverJ 推送消息
"""
if not push_config.get("PUSH_KEY"):
print("serverJ 服务的 PUSH_KEY 未设置!!\n取消推送")
return
print("serverJ 服务启动")
data = {"text": title, "desp": content.replace("\n", "\n\n")}
match = re.match(r'sctp(\d+)t', push_config.get("PUSH_KEY"))
match = re.match(r"sctp(\d+)t", push_config.get("PUSH_KEY"))
if match:
num = match.group(1)
url = f'https://{num}.push.ft07.com/send/{push_config.get("PUSH_KEY")}.send'
@ -326,7 +337,6 @@ def pushdeer(title: str, content: str) -> None:
通过PushDeer 推送消息
"""
if not push_config.get("DEER_KEY"):
print("PushDeer 服务的 DEER_KEY 未设置!!\n取消推送")
return
print("PushDeer 服务启动")
data = {
@ -352,7 +362,6 @@ def chat(title: str, content: str) -> None:
通过Chat 推送消息
"""
if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"):
print("chat 服务的 CHAT_URL或CHAT_TOKEN 未设置!!\n取消推送")
return
print("chat 服务启动")
data = "payload=" + json.dumps({"text": title + "\n" + content})
@ -367,26 +376,36 @@ def chat(title: str, content: str) -> None:
def pushplus_bot(title: str, content: str) -> None:
"""
通过 push+ 推送消息
通过 pushplus 推送消息
"""
if not push_config.get("PUSH_PLUS_TOKEN"):
print("PUSHPLUS 服务的 PUSH_PLUS_TOKEN 未设置!!\n取消推送")
return
print("PUSHPLUS 服务启动")
url = "http://www.pushplus.plus/send"
url = "https://www.pushplus.plus/send"
data = {
"token": push_config.get("PUSH_PLUS_TOKEN"),
"title": title,
"content": content,
"topic": push_config.get("PUSH_PLUS_USER"),
"template": push_config.get("PUSH_PLUS_TEMPLATE"),
"channel": push_config.get("PUSH_PLUS_CHANNEL"),
"webhook": push_config.get("PUSH_PLUS_WEBHOOK"),
"callbackUrl": push_config.get("PUSH_PLUS_CALLBACKURL"),
"to": push_config.get("PUSH_PLUS_TO"),
}
body = json.dumps(data).encode(encoding="utf-8")
headers = {"Content-Type": "application/json"}
response = requests.post(url=url, data=body, headers=headers).json()
if response["code"] == 200:
print("PUSHPLUS 推送成功!")
code = response["code"]
if code == 200:
print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"])
print(
"注意请求成功并不代表推送成功如未收到消息请到pushplus官网使用流水号查询推送最终结果"
)
elif code == 900 or code == 903 or code == 905 or code == 999:
print(response["msg"])
else:
url_old = "http://pushplus.hxtrip.com/send"
@ -405,7 +424,6 @@ def weplus_bot(title: str, content: str) -> None:
通过 微加机器人 推送消息
"""
if not push_config.get("WE_PLUS_BOT_TOKEN"):
print("微加机器人 服务的 WE_PLUS_BOT_TOKEN 未设置!!\n取消推送")
return
print("微加机器人 服务启动")
@ -437,7 +455,6 @@ def qmsg_bot(title: str, content: str) -> None:
使用 qmsg 推送消息
"""
if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"):
print("qmsg 的 QMSG_KEY 或者 QMSG_TYPE 未设置!!\n取消推送")
return
print("qmsg 服务启动")
@ -456,11 +473,10 @@ def wecom_app(title: str, content: str) -> None:
通过 企业微信 APP 推送消息
"""
if not push_config.get("QYWX_AM"):
print("QYWX_AM 未设置!!\n取消推送")
return
QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM"))
if 4 < len(QYWX_AM_AY) > 5:
print("QYWX_AM 设置错误!!\n取消推送")
print("QYWX_AM 设置错误!!")
return
print("企业微信 APP 服务启动")
@ -553,7 +569,6 @@ def wecom_bot(title: str, content: str) -> None:
通过 企业微信机器人 推送消息
"""
if not push_config.get("QYWX_KEY"):
print("企业微信机器人 服务的 QYWX_KEY 未设置!!\n取消推送")
return
print("企业微信机器人服务启动")
@ -579,7 +594,6 @@ def telegram_bot(title: str, content: str) -> None:
使用 telegram 机器人 推送消息
"""
if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"):
print("tg 服务的 bot_token 或者 user_id 未设置!!\n取消推送")
return
print("tg 服务启动")
@ -628,9 +642,6 @@ def aibotk(title: str, content: str) -> None:
or not push_config.get("AIBOTK_TYPE")
or not push_config.get("AIBOTK_NAME")
):
print(
"智能微秘书 的 AIBOTK_KEY 或者 AIBOTK_TYPE 或者 AIBOTK_NAME 未设置!!\n取消推送"
)
return
print("智能微秘书 服务启动")
@ -669,9 +680,6 @@ def smtp(title: str, content: str) -> None:
or not push_config.get("SMTP_PASSWORD")
or not push_config.get("SMTP_NAME")
):
print(
"SMTP 邮件 的 SMTP_SERVER 或者 SMTP_SSL 或者 SMTP_EMAIL 或者 SMTP_PASSWORD 或者 SMTP_NAME 未设置!!\n取消推送"
)
return
print("SMTP 邮件 服务启动")
@ -726,7 +734,6 @@ def pushme(title: str, content: str) -> None:
使用 PushMe 推送消息
"""
if not push_config.get("PUSHME_KEY"):
print("PushMe 服务的 PUSHME_KEY 未设置!!\n取消推送")
return
print("PushMe 服务启动")
@ -759,7 +766,6 @@ def chronocat(title: str, content: str) -> None:
or not push_config.get("CHRONOCAT_QQ")
or not push_config.get("CHRONOCAT_TOKEN")
):
print("CHRONOCAT 服务的 CHRONOCAT_URL 或 CHRONOCAT_QQ 未设置!!\n取消推送")
return
print("CHRONOCAT 服务启动")
@ -803,17 +809,17 @@ def ntfy(title: str, content: str) -> None:
"""
通过 Ntfy 推送消息
"""
def encode_rfc2047(text: str) -> str:
"""将文本编码为符合 RFC 2047 标准的格式"""
encoded_bytes = base64.b64encode(text.encode('utf-8'))
encoded_str = encoded_bytes.decode('utf-8')
return f'=?utf-8?B?{encoded_str}?='
encoded_bytes = base64.b64encode(text.encode("utf-8"))
encoded_str = encoded_bytes.decode("utf-8")
return f"=?utf-8?B?{encoded_str}?="
if not push_config.get("NTFY_TOPIC"):
print("ntfy 服务的 NTFY_TOPIC 未设置!!\n取消推送")
return
print("ntfy 服务启动")
priority = '3'
priority = "3"
if not push_config.get("NTFY_PRIORITY"):
print("ntfy 服务的NTFY_PRIORITY 未设置!!默认设置为3")
else:
@ -822,11 +828,15 @@ def ntfy(title: str, content: str) -> None:
# 使用 RFC 2047 编码 title
encoded_title = encode_rfc2047(title)
data = content.encode(encoding='utf-8')
headers = {
"Title": encoded_title, # 使用编码后的 title
"Priority": priority
}
data = content.encode(encoding="utf-8")
headers = {"Title": encoded_title, "Priority": priority, "Icon": "https://qn.whyour.cn/logo.png"} # 使用编码后的 title
if push_config.get("NTFY_TOKEN"):
headers['Authorization'] = "Bearer " + push_config.get("NTFY_TOKEN")
elif push_config.get("NTFY_USERNAME") and push_config.get("NTFY_PASSWORD"):
authStr = push_config.get("NTFY_USERNAME") + ":" + push_config.get("NTFY_PASSWORD")
headers['Authorization'] = "Basic " + base64.b64encode(authStr.encode('utf-8')).decode('utf-8')
if push_config.get("NTFY_ACTIONS"):
headers['Actions'] = encode_rfc2047(push_config.get("NTFY_ACTIONS"))
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
response = requests.post(url, data=data, headers=headers)
@ -835,6 +845,111 @@ def ntfy(title: str, content: str) -> None:
else:
print("Ntfy 推送失败!错误信息:", response.text)
def dodo_bot(title: str, content: str) -> None:
"""
通过 DoDo机器人 推送消息
"""
required_keys = [
'DODO_BOTTOKEN',
'DODO_BOTID',
'DODO_LANDSOURCEID',
'DODO_SOURCEID'
]
if not all(push_config.get(key) for key in required_keys):
missing = [key for key in required_keys if not push_config.get(key)]
print(f"DoDo 服务配置不完整,缺少以下参数: {', '.join(missing)}\n取消推送")
return
print("DoDo 服务启动")
url="https://botopen.imdodo.com/api/v2/personal/message/send"
botID=push_config.get('DODO_BOTID')
botToken=push_config.get('DODO_BOTTOKEN')
islandSourceId=push_config.get('DODO_LANDSOURCEID')
dodoSourceId=push_config.get('DODO_SOURCEID')
headers = {
'Authorization': f'Bot {botID}.{botToken}',
'Content-Type': 'application/json',
'Host': 'botopen.imdodo.com'
}
payload = json.dumps({
"islandSourceId": islandSourceId,
"dodoSourceId": dodoSourceId,
"messageType": 1,
"messageBody": {
"content": f"{title}\n\n{content}"
}
})
try:
response = requests.post(url, headers=headers, data=payload)
if response.status_code == 200:
response = response.json()
if response.get("status") == 0 and response.get("message") == "success":
print(f'DoDo 推送成功!')
else:
print(f'DoDo 推送失败!错误信息:\n{response}')
else:
print("DoDo 推送失败!错误信息:", response.text)
except Exception as e:
print(f"DoDo 推送请求异常: {str(e)}")
def wxpusher_bot(title: str, content: str) -> None:
"""
通过 wxpusher 推送消息
支持的环境变量:
- WXPUSHER_APP_TOKEN: appToken
- WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔
- WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔
"""
if not push_config.get("WXPUSHER_APP_TOKEN"):
return
url = "https://wxpusher.zjiecode.com/api/send/message"
# 处理topic_ids和uids将分号分隔的字符串转为数组
topic_ids = []
if push_config.get("WXPUSHER_TOPIC_IDS"):
topic_ids = [
int(id.strip())
for id in push_config.get("WXPUSHER_TOPIC_IDS").split(";")
if id.strip()
]
uids = []
if push_config.get("WXPUSHER_UIDS"):
uids = [
uid.strip()
for uid in push_config.get("WXPUSHER_UIDS").split(";")
if uid.strip()
]
# topic_ids uids 至少有一个
if not topic_ids and not uids:
print("wxpusher 服务的 WXPUSHER_TOPIC_IDS 和 WXPUSHER_UIDS 至少设置一个!!")
return
print("wxpusher 服务启动")
data = {
"appToken": push_config.get("WXPUSHER_APP_TOKEN"),
"content": f"<h1>{title}</h1><br/><div style='white-space: pre-wrap;'>{content}</div>",
"summary": title,
"contentType": 2,
"topicIds": topic_ids,
"uids": uids,
"verifyPayType": 0,
}
headers = {"Content-Type": "application/json"}
response = requests.post(url=url, json=data, headers=headers).json()
if response.get("code") == 1000:
print("wxpusher 推送成功!")
else:
print(f"wxpusher 推送失败!错误信息:{response.get('msg')}")
def parse_headers(headers):
if not headers:
return {}
@ -891,7 +1006,6 @@ def custom_notify(title: str, content: str) -> None:
通过 自定义通知 推送消息
"""
if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"):
print("自定义通知的 WEBHOOK_URL 或 WEBHOOK_METHOD 未设置!!\n取消推送")
return
print("自定义通知服务启动")
@ -993,10 +1107,21 @@ def add_notify_function():
and push_config.get("CHRONOCAT_TOKEN")
):
notify_function.append(chronocat)
if (
push_config.get("DODO_BOTTOKEN")
and push_config.get("DODO_BOTID")
and push_config.get("DODO_LANDSOURCEID")
and push_config.get("DODO_SOURCEID")
):
notify_function.append(dodo_bot)
if push_config.get("WEBHOOK_URL") and push_config.get("WEBHOOK_METHOD"):
notify_function.append(custom_notify)
if push_config.get("NTFY_TOPIC"):
notify_function.append(ntfy)
if push_config.get("WXPUSHER_APP_TOKEN") and (
push_config.get("WXPUSHER_TOPIC_IDS") or push_config.get("WXPUSHER_UIDS")
):
notify_function.append(wxpusher_bot)
if not notify_function:
print(f"无推送渠道,请检查通知变量是否正确")
return notify_function

View File

@ -75,7 +75,7 @@ docker run -d \
```json
{
"media_servers": {
"plugins": {
"emby": {
"url": "http://your-emby-server:8096",
"token": "YOUR_EMBY_TOKEN"
@ -92,3 +92,4 @@ docker run -d \
| ------- | -------------------- | --------------------------------------- |
| plex.py | 自动刷新 Plex 媒体库 | [zhazhayu](https://github.com/zhazhayu) |
| alist_strm_gen.py | 自动生成strm | [xiaoQQya](https://github.com/xiaoQQya) |
| alist_sync.py | 调用 alist 实现跨网盘转存 | [jenfonro](https://github.com/jenfonro) |

View File

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

View File

@ -137,7 +137,7 @@ class Alist_strm_gen:
if item.get("is_dir"):
self.check_dir(item_path)
else:
self.generate_strm(item_path)
self.generate_strm(item_path, item)
def get_file_list(self, path, force_refresh=False):
url = f"{self.url}/api/fs/list"
@ -157,7 +157,7 @@ class Alist_strm_gen:
print(f"📺 Alist-Strm生成: 获取文件列表出错❌ {e}")
return {}
def generate_strm(self, file_path):
def generate_strm(self, file_path, file_info):
ext = file_path.split(".")[-1]
if ext.lower() in self.video_exts:
strm_path = (
@ -169,8 +169,11 @@ class Alist_strm_gen:
return
if not os.path.exists(os.path.dirname(strm_path)):
os.makedirs(os.path.dirname(strm_path))
sign_param = (
"" if not file_info.get("sign") else f"?sign={file_info['sign']}"
)
with open(strm_path, "w", encoding="utf-8") as strm_file:
strm_file.write(f"{self.strm_server}{file_path}")
strm_file.write(f"{self.strm_server}{file_path}{sign_param}")
print(f"📺 生成STRM文件 {strm_path} 成功✅")
def get_root_folder_full_path(self, cookie, pdir_fid):

313
plugins/alist_sync.py Normal file
View File

@ -0,0 +1,313 @@
# plugins: 调用 alist 实现跨网盘转存
# author: https://github.com/jenfonro
import re
import json
import requests
class Alist_sync:
default_config = {
"url": "", # Alist服务器URL
"token": "", # Alist服务器Token
"quark_storage_id": "", # Alist 服务器夸克存储 ID
"save_storage_id": "", # Alist 服务器同步的存储 ID
"tv_mode": "", # TV库模式填入非0值开启
# TV库模式说明
# 1.开启后会验证文件名是否包含S01E01的正则格式目前仅包含mp4及mkv
# 2.会对比保存目录下是否存在该名称的mp4、mkv文件如果不存在才会进行同步
# 3.夸克目录及同步目录均会提取为S01E01的正则进行匹配不受其它字符影响
}
is_active = False
# 缓存参数
default_task_config = {
"enable": False, # 当前任务开关,
"save_path": "", # 需要同步目录,默认空时路径则会与夸克的保存路径一致,不开启完整路径模式时,默认根目录为保存驱动的根目录
"verify_path": "", # 验证目录主要用于影视库避免重复文件一般配合alist的别名功能及full_path_mode使用用于多个网盘的源合并成一个目录
"full_path_mode": False, # 完整路径模式
# 完整路径模式开启后不再限制保存目录的存储驱动将根据填入的路径进行保存需要填写完整的alist目录
}
def __init__(self, **kwargs):
if kwargs:
for key, _ in self.default_config.items():
if key in kwargs:
setattr(self, key, kwargs[key])
else:
print(f"{self.__class__.__name__} 模块缺少必要参数: {key}")
if self.url and self.token:
if self.verify_server():
self.is_active = True
def _send_request(self, method, url, **kwargs):
headers = {"Authorization": self.token, "Content-Type": "application/json"}
if "headers" in kwargs:
headers = kwargs["headers"]
del kwargs["headers"]
try:
response = requests.request(method, url, headers=headers, **kwargs)
# print(f"{response.text}")
# response.raise_for_status() # 检查请求是否成功但返回非200也会抛出异常
return response
except Exception as e:
print(f"_send_request error:\n{e}")
fake_response = requests.Response()
fake_response.status_code = 500
fake_response._content = b'{"status": 500, "message": "request error"}'
return fake_response
def verify_server(self):
url = f"{self.url}/api/me"
querystring = ""
headers = {"Authorization": self.token, "Content-Type": "application/json"}
try:
response = requests.request("GET", url, headers=headers, params=querystring)
response.raise_for_status()
response = response.json()
if response.get("code") == 200:
if response.get("data").get("username") == "guest":
print(f"Alist登陆失败请检查token")
else:
print(
f"Alist登陆成功当前用户: {response.get('data').get('username')}"
)
return True
else:
print(f"Alist同步: 连接服务器失败❌ {response.get('message')}")
except requests.exceptions.RequestException as e:
print(f"获取Alist信息出错: {e}")
return False
def run(self, task, **kwargs):
if not task["addition"]["alist_sync"]["enable"]:
return 0
print(f"开始进行alist同步")
# 这一块注释的是获取任务的参数在web界面可以看
# print("所有任务参数:")
# print(task)
# print(task['addition'])
# print(task['addition']['alist_sync'])
# print(task['addition']['alist_sync']['target_path'])
# 获取夸克挂载根目录
data = self.get_storage_path(self.quark_storage_id)
if data["driver"] != "Quark":
print(
f"Alist同步: 存储{self.quark_storage_id}非夸克存储❌ {data['driver']}"
)
return 0
quark_mount_root_path = re.sub(r".*root_folder_id\":\"", "", data["addition"])
quark_mount_root_path = re.sub(r"\",.*", "", quark_mount_root_path)
if quark_mount_root_path != "0" and quark_mount_root_path != "":
print(f"Alist同步: 存储{self.quark_storage_id}挂载的目录非夸克根目录❌")
return 0
self.quark_mount_path = data["mount_path"]
# 获取保存路径的挂载根目录
if self.save_storage_id != 0:
data = self.get_storage_path(self.save_storage_id)
self.save_mount_path = data["mount_path"]
# 保存的目录初始化
if task["addition"]["alist_sync"]["save_path"] == "":
self.save_path = f"{self.save_mount_path}/{task['savepath']}"
else:
self.save_path = task["addition"]["alist_sync"]["save_path"]
if not task["addition"]["alist_sync"]["full_path_mode"]:
if self.save_path.startswith("/"):
self.save_path = self.save_path[1:]
if self.save_path.endswith("/"):
self.save_path = self.save_path[:-1]
self.save_path = f"{self.save_mount_path}/{self.save_path}"
else:
# print('完整路径模式')
if not self.save_path.startswith("/"):
self.save_path = "/" + self.save_path
if self.save_path.endswith("/"):
self.save_path = self.save_path[:-1]
# 获取保存目录是否存在
if not self.get_path(self.save_path):
dir_exists = False
# 如果目录不存在判断两边路径是否一致,一致时直接创建复制目录任务即可
else:
dir_exists = True
copy_dir = False
# 初始化验证目录
# 如果没有填验证目录,则验证目录与保存目录一致
if task["addition"]["alist_sync"]["verify_path"]:
self.verify_path = task["addition"]["alist_sync"]["verify_path"]
if not task["addition"]["alist_sync"]["full_path_mode"]:
if self.verify_path.startswith("/"):
self.verify_path = self.save_path[1:]
if self.verify_path.endswith("/"):
self.verify_path = self.save_path[:-1]
self.verify_path = f"{self.save_mount_path}/{self.verify_path}"
else:
# print('完整路径模式')
if not self.verify_path.startswith("/"):
self.verify_path = "/" + self.save_path
if self.verify_path.endswith("/"):
self.verify_path = self.save_path[:-1]
else:
self.verify_path = self.save_path
# 初始化夸克目录
self.source_path = f"{self.quark_mount_path}/{task['savepath']}"
# 初始化任务名
self.taskname = f"{task['taskname']}"
# 获取网盘已有文件
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 == "":
self.tv_mode = False
else:
self.tv_mode = True
# 如果是新建的目录则将所有文件直接复制
if not dir_exists:
self.get_save_file([], source_dir_list)
else:
verify_dir_list = self.get_path_list(self.verify_path)
if verify_dir_list:
self.get_save_file(verify_dir_list, source_dir_list)
else:
self.get_save_file([], source_dir_list)
if self.save_file_data:
self.save_start(self.save_file_data)
print("同步的文件列表:")
for save_file in self.save_file_data:
print(f"└── 🎞️{save_file}")
else:
print("没有需要同步的文件")
def save_start(self, save_file_data):
url = f"{self.url}/api/fs/copy"
payload = json.dumps(
{
"src_dir": self.source_path,
"dst_dir": self.save_path,
"names": save_file_data,
}
)
response = self._send_request("POST", url, data=payload)
if response.status_code != 200:
print("未能进行Alist同步请手动同步")
else:
print("Alist创建任务成功")
self.copy_task = response.json()
def get_save_file(self, target_dir_list, source_dir_list):
self.save_file_data = []
if target_dir_list == []:
for source_list in source_dir_list:
if self.tv_mode:
if re.search(
self.taskname + r"\.s\d{1,3}e\d{1,3}\.(mkv|mp4)",
source_list["name"],
re.IGNORECASE,
):
self.save_file_data.append(source_list["name"])
else:
self.save_file_data.append(source_list["name"])
else:
for source_list in source_dir_list:
skip = False
source_list_filename = (
source_list["name"]
.replace(".mp4", "")
.replace(".mkv", "")
.replace(self.taskname + ".", "")
.lower()
)
for target_list in target_dir_list:
if source_list["is_dir"]:
# print(f"跳过目录同步")
skip = True
break
if self.tv_mode:
target_list_filename = (
target_list["name"]
.replace(".mp4", "")
.replace(".mkv", "")
.replace(self.taskname + ".", "")
.lower()
)
if source_list_filename == target_list_filename:
# print(f"文件存在,名称为:{target_list['name']}")
skip = True
break
else:
if source_list["name"] == target_list["name"]:
# print(f"文件存在,名称为:{target_dir['name']}")
skip = True
break
if self.tv_mode:
if re.search(
self.taskname + r"\.s\d{1,3}e\d{1,3}\.(mkv|mp4)",
source_list["name"],
re.IGNORECASE,
):
# 添加一句验证如果有MKVMP4存在时则只保存某一个格式
if re.search(
self.taskname + r"\.s\d{1,3}e\d{1,3}\.mp4",
source_list["name"],
re.IGNORECASE,
):
for all_file in source_dir_list:
if (
source_list["name"].replace(".mp4", ".mkv")
== all_file["name"]
):
print(
f"{source_list['name']}拥有相同版本的MKV文件跳过复制"
)
skip = True
if not skip:
self.save_file_data.append(source_list["name"])
def get_path_list(self, path):
url = f"{self.url}/api/fs/list"
payload = json.dumps(
{"path": path, "password": "", "page": 1, "per_page": 0, "refresh": True}
)
response = self._send_request("POST", url, data=payload)
if response.status_code != 200:
print(f"获取Alist目录出错: {response}")
return False
else:
return response.json()["data"]["content"]
def get_path(self, path):
url = f"{self.url}/api/fs/list"
payload = json.dumps({"path": path, "password": "", "force_root": False})
response = self._send_request("POST", url, data=payload)
if response.status_code != 200 or response.json()["message"] != "success":
return False
else:
return True
def get_storage_path(self, storage_id):
url = f"{self.url}/api/admin/storage/get"
headers = {"Authorization": self.token}
querystring = {"id": storage_id}
try:
response = requests.request("GET", url, headers=headers, params=querystring)
response.raise_for_status()
data = response.json()
if data.get("code") == 200:
return data.get("data", [])
else:
print(f"Alist同步: 存储{storage_id}连接失败❌ {data.get('message')}")
except Exception as e:
print(f"Alist同步: 获取Alist存储出错 {e}")
return []

View File

@ -46,6 +46,9 @@ class Aria2:
if not node.data.get("is_dir", True):
file_fids.append(node.data.get("fid"))
file_paths.append(node.data.get("path"))
if not file_fids:
print(f"Aria2下载: 没有下载任务,跳过")
return
download_return, cookie = account.download(file_fids)
file_urls = [item["download_url"] for item in download_return["data"]]
for index, file_url in enumerate(file_urls):

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
# -*- coding: utf-8 -*-
# Modify: 2024-11-13
# Modify: 2025-09-05
# Repo: https://github.com/Cp0204/quark_auto_save
# ConfigFile: quark_config.json
"""
@ -15,8 +15,10 @@ import time
import random
import requests
import importlib
import traceback
import urllib.parse
from datetime import datetime
from natsort import natsorted
# 兼容青龙
try:
@ -32,18 +34,6 @@ NOTIFYS = []
GH_PROXY = os.environ.get("GH_PROXY", "https://ghproxy.net/")
MAGIC_REGEX = {
"$TV": {
"pattern": r".*?([Ss]\d{1,2})?(?:[第EePpXx\.\-\_\( ]{1,2}|^)(\d{1,3})(?!\d).*?\.(mp4|mkv)",
"replace": r"\1E\2.\3",
},
"$BLACK_WORD": {
"pattern": r"^(?!.*纯享)(?!.*加更)(?!.*超前企划)(?!.*训练室)(?!.*蒸蒸日上).*",
"replace": "",
},
}
# 发送通知消息
def send_ql_notify(title, body):
try:
@ -52,7 +42,7 @@ def send_ql_notify(title, body):
# 如未配置 push_config 则使用青龙环境通知设置
if CONFIG_DATA.get("push_config"):
notify.push_config = CONFIG_DATA["push_config"].copy()
notify.push_config.update(CONFIG_DATA["push_config"])
notify.push_config["CONSOLE"] = notify.push_config.get("CONSOLE", True)
notify.send(title, body)
except Exception as e:
@ -106,20 +96,26 @@ class Config:
PLUGIN_FLAGS = os.environ.get("PLUGIN_FLAGS", "").split(",")
plugins_available = {}
task_plugins_config = {}
# 获取所有模块
py_ext = [".py", ".pyd"] if sys.platform == "win32" else [".py", ".so"]
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")
try:
with open(priority_path, encoding="utf-8") as f:
priority_modules = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
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]
except (FileNotFoundError, json.JSONDecodeError):
priority_modules = []
# 加载模块
for module_name in all_modules:
if f"-{module_name}" in PLUGIN_FLAGS:
continue
@ -138,33 +134,223 @@ class Config:
task_plugins_config[module_name] = plugin.default_task_config
except (ImportError, AttributeError) as e:
print(f"载入模块 {module_name} 失败: {e}")
print()
return plugins_available, plugins_config, task_plugins_config
def breaking_change_update(config_data):
if config_data.get("emby"):
print("🔼 Update config v0.3.6.1 to 0.3.7")
config_data.setdefault("media_servers", {})["emby"] = {
"url": config_data["emby"]["url"],
"token": config_data["emby"]["apikey"],
# 🔼 Update config v0.5.x to 0.6.0
for task in config_data.get("tasklist", []):
if "$TASKNAME" in task.get("replace", ""):
task["replace"] = task["replace"].replace("$TASKNAME", "{TASKNAME}")
class MagicRename:
magic_regex = {
"$TV": {
"pattern": r".*?([Ss]\d{1,2})?(?:[第EePpXx\.\-\_\( ]{1,2}|^)(\d{1,3})(?!\d).*?\.(mp4|mkv)",
"replace": r"\1E\2.\3",
},
"$BLACK_WORD": {
"pattern": r"^(?!.*纯享)(?!.*加更)(?!.*超前企划)(?!.*训练室)(?!.*蒸蒸日上).*",
"replace": "",
},
}
del config_data["emby"]
for task in config_data.get("tasklist", {}):
task["media_id"] = task.get("emby_id", "")
if task.get("emby_id"):
del task["emby_id"]
if config_data.get("media_servers"):
print("🔼 Update config v0.3.8 to 0.3.9")
config_data["plugins"] = config_data.get("media_servers")
del config_data["media_servers"]
for task in config_data.get("tasklist", {}):
task["addition"] = {
"emby": {
"media_id": task.get("media_id", ""),
magic_variable = {
"{TASKNAME}": "",
"{I}": 1,
"{EXT}": [r"(?<=\.)\w+$"],
"{CHINESE}": [r"[\u4e00-\u9fa5]{2,}"],
"{DATE}": [
r"(18|19|20)?\d{2}[\.\-/年]\d{1,2}[\.\-/月]\d{1,2}",
r"(?<!\d)[12]\d{3}[01]?\d[0123]?\d",
r"(?<!\d)[01]?\d[\.\-/月][0123]?\d",
],
"{YEAR}": [r"(?<!\d)(18|19|20)\d{2}(?!\d)"],
"{S}": [r"(?<=[Ss])\d{1,2}(?=[EeXx])", r"(?<=[Ss])\d{1,2}"],
"{SXX}": [r"[Ss]\d{1,2}(?=[EeXx])", r"[Ss]\d{1,2}"],
"{E}": [
r"(?<=[Ss]\d\d[Ee])\d{1,3}",
r"(?<=[Ee])\d{1,3}",
r"(?<=[Ee][Pp])\d{1,3}",
r"(?<=第)\d{1,3}(?=[集期话部篇])",
r"(?<!\d)\d{1,3}(?=[集期话部篇])",
r"(?!.*19)(?!.*20)(?<=[\._])\d{1,3}(?=[\._])",
r"^\d{1,3}(?=\.\w+)",
r"(?<!\d)\d{1,3}(?!\d)(?!$)",
],
"{PART}": [
r"(?<=[集期话部篇第])[上中下一二三四五六七八九十]",
r"[上中下一二三四五六七八九十]",
],
"{VER}": [r"[\u4e00-\u9fa5]+版"],
}
}
if task.get("media_id"):
del task["media_id"]
priority_list = [
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
]
def __init__(self, magic_regex={}, magic_variable={}):
self.magic_regex.update(magic_regex)
self.magic_variable.update(magic_variable)
self.dir_filename_dict = {}
def set_taskname(self, taskname):
"""设置任务名称"""
self.magic_variable["{TASKNAME}"] = taskname
def magic_regex_conv(self, pattern, replace):
"""魔法正则匹配"""
keyword = pattern
if keyword in self.magic_regex:
pattern = self.magic_regex[keyword]["pattern"]
if replace == "":
replace = self.magic_regex[keyword]["replace"]
return pattern, replace
def sub(self, pattern, replace, file_name):
"""魔法正则、变量替换"""
if not replace:
return file_name
# 预处理替换变量
for key, p_list in self.magic_variable.items():
if key in replace:
# 正则类替换变量
if p_list and isinstance(p_list, list):
for p in p_list:
match = re.search(p, file_name)
if match:
# 匹配成功,替换为匹配到的值
value = match.group()
# 日期格式处理:补全、格式化
if key == "{DATE}":
value = "".join(
[char for char in value if char.isdigit()]
)
value = (
str(datetime.now().year)[: (8 - len(value))] + value
)
replace = replace.replace(key, value)
break
# 非正则类替换变量
if key == "{TASKNAME}":
replace = replace.replace(key, self.magic_variable["{TASKNAME}"])
elif key == "{SXX}" and not match:
replace = replace.replace(key, "S01")
elif key == "{I}":
continue
else:
# 清理未匹配的 magic_variable key
replace = replace.replace(key, "")
if pattern and replace:
file_name = re.sub(pattern, replace, file_name)
else:
file_name = replace
return file_name
def _custom_sort_key(self, name):
"""自定义排序键"""
for i, keyword in enumerate(self.priority_list):
if keyword in name:
name = name.replace(keyword, f"_{i:02d}_") # 替换为数字,方便排序
return name
def sort_file_list(self, file_list, dir_filename_dict={}):
"""文件列表统一排序,给{I+}赋值"""
filename_list = [
# 强制加入`文件修改时间`字段供排序效果1无可排序字符时则按修改时间排序2和目录已有文件重名时始终在其后
f"{f['file_name_re']}_{f['updated_at']}"
for f in file_list
if f.get("file_name_re") and not f["dir"]
]
# print(f"filename_list_before: {filename_list}")
dir_filename_dict = dir_filename_dict or self.dir_filename_dict
# print(f"dir_filename_list: {dir_filename_list}")
# 合并目录文件列表
filename_list = list(set(filename_list) | set(dir_filename_dict.values()))
filename_list = natsorted(filename_list, key=self._custom_sort_key)
filename_index = {}
for name in filename_list:
if name in dir_filename_dict.values():
continue
i = filename_list.index(name) + 1
while i in dir_filename_dict.keys():
i += 1
dir_filename_dict[i] = name
filename_index[name] = i
for file in file_list:
if file.get("file_name_re"):
if match := re.search(r"\{I+\}", file["file_name_re"]):
i = filename_index.get(
f"{file['file_name_re']}_{file['updated_at']}", 0
)
file["file_name_re"] = re.sub(
match.group(),
str(i).zfill(match.group().count("I")),
file["file_name_re"],
)
def set_dir_file_list(self, file_list, replace):
"""设置目录文件列表"""
self.dir_filename_dict = {}
filename_list = [f["file_name"] for f in file_list if not f["dir"]]
filename_list.sort()
if not filename_list:
return
if match := re.search(r"\{I+\}", replace):
# 由替换式转换匹配式
magic_i = match.group()
pattern_i = r"\d" * magic_i.count("I")
pattern = replace.replace(match.group(), "🔢")
for key, _ in self.magic_variable.items():
if key in pattern:
pattern = pattern.replace(key, "🔣")
pattern = re.sub(r"\\[0-9]+", "🔣", pattern) # \1 \2 \3
pattern = f"({re.escape(pattern).replace('🔣', '.*?').replace('🔢', f')({pattern_i})(')})"
# print(f"pattern: {pattern}")
# 获取起始编号
if match := re.match(pattern, filename_list[-1]):
self.magic_variable["{I}"] = int(match.group(2))
# 目录文件列表
for filename in filename_list:
if match := re.match(pattern, filename):
self.dir_filename_dict[int(match.group(2))] = (
match.group(1) + magic_i + match.group(3)
)
# print(f"filename_list: {self.filename_list}")
def is_exists(self, filename, filename_list, ignore_ext=False):
"""判断文件是否存在,处理忽略扩展名"""
# print(f"filename: {filename} filename_list: {filename_list}")
if ignore_ext:
filename = os.path.splitext(filename)[0]
filename_list = [os.path.splitext(f)[0] for f in filename_list]
# {I+} 模式用I通配数字序号
if match := re.search(r"\{I+\}", filename):
magic_i = match.group()
pattern_i = r"\d" * magic_i.count("I")
pattern = re.escape(filename).replace(re.escape(magic_i), pattern_i)
for filename in filename_list:
if re.match(pattern, filename):
return filename
return None
else:
return filename if filename in filename_list else None
class Quark:
@ -172,7 +358,7 @@ class Quark:
BASE_URL_APP = "https://drive-m.quark.cn"
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch"
def __init__(self, cookie, index=None):
def __init__(self, cookie="", index=0):
self.cookie = cookie.strip()
self.index = index + 1
self.is_active = False
@ -240,7 +426,9 @@ class Quark:
print(f"_send_request error:\n{e}")
fake_response = requests.Response()
fake_response.status_code = 500
fake_response._content = b'{"status": 500, "message": "request error"}'
fake_response._content = (
b'{"status": 500, "code": 1, "message": "request error"}'
)
return fake_response
def init(self):
@ -312,12 +500,11 @@ class Quark:
response = self._send_request(
"POST", url, json=payload, params=querystring
).json()
if response.get("status") == 200:
return True, response["data"]["stoken"]
else:
return False, response["message"]
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 = []
page = 1
while True:
@ -335,10 +522,12 @@ class Quark:
"_fetch_share": _fetch_share,
"_fetch_total": "1",
"_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()
if response["code"] != 0:
return {"error": response["message"]}
return response
if response["data"]["list"]:
list_merge += response["data"]["list"]
page += 1
@ -347,7 +536,7 @@ class Quark:
if len(list_merge) >= response["metadata"]["_total"]:
break
response["data"]["list"] = list_merge
return response["data"]
return response
def get_fids(self, file_paths):
fids = []
@ -369,7 +558,7 @@ class Quark:
return fids
def ls_dir(self, pdir_fid, **kwargs):
file_list = []
list_merge = []
page = 1
while True:
url = f"{self.BASE_URL}/1/clouddrive/file/sort"
@ -384,18 +573,21 @@ class Quark:
"_fetch_sub_dirs": "0",
"_sort": "file_type:asc,updated_at:desc",
"_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()
if response["code"] != 0:
return {"error": response["message"]}
return response
if response["data"]["list"]:
file_list += response["data"]["list"]
list_merge += response["data"]["list"]
page += 1
else:
break
if len(file_list) >= response["metadata"]["_total"]:
if len(list_merge) >= response["metadata"]["_total"]:
break
return file_list
response["data"]["list"] = list_merge
return response
def save_file(self, fid_list, fid_token_list, to_pdir_fid, pwd_id, stoken):
url = f"{self.BASE_URL}/1/clouddrive/share/sharepage/save"
@ -435,7 +627,9 @@ class Quark:
"__t": datetime.now().timestamp(),
}
response = self._send_request("GET", url, params=querystring).json()
if response["data"]["status"] != 0:
if response["status"] != 200:
return response
if response["data"]["status"] == 2:
if retry_index > 0:
print()
break
@ -520,30 +714,6 @@ class Quark:
# ↑ 请求函数
# ↓ 操作函数
# 魔法正则匹配
def magic_regex_func(self, pattern, replace, taskname=None, magic_regex={}):
magic_regex = magic_regex or CONFIG_DATA.get("magic_regex") or MAGIC_REGEX
keyword = pattern
if keyword in magic_regex:
pattern = magic_regex[keyword]["pattern"]
if replace == "":
replace = magic_regex[keyword]["replace"]
if taskname:
replace = replace.replace("$TASKNAME", taskname)
return pattern, replace
# def get_id_from_url(self, url):
# url = url.replace("https://pan.quark.cn/s/", "")
# pattern = r"(\w+)(\?pwd=(\w+))?(#/list/share.*/(\w+))?"
# match = re.search(pattern, url)
# if match:
# pwd_id = match.group(1)
# passcode = match.group(3) if match.group(3) else ""
# pdir_fid = match.group(5) if match.group(5) else 0
# return pwd_id, passcode, pdir_fid
# else:
# return None
def extract_url(self, url):
# pwd_id
match_id = re.search(r"/s/(\w+)", url)
@ -552,11 +722,12 @@ class Quark:
match_pwd = re.search(r"pwd=(\w+)", url)
passcode = match_pwd.group(1) if match_pwd else ""
# path: fid-name
# Legacy 20250905
paths = []
matches = re.findall(r"/(\w{32})-?([^/]+)?", url)
for match in matches:
fid = match[0]
name = urllib.parse.unquote(match[1])
name = urllib.parse.unquote(match[1]).replace("*101", "-")
paths.append({"fid": fid, "name": name})
pdir_fid = paths[-1]["fid"] if matches else 0
return pwd_id, passcode, pdir_fid, paths
@ -595,13 +766,11 @@ class Quark:
def do_save_check(self, shareurl, savepath):
try:
pwd_id, passcode, pdir_fid, _ = self.extract_url(shareurl)
_, stoken = self.get_stoken(pwd_id, passcode)
share_file_list = self.get_detail(pwd_id, stoken, pdir_fid)["list"]
stoken = self.get_stoken(pwd_id, passcode)["data"]["stoken"]
share_file_list = self.get_detail(pwd_id, stoken, pdir_fid)["data"]["list"]
print(f"获取分享: {share_file_list}")
fid_list = [item["fid"] for item in share_file_list]
fid_token_list = [item["share_fid_token"] for item in share_file_list]
file_name_list = [item["file_name"] for item in share_file_list]
if not fid_list:
return
get_fids = self.get_fids([savepath])
to_pdir_fid = (
get_fids[0]["fid"] if get_fids else self.mkdir(savepath)["data"]["fid"]
@ -609,30 +778,31 @@ class Quark:
save_file = self.save_file(
fid_list, fid_token_list, to_pdir_fid, pwd_id, stoken
)
if save_file["code"] == 41017:
return
elif save_file["code"] == 0:
dir_file_list = self.ls_dir(to_pdir_fid)
del_list = [
item["fid"]
for item in dir_file_list
if (item["file_name"] in file_name_list)
and ((datetime.now().timestamp() - item["created_at"]) < 60)
]
print(f"转存文件: {save_file}")
if save_file["code"] == 0:
task_id = save_file["data"]["task_id"]
query_task = self.query_task(task_id)
print(f"查询转存: {query_task}")
if query_task["code"] == 0:
del_list = query_task["data"]["save_as"]["save_as_top_fids"]
if del_list:
self.delete(del_list)
delete_return = self.delete(del_list)
print(f"删除转存: {delete_return}")
recycle_list = self.recycle_list()
record_id_list = [
item["record_id"]
for item in recycle_list
if item["fid"] in del_list
]
self.recycle_remove(record_id_list)
return save_file
else:
recycle_remove = self.recycle_remove(record_id_list)
print(f"清理转存: {recycle_remove}")
print(f"✅ 转存测试成功")
return True
print(f"❌ 转存测试失败: 中断")
return False
except Exception as e:
print(f"转存测试失败: {str(e)}")
print(f"❌ 转存测试失败: {str(e)}")
traceback.print_exc()
def do_save_task(self, task):
# 判断资源失效记录
@ -644,15 +814,23 @@ class Quark:
pwd_id, passcode, pdir_fid, _ = self.extract_url(task["shareurl"])
# 获取stoken同时可验证资源是否失效
is_sharing, stoken = self.get_stoken(pwd_id, passcode)
if not is_sharing:
add_notify(f"❌《{task['taskname']}》:{stoken}\n")
task["shareurl_ban"] = stoken
get_stoken = self.get_stoken(pwd_id, passcode)
if get_stoken.get("status") == 200:
stoken = get_stoken["data"]["stoken"]
elif get_stoken.get("status") == 500:
print(f"跳过任务:网络异常 {get_stoken.get('message')}")
return
else:
message = get_stoken.get("message")
add_notify(f"❌《{task['taskname']}》:{message}\n")
task["shareurl_ban"] = message
return
# print("stoken: ", stoken)
updated_tree = self.dir_check_and_save(task, pwd_id, stoken, pdir_fid)
if updated_tree.size(1) > 0:
self.do_rename(updated_tree)
print()
add_notify(f"✅《{task['taskname']}》添加追更:\n{updated_tree}")
return updated_tree
else:
@ -662,7 +840,7 @@ class Quark:
def dir_check_and_save(self, task, pwd_id, stoken, pdir_fid="", subdir_path=""):
tree = Tree()
# 获取分享文件列表
share_file_list = self.get_detail(pwd_id, stoken, pdir_fid)["list"]
share_file_list = self.get_detail(pwd_id, stoken, pdir_fid)["data"]["list"]
# print("share_file_list: ", share_file_list)
if not share_file_list:
@ -678,7 +856,7 @@ class Quark:
print("🧠 该分享是一个文件夹,读取文件夹内列表")
share_file_list = self.get_detail(
pwd_id, stoken, share_file_list[0]["fid"]
)["list"]
)["data"]["list"]
# 获取目标目录文件列表
savepath = re.sub(r"/{2,}", "/", f"/{task['savepath']}{subdir_path}")
@ -689,7 +867,8 @@ class Quark:
print(f"❌ 目录 {savepath} fid获取失败跳过转存")
return tree
to_pdir_fid = self.savepath_fid[savepath]
dir_file_list = self.ls_dir(to_pdir_fid)
dir_file_list = self.ls_dir(to_pdir_fid)["data"]["list"]
dir_filename_list = [dir_file["file_name"] for dir_file in dir_file_list]
# print("dir_file_list: ", dir_file_list)
tree.create_node(
@ -700,47 +879,78 @@ class Quark:
},
)
# 文件命名类
mr = MagicRename(CONFIG_DATA.get("magic_regex", {}))
mr.set_taskname(task["taskname"])
# 魔法正则转换
pattern, replace = mr.magic_regex_conv(
task.get("pattern", ""), task.get("replace", "")
)
# 需保存的文件清单
need_save_list = []
# 添加符合的
for share_file in share_file_list:
if share_file["dir"] and task.get("update_subdir", False):
pattern, replace = task["update_subdir"], ""
else:
pattern, replace = self.magic_regex_func(
task.get("pattern", ""), task.get("replace", ""), task["taskname"]
search_pattern = (
task["update_subdir"]
if share_file["dir"] and task.get("update_subdir")
else pattern
)
# 正则文件名匹配
if re.search(pattern, share_file["file_name"]):
# 替换后的文件名
save_name = (
re.sub(pattern, replace, share_file["file_name"])
if replace != ""
else share_file["file_name"]
)
# 忽略后缀
if task.get("ignore_extension") and not share_file["dir"]:
compare_func = lambda a, b1, b2: (
os.path.splitext(a)[0] == os.path.splitext(b1)[0]
or os.path.splitext(a)[0] == os.path.splitext(b2)[0]
)
if re.search(search_pattern, share_file["file_name"]):
# 判断原文件名是否存在,处理忽略扩展名
if not mr.is_exists(
share_file["file_name"],
dir_filename_list,
(task.get("ignore_extension") and not share_file["dir"]),
):
# 文件夹、子目录文件不进行重命名
if share_file["dir"] or subdir_path:
share_file["file_name_re"] = share_file["file_name"]
need_save_list.append(share_file)
else:
compare_func = lambda a, b1, b2: (a == b1 or a == b2)
# 判断目标目录文件是否存在
file_exists = any(
compare_func(
dir_file["file_name"], share_file["file_name"], save_name
)
for dir_file in dir_file_list
)
if not file_exists:
share_file["save_name"] = save_name
# 替换后的文件名
file_name_re = mr.sub(pattern, replace, share_file["file_name"])
# 判断替换后的文件名是否存在
if not mr.is_exists(
file_name_re,
dir_filename_list,
task.get("ignore_extension"),
):
share_file["file_name_re"] = file_name_re
need_save_list.append(share_file)
elif share_file["dir"]:
# 存在并是一个文件夹
if task.get("update_subdir", False):
if re.search(task["update_subdir"], share_file["file_name"]):
print(f"检查子文件夹:{savepath}/{share_file['file_name']}")
# 存在并是一个目录,历遍子目录
if task.get("update_subdir", False) and re.search(
task["update_subdir"], share_file["file_name"]
):
if task.get("update_subdir_resave_mode", False):
# 重存模式:删除该目录下所有文件,重新转存
print(f"重存子目录:{savepath}/{share_file['file_name']}")
# 删除子目录、回收站中彻底删除
subdir = next(
(
f
for f in dir_file_list
if f["file_name"] == share_file["file_name"]
),
None,
)
delete_return = self.delete([subdir["fid"]])
self.query_task(delete_return["data"]["task_id"])
recycle_list = self.recycle_list()
record_id_list = [
item["record_id"]
for item in recycle_list
if item["fid"] == subdir["fid"]
]
self.recycle_remove(record_id_list)
# 作为新文件添加到转存列表
share_file["file_name_re"] = share_file["file_name"]
need_save_list.append(share_file)
else:
# 递归模式
print(f"检查子目录:{savepath}/{share_file['file_name']}")
subdir_tree = self.dir_check_and_save(
task,
pwd_id,
@ -763,33 +973,30 @@ class Quark:
if share_file["fid"] == task.get("startfid", ""):
break
if re.search(r"\{I+\}", replace):
mr.set_dir_file_list(dir_file_list, replace)
mr.sort_file_list(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]
if fid_list:
save_file_return = self.save_file(
fid_list, fid_token_list, to_pdir_fid, pwd_id, stoken
)
err_msg = None
save_as_top_fids = []
while fid_list:
# 分次转存100个/次因query_task返回save_as_top_fids最多100
save_file_return = self.save_file(
fid_list[:100], fid_token_list[:100], to_pdir_fid, pwd_id, stoken
)
fid_list = fid_list[100:]
fid_token_list = fid_token_list[100:]
if save_file_return["code"] == 0:
# 转存成功,查询转存结果
task_id = save_file_return["data"]["task_id"]
query_task_return = self.query_task(task_id)
if query_task_return["code"] == 0:
# 建立目录树
for index, item in enumerate(need_save_list):
icon = (
"📁"
if item["dir"] == True
else "🎞️" if item["obj_category"] == "video" else ""
)
tree.create_node(
f"{icon}{item['save_name']}",
item["fid"],
parent=pdir_fid,
data={
"fid": f"{query_task_return['data']['save_as']['save_as_top_fids'][index]}",
"path": f"{savepath}/{item['save_name']}",
"is_dir": item["dir"],
},
save_as_top_fids.extend(
query_task_return["data"]["save_as"]["save_as_top_fids"]
)
else:
err_msg = query_task_return["message"]
@ -797,43 +1004,51 @@ class Quark:
err_msg = save_file_return["message"]
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
def do_rename_task(self, task, subdir_path=""):
pattern, replace = self.magic_regex_func(
task.get("pattern", ""), task.get("replace", ""), task["taskname"]
)
if not pattern or not replace:
return 0
savepath = re.sub(r"/{2,}", "/", f"/{task['savepath']}{subdir_path}")
if not self.savepath_fid.get(savepath):
self.savepath_fid[savepath] = self.get_fids([savepath])[0]["fid"]
dir_file_list = self.ls_dir(self.savepath_fid[savepath])
dir_file_name_list = [item["file_name"] for item in dir_file_list]
is_rename_count = 0
for dir_file in dir_file_list:
if dir_file["dir"]:
is_rename_count += self.do_rename_task(
task, f"{subdir_path}/{dir_file['file_name']}"
)
if re.search(pattern, dir_file["file_name"]):
save_name = (
re.sub(pattern, replace, dir_file["file_name"])
if replace != ""
else dir_file["file_name"]
)
if save_name != dir_file["file_name"] and (
save_name not in dir_file_name_list
):
rename_return = self.rename(dir_file["fid"], save_name)
if rename_return["code"] == 0:
print(f"重命名:{dir_file['file_name']}{save_name}")
is_rename_count += 1
else:
print(
f"重命名:{dir_file['file_name']}{save_name} 失败,{rename_return['message']}"
)
return is_rename_count > 0
def do_rename(self, tree, node_id=None):
if node_id is None:
node_id = tree.root
for child in tree.children(node_id):
file = child.data
if file.get("is_dir"):
# self.do_rename(tree, child.identifier)
pass
elif file.get("file_name_re") and file["file_name_re"] != file["file_name"]:
rename_ret = self.rename(file["fid"], file["file_name_re"])
print(f"重命名:{file['file_name']}{file['file_name_re']}")
if rename_ret["code"] != 0:
print(f" ↑ 失败,{rename_ret['message']}")
def _get_file_icon(self, f):
if f.get("dir"):
return "📁"
ico_maps = {
"video": "🎞️",
"image": "🖼️",
"audio": "🎵",
"doc": "📄",
"archive": "📦",
"default": "",
}
return ico_maps.get(f.get("obj_category"), "")
def verify_account(account):
@ -900,6 +1115,7 @@ def do_save(account, tasklist=[]):
plugins, CONFIG_DATA["plugins"], task_plugins_config = Config.load_plugins(
CONFIG_DATA.get("plugins", {})
)
print()
print(f"转存账号: {account.nickname}")
# 获取全部保存目录fid
account.update_savepath_fid(tasklist)
@ -932,7 +1148,7 @@ def do_save(account, tasklist=[]):
print(f"更子目录: {task['update_subdir']}")
if task.get("runweek") or task.get("enddate"):
print(
f"运行周期: WK{task.get("runweek",[])} ~ {task.get('enddate','forever')}"
f"运行周期: WK{task.get('runweek',[])} ~ {task.get('enddate','forever')}"
)
print()
# 判断任务周期
@ -940,7 +1156,6 @@ def do_save(account, tasklist=[]):
print(f"任务不在运行周期内,跳过")
else:
is_new_tree = account.do_save_task(task)
is_rename = account.do_rename_task(task)
# 补充任务的插件配置
def merge_dicts(a, b):
@ -960,14 +1175,21 @@ def do_save(account, tasklist=[]):
task.get("addition", {}), task_plugins_config
)
# 调用插件
if is_new_tree or is_rename:
if is_new_tree:
print(f"🧩 调用插件")
for plugin_name, plugin in plugins.items():
if plugin.is_active and (is_new_tree or is_rename):
if plugin.is_active:
task = (
plugin.run(task, account=account, tree=is_new_tree) or task
)
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():
@ -978,6 +1200,21 @@ def main():
print()
# 读取启动参数
config_path = sys.argv[1] if len(sys.argv) > 1 else "quark_config.json"
# 推送测试
if os.environ.get("QUARK_TEST", "").lower() == "true":
print(f"===============通知测试===============")
CONFIG_DATA["push_config"] = json.loads(os.environ.get("PUSH_CONFIG"))
send_ql_notify(
"【夸克自动转存】",
f"通知测试\n\n{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
)
print()
if cookies := json.loads(os.environ.get("COOKIE", "[]")):
print(f"===============转存测试===============")
accounts = Quark(cookies[0])
accounts.do_save_check("https://pan.quark.cn/s/1ed94d530d63", "/来自:分享")
print()
return
# 从环境变量中获取 TASKLIST
tasklist_from_env = []
if tasklist_json := os.environ.get("TASKLIST"):
@ -1004,8 +1241,6 @@ def main():
CONFIG_DATA = Config.read_json(config_path)
Config.breaking_change_update(CONFIG_DATA)
cookie_val = CONFIG_DATA.get("cookie")
if not CONFIG_DATA.get("magic_regex"):
CONFIG_DATA["magic_regex"] = MAGIC_REGEX
cookie_form_file = True
# 获取cookie
cookies = Config.get_cookies(cookie_val)
@ -1035,7 +1270,7 @@ def main():
if NOTIFYS:
notify_body = "\n".join(NOTIFYS)
print(f"===============推送通知===============")
send_ql_notify("【夸克自动追更", notify_body)
send_ql_notify("【夸克自动转存", notify_body)
print()
if cookie_form_file:
# 更新配置

View File

@ -6,36 +6,51 @@
"QUARK_SIGN_NOTIFY": true,
"其他推送渠道//此项可删": "配置方法同青龙"
},
"media_servers": {
"plugins": {
"emby": {
"url": "",
"token": ""
}
},
"magic_regex": {
"$TV": {
"$TV_REGEX": {
"pattern": ".*?([Ss]\\d{1,2})?(?:[第EePpXx\\.\\-\\_\\( ]{1,2}|^)(\\d{1,3})(?!\\d).*?\\.(mp4|mkv)",
"replace": "\\1E\\2.\\3"
},
"$BLACK_WORD": {
"pattern": "^(?!.*纯享)(?!.*加更)(?!.*超前企划)(?!.*训练室)(?!.*蒸蒸日上).*",
"replace": ""
},
"$SHOW_MAGIC": {
"pattern": "^(?!.*纯享)(?!.*加更)(?!.*抢先)(?!.*预告).*?第\\d+期.*",
"replace": "{TASKNAME}.{SXX}E{II}.第{E}期{PART}.{EXT}"
},
"$TV_MAGIC": {
"pattern": ".*\\.(mp4|mkv|mov|m4v|avi|mpeg|ts)$",
"replace": "{TASKNAME}.{SXX}E{E}.{EXT}"
}
},
"tasklist": [
{
"taskname": "测试-魔法匹配剧集这是一组有效分享配置CK后可测试任务是否正常",
"shareurl": "https://pan.quark.cn/s/d07a34a9c695#/list/share/7e25ddd87cf64443b637125478733295-夸克自动转存测试",
"savepath": "/夸克自动转存测试",
"pattern": "$TV",
"savepath": "/夸克自动转存测试/剧集",
"pattern": "$TV_REGEX",
"replace": "",
"enddate": "2099-01-30",
"update_subdir": "4k|1080p"
},
{
"taskname": "测试-广告过滤",
"taskname": "测试-综艺命名",
"shareurl": "https://pan.quark.cn/s/d07a34a9c695#/list/share/7e25ddd87cf64443b637125478733295-%E5%A4%B8%E5%85%8B%E8%87%AA%E5%8A%A8%E8%BD%AC%E5%AD%98%E6%B5%8B%E8%AF%95/71df3902f42d4270a58c0eb12aa2b014-%E7%BB%BC%E8%89%BA%E5%91%BD%E5%90%8D",
"savepath": "/夸克自动转存测试/综艺命名",
"pattern": "^(?!.*纯享)(?!.*加更)(?!.*抢先)(?!.*预告).*?第\\d+期.*",
"replace": "{II}.{TASKNAME}.{DATE}.第{E}期{PART}.{EXT}"
},
{
"taskname": "测试-去广告字符",
"shareurl": "https://pan.quark.cn/s/d07a34a9c695#/list/share/7e25ddd87cf64443b637125478733295-夸克自动转存测试/680d91e490814da0927c38b432f88edc-带广告文件夹",
"savepath": "/夸克自动转存测试/带广告文件夹",
"savepath": "/夸克自动转存测试/去广告字符",
"pattern": "【XX电影网】(.*)\\.(mp4|mkv)",
"replace": "\\1.\\2",
"enddate": "2099-01-30"
@ -43,7 +58,7 @@
{
"taskname": "测试-超期任务",
"shareurl": "https://pan.quark.cn/s/d07a34a9c695#/list/share/7e25ddd87cf64443b637125478733295-夸克自动转存测试",
"savepath": "/夸克自动转存测试",
"savepath": "/夸克自动转存测试/不会运行",
"pattern": "",
"replace": "",
"enddate": "2000-01-30",

View File

@ -2,3 +2,4 @@ flask
apscheduler
requests
treelib
natsort