Compare commits

...

262 Commits

Author SHA1 Message Date
jxxghp
9862c81477 Merge pull request #1015 from wumode/clashruleprovider 2026-05-02 17:57:09 +08:00
wumode
9fb3e09042 fix(ClashRuleProvider): add missing xhttp_opts and correct field types 2026-05-02 17:26:08 +08:00
wumode
1ad19a5b23 feat(ClashRuleProvider): bump version to 2.1.5 and support xhttp protocol 2026-05-02 17:03:31 +08:00
jxxghp
527327c6cb Merge pull request #1014 from Nanako718/feat/alidnsddns 2026-04-26 07:57:29 +08:00
DTZSGHNR
a398dcb0b8 fix(alidnsddns): 根据 Code Review 建议修复四处问题
- 移除 init_plugin 中重复的 interval 调度,定时任务统一由宿主 get_service() 管理
- 将废弃的 datetime.utcnow() 替换为 datetime.now(timezone.utc)
- API 请求增加 HTTPError/URLError 捕获,读取响应体输出详细错误信息
- upsert() 改为更新所有匹配记录,而不只取第一条
2026-04-26 07:05:57 +08:00
DTZSGHNR
f3232dba0a feat(alidnsddns): 新增阿里云 DDNS 插件 v1.0
- 定时检测公网 IPv4/IPv6 地址,自动更新阿里云 DNS 解析记录
- 支持泛域名(* 记录)、根域(@ 记录)及任意子域名
- 支持同时维护多条 A / AAAA 记录
- 详情页展示更新历史(VDataTable,最多 100 条)
- IP 变化时推送通知(兼容所有通知渠道)
- 纯标准库实现阿里云 DNS API(HMAC-SHA1 签名),无额外依赖
2026-04-26 07:05:57 +08:00
jxxghp
e78a371663 docs: split plugin faq into separate pages 2026-04-20 21:43:30 +08:00
jxxghp
068838d013 docs: improve plugin guidance 2026-04-20 21:32:50 +08:00
jxxghp
57f2ad523c fix(wechatclawbot): 状态信息恢复单块VAlert,用white-space:pre-line修复换行 2026-04-10 13:02:32 +08:00
jxxghp
615f85f02b chore(wechatclawbot): 版本升至 0.2.1 2026-04-10 12:57:11 +08:00
jxxghp
74f47c7131 fix(wechatclawbot): 状态信息拆分为独立卡片逐项展示,修复换行问题 2026-04-10 12:56:17 +08:00
jxxghp
87224308d6 feat(wechatclawbot): 优化配置页UI布局,修复回复消息多余类型前缀,升级至v0.2.0
- get_form 改用 VRow/VCol 响应式两列布局,增加使用说明提示
- get_page 所有元素用 VRow/VCol 包裹,二维码居中显示
- 移除 _notification_to_title_lines 中 mtype 前缀拼接,修复回复时出现【智能体】等字样
- package.v2.json 同步版本号至 0.2.0
2026-04-10 12:54:00 +08:00
jxxghp
4b413d93a8 Merge pull request #1013 from mijjjj/main 2026-04-09 21:44:06 +08:00
Jc Fang
34e72a7ae3 Merge branch 'main' into main 2026-04-09 21:35:08 +08:00
Fangjc
944af59468 fix:修复错误 2026-04-09 21:32:08 +08:00
Fangjc
0f898f283e fix:修复插件package写错文件问题 2026-04-09 21:16:08 +08:00
Fangjc
07a4731feb 添加package.json 2026-04-09 21:12:57 +08:00
Fangjc
d3faafe6ee fix:修复依赖问题 2026-04-09 21:05:23 +08:00
Fangjc
8bff87f1c5 添加wechatbot通知支持 2026-04-09 21:05:23 +08:00
Fangjc
889f393d2a fix:修复依赖问题 2026-04-09 21:01:15 +08:00
Fangjc
e008da0c2b 添加wechatbot通知支持 2026-04-09 17:16:02 +08:00
wumode
f3d1aa1ea9 fix(lexiannot): No module named 'langchain.output_parsers' 2026-03-27 15:19:25 +08:00
DDSRem
77f399ffa0 fix python_hosts 2026-03-26 18:01:58 +08:00
DDSRem
e101d5c2bd fix cacheout 2026-03-26 18:01:58 +08:00
jxxghp
a0d25abe25 Merge pull request #1004 from DDSRem-Dev/main 2026-03-25 11:57:14 +08:00
DDSRem
bd3f6fe2e5 fix python-hosts 2026-03-25 11:45:08 +08:00
jxxghp
7f41a8a5f2 fix cacheout 2026-03-24 22:07:29 +08:00
jxxghp
c33e7fe9df fix bencode 2026-03-24 22:05:27 +08:00
jxxghp
20e18117ab Merge pull request #1001 from Raymond38324/main 2026-03-16 06:53:56 +08:00
raymond531
750d5917a2 feat: 修改版本号 2026-03-15 22:26:21 +08:00
raymond531
fc23e3639d fix: 版本号修改 2026-03-15 21:47:29 +08:00
raymond531
8a5b01f58f feat: 修改配置 2026-03-15 21:45:12 +08:00
raymond531
72bb3320ac fix: 修复NoneType错误,增强空值处理 v1.5.1 2026-03-14 23:06:44 +08:00
raymond531
2a4002032d feat: 详情页新增清空历史按钮,可重置空间统计 2026-03-14 22:56:18 +08:00
raymond531
be12618b0f bug 修改 2026-03-14 21:01:21 +08:00
raymond531
4d2bc309ac bug 修改 2026-03-14 21:00:27 +08:00
raymond531
2f78083c7f 添加插件占用空间限制 2026-03-14 20:38:41 +08:00
raymond531
f1355f3400 bug 修改 2026-03-14 18:47:37 +08:00
raymond531
6a03f626be 修改路径 2026-03-14 18:40:39 +08:00
raymond531
5cf62a221a 添加首播试看插件 2026-03-14 18:34:19 +08:00
jxxghp
9662a4c457 Merge pull request #1000 from KoWming/main 2026-03-13 14:55:31 +08:00
KoWming
3ad3de299c Update __init__.py
性能优化
2026-03-13 13:36:11 +08:00
KoWming
e760cd6afa Update 药丸签到2.0.3
增加启用浏览器仿真功能发送请求
2026-03-13 13:13:10 +08:00
jxxghp
8d30ba5c69 Merge pull request #996 from wumode/clashruleprovider 2026-03-08 07:46:10 +08:00
wumode
a9b66c4f43 fix(tobypasstrackers): improve torrents downloading 2026-03-07 22:41:12 +08:00
wumode
cdc062d681 fix(clashruleprovider): unable to delete proxies 2026-03-07 22:33:01 +08:00
jxxghp
437b2b05d4 Merge pull request #993 from honue/main 2026-02-25 15:13:11 +08:00
honue
944919fc34 feat: 共享识别词支持 JSON 格式远程识别词集合订阅 2026-02-25 14:47:56 +08:00
jxxghp
1ae826cf14 Merge pull request #991 from xiaoQQya/develop 2026-02-24 19:57:39 +08:00
xiaoQQya
f438490ca5 perf(AutoSignIn): 优化站点 Rousi Pro 签到失败提示信息 2026-02-23 21:22:13 +08:00
jxxghp
b938ca5bf3 Merge pull request #988 from YuHoYe/feat/dailysummary 2026-02-10 06:56:14 +08:00
YuHoYe
028103b900 fix(DailySummary): 简化配置界面 + 修复通知标题重复
- 移除高级设置 tab(signin_plugin_id / brush_plugin_ids / storage_paths)
  这些内部实现细节不该暴露给用户,改为代码内硬编码默认值
  存储路径改为纯自动检测 MP 的 LIBRARY_PATH / DOWNLOAD_PATH
- 去掉 VTabs,报告模块选择器直接平铺
- Cron 字段和开关移到 VTabs 外面,避免弹出菜单被裁剪
- 修复通知标题重复:text 中不再拼接 header,由 post_message 的 title 参数单独传递
2026-02-09 23:25:51 +08:00
YuHoYe
bb1f159198 feat(DailySummary): Cron 字段改用 VCronField GUI 选择器 2026-02-09 23:23:10 +08:00
YuHoYe
6fa42abc17 fix(DailySummary): 旧配置升级时回写默认模块选择 2026-02-09 23:23:10 +08:00
YuHoYe
95b952c27f feat(DailySummary): 详情页展示模块配置和发送统计 2026-02-09 23:23:10 +08:00
jxxghp
6631d06a04 Merge pull request #987 from YuHoYe/feat/dailysummary 2026-02-09 06:24:53 +08:00
jxxghp
1afce8c607 Merge pull request #986 from BlueflameLi/main 2026-02-09 06:23:50 +08:00
YuHoYe
82c825e349 fix(DailySummary): 修复详情页面不显示问题
- 参考 BrushFlow 的 get_page 结构,将所有内容放在单个 VRow 内
- 无数据时返回「暂无数据」提示
- 表格 height 改为 '30rem'(字符串)
- 统计卡片对齐官方组件风格
2026-02-09 00:41:43 +08:00
YuHoYe
ff7d7b1fa4 feat(DailySummary): 新增活动总结插件
定时发送每日/每周/每月活动总结通知,支持自定义报告模块和历史记录查看。
2026-02-08 23:06:18 +08:00
BlueflameLi
328ed9884a 🐞 fix(MoviePilotUpdateNotify): 修复版本号比较逻辑 2026-02-08 22:00:03 +08:00
jxxghp
4d1b90abc8 Merge pull request #980 from xiaoQQya/develop 2026-01-26 18:42:27 +08:00
xiaoQQya
c5afdfc2da fix(AutoSignIn): 更新站点 Rousi Pro 签到接口 2026-01-25 14:27:32 +08:00
jxxghp
fdbd5ad501 Merge pull request #979 from TimoYoung/main
fix(autosubv2): v2.5 fix openai client init problem
2026-01-23 22:54:49 +08:00
TimoYoung
d66605ae99 fix(autosubv2): v2.5 fix openai client init problem 2026-01-23 22:07:07 +08:00
jxxghp
145e9747a9 Merge pull request #977 from wumode/imdbsource 2026-01-21 21:35:14 +08:00
jxxghp
87e4dcd211 Merge pull request #976 from TimoYoung/main 2026-01-21 17:38:19 +08:00
TimoYoung
633c8bad97 autosubv2: v2.4 适配openai api v1 2026-01-21 17:30:59 +08:00
wumode
0927d0388a feat(imdbsource): add production company filter and optimize year selection 2026-01-21 14:48:28 +08:00
wumode
323289aa74 fix(ClashRuleProvider): 规则集禁用失效 2026-01-21 14:48:28 +08:00
wumode
1f80e3b078 fix(LexiAnnot): 避免潜在的数据校验错误 2026-01-21 14:48:28 +08:00
jxxghp
0ac725383e Merge pull request #975 from AkaiShuichi7/main 2026-01-21 06:44:27 +08:00
AkaiShuichi7
659f4f2b0d fix(MediaServerMsg): 优化去重逻辑并修复潜在内存泄漏 (PR review) 2026-01-20 23:43:51 +08:00
AkaiShuichi7
d65979323e feat(MediaServerMsg): 修复emby多条相同新入库消息推送多次的问题 2026-01-20 23:22:47 +08:00
jxxghp
d2503648a9 Merge pull request #974 from xiaoQQya/develop 2026-01-14 20:00:43 +08:00
xiaoQQya
fffad33cc5 feat(AutoSignIn): 适配站点 Rousi Pro 2026-01-14 19:23:57 +08:00
jxxghp
ae99671190 Merge pull request #971 from wumode/clashruleprovider 2026-01-13 07:05:42 +08:00
wumode
528b938f0f fix(ClashRuleProvider): fix rule-providers serialization error 2026-01-12 23:09:32 +08:00
jxxghp
722f8da96d Merge pull request #970 from wumode/lexiannot 2026-01-12 15:01:58 +08:00
wumode
c53a3dc152 fix(LexiAnnot): typo 2026-01-12 13:41:03 +08:00
jxxghp
e29f59c28c Merge pull request #969 from wumode/clashruleprovider 2026-01-10 21:00:44 +08:00
wumode
c2c1320b18 fix(ClashRuleProvider): remove proxiesmanager.py 2026-01-10 20:05:20 +08:00
wumode
e15733b7de refactor(ClashRuleProvider): 重构后端核心逻辑与数据模型
- 数据模型重构: 全面引入 Pydantic 模型(ClashConfig, Proxy, ProxyGroup 等)替代原有字典结构,提供更严格的数据验证与类型安全。
- 数据迁移机制: 新增 v2.1.0 数据升级脚本,支持将旧版代理、策略组及规则数据自动迁移至新架构。
- 配置补丁系统: 实现基于 JSON Patch 的细粒度配置修补机制,替代旧版覆盖逻辑,提升配置修改的灵活性。
- 服务层优化: 重写 ClashRuleProviderService 以适配新对象模型,增强代码可维护性与扩展性。
- API模型同步: 更新相关 API 数据模型以保持与内部数据结构的一致性。
- 用户界面: 批量规则管理和数据项隐藏支持
2026-01-10 19:23:32 +08:00
jxxghp
02a2518fce Merge pull request #966 from wumode/lexiannot 2026-01-07 16:40:08 +08:00
wumode
861f416aad fix(LexiAnnot): typo 2026-01-07 13:16:14 +08:00
wumode
17cf85c1c1 Update ImdbSource to v1.6.6 2026-01-07 12:55:52 +08:00
wumode
6dbf539d88 feat(lexiannot): optimize prompts and subtitle extraction 2026-01-07 12:54:09 +08:00
jxxghp
24b9c2ec29 Merge pull request #965 from BlueflameLi/main 2026-01-07 06:50:04 +08:00
BlueflameLi
9a8e939414 fix(MoviePilotUpdateNotify): 更新版本号和历史 2026-01-06 23:21:27 +08:00
jxxghp
a6b5286bf9 Merge pull request #963 from wumode/tobypasstrackers 2026-01-06 15:19:45 +08:00
wumode
490c740c54 feat(tobypasstrackers): 支持从站点首页获取最新 Trackers 2026-01-06 15:12:08 +08:00
jxxghp
39d64a1cf4 Merge pull request #962 from BlueflameLi/main 2026-01-05 22:32:27 +08:00
BlueflameLi
a0272dfcaf fix(MoviePilotUpdateNotify): 修复版本描述为空时的报错 2026-01-05 21:31:41 +08:00
jxxghp
44d3db72b4 Merge pull request #961 from cddjr/fix_947 2026-01-04 13:15:07 +08:00
景大侠
48b5d1018e 更新登录壁纸本地化插件 适配新版MP 2026-01-04 12:05:30 +08:00
jxxghp
738e224ba3 Merge pull request #960 from Seed680/main 2025-12-30 07:02:30 +08:00
noone
6f2a0b2213 fix(mediaservermsg): 修复媒体服务器通知插件的多个问题
- 修复多集时有概率图片获取失败的问题
- 修复emby测试通知类型接收失败的问题
- 修复单集剧情信息有概率获取失败的问题
- 更新插件版本号至1.8.2.1
- 修正webhook测试事件类型从system.webhooktest改为system.notificationtest
- 添加事件类型过滤调试日志
- 优化图片URL处理逻辑,改进单集和多集的图片获取策略
- 完善剧集概述信息获取的安全性处理
2025-12-29 22:15:24 +08:00
jxxghp
c2ccdf2b8e Merge pull request #959 from Seed680/main 2025-12-29 19:18:23 +08:00
noone
adb6230eea Merge branch 'main' of https://github.com/Seed680/MoviePilot-Plugins-main 2025-12-29 17:35:16 +08:00
noone
aa89750d1f fix(brushflow): 提升匹配规则时的健壮性
- 在包含规则和排除规则中添加正则表达式错误处理
- 防止因正则表达式错误导致的匹配失败
- 添加对torrent标题和描述的空值检查
- 修复RSS支持配置选项的处理逻辑
- 更新插件版本到4.3.5
2025-12-29 17:35:12 +08:00
Seed680
4ca2d14076 Merge branch 'jxxghp:main' into main 2025-12-29 17:19:21 +08:00
noone
8bd590e1ea fix(mediaservermsg): 修复多集图片获取失败及emby测试通知问题
- 修复多集时有概率图片获取失败的问题
- 修复emby测试通知类型接收失败的问题
- 更新版本号至1.8.2
- 将webhook事件映射中的system.notificationtest改为system.webhooktest
2025-12-29 17:18:44 +08:00
jxxghp
d7effcd625 Merge pull request #955 from Seed680/main 2025-12-23 08:43:19 +08:00
noone
a7b830e4fd fix(mediaservermsg): 修复单集剧情信息有概率获取失败的问题
- 重构safe_get_overview函数,增加详细的文档说明
- 优化单集剧情获取逻辑,优先使用webhook事件的overview
2025-12-23 07:54:42 +08:00
jxxghp
5b8f5b406f Merge pull request #954 from wumode/imdbsource 2025-12-22 19:05:12 +08:00
jxxghp
69b430bdc3 Merge pull request #953 from wumode/lexiannot 2025-12-22 19:04:48 +08:00
wumode
00d3346dfc fix(lexiannot): ValueError 2025-12-22 18:01:19 +08:00
wumode
7452540a93 fix(imdbsource): TypeError 2025-12-22 17:56:21 +08:00
wumode
d98902e536 feat(lexiannot): 改进字幕样式获取方法 2025-12-22 13:36:09 +08:00
wumode
5ecefb4a41 feat(ImdbSource): 仪表盘组件支持图片缓存 2025-12-22 13:23:37 +08:00
jxxghp
814149e0f3 删除 requirements.txt 2025-12-22 11:27:04 +08:00
jxxghp
d306145a14 更新 package.v2.json 2025-12-22 11:26:49 +08:00
jxxghp
da72e1b252 删除 package.json 2025-12-22 11:24:47 +08:00
jxxghp
b6fc76cdb7 Merge pull request #952 from jxxghp/copilot/add-plugin-definition-tmdbwallpaper 2025-12-22 11:22:39 +08:00
copilot-swe-agent[bot]
7842375d11 Remove trailing newlines from package.json and requirements.txt
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2025-12-22 03:21:01 +00:00
copilot-swe-agent[bot]
f6d83a5d31 Add package.json and requirements.txt for tmdbwallpaper plugin
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2025-12-22 03:19:36 +00:00
jxxghp
97b8e7028a Merge pull request #950 from jxxghp/copilot/remove-v2-compatibility-update-references 2025-12-22 11:17:10 +08:00
copilot-swe-agent[bot]
cc6cc55ad0 Initial plan 2025-12-22 03:15:48 +00:00
copilot-swe-agent[bot]
52063367f8 Remove v2 compatibility marker from TmdbWallpaper plugin
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2025-12-22 03:14:58 +00:00
jxxghp
0003e4382b Merge pull request #949 from jxxghp/copilot/copy-wallpaper-plugin-to-v2 2025-12-22 11:12:41 +08:00
copilot-swe-agent[bot]
e2cbe22e8d Initial plan 2025-12-22 03:11:19 +00:00
copilot-swe-agent[bot]
436983e49e Copy tmdbwallpaper plugin to plugins.v2 directory
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2025-12-22 03:05:34 +00:00
copilot-swe-agent[bot]
8829414a47 Initial plan 2025-12-22 03:02:42 +00:00
jxxghp
9f46c829db Merge pull request #948 from ArvinChen9539/remove-playletfortunewheel 2025-12-22 10:08:55 +08:00
ArvinChen9539
0de6531aed PlayLet移除自动抽奖插件 2025-12-22 09:46:28 +08:00
jxxghp
a5a96b74e3 Merge pull request #946 from 13bit-X/main 2025-12-21 22:28:03 +08:00
jofyxu
f7b1a027f5 fix: 升级插件版本至 1.3
升级插件版本至 1.3
同时更新了 package.json 中的版本号和历史记录。
2025-12-21 21:42:04 +08:00
jofyxu
bde04fd7e1 fix(ntfymsg): 修复标题和文本为空时的日志警告问题
当标题和文本都为空时,使用空字符代替空字符串,以避免自动生成 “triggered” 文本。

- 将 `title = msg_body.get("title") or ""` 修改为 `title = msg_body.get("title") or "\u200b"`
- 将 `text = msg_body.get("text") or ""` 修改为 `text = msg_body.get("text") or "\u200b"`
2025-12-21 21:30:46 +08:00
jofyxu
af38909f58 fix: 修复消息标题和内容为空时日志错误
修复了当消息的标题和内容都为空时,日志记录的错误信息不完整的问题。现在,如果消息的标题或内容为空,将返回空字符串而不是None,从而避免日志记录的警告信息中出现None。

- 修改了消息标题 (`title = msg_body.get("title") or ""`) 的获取方式,当标题为空时返回空字符串。
- 修改了消息文本 (`text = msg_body.get("text") or ""`) 的获取方式,当文本为空时返回空字符串。
2025-12-21 20:57:40 +08:00
jxxghp
5ccd80c4f1 Merge pull request #945 from 13bit-X/main 2025-12-21 17:35:25 +08:00
13bit
ebf407b8b2 Update package.json
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-21 16:24:00 +08:00
13bit
d0be1feec5 将插件版本从 1.1 升级到 1.2 2025-12-21 16:11:39 +08:00
13bit
02fbbc87b4 将 NtfyMsg 版本升级到 1.2
将 NtfyMsg 版本更新至 1.2,并为版本 1.2 添加了更新记录。
2025-12-21 16:09:45 +08:00
13bit
ce1804cd0f 修复 ntfy 通知图标链接失效的问题 2025-12-21 16:06:57 +08:00
jxxghp
53da73f11e Merge pull request #944 from ArvinChen9539/feature-playlet-fortune-wheel 2025-12-21 10:13:08 +08:00
陈敬
fb3d8e9c0d PlayLet幸运大转盘 2025-12-21 02:01:07 +08:00
jxxghp
5039a94bbf Merge pull request #943 from EkkoG/main 2025-12-18 21:00:36 +08:00
Ekko
3ae993050b 豆瓣榜单-支持设置 rsshub 实例地址 2025-12-18 17:51:15 +08:00
jxxghp
0dddb4675f Merge pull request #942 from EkkoG/main 2025-12-18 14:15:26 +08:00
Ekko
56abaaf31c 豆瓣榜单-支持设置 rsshub 示例地址 2025-12-18 13:01:19 +08:00
jxxghp
900f4fec95 Merge pull request #941 from Seed680/main 2025-12-18 12:43:05 +08:00
jxxghp
88688672db Merge pull request #939 from KoWming/main 2025-12-18 12:42:40 +08:00
noone
cc6b95e5a1 fix(mediaservermsg): 优化剧集名称提取逻辑并改进错误日志
- 将 TMDB 信息获取错误的日志级别从 debug 提升为 error
- 从 json_object 中提取 SeriesName 作为剧集名称,提高准确性
- 优化消息标题格式,去除冗余文字,提升可读性
- 添加异常处理以确保 SeriesName 提取过程的稳定性
2025-12-18 09:30:13 +08:00
noone
377808f3da refactor(mediaservermsg): 优化TV剧集消息聚合处理逻辑
- 移除未使用的Emby和RequestUtils导入模块
2025-12-18 09:23:00 +08:00
noone
1d5e44e02c feat(mediaservermsg): 增强媒体服务器通知插件功能
- 当整理路径中没有tmdbid时,会尝试从媒体服务器中获取
- 增强错误处理和异常捕获机制
- 改进消息发送流程的安全性与稳定性
- 更新插件版本至1.8
2025-12-18 09:13:45 +08:00
KoWming
ff9c35041e perf: 增加签到检测机制防止重复签到,增强代码健壮性。 2025-12-17 10:13:21 +08:00
KoWming
d9afb64d00 perf: 尝试修复签到失败问题,新增使用代理、Cookie自动更新功能
尝试修复签到失败问题,新增使用代理、Cookie自动更新功能
2025-12-16 11:24:17 +08:00
jxxghp
6d60123272 Merge pull request #938 from wumode/lexiannot 2025-12-13 12:41:26 +08:00
wumode
84fcc3762f fix(LexiAnnot): Array index error 2025-12-12 19:15:21 +08:00
wumode
77b34dba5c feat(lexiannot): Add QueryAnnotationTasksTool 2025-12-12 15:01:56 +08:00
wumode
4d8f36f674 feat(lexiannot): Add exam_distribution to SegmentList schema 2025-12-12 11:19:40 +08:00
wumode
5ccbb412eb feat(tobypasstrackers): Add new trackers and refine IP exclusion logic 2025-12-11 11:34:36 +08:00
wumode
4a0c700e6b feat(imdbsource): Add background image support to metadata and update version to 1.6.4 2025-12-11 11:23:12 +08:00
wumode
00c65a0983 feat(lexiannot): Integrate LLM for advanced vocabulary processing 2025-12-11 11:23:12 +08:00
jxxghp
b961a52440 Merge pull request #937 from Seed680/main 2025-12-10 12:10:57 +08:00
noone
707feedda2 fix(mediaservermsg): 优化剧集图片查询逻辑
- 修改查询条件,仅当项目类型为TV或SHOW时执行剧集图片查询
2025-12-10 10:41:55 +08:00
noone
07c6ee1341 fix(mediaservermsg): 修复媒体服务器消息图片查询逻辑
- 修正了电影图片查询条件,确保仅在有 tmdb_id 时执行
2025-12-10 10:40:55 +08:00
noone
fd360cf21d bugfix:MediaServerMsg1.7.1 2025-12-10 10:36:56 +08:00
noone
a267df9e5d Merge branch 'main' of https://github.com/Seed680/MoviePilot-Plugins-main 2025-12-10 18:18:40 +08:00
noone
8feecbcb42 fix(mediaservermsg): 修复未获取到tmdb信息时的消息发送逻辑
- 当tmdb_id为空时,回退到原有逻辑发送通知消息
- 为电影类型添加海报图片查询支持
2025-12-10 18:18:25 +08:00
jxxghp
4224939f30 Merge pull request #936 from Seed680/main 2025-12-09 11:51:43 +08:00
Seed680
234ceba60c Merge branch 'jxxghp:main' into main 2025-12-09 10:23:50 +08:00
noone
5c8a6647e2 feat(mediaservermsg): 新增TV剧集结入库聚合功能
- 实现TV剧集入库事件的智能聚合,避免消息轰炸
- 添加聚合时间窗口配置,默认15秒
- 支持通过TMDB ID获取剧集详细信息用于消息展示
- 自动合并连续集数信息,优化消息可读性
- 增加聚合功能开关和时间配置项到插件表单
- 完善消息构造逻辑,支持剧集封面和背景图展示
- 优化消息缓存机制,提升重复消息过滤效果
- 补充详细的日志记录便于问题排查
- 更新插件版本至1.7并添加更新说明提示
2025-12-09 10:22:52 +08:00
jxxghp
5b763dff42 Merge pull request #933 from cikezhu/main 2025-12-06 03:52:20 +08:00
cikezhu
ee453841df fix 更新标签而传递旧的种子列表 2025-12-05 22:51:50 +08:00
cikezhu
6768d2c244 add 站点/剧名前缀功能 2025-12-05 22:44:49 +08:00
jxxghp
cb14efcc68 Merge pull request #932 from cikezhu/main 2025-12-05 17:54:08 +08:00
cikezhu
7871dfd0b8 fix gemini-code-assist suggestion‌ 2025-12-05 17:01:33 +08:00
cikezhu
99d1bfe37e fix 2025-12-05 16:24:15 +08:00
cikezhu
b65c1b8bf7 首次运行时初始化rid映射 2025-12-05 16:18:09 +08:00
cikezhu
517a16f0a3 优化采用公共服务自动清理未使用标签 2025-12-05 16:13:38 +08:00
jxxghp
89bfb9750d Merge pull request #930 from cikezhu/main 2025-12-04 22:28:59 +08:00
cikezhu
01eac66a6a fix _get_label 2025-12-04 22:11:57 +08:00
cikezhu
cd53b8d454 add 自动清除未使用标签 2025-12-04 21:56:48 +08:00
jxxghp
d986f45634 Merge pull request #928 from cikezhu/main 2025-12-03 14:40:56 +08:00
cikezhu
0ceb633d96 通过直接解析而不是硬编码解决代码冗余问题 2025-12-03 10:54:12 +08:00
cikezhu
2965743cfe fix 首次更新时页面配置内容 2025-12-03 09:55:16 +08:00
cikezhu
9fa02d62e2 调整界面显示效果 2025-12-03 09:34:18 +08:00
cikezhu
b2bd0f3701 增加tracker映射配置 2025-12-03 09:26:01 +08:00
jxxghp
de0e83f830 Merge pull request #924 from wumode/tobypasstrackers 2025-11-20 17:39:24 +08:00
wumode
94b6df246e fix(LexiAnnot): Handle subtitle duration parsing errors 2025-11-20 17:35:17 +08:00
wumode
6b895919a0 update package.v2.json 2025-11-20 11:53:38 +08:00
wumode
a9830202e8 feat(LexiAnnot): Improve subtitle selection strategy 2025-11-20 10:41:06 +08:00
wumode
e96eece117 feat(ToBypassTrackers): Add Page UI, IP check command, and refactor DNS resolution 2025-11-19 19:34:10 +08:00
jxxghp
107b8e408f 更新 __init__.py 2025-11-19 07:13:59 +08:00
jxxghp
6629aeadef Merge pull request #923 from jxxghp/cursor/update-openai-library-for-chatgpt-plugin-2cf9 2025-11-19 07:08:20 +08:00
Cursor Agent
b0e5680260 Fix: Update OpenAI API compatibility and version
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-11-18 23:06:26 +00:00
Cursor Agent
a322274d77 Refactor: Use new OpenAI client and handle exceptions
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-11-18 23:02:16 +00:00
jxxghp
be2289739a Merge pull request #922 from xslidi/main 2025-11-18 21:03:50 +08:00
xslidi
7536a8782e Support GPW url 2025-11-18 20:34:16 +08:00
jxxghp
4d71a24fbc fix requirements 2025-11-18 16:47:48 +08:00
jxxghp
85ac9dd393 feat(README): 添加get_tool_message方法的说明,要求实现友好的提示消息 2025-11-18 12:52:23 +08:00
jxxghp
75c65b96d4 feat(README): 添加插件中注册智能体工具的说明和示例代码 2025-11-18 11:25:09 +08:00
jxxghp
7d8433b768 Merge pull request #920 from wumode/pydantic_v2 2025-11-17 15:03:31 +08:00
jxxghp
d66413dd7a Merge pull request #918 from Seed680/main 2025-11-17 15:03:08 +08:00
wumode
a0c9afc3ed refactor(ClashRuleProvider, ImdbSource and LexiAnnot): Adapt to Pydantic V2 2025-11-17 14:18:54 +08:00
noone
e0c39170e6 feat(brushflow): 添加RSS支持配置选项 2025-11-06 16:36:22 +08:00
noone
8e199afe24 feat(brushflow): 添加RSS支持配置选项
- 在BrushConfig中新增rss_support字段,默认值为False
- 更新配置描述和默认配置示例,包含rss_support选项
- 在插件页面中添加RSS支持开关组件
- 根据rss_support配置决定使用browse或rss方法获取种子
- 升级插件版本从4.3.3到4.3.4
2025-11-06 16:34:07 +08:00
jxxghp
e68d915f36 Merge pull request #917 from developer-wlj/main 2025-11-04 18:17:56 +08:00
developer-wlj
b3e78c3e5e chore(plugins): 更新媒体同步删除插件版本至1.7.2
- 兼容Windows路径格式,将反斜杠转换为正斜杠
2025-11-04 15:55:32 +08:00
jxxghp
f02b90552b Merge pull request #914 from wumode/imdbsource 2025-11-01 19:51:22 +08:00
jxxghp
e93bfc6667 Merge pull request #913 from KoWming/main 2025-11-01 19:51:06 +08:00
wumode
131463cfbe update(ImdbSource): set delay to 1s 2025-10-26 23:01:05 +08:00
KoWming
b963398987 修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI
增强代码健壮性。
2025-10-23 11:35:18 +08:00
KoWming
ed395a26a9 Update package.json 2025-10-23 11:34:40 +08:00
wumode
03a2b35930 fix(ImdbSource): API 查询错误重试 2025-10-23 01:19:48 +08:00
KoWming
5a642e1e51 修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI。 2025-10-22 10:12:42 +08:00
KoWming
a8813b0272 Update package.json 2025-10-22 10:12:05 +08:00
jxxghp
66ce816a31 Merge pull request #908 from wumode/clashruleprovider 2025-10-08 10:30:35 +08:00
wumode
241e3200f8 fix: typo 2025-10-08 00:57:09 +08:00
wumode
19f52d6217 update: ImdbSource & ClashRuleProvider
ImdbSource
- 使用 Pydantic 重构 IMDb API

ClashRuleProvider
- fix: 过早实例化系统 Scheduler
- fix: 缺少 PyYAML
- 配置使用 Pydantic
2025-10-08 00:40:47 +08:00
jxxghp
884efaebbf Merge pull request #907 from wumode/clashruleprovider 2025-10-02 20:57:53 +08:00
wumode
b51ba3d92a fix(ClashRuleProvider): rule comparing 2025-10-02 17:39:35 +08:00
wumode
ec74481160 fix(ClashRuleProvider): rule comparing 2025-10-02 01:33:56 +08:00
jxxghp
c60a4f01aa Merge pull request #906 from wumode/clashruleprovider 2025-10-01 12:48:13 +08:00
wumode
e34cafd641 fix(ClashRuleProvider): typo 2025-10-01 11:57:14 +08:00
wumode
5f8bb72641 update(ClashRuleProvider): 使用secrets.compare_digest() 2025-10-01 10:53:44 +08:00
wumode
df3e42987a update(LexiAnnot): 增加上下文上限 2025-09-30 01:00:34 +08:00
wumode
8a738b7684 refactor: ClashRuleProvider
- 优化插件目录结构和数据结构, 解耦API层和服务层
- 添加了一些Pydantic模型, 用于校验配置
- 支持独立的订阅链接配置
- 新增覆写代理组和出站代理操作
- 支持 smart 组和代理集合
- 代理组回环检测
- 使用异步调度器
- 显示规则更改日期
- 完善了对嵌套逻辑规则和子规则的配置和验证
2025-09-30 00:54:24 +08:00
jxxghp
491f40663b fix logging 2025-09-17 13:37:56 +08:00
jxxghp
fe8a7c6cd2 Merge pull request #897 from hizml/main 2025-09-09 18:17:17 +08:00
ZhaoML
6245940466 fix(Cloudflare IP优选): 修复 IPv6 地址含双引号导致的执行错误
- 移除了 cloudflarespeedtest 插件中执行命令时对 IPv6 地址的双引号
- 该修改解决了当 IPv6 地址包含双引号时,命令无法正确执行的问题
2025-09-09 17:52:06 +08:00
ZhaoML
c86cbc473f feat(cloudflarespeedtest): 适配 CloudflareSpeedTest 新版名称
- 更新插件版本至 1.5
- 修改二进制文件名称从 CloudflareST 到 cfst
- 增加旧版本兼容性处理
- 更新下载链接和安装逻辑以适应新名称
- 在 package.json 中添加新版本历史记录
2025-09-09 17:27:44 +08:00
jxxghp
d93665a572 fix BugReporter 2025-09-08 15:38:21 +08:00
jxxghp
250ee4ada8 Merge pull request #896 from wumode/clashruleprovider 2025-09-08 15:05:11 +08:00
wumode
dfe2247b25 update(ClashRuleProvider): 支持显示节点链接 2025-09-08 13:02:10 +08:00
jxxghp
858261ddcc Merge pull request #895 from JavaZeroo/fix_autosubv2_zero_lang 2025-09-07 12:40:49 +08:00
JavaZeroo
47bf56afe4 feat(AutoSubv2): add auto language detection and improve translation retry logic 2025-09-07 12:12:08 +08:00
jxxghp
af3956d86f Merge pull request #894 from JavaZeroo/fix_autosubv2_zero_division 2025-09-07 06:56:01 +08:00
JavaZeroo
a69feb73ca fix(AutoSubv2): handle empty subtitle files and improve success rate logging 2025-09-07 01:00:30 +08:00
jxxghp
88b29169fc Merge pull request #890 from wumode/clashruleprovider 2025-09-02 18:23:59 +08:00
wumode
2c9e108ac4 fix(ClashRuleProvider): 保持键名一致性 2025-09-02 13:24:24 +08:00
wumode
73b2d778a0 fix(ClashRuleProvider): 配置模板保存问题 2025-09-02 12:39:13 +08:00
jxxghp
bf67d6e567 Merge pull request #889 from wumode/clashruleprovider 2025-09-01 20:38:22 +08:00
wumode
5e9da0802d update(ClashRuleProvider): 优化性能 2025-09-01 20:38:03 +08:00
wumode
2811021996 update(ClashRuleProvider): 优化 UI 2025-09-01 20:21:09 +08:00
jxxghp
8c0a05b2de Merge pull request #888 from wumode/lexiannot 2025-08-29 18:45:54 +08:00
wumode
bb070bf83e 使用字典键直接访问 token 2025-08-29 18:41:27 +08:00
wumode
21aec36ea5 update(LexiAnnot): 避免spaCy模型常驻内存 2025-08-29 15:36:48 +08:00
jxxghp
6019cf92ac fix BugReporter 2025-08-28 08:21:15 +08:00
jxxghp
42d5dd1e89 fix BugReporter 2025-08-27 17:43:58 +08:00
jxxghp
0b3313e078 update PersonMeta 2025-08-27 16:07:01 +08:00
jxxghp
5684ba056a update package.v2.json 2025-08-27 09:59:39 +08:00
jxxghp
44af7dbb78 add BugReporter 2025-08-27 09:53:15 +08:00
jxxghp
2102a03740 Merge pull request #885 from wumode/clashruleprovider 2025-08-24 18:46:30 +08:00
wumode
0a9cadf7ab update(ClashRuleProvider): 通过emoji识别国家 2025-08-24 18:06:43 +08:00
jxxghp
279efe8000 Merge pull request #883 from wumode/lexiannot 2025-08-23 17:18:18 +08:00
wumode
fd92e58f81 update(ImdbSource) 修复错误 2025-08-23 16:58:00 +08:00
wumode
fe93e46e02 update(ImdbSource) 修改UA 2025-08-23 00:01:13 +08:00
wumode
cbf541992f update(LexiAnnot): 添加任务页面 2025-08-22 17:03:07 +08:00
jxxghp
8e1d336250 add 统一缓存使用说明 2025-08-21 16:06:23 +08:00
jxxghp
12e0e2b9f5 Merge pull request #881 from wumode/imdbsource 2025-08-20 00:34:31 +08:00
wumode
ac914f70f3 update: ImdbSource&ToBypassTrackers 2025-08-20 00:10:09 +08:00
jxxghp
a07b8a4f4a Merge pull request #878 from wumode/lexiannot 2025-08-17 20:13:53 +08:00
wumode
6960b3f7aa update(LexiAnnot): 支持考试词汇标注 2025-08-17 19:50:30 +08:00
jxxghp
fe83ff1be8 Merge pull request #876 from liuhangbin/multiclass 2025-08-14 19:40:50 +08:00
Hangbin Liu
6357dc8e4a plugins.v2: 添加多级分类插件
目前MP默认只支持二级分类,但是部分用户有多级目录的需求,比如增加按照年代
或者评分度分类。因此增加一个支持多级分类的插件, 目前仅支持电影多级分类。

Signed-off-by: Hangbin Liu <liuhangbin@gmail.com>
2025-08-14 19:16:45 +08:00
jxxghp
f1d94d0aa3 Merge pull request #875 from yelantf/main 2025-08-12 12:08:56 +08:00
夜阑听风
53dd3bc796 Update dingdingmsg in package.json 2025-08-12 10:59:53 +08:00
夜阑听风
a9d528fc05 Update dingdingmsg version 2025-08-12 10:58:25 +08:00
夜阑听风
0388c437b1 update dingdingmsg to support breakline 2025-08-12 10:56:24 +08:00
jxxghp
ac4b53e745 AutoSignIn v2.7 2025-08-12 08:25:09 +08:00
jxxghp
53297fccaf 更新 release.yml 2025-08-10 13:48:02 +08:00
210 changed files with 43898 additions and 20803 deletions

View File

@@ -90,15 +90,18 @@ jobs:
rm -f "$asset"
(cd "$(dirname "$plugin_dir")" && zip -r "$GITHUB_WORKSPACE/$asset" "$(basename "$plugin_dir")" -x "*/__pycache__/*" -x "*.pyc") >/dev/null
# If same tag exists, delete release and remote tag first
# If same tag exists, delete release and both remote/local tag first
if gh release view "$tag" >/dev/null 2>&1; then
echo "Release $tag exists, deleting..."
gh release delete "$tag" -y
git push origin :refs/tags/"$tag" || true
fi
# Ensure no stale local tag remains
git tag -d "$tag" >/dev/null 2>&1 || true
echo "Creating release $tag"
gh release create "$tag" "$asset" --title "$tag" --notes "Automated release of $plugin_id $plugin_version" --latest
gh release create "$tag" "$asset" --title "$tag" --notes "Automated release of $plugin_id $plugin_version" --latest --target "$GITHUB_SHA"
echo "$tag" >> processed_tags.txt
done

1282
README.md

File diff suppressed because it is too large Load Diff

20
docs/FAQ.md Normal file
View File

@@ -0,0 +1,20 @@
# MoviePilot 插件常见问题
常见问题已从主 README 拆分为独立文档,按主题查阅即可。
- [1. 如何扩展消息推送渠道?](./faq/01-extend-notification-channel.md)
- [2. 如何在插件中实现远程命令响应?](./faq/02-remote-command-handler.md)
- [3. 如何在插件中对外暴露API](./faq/03-expose-plugin-api.md)
- [4. 如何在插件中注册公共定时服务?](./faq/04-register-service.md)
- [5. 如何通过插件增强MoviePilot的识别功能](./faq/05-enhance-recognition.md)
- [6. 如何扩展内建索引器的索引站点?](./faq/06-extend-indexer-sites.md)
- [7. 如何在插件中调用API接口](./faq/07-call-api-from-plugin.md)
- [8. 如何将插件内容显示到仪表板?](./faq/08-render-dashboard.md)
- [9. 如何扩展探索功能的媒体数据源?](./faq/09-extend-discovery-source.md)
- [10. 如何扩展推荐功能的媒体数据源?](./faq/10-extend-recommend-source.md)
- [11. 如何通过插件重载实现系统模块功能?](./faq/11-override-system-module.md)
- [12. 如何通过插件扩展支持的存储类型?](./faq/12-extend-storage-type.md)
- [13. 如何将插件功能集成到工作流?](./faq/13-integrate-workflow.md)
- [14. 如何在插件中通过消息持续与用户交互?](./faq/14-message-interaction.md)
- [15. 如何在插件中使用系统级统一缓存?](./faq/15-use-system-cache.md)
- [16. 如何在插件中注册智能体工具?](./faq/16-register-agent-tools.md)

264
docs/Repository_Guide.md Normal file
View File

@@ -0,0 +1,264 @@
# MoviePilot-Plugins 仓库指南
本文档面向维护者和插件开发者,说明 `MoviePilot-Plugins` 在整个 MoviePilot 体系中的职责、目录约定、元数据规则、发布流程,以及与 `MoviePilot` / `MoviePilot-Frontend` 两个主仓库的边界。
## 1. 仓库职责
`MoviePilot-Plugins` 不是独立运行时,而是插件市场和插件源码仓库。
- `MoviePilot` 后端仓库负责:
- 插件类加载与生命周期管理
- 事件与链式扩展
- 插件 API / 服务 / 仪表板注册
- 配置、插件数据、权限控制
- 插件安装、升级、分身、远程组件静态资源服务
- `MoviePilot-Frontend` 前端仓库负责:
- 插件市场与插件卡片展示
- 插件配置页、详情页、仪表板渲染
- Vue 联邦远程组件加载
- 插件侧栏全页入口
- `MoviePilot-Plugins` 负责:
- 插件源码目录
- 插件市场索引文件
- 插件图标资源
- 插件开发与维护文档
因此,开发插件时要避免把“宿主逻辑”误写进本仓库文档。例如:
- 某个 `get_api()` 为什么没有被挂载,应该先看 `MoviePilot/app/api/endpoints/plugin.py`
- 某个 Vue 远程页面为什么没有出现在侧栏,应该先看 `MoviePilot-Frontend` 的联邦加载与菜单逻辑
- 某个插件为什么在插件市场里没显示,才应该先看本仓库的 `package.json` / `package.v2.json`
## 2. 目录结构
本仓库当前采用如下结构:
```text
MoviePilot-Plugins/
├── plugins/ # 默认插件目录
├── plugins.v2/ # V2 专用插件目录
├── icons/ # 插件图标
├── docs/ # 文档
├── package.json # 默认插件索引
├── package.v2.json # V2 优先插件索引
└── .github/workflows/ # 自动发布工作流
```
关键约定:
- 一个插件一个目录。
- 目录名必须是插件类名的小写,例如 `class AutoSignIn` 对应目录 `autosignin/`
- 插件主类必须定义在该目录的 `__init__.py` 中。
- 插件目录内可附带:
- `requirements.txt`:额外 Python 依赖
- `README.md`:插件专属使用说明
- `dist/assets/`Vue 联邦构建产物
- 其他运行时所需静态文件
## 3. 元数据文件说明
### 3.1 `package.json`
默认插件索引文件,用于:
- 旧版兼容或默认版本插件
- 对 V2 兼容但不需要单独维护代码目录的插件
如果某个默认插件也能用于 V2需要在条目上声明
```json
{
"MyPlugin": {
"version": "1.2.3",
"v2": true
}
}
```
### 3.2 `package.v2.json`
V2 优先插件索引文件。MoviePilot 在 V2 环境下会优先读取这里的条目;找不到时,才会回退到 `package.json` 中声明了 `"v2": true` 的兼容插件。
### 3.3 常用字段
每个索引条目通常包含:
- `name`:插件展示名
- `description`:插件简介
- `labels`:标签,多个标签使用英文逗号分隔
- `version`:插件版本
- `icon`:图标文件名或完整 HTTP URL
- `author`:作者
- `level`:用户可见级别
- `history`:更新日志
- `release`:是否使用 GitHub Release 压缩包发布
- `v2`:默认索引中的插件是否兼容 V2
这些字段是“插件市场展示元数据”,而不是运行时唯一真相。真正加载后的插件类仍然需要在代码里声明自己的 `plugin_name``plugin_desc``plugin_version` 等属性。两者必须同步。
## 4. 版本选择与加载规则
MoviePilot 当前的插件版本选择逻辑可以概括为:
1. 先确定当前宿主版本标识,例如 `v2`
2. 优先检查 `package.v2.json` 中是否存在该插件
3. 若不存在,再检查 `package.json`
4. 只有当 `package.json` 中对应条目显式声明 `"v2": true` 时,才会作为 V2 兼容插件继续使用
这意味着:
- 同一个插件若在 `package.v2.json` 中已有专用实现,就不要再依赖 `package.json` 中的兼容声明做“隐式覆盖”。
- 新写的 V2 专用插件,优先放 `plugins.v2/`,并把元数据写入 `package.v2.json`
- 真正跨版本共用一套实现时,再使用 `package.json + "v2": true` 的方式。
## 5. 与宿主仓库的协作边界
### 5.1 与 `MoviePilot` 后端的边界
本仓库只保存插件实现,不应复制宿主的公共能力。插件应优先复用后端仓库已经提供的抽象,例如:
- `_PluginBase`
- `eventmanager`
- `DownloaderHelper` / `MediaServerHelper` / `NotificationHelper`
- `save_data()` / `get_data()` / `get_data_path()`
- 插件 API 动态注册
- 插件仪表板、服务、工作流动作、智能体工具扩展点
如果插件需要新增宿主接口,例如:
- 新的链式事件
- 新的插件 API 渲染能力
- 新的工作流动作契约
- 新的智能体工具注入点
应先在 `MoviePilot` 中补齐宿主能力,再回到本仓库落插件实现。
### 5.2 与 `MoviePilot-Frontend` 的边界
插件有两种主要 UI 方式:
- Vuetify JSON 配置
- Vue 联邦远程组件
前者的宿主渲染在 `MoviePilot-Frontend` 已经实现,插件只需要返回 JSON 结构;后者需要遵守前端仓库的联邦组件暴露规范、共享依赖规范和侧栏入口规范。
如果你在本仓库写了 Vue 模式插件,需要同时关注:
- `MoviePilot-Frontend/docs/module-federation-guide.md`
- `MoviePilot-Frontend/src/utils/federationLoader.ts`
- `MoviePilot-Frontend` 中与插件页面、侧栏导航、仪表板相关的组件
## 6. 开发一个插件时的推荐流程
### 6.1 先判断插件形态
- 只是扩展后端能力、配置项简单:优先写 Vuetify JSON 模式插件
- 需要复杂交互或完整页面:使用 Vue 联邦模式
- 只是给现有插件补 V2 兼容:优先评估能否复用 `package.json + "v2": true`
- 已经与 V1 / 默认版本差异很大:直接转为 `plugins.v2/ + package.v2.json`
### 6.2 再落目录与元数据
最小步骤通常是:
1.`plugins/``plugins.v2/` 下新建目录
2.`__init__.py` 中实现插件类
3. 如有依赖,增加 `requirements.txt`
4.`package.json``package.v2.json` 中补齐元数据
5. 如有插件文档,在插件目录补充 `README.md`
6. 如有 Vue UI构建后把产物放进 `dist/assets/`
### 6.3 维护版本一致性
发布前至少核对以下三处是否一致:
- 索引里的 `version`
- 插件类里的 `plugin_version`
- `history` 中最新一条变更说明
## 7. 校验建议
这个仓库没有独立的完整测试宿主,因此校验应该尽量贴近真实运行层。
### 7.1 Python 插件代码
建议在宿主环境里做最小校验:
```bash
# 对修改过的插件文件做语法检查
python3 -m py_compile plugins.v2/myplugin/__init__.py
# 或者对整个插件目录做批量编译检查
python3 -m compileall plugins.v2/myplugin
# 顺手检查 diff 中是否有空白符问题
git diff --check
```
### 7.2 Vue 远程组件
如果插件使用独立的前端工程,建议至少执行:
```bash
# 类型检查
yarn typecheck
# 构建联邦产物
yarn build
```
然后再把构建产物拷贝到插件目录中的 `dist/assets/`
### 7.3 宿主联调
以下场景必须回到宿主仓库验证:
- `get_api()` 是否真正注册成功
- `get_service()` 是否出现在服务列表
- `get_dashboard()` / `get_dashboard_meta()` 是否正常显示
- `get_render_mode() == "vue"` 的远程组件是否能成功加载
- `get_sidebar_nav()` 是否正确出现在前端侧栏
## 8. 发布流程
本仓库的自动发布逻辑位于 `.github/workflows/release.yml`,当前规则如下:
- 只有当 `package.json``package.v2.json` 发生变更时,工作流才会触发
- 只有索引条目中声明了 `"release": true` 的插件会参与自动打包
- 工作流会尝试在 `plugins/<plugin_id_lower>``plugins.v2/<plugin_id_lower>` 中寻找插件目录
- Release Tag 格式为 `插件ID_v插件版本号`
- 压缩包文件名格式为 `插件目录小写_v插件版本号.zip`
- 若插件目录自上一个 Tag 以来没有变化,则会跳过打包
- 若同名 Release / Tag 已存在,工作流会先删除旧对象再重新创建
这意味着发布一个可下载压缩包的插件时,最少要确认:
1. 插件目录存在且名称正确
2. 索引条目中已声明 `"release": true`
3. 索引版本号与代码版本号一致
4. 目标目录自上一个同插件 Tag 以来确实有代码变化
## 9. 文档维护建议
如果一次改动同时涉及:
- 插件能力扩展点变更
- 宿主后端新增接口或新契约
- 前端新增加载规则或侧栏行为
应同步更新对应仓库文档,不要只改本仓库 README。
推荐文档分工:
- 本仓库 `README.md`:总览与主入口
- 本仓库 `docs/FAQ.md`FAQ 索引与场景入口
- 本仓库 `docs/Repository_Guide.md`:仓库维护与发布规则
- 本仓库 `docs/V2_Plugin_Development.md`V2 插件开发主文档
- 前端仓库 `docs/module-federation-guide.md`Vue 联邦远程组件开发规范
## 10. 开始之前先读哪一份
- 想知道“这个仓库该怎么维护、改哪个文件、怎么发布”:看本文档
- 想直接开发一个 V2 插件:看 `docs/V2_Plugin_Development.md`
- 想做 Vue 远程组件或侧栏全页:看前端仓库模块联邦文档
- 想按功能场景抄现成模式:看 `docs/FAQ.md``docs/faq/` 下的独立 FAQ 文档

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
# 如何扩展消息推送渠道?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
- 注册 `NoticeMessage` 事件响应,`event_data` 包含消息中的所有数据,参考 `IYUU消息通知` 插件:
注册事件:
```python
@eventmanager.register(EventType.NoticeMessage)
```
- 事件对象:
```json
{
"channel": MessageChannel|None,
"type": NotificationType|None,
"title": str,
"text": str,
"image": str,
"userid": str|int,
}
```
- MoviePilot中所有事件清单V2版本可以通过实现这些事情来扩展功能同时插件之前也可以通过发送和监听事件实现联动V1、V2事件清单有差异且可能会变化最新请参考源代码
```python
# 异步广播事件
class EventType(Enum):
# 插件需要重载
PluginReload = "plugin.reload"
# 触发插件动作
PluginAction = "plugin.action"
# 插件触发事件
PluginTriggered = "plugin.triggered"
# 执行命令
CommandExcute = "command.excute"
# 站点已删除
SiteDeleted = "site.deleted"
# 站点已更新
SiteUpdated = "site.updated"
# 站点已刷新
SiteRefreshed = "site.refreshed"
# 转移完成
TransferComplete = "transfer.complete"
# 下载已添加
DownloadAdded = "download.added"
# 删除历史记录
HistoryDeleted = "history.deleted"
# 删除下载源文件
DownloadFileDeleted = "downloadfile.deleted"
# 删除下载任务
DownloadDeleted = "download.deleted"
# 收到用户外来消息
UserMessage = "user.message"
# 收到Webhook消息
WebhookMessage = "webhook.message"
# 发送消息通知
NoticeMessage = "notice.message"
# 订阅已添加
SubscribeAdded = "subscribe.added"
# 订阅已调整
SubscribeModified = "subscribe.modified"
# 订阅已删除
SubscribeDeleted = "subscribe.deleted"
# 订阅已完成
SubscribeComplete = "subscribe.complete"
# 系统错误
SystemError = "system.error"
# 刮削元数据
MetadataScrape = "metadata.scrape"
# 模块需要重载
ModuleReload = "module.reload"
# 同步链式事件
class ChainEventType(Enum):
# 名称识别
NameRecognize = "name.recognize"
# 认证验证
AuthVerification = "auth.verification"
# 认证拦截
AuthIntercept = "auth.intercept"
# 命令注册
CommandRegister = "command.register"
# 整理重命名
TransferRename = "transfer.rename"
# 整理拦截
TransferIntercept = "transfer.intercept"
# 资源选择
ResourceSelection = "resource.selection"
# 资源下载
ResourceDownload = "resource.download"
# 发现数据源
DiscoverSource = "discover.source"
# 媒体识别转换
MediaRecognizeConvert = "media.recognize.convert"
# 推荐数据源
RecommendSource = "recommend.source"
# 工作流执行
WorkflowExecution = "workflow.execution"
# 存储操作选择
StorageOperSelection = "storage.operation"
```

View File

@@ -0,0 +1,30 @@
# 如何在插件中实现远程命令响应?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
- 实现 `get_command()` 方法,按以下格式返回命令列表:
```json
[{
"cmd": "/douban_sync", // 动作ID必须以/开始
"event": EventType.PluginAction,// 事件类型,固定值
"desc": "命令名称",
"category": "命令菜单(微信)",
"data": {
"action": "douban_sync" // 动作标识
}
}]
```
- 注册 `PluginAction` 事件响应,根据 `event_data.action` 是否为插件设定的动作标识来判断是否为本插件事件:
注册事件:
```python
@eventmanager.register(EventType.PluginAction)
```
事件判定:
```python
event_data = event.event_data
if not event_data or event_data.get("action") != "douban_sync":
return
```

View File

@@ -0,0 +1,17 @@
# 如何在插件中对外暴露API
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
- 实现 `get_api()` 方法按以下格式返回API列表
```json
[{
"path": "/refresh_by_domain", // API路径必须以/开始
"endpoint": self.refresh_by_domain, // API响应方法
"methods": ["GET"], // 请求方式GET/POST/PUT/DELETE
"summary": "刷新站点数据", // API名称
"description": "刷新对应域名的站点数据", // API描述
}]
```
注意在插件中暴露API接口时注意安全控制推荐使用`settings.API_TOKEN`进行身份验证。
- 在对应的方法中实现API响应方法逻辑通过 `http://localhost:3001/docs` 查看API文档和调试

View File

@@ -0,0 +1,15 @@
# 如何在插件中注册公共定时服务?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
- 注册公共定时服务后,可以在`设定-服务`中查看运行状态和手动启动,更加便捷。
- 实现 `get_service()` 方法,按以下格式返回服务注册信息:
```json
[{
"id": "服务ID", // 不能与其它服务ID重复
"name": "服务名称", // 显示在服务列表中的名称
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx, // 服务方法
"kwargs": {} // 定时器参数参考APScheduler
}]
```

View File

@@ -0,0 +1,33 @@
# 如何通过插件增强MoviePilot的识别功能
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
- V1按如下步骤实现V2版本直接实现对应链式事件即可参考ChatGPT插件。注意只有主程序无法识别时才会触发。
- 注册 `NameRecognize` 事件,实现识别逻辑。
```python
@eventmanager.register(EventType.NameRecognize)
```
- 完成识别后发送 `NameRecognizeResult` 事件,将识别结果注入主程序
```python
eventmanager.send_event(
EventType.NameRecognizeResult,
{
'title': title, # 原传入标题
'name': str, # 识别的名称
'year': str, # 识别的年份
'season': int, # 识别的季号
'episode': int, # 识别的集号
}
)
```
- 注意识别请求需要在15秒内响应否则结果会被丢弃**插件未启用或参数不完整时应立即回复空结果事件,避免主程序等待;** 多个插件开启识别功能时,以先收到的识别结果事件为准。
```python
eventmanager.send_event(
EventType.NameRecognizeResult,
{
'title': title # 结果只含原标题,代表空识别结果事件
}
)
```

View File

@@ -0,0 +1,259 @@
# 如何扩展内建索引器的索引站点?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
- 通过调用 `SitesHelper().add_indexer(domain: str, indexer: dict)` 方法,新增或修改内建索引器的支持范围,其中`indexer`为站点配置Json格式示例如下
示例一:
```json
{
"id": "nyaa",
"name": "Nyaa",
"domain": "https://nyaa.si/",
"encoding": "UTF-8",
"public": true,
"proxy": true,
"result_num": 100,
"timeout": 30,
"search": {
"paths": [
{
"path": "?f=0&c=0_0&q={keyword}",
"method": "get"
}
]
},
"browse": {
"path": "?p={page}",
"start": 1
},
"torrents": {
"list": {
"selector": "table.torrent-list > tbody > tr"
},
"fields": {
"id": {
"selector": "a[href*=\"/view/\"]",
"attribute": "href",
"filters": [
{
"name": "re_search",
"args": [
"\\d+",
0
]
}
]
},
"title": {
"selector": "td:nth-child(2) > a"
},
"details": {
"selector": "td:nth-child(2) > a",
"attribute": "href"
},
"download": {
"selector": "td:nth-child(3) > a[href*=\"/download/\"]",
"attribute": "href"
},
"date_added": {
"selector": "td:nth-child(5)"
},
"size": {
"selector": "td:nth-child(4)"
},
"seeders": {
"selector": "td:nth-child(6)"
},
"leechers": {
"selector": "td:nth-child(7)"
},
"grabs": {
"selector": "td:nth-child(8)"
},
"downloadvolumefactor": {
"case": {
"*": 0
}
},
"uploadvolumefactor": {
"case": {
"*": 1
}
}
}
}
}
```
示例二:
```json
{
"id": "xxx",
"name": "站点名称",
"domain": "https://www.xxx.com/",
"ext_domains": [
"https://www.xxx1.com/",
"https://www.xxx2.com/"
],
"encoding": "UTF-8",
"public": false,
"search": {
"paths": [
{
"path": "torrents.php",
"method": "get"
}
],
"params": {
"search": "{keyword}",
"search_area": 4
},
"batch": {
"delimiter": " ",
"space_replace": "_"
}
},
"category": {
"movie": [
{
"id": 401,
"cat": "Movies",
"desc": "Movies电影"
},
{
"id": 405,
"cat": "Anime",
"desc": "Animations动漫"
},
{
"id": 404,
"cat": "Documentary",
"desc": "Documentaries纪录片"
}
],
"tv": [
{
"id": 402,
"cat": "TV",
"desc": "TV Series电视剧"
},
{
"id": 403,
"cat": "TV",
"desc": "TV Shows综艺"
},
{
"id": 404,
"cat": "Documentary",
"desc": "Documentaries纪录片"
},
{
"id": 405,
"cat": "Anime",
"desc": "Animations动漫"
}
]
},
"torrents": {
"list": {
"selector": "table.torrents > tr:has(\"table.torrentname\")"
},
"fields": {
"id": {
"selector": "a[href*=\"details.php?id=\"]",
"attribute": "href",
"filters": [
{
"name": "re_search",
"args": [
"\\d+",
0
]
}
]
},
"title_default": {
"selector": "a[href*=\"details.php?id=\"]"
},
"title_optional": {
"optional": true,
"selector": "a[title][href*=\"details.php?id=\"]",
"attribute": "title"
},
"title": {
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}"
},
"details": {
"selector": "a[href*=\"details.php?id=\"]",
"attribute": "href"
},
"download": {
"selector": "a[href*=\"download.php?id=\"]",
"attribute": "href"
},
"imdbid": {
"selector": "div.imdb_100 > a",
"attribute": "href",
"filters": [
{
"name": "re_search",
"args": [
"tt\\d+",
0
]
}
]
},
"date_elapsed": {
"selector": "td:nth-child(4) > span",
"optional": true
},
"date_added": {
"selector": "td:nth-child(4) > span",
"attribute": "title",
"optional": true
},
"size": {
"selector": "td:nth-child(5)"
},
"seeders": {
"selector": "td:nth-child(6)"
},
"leechers": {
"selector": "td:nth-child(7)"
},
"grabs": {
"selector": "td:nth-child(8)"
},
"downloadvolumefactor": {
"case": {
"img.pro_free": 0,
"img.pro_free2up": 0,
"img.pro_50pctdown": 0.5,
"img.pro_50pctdown2up": 0.5,
"img.pro_30pctdown": 0.3,
"*": 1
}
},
"uploadvolumefactor": {
"case": {
"img.pro_50pctdown2up": 2,
"img.pro_free2up": 2,
"img.pro_2up": 2,
"*": 1
}
},
"description": {
"selector": "td:nth-child(2) > table > tr > td.embedded > span[style]",
"contents": -1
},
"labels": {
"selector": "td:nth-child(2) > table > tr > td.embedded > span.tags"
}
}
}
}
```
- 需要注意的是,如果你没有完成用户认证,通过插件配置进去的索引站点也是无法正常使用的。
- **请不要添加对黄赌毒站点的支持,否则随时封闭接口。**

View File

@@ -0,0 +1,23 @@
# 如何在插件中调用API接口
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
**(仅支持 `v1.8.4+` 版本)**
- 在插件的数据页面支持`GET/POST`API接口调用可调用插件自身、主程序或其它插件的API。
-`get_page`中定义好元素的事件以及相应的API参数具体可参考插件`豆瓣想看`
```json
{
"component": "VDialogCloseBtn", // 触发事件的元素
"events": {
"click": { // 点击事件
"api": "plugin/DoubanSync/delete_history", // API的相对路径
"method": "get", // GET/POST
"params": {
// API上送参数
"doubanid": ""
}
}
}
}
```
- 每次API调用完成后均会自动刷新一次插件数据页。

View File

@@ -0,0 +1,47 @@
# 如何将插件内容显示到仪表板?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
**(仅支持 `v1.8.7+` 版本)**
- 将插件的内容显示到仪表盘,并支持定义占据的单元格大小,插件产生的仪表板仅管理员可见。
- 1. 根据插件需要展示的Widget内容规划展示内容的样式和规格也可设计多个规格样式并提供配置项供用户选择。
- 2. 实现 `get_dashboard_meta` 方法定义仪表板key及名称支持一个插件有多个仪表板
```python
def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]:
"""
获取插件仪表盘元信息
返回示例:
[{
"key": "dashboard1", // 仪表盘的key在当前插件范围唯一
"name": "仪表盘1" // 仪表盘的名称
}, {
"key": "dashboard2",
"name": "仪表盘2"
}]
"""
pass
```
- 3. 实现 `get_dashboard` 方法根据key返回仪表盘的详细配置信息包括仪表盘的cols列配置适配不同屏幕以及仪表盘的页面配置json具体可参考插件`站点数据统计`
```python
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
"""
获取插件仪表盘页面需要返回1、仪表板col配置字典2、全局配置自动刷新等3、仪表板页面元素配置json含数据
1、col配置参考
{
"cols": 12, "md": 6
}
2、全局配置参考
{
"refresh": 10, // 自动刷新时间,单位秒
"border": True, // 是否显示边框默认True为False时取消组件边框和边距由插件自行控制
"title": "组件标题", // 组件标题,如有将显示该标题,否则显示插件名称
"subtitle": "组件子标题", // 组件子标题,缺省时不展示子标题
}
3、页面配置使用Vuetify组件拼装参考https://vuetifyjs.com/
kwargs参数可获取的值1、user_agent浏览器UA
:param key: 仪表盘key根据指定的key返回相应的仪表盘数据缺省时返回一个固定的仪表盘数据兼容旧版
"""
pass
```

View File

@@ -0,0 +1,61 @@
# 如何扩展探索功能的媒体数据源?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
**(仅支持 `v2.2.7+` 版本)**
- 探索功能仅内置`TheMovieDb``豆瓣``Bangumi`数据源,可通过插件扩展探索功能的数据源范围,按以下方法开发插件(参考`TheTVDB探索`插件):
- 1. 实现`ChainEventType.DiscoverSource`链式事件响应,将额外的媒体数据源塞入事件数据`extra_sources`数组中(注意:如果事件中已经有其它数据源,需要叠加而不是替换,避免影响其它插件塞入的数据)
- `name`:数据源名称
- `mediaid_prefix`数据源的唯一ID
- `api_path`数据获取API相对路径需要在插件中实现API接口功能GET模式接收过滤参数注意page参数默认需要有返回`List[schemas.MediaInfo])`格式数据注意mediaid_prefix和media_id需要赋值用于唯一索引媒体详细信息和转换媒体数据
- `filter_params`数据源过滤参数名的字典相关参数会传入插件API的GET请求中
- `filter_ui`数据过滤选项的UI配置json与插件配置表单方式一致
- `depends`: UI依赖关系字典Dict[str, list],关过滤条件存在依赖关系时需要设置,以便上级条件变化时清空下级条件值
```python
class DiscoverMediaSource(BaseModel):
"""
探索媒体数据源的基类
"""
name: str = Field(..., description="数据源名称")
mediaid_prefix: str = Field(..., description="媒体ID的前缀不含:")
api_path: str = Field(..., description="媒体数据源API地址")
filter_params: Optional[Dict[str, Any]] = Field(default=None, description="过滤参数")
filter_ui: Optional[List[dict]] = Field(default=[], description="过滤参数UI配置")
class DiscoverSourceEventData(ChainEventData):
"""
DiscoverSource 事件的数据模型
Attributes:
# 输出参数
extra_sources (List[DiscoverMediaSource]): 额外媒体数据源
"""
# 输出参数
extra_sources: List[DiscoverMediaSource] = Field(default_factory=list, description="额外媒体数据源")
```
- 2. 实现`ChainEventType.MediaRecognizeConvert`链式事件响应(**可选**如不实现则默认按标题重新识别媒体信息根据媒体ID和转换类型返回TheMovieDb或豆瓣的媒体数据将转换后的数据注入事件数据`media_dict`中,可参考`app/chain/media.py`中的`get_tmdbinfo_by_bangumiid`
- `mediaid`媒体ID格式为`mediaid_prefix:media_id`,如 tmdb:12345、douban:1234567
- `convert_type`转换类型仅支持themoviedb/douban需要转换为对应的媒体数据并返回
- `media_dict`:转换后的媒体数据,格式为`TheMovieDb/豆瓣`的媒体数据
```python
class MediaRecognizeConvertEventData(ChainEventData):
"""
MediaRecognizeConvert 事件的数据模型
Attributes:
# 输入参数
mediaid (str): 媒体ID格式为`前缀:ID值`,如 tmdb:12345、douban:1234567
convert_type (str): 转换类型 仅支持themoviedb/douban需要转换为对应的媒体数据并返回
# 输出参数
media_dict (dict): TheMovieDb/豆瓣的媒体数据
"""
# 输入参数
mediaid: str = Field(..., description="媒体ID")
convert_type: str = Field(..., description="转换类型themoviedb/douban")
# 输出参数
media_dict: dict = Field(default=dict, description="转换后的媒体信息TheMovieDb/豆瓣)")
```
- 3. 启用插件后点击探索功能将自动生成额外的数据源标签及页面页面中选择不同的过滤条件时会重新触发API请求。

View File

@@ -0,0 +1,28 @@
# 如何扩展推荐功能的媒体数据源?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
**(仅支持 `v2.2.8+` 版本)**
- 实现`ChainEventType.RecommendSource`链式事件响应,将额外的媒体数据源塞入事件数据`extra_sources`数组中(注意:如果事件中已经有其它数据源,需要叠加而不是替换,避免影响其它插件塞入的数据)
- `name`:数据源名称
- `api_path`数据获取API相对路径需要在插件中实现API接口功能GET模式接收过滤参数注意page参数默认需要有返回`List[schemas.MediaInfo])`格式数据,参考`app/api/endpoints/recommend.py` 中的 `tmdb_trending`
```python
class RecommendMediaSource(BaseModel):
"""
推荐媒体数据源的基类
"""
name: str = Field(..., description="数据源名称")
api_path: str = Field(..., description="媒体数据源API地址")
class RecommendSourceEventData(ChainEventData):
"""
RecommendSource 事件的数据模型
Attributes:
# 输出参数
extra_sources (List[RecommendMediaSource]): 额外媒体数据源
"""
# 输出参数
extra_sources: List[RecommendMediaSource] = Field(default_factory=list, description="额外媒体数据源")
```

View File

@@ -0,0 +1,19 @@
# 如何通过插件重载实现系统模块功能?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
**(仅支持 `v2.4.4+` 版本)**
- MoviePilot中通过`chain`层实现业务逻辑,在`modules`中实现各自独立的功能模块。`chain`处理链通过查找`modules`中实现了所需方法(比如: post_message的所有模块并按一定的规则执行从而编排各模块能力来实现复杂的业务功能。v2.4.4+版本中赋于插件胁持系统模块的能力,可以通过插件来重新实现系统所有内置模块的功能,比如支持新的下载器、媒体服务器等(在用户界面中配合新增自定义下载器和媒体服务器)。
- 1. 在插件中实现`get_module`方法,申明插件要重载的模块方法。所有可用的模块方法名参考`chain`目录下的处理链文件run_module方法的第一个参数公共处理在`chain/__init__.py`中,方法入参和出参需要保持一致。
```python
def get_module(self) -> Dict[str, Any]:
"""
获取插件模块声明,用于胁持系统模块实现(方法名:方法实现)
{
"id1": self.xxx1,
"id2": self.xxx2,
}
"""
pass
```
- 2. 在插件中实现声名的方法逻辑,处理链执行时,会优先处理插件声明的方法。如果插件方法未实现或者返回`None`,将继续执行下一个插件或者系统模块的相同声明方法;如果对应的方法需要返回是的列表对象,则会执行所有插件和系统模块的方法后将结果组合返回。

View File

@@ -0,0 +1,319 @@
# 如何通过插件扩展支持的存储类型?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
**(仅支持 `v2.4.4+` 版本)**
- 1. 用户在系统设定存储中新增自定义存储,并设定一个自定义类型和名称,该类型与插件绑定,用于插件判断使用。或者在插件启动时直接注册自定义存储。
```python
# 检查是否有xxx网盘选项如没有则自动添加
storage_helper = StorageHelper()
storages = StorageHelper().get_storagies()
if not any(s.type == "xxx" for s in storages):
# 添加存储配置
storage_helper.add_storage("xxx", name="xxx网盘", conf={})
```
- 2. 在插件的存储操作类中,实现以下对应的文件操作(具体可参考:`app/modules/filemanager/storages/__init__.py`),不支持的可跳过
```python
class XxxApi:
def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:
"""
浏览文件
"""
pass
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
"""
创建目录
:param fileitem: 父目录
:param name: 目录名
"""
pass
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
"""
获取目录,如目录不存在则创建
"""
pass
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
"""
获取文件或目录不存在返回None
"""
pass
def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
"""
获取父目录
"""
return self.get_item(Path(fileitem.path).parent)
def delete(self, fileitem: schemas.FileItem) -> bool:
"""
删除文件
"""
pass
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
"""
重命名文件
"""
pass
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Path:
"""
下载文件,保存到本地,返回本地临时文件地址
:param fileitem: 文件项
:param path: 文件保存路径
"""
pass
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
"""
上传文件
:param fileitem: 上传目录项
:param path: 本地文件路径
:param new_name: 上传后文件名
"""
pass
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
"""
获取文件详情
"""
pass
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
复制文件
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
pass
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
移动文件
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
pass
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
"""
硬链接文件
"""
pass
def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
"""
软链接文件
"""
pass
def usage(self) -> Optional[schemas.StorageUsage]:
"""
存储使用情况
"""
pass
```
- 3. 实现 `ChainEventType.StorageOperSelection`链式事件响应,根据传入的存储对象名称判断是否为该插件支持的存储,如是则返回存储操作对象
```python
@eventmanager.register(ChainEventType.StorageOperSelection)
def storage_oper_selection(self, event: Event):
"""
监听存储选择事件,返回当前类为操作对象
"""
if not self._enabled:
return
event_data: StorageOperSelectionEventData = event.event_data
if event_data.storage == "xxx":
event_data.storage_oper = self.api # api为插件的存储操作对象
```
- 4. 参考 [《如何通过插件重载实现系统模块功能?》](./11-override-system-module.md) 实现 `get_module`,在插件中声明和实现以下模块方法(具体可参考:`app/modules/filemanager/__init__.py`),其实就是对上一步的方法再做一下封装:
```python
def get_module(self) -> Dict[str, Any]:
"""
获取插件模块声明,用于胁持系统模块实现(方法名:方法实现)
{
"id1": self.xxx1,
"id2": self.xxx2,
}
"""
return {
"list_files": self.list_files,
"any_files": self.any_files,
"download_file": self.download_file,
"upload_file": self.upload_file,
"delete_file": self.delete_file,
"rename_file": self.rename_file,
"get_file_item": self.get_file_item,
"get_parent_item": self.get_parent_item,
"snapshot_storage": self.snapshot_storage,
"storage_usage": self.storage_usage,
"support_transtype": self.support_transtype
}
def list_files(self, fileitem: schemas.FileItem, recursion: bool = False) -> Optional[List[schemas.FileItem]]:
"""
查询当前目录下所有目录和文件
"""
if fileitem.storage != "xxx":
return None
def __get_files(_item: FileItem, _r: Optional[bool] = False):
"""
递归处理
"""
_items = self.api.list(_item)
if _items:
if _r:
for t in _items:
if t.type == "dir":
__get_files(t, _r)
else:
result.append(t)
else:
result.extend(_items)
# 返回结果
result = []
__get_files(fileitem, recursion)
return result
def any_files(self, fileitem: schemas.FileItem, extensions: list = None) -> Optional[bool]:
"""
查询当前目录下是否存在指定扩展名任意文件
"""
if fileitem.storage != "xxx":
return None
def __any_file(_item: FileItem):
"""
递归处理
"""
_items = self.api.list(_item)
if _items:
if not extensions:
return True
for t in _items:
if (t.type == "file"
and t.extension
and f".{t.extension.lower()}" in extensions):
return True
elif t.type == "dir":
if __any_file(t):
return True
return False
# 返回结果
return __any_file(fileitem)
def download_file(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
"""
下载文件
:param fileitem: 文件项
:param path: 本地保存路径
"""
if fileitem.storage != "xxx":
return None
return self.api.download(fileitem, path)
def upload_file(self, fileitem: schemas.FileItem, path: Path,
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
"""
上传文件
:param fileitem: 保存目录项
:param path: 本地文件路径
:param new_name: 新文件名
"""
if fileitem.storage != "xxx":
return None
return self.api.upload(fileitem, path, new_name)
def delete_file(self, fileitem: schemas.FileItem) -> Optional[bool]:
"""
删除文件或目录
"""
if fileitem.storage != "xxx":
return None
return self.api.delete(fileitem)
def rename_file(self, fileitem: schemas.FileItem, name: str) -> Optional[bool]:
"""
重命名文件或目录
"""
if fileitem.storage != "xxx":
return None
return self.api.rename(fileitem, name)
def get_file_item(self, storage: str, path: Path) -> Optional[schemas.FileItem]:
"""
根据路径获取文件项
"""
if storage != "xxx":
return None
return self.api.get_item(path)
def get_parent_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
"""
获取上级目录项
"""
if fileitem.storage != "xxx":
return None
return self.api.get_parent(fileitem)
def snapshot_storage(self, storage: str, path: Path) -> Optional[Dict[str, float]]:
"""
快照存储
"""
if storage != "xxx":
return None
files_info = {}
def __snapshot_file(_fileitm: schemas.FileItem):
"""
递归获取文件信息
"""
if _fileitm.type == "dir":
for sub_file in self.api.list(_fileitm):
__snapshot_file(sub_file)
else:
files_info[_fileitm.path] = _fileitm.size
fileitem = self.api.get_item(path)
if not fileitem:
return {}
__snapshot_file(fileitem)
return files_info
def storage_usage(self, storage: str) -> Optional[schemas.StorageUsage]:
"""
存储使用情况
"""
return self.api.usage()
@staticmethod
def support_transtype(storage: str) -> Optional[dict]:
"""
获取支持的整理方式
"""
return {
"move": "移动",
"copy": "复制"
}
```

View File

@@ -0,0 +1,24 @@
# 如何将插件功能集成到工作流?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
**(仅支持 v2.4.8+ 版本)**
- 插件实现以下接口,声明插件支持的动作实现
```python
def get_actions(self) -> List[Dict[str, Any]]:
"""
获取插件工作流动作
[{
"id": "动作ID",
"name": "动作名称",
"func": self.xxx,
"kwargs": {} # 需要附加传递的参数
}]
对实现函数的要求:
1、函数的第一个参数固定为 ActionContent 实例如需要传递额外参数在kwargs中定义
2、函数的返回执行状态 True / False更新后的 ActionContent 实例
"""
pass
```
- 编辑工作流流程,添加`调用插件`组件,选择该插件的对应动作,将插件的功能串接到工作流程中

View File

@@ -0,0 +1,162 @@
# 如何在插件中通过消息持续与用户交互?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
**(仅支持 v2.5.7+ 版本)**
- 插件可以通过实现命令响应和消息按钮回调实现与用户的持续交互对话支持多轮对话和菜单式操作适用于支持按钮回调的通知渠道如Telegram、Slack等
- 1. 实现远程命令响应,参考 [《如何在插件中实现远程命令响应?》](./02-remote-command-handler.md) 实现 `get_command()` 方法和 `PluginAction` 事件响应:
```python
def get_command(self) -> List[Dict[str, Any]]:
"""
注册插件远程命令
"""
return [{
"cmd": "/interactive_demo",
"event": EventType.PluginAction,
"desc": "交互演示",
"category": "插件交互",
"data": {
"action": "interactive_demo"
}
}]
@eventmanager.register(EventType.PluginAction)
def command_action(self, event: Event):
"""
远程命令响应
"""
event_data = event.event_data
if not event_data or event_data.get("action") != "interactive_demo":
return
# 获取用户信息
channel = event_data.get("channel")
source = event_data.get("source")
user = event_data.get("user")
# 发送带有交互按钮的消息
self._send_main_menu(channel, source, user)
```
- 2. 注册 `MessageAction` 事件响应,处理用户的按钮回调:
```python
@eventmanager.register(EventType.MessageAction)
def message_action(self, event: Event):
"""
处理消息按钮回调
"""
event_data = event.event_data
if not event_data:
return
# 检查是否为本插件的回调
plugin_id = event_data.get("plugin_id")
if plugin_id != self.__class__.__name__:
return
# 获取回调数据
text = event_data.get("text", "")
channel = event_data.get("channel")
source = event_data.get("source")
userid = event_data.get("userid")
# 获取原始消息ID和聊天ID用于直接更新原消息
original_message_id = event_data.get("original_message_id")
original_chat_id = event_data.get("original_chat_id")
# 根据回调内容处理不同的交互
if text == "menu1":
self._handle_menu1(channel, source, userid, original_message_id, original_chat_id)
elif text == "menu2":
self._handle_menu2(channel, source, userid, original_message_id, original_chat_id)
elif text == "back":
self._send_main_menu(channel, source, userid, original_message_id, original_chat_id)
elif text.startswith("action_"):
action_id = text.replace("action_", "")
self._handle_action(action_id, channel, source, userid, original_message_id, original_chat_id)
```
- 3. 实现具体的交互处理方法,在消息中使用 `[PLUGIN]插件ID|内容` 格式的按钮:
```python
def _send_main_menu(self, channel, source, userid, original_message_id=None, original_chat_id=None):
"""
发送主菜单
"""
buttons = [
[
{"text": "🎬 媒体管理", "callback_data": f"[PLUGIN]{self.__class__.__name__}|menu1"},
{"text": "⚙️ 系统设置", "callback_data": f"[PLUGIN]{self.__class__.__name__}|menu2"}
],
[
{"text": "📊 查看状态", "callback_data": f"[PLUGIN]{self.__class__.__name__}|status"}
]
]
self.post_message(
channel=channel,
title="🤖 插件交互演示",
text="请选择要执行的操作:",
userid=userid,
buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id
)
def _handle_menu1(self, channel, source, userid, original_message_id, original_chat_id):
"""
处理媒体管理菜单
"""
buttons = [
[
{"text": "🔍 搜索媒体", "callback_data": f"[PLUGIN]{self.__class__.__name__}|action_search"},
{"text": "📥 下载管理", "callback_data": f"[PLUGIN]{self.__class__.__name__}|action_download"}
],
[
{"text": "🔙 返回主菜单", "callback_data": f"[PLUGIN]{self.__class__.__name__}|back"}
]
]
self.post_message(
channel=channel,
title="🎬 媒体管理",
text="选择媒体管理功能:",
userid=userid,
buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id
)
def _handle_action(self, action_id, channel, source, userid, original_message_id, original_chat_id):
"""
处理具体动作
"""
if action_id == "search":
# 执行搜索逻辑
result = "搜索功能已执行"
elif action_id == "download":
# 执行下载逻辑
result = "下载管理已开启"
else:
result = "未知操作"
# 发送执行结果并提供返回按钮
buttons = [
[{"text": "🔙 返回主菜单", "callback_data": f"[PLUGIN]{self.__class__.__name__}|back"}]
]
self.post_message(
channel=channel,
title="✅ 操作完成",
text=result,
userid=userid,
buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id
)
```
- 注意事项:
- 回调按钮的 `callback_data` 必须使用 `[PLUGIN]插件ID|内容` 格式其中插件ID为插件类名
- 只有支持按钮回调的通知渠道如Telegram、Slack才能使用此功能
- 建议在交互中保存用户状态数据,以支持复杂的多步骤操作
- 可以结合插件数据存储功能保存用户的交互历史和偏好设置

View File

@@ -0,0 +1,186 @@
# 如何在插件中使用系统级统一缓存?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
**(仅支持 `v2.7.4+` 版本)**
- MoviePilot提供了统一的缓存系统支持内存缓存、文件系统缓存和Redis缓存自动管理当有Redis时优先使用Redis否则使用内存或文件系统。插件可以通过系统提供的缓存接口实现高效的缓存管理无需关心系统设置。
- 1. 使用缓存装饰器:
```python
from app.core.cache import cached
class MyPlugin(_PluginBase):
@cached(region="my_plugin", ttl=3600)
def get_data(self, key: str):
"""
使用缓存装饰器缓存结果1小时
"""
# 复杂的计算或网络请求
return expensive_operation(key)
@cached(region="my_plugin_async", ttl=1800, skip_none=True)
async def get_async_data(self, key: str):
"""
异步函数缓存跳过None值
"""
return await async_expensive_operation(key)
```
- 2. 使用TTLCache类
```python
from app.core.cache import TTLCache
class MyPlugin(_PluginBase):
def __init__(self):
super().__init__()
# 创建缓存实例最大128项TTL 30分钟
self.cache = TTLCache(region="my_plugin", maxsize=128, ttl=1800)
def process_data(self, key: str):
# 检查缓存
if key in self.cache:
return self.cache[key]
# 计算并缓存结果
result = expensive_operation(key)
self.cache[key] = result
return result
def clear_cache(self):
"""
清理插件缓存
"""
self.cache.clear()
```
- 3. 使用文件缓存后端(适用于大文件缓存):
```python
from app.core.cache import FileCache, AsyncFileCache
from pathlib import Path
class MyPlugin(_PluginBase):
def __init__(self):
super().__init__()
# 获取文件缓存后端支持Redis和文件系统
self.file_cache = FileCache(
base=Path("/tmp/my_plugin_cache"),
ttl=86400 # 24小时
)
def cache_large_file(self, key: str, data: bytes):
"""
缓存大文件数据
"""
self.file_cache.set(key, data, region="large_files")
def get_cached_file(self, key: str) -> Optional[bytes]:
"""
获取缓存的文件数据
"""
return self.file_cache.get(key, region="large_files")
async def async_cache_operations(self):
"""
异步文件缓存操作
"""
async_cache = AsyncFileCache(
base=Path("/tmp/my_plugin_async_cache"),
ttl=3600
)
# 异步设置缓存
await async_cache.set("async_key", b"async_data", region="async_files")
# 异步获取缓存
data = await async_cache.get("async_key", region="async_files")
await async_cache.close()
```
- 4. 直接使用缓存后端(高级用法):
```python
from app.core.cache import Cache
class MyPlugin(_PluginBase):
def __init__(self):
super().__init__()
# 直接获取缓存后端实例系统自动选择Redis或内存缓存
self.cache_backend = Cache(maxsize=256, ttl=3600)
def custom_cache_operation(self, key: str, value: Any):
"""
自定义缓存操作
"""
# 设置缓存
self.cache_backend.set(key, value, region="custom_region")
# 检查缓存是否存在
if self.cache_backend.exists(key, region="custom_region"):
# 获取缓存
cached_value = self.cache_backend.get(key, region="custom_region")
return cached_value
return None
def iterate_cache_items(self):
"""
遍历缓存项
"""
for key, value in self.cache_backend.items(region="custom_region"):
print(f"缓存键: {key}, 值: {value}")
def cleanup(self):
"""
清理缓存
"""
self.cache_backend.clear(region="custom_region")
self.cache_backend.close()
```
- 5. 缓存装饰器参数说明:
```python
@cached(
region="my_plugin", # 缓存区域,用于隔离不同插件的缓存
maxsize=512, # 最大缓存条目数(仅内存缓存有效)
ttl=1800, # 缓存存活时间(秒)
skip_none=True, # 是否跳过None值缓存
skip_empty=False # 是否跳过空值缓存(空列表、空字典等)
)
def my_function(self, param):
pass
```
- 6. 缓存管理功能:
```python
class MyPlugin(_PluginBase):
@cached(region="my_plugin")
def cached_function(self, param):
return expensive_operation(param)
def clear_my_cache(self):
"""
清理指定区域的缓存
"""
self.cached_function.cache_clear()
def get_cache_info(self):
"""
获取缓存信息
"""
cache_region = self.cached_function.cache_region
return f"缓存区域: {cache_region}"
```
- 7. 缓存后端自动选择:
- 系统会根据配置自动选择缓存后端:
- `CACHE_BACKEND_TYPE=redis`使用Redis作为缓存后端
- `CACHE_BACKEND_TYPE=memory`使用内存缓存cachetools
- 插件代码无需修改,系统会自动处理缓存后端的切换
- 8. 最佳实践:
- 为每个插件使用独立的缓存区域region避免缓存键冲突
- 合理设置TTL避免缓存过期时间过长导致数据过期
- 对于频繁访问的数据使用较长的TTL对于实时性要求高的数据使用较短的TTL
- 使用`skip_none=True`避免缓存无意义的None值
- 大文件或二进制数据建议使用文件缓存后端
- 在插件卸载时清理相关缓存,避免内存泄漏

View File

@@ -0,0 +1,103 @@
# 如何在插件中注册智能体工具?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
**(仅支持 `v2.8.0+` 版本)**
- MoviePilot的AI智能体功能支持通过插件扩展工具能力插件可以注册自定义工具供智能体调用实现更丰富的功能扩展。
- 1. 实现 `get_agent_tools()` 方法,返回工具类列表:
```python
def get_agent_tools(self) -> List[Type]:
"""
获取插件智能体工具
返回工具类列表,每个工具类必须继承自 MoviePilotTool
"""
return [MyCustomTool, AnotherTool]
```
- 2. 创建工具类,必须继承自 `MoviePilotTool` 并实现相关要求:
```python
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.chain import ToolChain
class MyToolInput(BaseModel):
"""工具输入参数模型"""
explanation: str = Field(..., description="工具使用说明")
query: str = Field(..., description="查询内容")
limit: Optional[int] = Field(10, description="返回结果数量限制")
class MyCustomTool(MoviePilotTool):
"""自定义工具示例"""
# 工具名称,用于智能体识别和调用
name: str = "my_custom_tool"
# 工具描述,用于智能体理解工具功能,建议详细描述工具用途和使用场景
description: str = "This tool is used to perform custom operations. Use it when you need to query or process specific data."
# 输入参数模型,定义工具接收的参数及其类型和说明
args_schema: Type[BaseModel] = MyToolInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据订阅参数生成友好的提示消息"""
pass
async def run(self, query: str, limit: Optional[int] = None, **kwargs) -> str:
"""
实现工具的核心逻辑(异步方法)
:param query: 查询内容
:param limit: 结果数量限制
:param kwargs: 其他参数,包含 explanation工具使用说明
:return: 工具执行结果,返回字符串格式
"""
try:
# 获取上下文信息(系统自动注入)
session_id = self._session_id
user_id = self._user_id
channel = self._channel
source = self._source
username = self._username
# 执行工具逻辑
result = await self._perform_operation(query, limit)
# 可以通过 send_tool_message 发送消息给用户
await self.send_tool_message(f"操作完成: {result}", title="工具执行")
# 返回执行结果
return f"成功处理查询 '{query}',返回 {len(result)} 条结果"
except Exception as e:
return f"执行失败: {str(e)}"
async def _perform_operation(self, query: str, limit: int):
"""内部方法,执行具体操作"""
# 实现具体业务逻辑
pass
```
- 3. 工具类可用的上下文属性和方法:
- `self._session_id`: 当前会话ID
- `self._user_id`: 用户ID
- `self._channel`: 消息渠道(如 Telegram、Slack 等)
- `self._source`: 消息来源
- `self._username`: 用户名
- `self.send_tool_message(message: str, title: str = "")`: 发送消息给用户
- `ToolChain()`: 访问处理链功能,可调用系统其他功能
- 4. 工具类实现要求:
- **必须继承自 `app.agent.tools.base.MoviePilotTool`**
- **必须实现 `run` 方法**(异步方法),接收参数并返回字符串结果
- **必须实现 `get_tool_message` 方法**,以显示友好的工具执行提示给用户
- **必须定义 `name` 属性**(字符串),工具的唯一标识
- **必须定义 `description` 属性**(字符串),详细描述工具功能,帮助智能体理解何时使用该工具
- **可选定义 `args_schema` 属性**Pydantic模型类用于定义输入参数的结构和验证
- 5. 注意事项:
- 工具的描述(`description`)应该清晰明确,帮助智能体理解工具的功能和使用场景
- 工具的参数模型(`args_schema`)应该包含详细的字段描述,帮助智能体正确构造参数
- 工具执行结果应该返回有意义的字符串,便于智能体理解和向用户展示
- 工具可以通过 `send_tool_message` 方法向用户发送实时消息,提升交互体验
- 工具类在初始化时会自动注入会话和用户信息,可以通过私有属性访问
- 如果工具需要访问插件实例,需要自行通过 `PluginManager` 获取
- 工具执行时间应该尽量短,避免阻塞智能体的响应
- 建议在工具执行过程中添加适当的错误处理和日志记录

BIN
icons/AliDnsDDNS.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -26,7 +26,7 @@
"name": "AI字幕自动生成(v2)",
"description": "使用whisper自动生成视频文件字幕,使用大模型翻译字幕成中文。",
"labels": "字幕",
"version": "2.3",
"version": "2.5.1",
"icon": "autosubtitles.jpeg",
"author": "TimoYoung",
"level": 1,
@@ -38,7 +38,9 @@
"v2.0": "1.引入任务队列 2.支持监听媒体入库自动生成字幕 3.增加任务状态展示界面",
"v2.1": "支持清除历史记录",
"v2.2": "fix",
"v2.3": "支持独立的大模型调用配置"
"v2.3": "支持独立的大模型调用配置",
"v2.5": "适配openai api v1",
"v2.5.1": "更新依赖"
}
},
"CustomSites": {
@@ -174,11 +176,12 @@
"name": "媒体文件同步删除",
"description": "同步删除历史记录、源文件和下载任务。",
"labels": "文件整理",
"version": "1.7.1",
"version": "1.7.2",
"icon": "mediasyncdel.png",
"author": "thsrite",
"level": 1,
"history": {
"v1.7.2": "兼容windows路径",
"v1.7.1": "修复删除剧集辅种失败报错问题",
"v1.7": "修复重新整理被一并删除问题",
"v1.6": "修复删除辅种",
@@ -189,12 +192,13 @@
"name": "自定义Hosts",
"description": "修改系统hosts文件加速网络访问。",
"labels": "网络",
"version": "1.2",
"version": "1.2.1",
"icon": "hosts.png",
"author": "thsrite",
"level": 1,
"v2": true,
"history": {
"v1.2.1": "更新依赖",
"v1.2": "支持写入注释",
"v1.1": "关闭插件时自动恢复系统hosts"
}
@@ -217,12 +221,14 @@
"name": "Cloudflare IP优选",
"description": "🌩 测试 Cloudflare CDN 延迟和速度自动优选IP。",
"labels": "网络,站点",
"version": "1.4",
"version": "1.5.1",
"icon": "cloudflare.jpg",
"author": "thsrite",
"level": 1,
"v2": true,
"history": {
"v1.5.1": "更新依赖",
"v1.5": "适配CloudflareSpeedTest新版名称",
"v1.4": "修复立即运行一次",
"v1.3": "调整插件开启状态判断条件",
"v1.2": "增强API安全性"
@@ -319,11 +325,12 @@
"name": "IYUU自动辅种",
"description": "基于IYUU官方Api实现自动辅种。",
"labels": "做种,IYUU",
"version": "1.9.11",
"version": "1.9.12",
"icon": "IYUU.png",
"author": "jxxghp",
"level": 2,
"history": {
"v1.9.12": "修复海豹不能辅种的问题",
"v1.9.11": "修复馒头不能辅种的问题",
"v1.9.10": "Revert 辅种结束后,一起开始所有辅种后暂停的种子(排除了出错的种子)",
"v1.9.9": "修复qb辅种结束后自动开始暂停的种子",
@@ -463,12 +470,17 @@
"name": "药丸签到",
"description": "药丸论坛签到。",
"labels": "站点",
"version": "1.4.1",
"version": "2.0.3",
"icon": "invites.png",
"author": "thsrite",
"level": 2,
"v2": true,
"release": true,
"history": {
"v2.0.3": "增加启用浏览器仿真功能发送请求",
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
"v2.0.1": "尝试修复签到失败问题新增使用代理、Cookie自动更新功能",
"v2.0.0": "修复签到失败问题新增账户登录签到功能、新增签到失败重试机制美化界面UI",
"v1.4.1": "更新签到域名前缀",
"v1.4": "自定义保留消息天数"
}
@@ -477,11 +489,12 @@
"name": "演职人员刮削",
"description": "刮削演职人员图片以及中文名称。",
"labels": "媒体库,刮削",
"version": "1.4",
"version": "1.4.1",
"icon": "actor.png",
"author": "jxxghp",
"level": 1,
"history": {
"v1.4.1": "修复异常报错问题",
"v1.4": "人物图片调整为优先从TMDB获取避免douban图片CDN加载过慢的问题",
"v1.3": "修复v1.8.5版本后刮削报错问题"
}
@@ -490,11 +503,13 @@
"name": "MoviePilot更新推送",
"description": "MoviePilot推送release更新通知、自动重启。",
"labels": "消息通知,自动更新",
"version": "1.4",
"version": "1.5.1",
"icon": "Moviepilot_A.png",
"author": "thsrite",
"level": 1,
"history": {
"v1.5.1": "修复版本号比较逻辑",
"v1.5": "修复版本描述为空时的报错",
"v1.4": "兼容更新内容带版本号的情况",
"v1.3": "增加前端版本更新检查需要主程序升级至v1.8.4+版本"
}
@@ -560,12 +575,13 @@
"name": "TMDB剧集组刮削",
"description": "从TMDB剧集组刮削季集的实际顺序。",
"labels": "刮削",
"version": "2.6",
"version": "2.6.1",
"icon": "Element_A.png",
"author": "叮叮当",
"level": 1,
"v2": true,
"history": {
"v2.6.1": "修复异常报错日志",
"v2.6": "修复无法获取媒体库中季0的问题",
"v2.5": "修复当媒体服务器中剧集的季不完整时会中断的问题",
"v2.3": "修复v2版本无法读取媒体库的问题",
@@ -667,12 +683,13 @@
"name": "共享识别词",
"description": "从Github、Etherpad等远程文件中获取共享识别词并应用。",
"labels": "识别",
"version": "2.3",
"version": "2.4",
"icon": "words.png",
"author": "honue",
"level": 1,
"v2": true,
"history": {
"v2.4": "支持 JSON 格式远程识别词集合订阅",
"v2.3": "更换默认共享识别词地址"
}
},
@@ -801,13 +818,15 @@
"name": "ntfy消息推送",
"description": "支持使用ntfy发送消息通知。",
"labels": "消息通知",
"version": "1.1",
"version": "1.3",
"icon": "Ntfy_A.png",
"author": "lethargicScribe",
"level": 1,
"v2": true,
"history": {
"v1.1": "添加Token认证和用户动作"
"v1.1": "添加Token认证和用户动作",
"v1.2": "修复 ntfy 通知图标链接失效的问题",
"v1.3": "修复标题或文本为空时,通知发送失败的问题"
}
},
"GotifyMsg": {
@@ -838,7 +857,6 @@
"icon": "Macos_Sierra.png",
"author": "jxxghp",
"level": 1,
"v2": true,
"history": {
"v1.4.1": "修复Bing壁纸命名问题",
"v1.3": "适配MoviePilot v2.5.3+版本",
@@ -943,11 +961,14 @@
"name": "钉钉机器人",
"description": "支持使用钉钉机器人发送消息通知。",
"labels": "消息通知,钉钉机器人",
"version": "1.12",
"version": "1.13",
"icon": "Dingding_A.png",
"author": "nnlegenda",
"level": 1,
"v2": true
"v2": true,
"history": {
"v1.13": "优化钉钉消息换行"
}
},
"DynamicWeChat": {
"name": "动态企微可信IP",
@@ -1040,5 +1061,17 @@
"author": "cddjr",
"level": 1,
"v2": true
},
"AliDnsDDNS": {
"name": "阿里云 DDNS",
"description": "定时检测公网 IP自动更新阿里云 DNS 解析记录,支持泛域名(* 记录)及 IPv6AAAA。",
"labels": "网络",
"version": "1.0",
"icon": "AliDnsDDNS.png",
"author": "dtzsghnr",
"level": 1,
"history": {
"v1.0": "初始版本,支持 IPv4/IPv6、泛域名、多记录配置、更新历史详情页"
}
}
}
}

View File

@@ -24,11 +24,13 @@
"name": "站点刷流",
"description": "自动托管刷流,将会提高对应站点的访问频率。",
"labels": "刷流,仪表板",
"version": "4.3.3",
"version": "4.3.5",
"icon": "brush.jpg",
"author": "jxxghp,InfinityPacer",
"author": "jxxghp,InfinityPacer,Seed680",
"level": 2,
"history": {
"v4.3.5": "提升匹配规则时的健壮性",
"v4.3.4": "添加RSS支持配置选项",
"v4.3.2": "增加'删除促销结束的未完成下载'功能",
"v4.3.1": "修复了一些细节问题",
"v4.3": "支持带宽采样并计算平均值,以优化刷流效率",
@@ -42,12 +44,16 @@
"name": "站点自动签到",
"description": "自动模拟登录、签到站点。",
"labels": "站点",
"version": "2.6",
"version": "2.8.2",
"icon": "signin.png",
"author": "thsrite",
"level": 2,
"release": true,
"history": {
"v2.8.2": "优化站点 Rousi Pro 签到失败提示信息",
"v2.8.1": "更新站点 Rousi Pro 签到接口",
"v2.8": "适配站点 Rousi Pro",
"v2.7": "站点请求使用站点设置的超时时间",
"v2.6": "感谢madrays佬提供的UI!",
"v2.5.4": "增加保号风险提示",
"v2.5.3": "优化执行周期输入需要MoviePilot v2.2.1+",
@@ -60,11 +66,15 @@
"name": "下载任务分类与标签",
"description": "自动给下载任务分类与打站点标签、剧集名称标签",
"labels": "下载管理",
"version": "2.2",
"version": "2.6",
"icon": "Youtube-dl_B.png",
"author": "叮叮当",
"level": 1,
"history": {
"v2.6": "增加站点/剧名前缀功能",
"v2.5": "优化采用公共服务自动清理未使用标签",
"v2.4": "增加自动清理未使用标签",
"v2.3": "增加tracker映射配置",
"v2.2": "MoviePilot V2 版本下载任务分类与标签插件"
}
},
@@ -87,11 +97,17 @@
"name": "媒体库服务器通知",
"description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。",
"labels": "消息通知,媒体库",
"version": "1.6",
"version": "1.8.2.2",
"icon": "mediaplay.png",
"author": "jxxghp",
"level": 1,
"history": {
"v1.8.2.2": "修复emby多条相同新入库消息推送多次的问题",
"v1.8.2.1": "修复多集时有概率图片获取失败的问题修复emby测试通知类型接收失败的问题",
"v1.8.1": "修复单集剧情信息有概率获取失败的问题",
"v1.8": "当整理路径中没有tmdbid时会尝试从媒体服务器中获取",
"v1.7.1": "未获取到tmdb信息则按原有逻辑发送电影显示海报",
"v1.7": "对TV剧集入库事件进行聚合避免消息轰炸。更新后如果打不开插件请重置插件",
"v1.6": "查询剧集图片兼容没有季集信息的情况",
"v1.5": "支持独立控制媒体服务器通知",
"v1.4": "MoviePilot V2 版本媒体库服务器通知插件"
@@ -101,11 +117,13 @@
"name": "ChatGPT",
"description": "消息交互支持与ChatGPT对话。",
"labels": "消息通知,识别",
"version": "2.1.7",
"version": "2.1.9",
"icon": "Chatgpt_A.png",
"author": "jxxghp",
"level": 1,
"history": {
"v2.1.9": "更新依赖库",
"v2.1.8": "修复 OpenAI API >=1.0.0 兼容性问题",
"v2.1.7":"独立安装OpenAi SDK依赖",
"v2.1.6": "支持自定义辅助识别提示词",
"v2.1.5": "兼容一些模型返回json数据信息用markdown语法包裹的情况",
@@ -122,11 +140,12 @@
"name": "自动转移做种",
"description": "定期转移下载器中的做种任务到另一个下载器。",
"labels": "做种",
"version": "1.10.2",
"version": "1.10.3",
"icon": "seed.png",
"author": "jxxghp",
"level": 2,
"history": {
"v1.10.3": "更新依赖库",
"v1.10.2": "增加保留原标签和原分类的选项",
"v1.10.1": "优化“立即运行一次”按钮位置",
"v1.10": "支持跳过校验(仅支持 qBittorrent",
@@ -182,11 +201,13 @@
"name": "演职人员刮削",
"description": "刮削演职人员图片以及中文名称。",
"labels": "媒体库,刮削",
"version": "2.2",
"version": "2.2.2",
"icon": "actor.png",
"author": "jxxghp",
"level": 1,
"history": {
"v2.2.2": "修复异常日志问题",
"v2.2.1": "优化错误数据兼容处理",
"v2.2": "修改使用自定义图片域名时无法下载图片的问题",
"v2.1": "优化执行周期输入需要MoviePilot v2.2.1+",
"v2.0": "兼容MoviePilot V2 版本",
@@ -240,11 +261,12 @@
"name": "IYUU自动辅种",
"description": "基于IYUU官方Api实现自动辅种。",
"labels": "做种,IYUU",
"version": "2.14",
"version": "2.15",
"icon": "IYUU.png",
"author": "jxxghp,CKun",
"level": 2,
"history": {
"v2.15": "修复海豹不能辅种的问题",
"v2.14": "修复馒头不能辅种的问题",
"v2.13": "开启跳过校验后需手动开启自动开始",
"v2.12": "增加qb下载器分类复用配置",
@@ -266,11 +288,12 @@
"name": "青蛙辅种助手",
"description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种支持站点青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。",
"labels": "做种",
"version": "3.0.1",
"version": "3.0.2",
"icon": "qingwa.png",
"author": "233@qingwa",
"level": 2,
"history": {
"v3.0.2": "更新依赖库",
"v3.0.1": "遗漏了一个私有属性",
"v3.0": "兼容MoviePilot V2 版本"
}
@@ -350,15 +373,29 @@
"v2.0": "适配新的目录结构变化,短剧分类名称调整为配置目录路径,升级后需要重新调整设置后才能使用。"
}
},
"MultiClass": {
"name": "视频多级分类",
"description": "支持视频多级分类",
"labels": "文件整理",
"version": "0.1",
"icon": "Calibreweb_B.png",
"author": "liuhangbin",
"level": 1,
"history": {
"v0.1": "视频多级分类插件, 目前仅支持电影按评分,年代,系列分类。"
}
},
"MoviePilotUpdateNotify": {
"name": "MoviePilot更新推送",
"description": "MoviePilot推送release更新通知、自动重启。",
"labels": "消息通知,自动更新",
"version": "2.2",
"version": "2.3.1",
"icon": "Moviepilot_A.png",
"author": "thsrite",
"level": 1,
"history": {
"v2.3.1": "修复版本号比较逻辑",
"v2.3": "修复版本描述为空时的报错",
"v2.2": "支持 MoviePilot v2.5.0+",
"v2.1": "优化执行周期输入需要MoviePilot v2.2.1+",
"v2.0": "兼容MoviePilot V2"
@@ -419,11 +456,16 @@
"name": "绕过Trackers",
"description": "提供tracker服务器IP地址列表帮助IPv6连接绕过OpenClash。",
"labels": "工具",
"version": "1.4.2",
"version": "1.5.3",
"icon": "Clash_A.png",
"author": "wumode",
"level": 2,
"history": {
"v1.5.3": "修复 Rousi 种子获取问题",
"v1.5.2": "支持从站点首页获取最新 Trackers",
"v1.5.1": "新增 Tracker",
"v1.5.0": "新增 Page 界面; 支持通过`/check_ip` 命令检查IP; 改进 UI",
"v1.4.3": "修复 bug",
"v1.4.2": "修复插件动作",
"v1.4.1": "修复通知类型错误",
"v1.4": "异步查询DNS",
@@ -437,11 +479,20 @@
"name": "IMDb源",
"description": "让探索推荐和媒体识别支持IMDb数据源。",
"labels": "探索",
"version": "1.5.6",
"version": "1.6.7",
"icon": "IMDb_IOS-OSX_App.png",
"author": "wumode",
"level": 1,
"history": {
"v1.6.7": "优化界面显示; 增加榜单排名显示; 添加制作公司过滤项",
"v1.6.6": "优化主页组件链接跳转",
"v1.6.5": "仪表盘组件支持图片缓存",
"v1.6.4": "为元数据增加背景图",
"v1.6.3": "优化媒体识别速度; 适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
"v1.6.2": "修复 API 查询错误重试问题",
"v1.6.1": "添加中文主屏幕组件; 修复 bug",
"v1.5.8": "修改UA",
"v1.5.7": "改进异常处理",
"v1.5.6": "固定仪表盘组件海报比例; 修复 bug",
"v1.5.5": "修复初始化错误",
"v1.5.4": "改进媒体识别",
@@ -453,7 +504,7 @@
"v1.4.3": "为仪表盘组件添加缓存",
"v1.4.2": "优化小屏幕组件显示",
"v1.4.1": "优化亮色主题显示",
"v1.4.0":"添加仪表盘组件: IMDb 编辑精选",
"v1.4.0": "添加仪表盘组件: IMDb 编辑精选",
"v1.3.3": "修复依赖问题",
"v1.3.2": "更新 API query hash",
"v1.3.1": "修复按日期排序错误",
@@ -467,12 +518,31 @@
"name": "Clash Rule Provider",
"description": "随时为Clash添加一些额外的规则。",
"labels": "工具",
"version": "1.3.2",
"version": "2.1.5",
"icon": "Mihomo_Meta_A.png",
"author": "wumode",
"level": 1,
"release": true,
"history": {
"v2.1.5": "优化仪表盘连接鉴权;优化订阅更新提示",
"v2.1.4": "支持 xhttp 协议",
"v2.1.3": "修复代理删除问题",
"v2.1.2": "修复规则集序列化错误",
"v2.1.1": "增强数据管理功能",
"v2.0.10": "适配 MoviePilot 2.8.4",
"v2.0.9": "适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
"v2.0.8": "修复已知问题",
"v2.0.7": "修复子规则比较错误",
"v2.0.6": "修复已知问题; 改进对代理组的配置和验证",
"v2.0.5": "完善了对嵌套逻辑规则和子规则的配置和验证",
"v2.0.4": "修复已知问题; 使用异步调度器; 显示规则更改日期",
"v2.0.3": "修复已知问题",
"v2.0.2": "修复分享链接转换问题",
"v2.0.1": "支持独立的订阅链接配置, 覆写代理组和出站代理; 优化数据结构; 修复分享链接解析问题",
"v1.4.2": "优化移动端 UI; 支持显示节点链接",
"v1.4.1": "修复配置模板保存错误, 请重新配置Clash模板",
"v1.4.0": "优化 UI; 支持连接多个 Clash Dashboards",
"v1.3.3": "通过emoji识别国家; 按国家分组节点; mrs格式支持",
"v1.3.2": "注册插件动作",
"v1.3.1": "支持配置 Hosts",
"v1.2.8": "改进导入界面",
@@ -495,11 +565,21 @@
"name": "美剧生词标注",
"description": "根据CEFR等级为英语影视剧标注高级词汇。",
"labels": "英语",
"version": "1.0.1",
"version": "1.2.5",
"icon": "LexiAnnot.png",
"author": "wumode",
"level": 1,
"history": {
"v1.2.5": "langchain 1.x 兼容 (主程序版本需高于 2.9.17)",
"v1.2.4": "增强数据校验",
"v1.2.3": "优化提示词",
"v1.2.1": "改进字幕样式获取方法",
"v1.2.0": "引入大模型候选词决策和词义丰富处理链; 支持读取系统智能体配置; 添加智能体工具; 优化通知样式; 改进 UI",
"v1.1.4": "优化字幕选择决策",
"v1.1.3": "适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
"v1.1.2": "使用子进程避免 spaCy 模型常驻内存",
"v1.1.1": "添加任务页面; 改进 spaCy 模型加载逻辑",
"v1.1.0": "支持考试词汇标注; 优化分词处理; 修复错误",
"v1.0.1": "合并连字符词; 避免ARM平台依赖问题",
"v1.0": "新增LexiAnnot"
}
@@ -516,5 +596,71 @@
"v1.0.0": "首个版本新增MeoW消息通知",
"v1.0.1": "优化代码,修复运行一次按钮没办法自动关闭的问题"
}
},
"BugReporter": {
"name": "Bug反馈",
"description": "自动上报异常,协助开发者发现和解决问题。",
"labels": "开发",
"version": "1.3",
"icon": "Alist_encrypt_A.png",
"author": "jxxghp",
"level": 1,
"history": {
"v1.3": "减少网络异常信息上送",
"v1.2": "优化上报信息量",
"v1.1": "加强脱敏处理"
}
},
"TmdbWallpaper": {
"name": "登录壁纸本地化",
"description": "将MoviePilot的登录壁纸下载到本地。",
"labels": "壁纸,本地化",
"version": "1.4.2",
"icon": "Macos_Sierra.png",
"author": "jxxghp",
"level": 1,
"history": {
"v1.4.2": "适配MoviePilot v2.8.8+",
"v1.4.1": "MoviePilot V2 版本登录壁纸本地化插件"
}
},
"DailySummary": {
"name": "活动总结",
"description": "定时发送每日/每周/每月活动总结通知,支持自定义报告模块、历史记录查看",
"labels": "通知",
"version": "2.0.0",
"icon": "Bark_A.png",
"author": "yuhoye",
"level": 1,
"history": {
"v2.0.0": "首次发布:可配置报告模块、历史记录页面、下载器通用化、存储路径可配置"
}
},
"TvFirstWatch": {
"name": "首播试看",
"description": "定时抓取RSS,只下载电视剧前N集,自动跳过合集和过大文件。",
"labels": "订阅,RSS",
"version": "1.0.0",
"icon": "rss.png",
"author": "Raymond38324",
"level": 2,
"history": {
"v1.0.0": "首次发布"
}
},
"WechatClawBot": {
"name": "WechatClawBot消息推送",
"description": "支持使用微信(通过ClawBot)发送消息通知。",
"labels": "消息通知,微信",
"version": "0.2.1",
"icon": "Wechat_A.png",
"author": "mijjjj",
"level": 1,
"v2": true,
"history": {
"v0.2.1": "修复详情页状态信息换行显示问题",
"v0.2.0": "优化配置页UI布局修复回复消息携带多余类型前缀的问题",
"v0.1.0": "初始版本"
}
}
}

View File

@@ -7,10 +7,6 @@ from typing import Any, List, Dict, Tuple, Optional
from urllib.parse import urljoin
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from ruamel.yaml import CommentedMap
from app import schemas
from app.core.config import settings
from app.core.event import eventmanager, Event
@@ -26,6 +22,9 @@ from app.utils.http import RequestUtils
from app.utils.site import SiteUtils
from app.utils.string import StringUtils
from app.utils.timer import TimerUtils
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from ruamel.yaml import CommentedMap
class AutoSignIn(_PluginBase):
@@ -36,7 +35,7 @@ class AutoSignIn(_PluginBase):
# 插件图标
plugin_icon = "signin.png"
# 插件版本
plugin_version = "2.6"
plugin_version = "2.8.2"
# 插件作者
plugin_author = "thsrite"
# 作者主页
@@ -1545,6 +1544,7 @@ class AutoSignIn(_PluginBase):
render = site_info.get("render")
proxies = settings.PROXY if site_info.get("proxy") else None
proxy_server = settings.PROXY_SERVER if site_info.get("proxy") else None
timeout = site_info.get("timeout") or 60
if not site_url or not site_cookie:
logger.warn(f"未配置 {site} 的站点地址或Cookie无法签到")
return False, ""
@@ -1560,7 +1560,8 @@ class AutoSignIn(_PluginBase):
page_source = PlaywrightHelper().get_page_source(url=checkin_url,
cookies=site_cookie,
ua=ua,
proxies=proxy_server)
proxies=proxy_server,
timeout=timeout)
if not SiteUtils.is_logged_in(page_source):
if under_challenge(page_source):
return False, f"无法通过Cloudflare"
@@ -1574,13 +1575,15 @@ class AutoSignIn(_PluginBase):
else:
res = RequestUtils(cookies=site_cookie,
ua=ua,
proxies=proxies
proxies=proxies,
timeout=timeout
).get_res(url=checkin_url)
if not res and site_url != checkin_url:
logger.info(f"开始站点模拟登录:{site},地址:{site_url}...")
res = RequestUtils(cookies=site_cookie,
ua=ua,
proxies=proxies
proxies=proxies,
timeout=timeout
).get_res(url=site_url)
# 判断登录状态
if res and res.status_code in [200, 500, 403]:
@@ -1647,6 +1650,7 @@ class AutoSignIn(_PluginBase):
render = site_info.get("render")
proxies = settings.PROXY if site_info.get("proxy") else None
proxy_server = settings.PROXY_SERVER if site_info.get("proxy") else None
timeout = site_info.get("timeout") or 60
if not site_url or not site_cookie:
logger.warn(f"未配置 {site} 的站点地址或Cookie无法签到")
return False, ""
@@ -1659,7 +1663,8 @@ class AutoSignIn(_PluginBase):
page_source = PlaywrightHelper().get_page_source(url=site_url,
cookies=site_cookie,
ua=ua,
proxies=proxy_server)
proxies=proxy_server,
timeout=timeout)
if not SiteUtils.is_logged_in(page_source):
if under_challenge(page_source):
return False, f"无法通过Cloudflare"
@@ -1669,7 +1674,8 @@ class AutoSignIn(_PluginBase):
else:
res = RequestUtils(cookies=site_cookie,
ua=ua,
proxies=proxies
proxies=proxies,
timeout=timeout
).get_res(url=site_url)
# 判断登录状态
if res and res.status_code in [200, 500, 403]:

View File

@@ -2,13 +2,12 @@ import random
import re
from typing import Tuple
from lxml import etree
from app.core.config import settings
from app.log import logger
from app.plugins.autosignin.sites import _ISiteSigninHandler
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
from lxml import etree
class Pt52(_ISiteSigninHandler):
@@ -46,14 +45,16 @@ class Pt52(_ISiteSigninHandler):
ua = site_info.get("ua")
render = site_info.get("render")
proxy = site_info.get("proxy")
timeout = site_info.get("timeout")
# 判断今日是否已签到
html_text = self.get_page_source(url='https://52pt.site/bakatest.php',
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,请检查站点连通性")
return False, '签到失败,请检查站点连通性'
@@ -97,14 +98,16 @@ class Pt52(_ISiteSigninHandler):
site_cookie=site_cookie,
ua=ua,
proxy=proxy,
site=site)
site=site,
timeout=timeout)
def __signin(self, questionid: str,
choice: list,
site: str,
site_cookie: str,
ua: str,
proxy: bool) -> Tuple[bool, str]:
proxy: bool,
timeout: int) -> Tuple[bool, str]:
"""
签到请求
questionid: 450
@@ -124,7 +127,8 @@ class Pt52(_ISiteSigninHandler):
sign_res = RequestUtils(cookies=site_cookie,
ua=ua,
proxies=settings.PROXY if proxy else None
proxies=settings.PROXY if proxy else None,
timeout=timeout
).post_res(url='https://52pt.site/bakatest.php', data=data)
if not sign_res or sign_res.status_code != 200:
logger.error(f"{site} 签到失败,签到接口请求失败")

View File

@@ -42,7 +42,8 @@ class _ISiteSigninHandler(metaclass=ABCMeta):
pass
@staticmethod
def get_page_source(url: str, cookie: str, ua: str, proxy: bool, render: bool, token: str = None) -> str:
def get_page_source(url: str, cookie: str, ua: str, proxy: bool, render: bool,
token: str = None, timeout: int = None) -> str:
"""
获取页面源码
:param url: Url地址
@@ -51,13 +52,15 @@ class _ISiteSigninHandler(metaclass=ABCMeta):
:param proxy: 是否使用代理
:param render: 是否渲染
:param token: JWT Token
:param timeout: 请求超时时间,单位秒
:return: 页面源码,错误信息
"""
if render:
return PlaywrightHelper().get_page_source(url=url,
cookies=cookie,
ua=ua,
proxies=settings.PROXY_SERVER if proxy else None)
proxies=settings.PROXY_SERVER if proxy else None,
timeout=timeout or 60)
else:
if token:
headers = {
@@ -70,7 +73,8 @@ class _ISiteSigninHandler(metaclass=ABCMeta):
"Cookie": cookie
}
res = RequestUtils(headers=headers,
proxies=settings.PROXY if proxy else None).get_res(url=url)
proxies=settings.PROXY if proxy else None,
timeout=timeout or 20).get_res(url=url)
if res is not None:
# 使用chardet检测字符编码
raw_data = res.content

View File

@@ -37,6 +37,7 @@ class BTSchool(_ISiteSigninHandler):
ua = site_info.get("ua")
render = site_info.get("render")
proxy = site_info.get("proxy")
timeout = site_info.get("timeout")
logger.info(f"{site} 开始签到")
# 判断今日是否已签到
@@ -44,7 +45,8 @@ class BTSchool(_ISiteSigninHandler):
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,请检查站点连通性")
@@ -63,7 +65,8 @@ class BTSchool(_ISiteSigninHandler):
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,签到接口请求失败")

View File

@@ -47,13 +47,15 @@ class CHDBits(_ISiteSigninHandler):
ua = site_info.get("ua")
proxy = site_info.get("proxy")
render = site_info.get("render")
timeout = site_info.get("timeout")
# 判断今日是否已签到
html_text = self.get_page_source(url='https://ptchdbits.co/bakatest.php',
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,请检查站点连通性")

View File

@@ -37,21 +37,24 @@ class HaiDan(_ISiteSigninHandler):
ua = site_info.get("ua")
proxy = site_info.get("proxy")
render = site_info.get("render")
timeout = site_info.get("timeout")
# 签到
# 签到页会重定向到index.php由于302重定向特性导致index.php没有携带cookie
self.get_page_source(url='https://www.haidan.video/signin.php',
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render,
timeout=timeout)
# 重新携带cookie获取index.php查看签到结果
html_text = self.get_page_source(url='https://www.haidan.video/index.php',
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,请检查站点连通性")
return False, '签到失败,请检查站点连通性'

View File

@@ -40,13 +40,15 @@ class Hares(_ISiteSigninHandler):
ua = site_info.get("ua")
proxy = site_info.get("proxy")
render = site_info.get("render")
timeout = site_info.get("timeout")
# 获取页面html
html_text = self.get_page_source(url='https://club.hares.top',
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 模拟访问失败,请检查站点连通性")
@@ -66,7 +68,8 @@ class Hares(_ISiteSigninHandler):
}
sign_res = RequestUtils(cookies=site_cookie,
headers=headers,
proxies=settings.PROXY if proxy else None
proxies=settings.PROXY if proxy else None,
timeout=timeout
).get_res(url="https://club.hares.top/attendance.php?action=sign")
if not sign_res or sign_res.status_code != 200:
logger.error(f"{site} 签到失败,签到接口请求失败")

View File

@@ -40,6 +40,7 @@ class HDArea(_ISiteSigninHandler):
site_cookie = site_info.get("cookie")
ua = site_info.get("ua")
proxies = settings.PROXY if site_info.get("proxy") else None
timeout = site_info.get("timeout")
# 获取页面html
data = {
@@ -47,7 +48,8 @@ class HDArea(_ISiteSigninHandler):
}
html_res = RequestUtils(cookies=site_cookie,
ua=ua,
proxies=proxies
proxies=proxies,
timeout=timeout
).post_res(url="https://hdarea.club/sign_in.php", data=data)
if not html_res or html_res.status_code != 200:
logger.error(f"{site} 签到失败,请检查站点连通性")

View File

@@ -40,6 +40,7 @@ class HDChina(_ISiteSigninHandler):
site_cookie = site_info.get("cookie")
ua = site_info.get("ua")
proxies = settings.PROXY if site_info.get("proxy") else None
timeout = site_info.get("timeout")
# 尝试解决瓷器cookie每天签到后过期,只保留hdchina=部分
cookie = ""
@@ -59,7 +60,8 @@ class HDChina(_ISiteSigninHandler):
# 获取页面html
html_res = RequestUtils(cookies=site_cookie,
ua=ua,
proxies=proxies
proxies=proxies,
timeout=timeout
).get_res(url="https://hdchina.org/index.php")
if not html_res or html_res.status_code != 200:
logger.error(f"{site} 签到失败,请检查站点连通性")
@@ -99,7 +101,8 @@ class HDChina(_ISiteSigninHandler):
}
sign_res = RequestUtils(cookies=site_cookie,
ua=ua,
proxies=proxies
proxies=proxies,
timeout=timeout
).post_res(url="https://hdchina.org/plugin_sign-in.php?cmd=signin", data=data)
if not sign_res or sign_res.status_code != 200:
logger.error(f"{site} 签到失败,签到接口请求失败")

View File

@@ -39,13 +39,15 @@ class HDCity(_ISiteSigninHandler):
ua = site_info.get("ua")
proxy = site_info.get("proxy")
render = site_info.get("render")
timeout = site_info.get("timeout")
# 获取页面html
html_text = self.get_page_source(url='https://hdcity.city/sign',
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,请检查站点连通性")
return False, '签到失败,请检查站点连通性'

View File

@@ -43,13 +43,15 @@ class HDSky(_ISiteSigninHandler):
proxy = site_info.get("proxy")
render = site_info.get("render")
referer = site_info.get("url")
timeout = site_info.get("timeout")
# 判断今日是否已签到
html_text = self.get_page_source(url='https://hdsky.me',
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,请检查站点连通性")
return False, '签到失败,请检查站点连通性'
@@ -73,7 +75,8 @@ class HDSky(_ISiteSigninHandler):
content_type='application/x-www-form-urlencoded; charset=UTF-8',
referer="https://hdsky.me/index.php",
accept_type="*/*",
proxies=settings.PROXY if proxy else None
proxies=settings.PROXY if proxy else None,
timeout=timeout
).post_res(url='https://hdsky.me/image_code_ajax.php',
data={'action': 'new'})
if image_res and image_res.status_code == 200:

View File

@@ -41,13 +41,15 @@ class HDUpt(_ISiteSigninHandler):
ua = site_info.get("ua")
proxy = site_info.get("proxy")
render = site_info.get("render")
timeout = site_info.get("timeout")
# 获取页面html
html_text = self.get_page_source(url='https://pt.hdupt.com',
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,请检查站点连通性")
return False, '签到失败,请检查站点连通性'
@@ -67,7 +69,8 @@ class HDUpt(_ISiteSigninHandler):
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,请检查站点连通性")
return False, '签到失败,请检查站点连通性'

View File

@@ -1,5 +1,4 @@
from typing import Tuple
from urllib.parse import urljoin
from ruamel.yaml import CommentedMap
@@ -38,10 +37,11 @@ class MTorrent(_ISiteSigninHandler):
"Authorization": site_info.get("token")
}
url = site_info.get('url')
timeout = site_info.get("timeout")
domain = StringUtils.get_url_domain(url)
# 更新最后访问时间
res = RequestUtils(headers=headers,
timeout=60,
timeout=timeout,
proxies=settings.PROXY if site_info.get("proxy") else None,
referer=f"{url}index"
).post_res(url=f"https://api.{domain}/api/member/updateLastBrowse")

View File

@@ -40,6 +40,7 @@ class NexusHD(_ISiteSigninHandler):
site_cookie = site_info.get("cookie")
ua = site_info.get("ua")
proxies = settings.PROXY if site_info.get("proxy") else None
timeout = site_info.get("timeout")
# 获取页面html
data = {
@@ -48,7 +49,8 @@ class NexusHD(_ISiteSigninHandler):
}
html_res = RequestUtils(cookies=site_cookie,
ua=ua,
proxies=proxies
proxies=proxies,
timeout=timeout
).post_res(url="https://v6.nexushd.org/signin.php", data=data)
if not html_res or html_res.status_code != 200:
logger.error(f"{site} 签到失败,请检查站点连通性")

View File

@@ -43,13 +43,15 @@ class Opencd(_ISiteSigninHandler):
ua = site_info.get("ua")
proxy = site_info.get("proxy")
render = site_info.get("render")
timeout = site_info.get("timeout")
# 判断今日是否已签到
html_text = self.get_page_source(url='https://www.open.cd',
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,请检查站点连通性")
return False, '签到失败,请检查站点连通性'

View File

@@ -35,13 +35,15 @@ class PTerClub(_ISiteSigninHandler):
ua = site_info.get("ua")
proxy = site_info.get("proxy")
render = site_info.get("render")
timeout = site_info.get("timeout")
# 签到
html_text = self.get_page_source(url='https://pterclub.com/attendance-ajax.php',
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,请检查站点连通性")
return False, '签到失败,请检查站点连通性'

View File

@@ -37,6 +37,7 @@ class PTTime(_ISiteSigninHandler):
ua = site_info.get("ua")
proxy = site_info.get("proxy")
render = site_info.get("render")
timeout = site_info.get("timeout")
# 签到
# 签到返回:<html><head></head><body>签到成功</body></html>
@@ -44,7 +45,8 @@ class PTTime(_ISiteSigninHandler):
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,请检查站点连通性")

View File

@@ -0,0 +1,114 @@
from typing import Tuple
from ruamel.yaml import CommentedMap
from app.log import logger
from app.core.config import settings
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
from app.plugins.autosignin.sites import _ISiteSigninHandler
class RousiPro(_ISiteSigninHandler):
"""
rousi pro 签到
"""
# 匹配的站点Url每一个实现类都需要设置为自己的站点Url
site_url = "rousi.pro"
@classmethod
def match(cls, url: str) -> bool:
"""
根据站点Url判断是否匹配当前站点签到类大部分情况使用默认实现即可
:param url: 站点Url
:return: 是否匹配如匹配则会调用该类的signin方法
"""
return True if StringUtils.url_equal(url, cls.site_url) else False
def signin(self, site_info: CommentedMap) -> Tuple[bool, str]:
"""
执行签到操作,固定签到
:param site_info: 站点信息含有站点Url、站点Cookie、UA等信息
:return: 签到结果信息
"""
site = site_info.get("name")
ua = site_info.get("ua")
token = site_info.get("token")
timeout = site_info.get("timeout")
if not token or token.strip() == "":
logger.error(f"{site} 签到失败,缺少 Authorization 信息")
return False, "签到失败,缺少 Authorization 信息"
headers = {
"Content-Type": "application/json",
"User-Agent": ua,
"Accept": "application/json, text/plain, */*",
"Authorization": token if token.startswith("Bearer ") else f"Bearer {token}"
}
body = {
"mode": "fixed"
}
res = RequestUtils(
headers=headers,
timeout=timeout,
proxies=settings.PROXY if site_info.get("proxy") else None,
).post_res(
url="https://rousi.pro/api/points/attendance",
json=body
)
if res is not None and res.status_code == 200 and res.json().get("code", -1) == 0:
logger.info(f"{site} 签到成功")
return True, "签到成功"
elif res is not None and res.status_code == 400 and res.json().get("code", -1) == 1:
logger.info(f"{site} 今日已签到")
return True, "今日已签到"
elif res is not None and res.status_code == 401:
logger.error(f"{site} 签到失败Authorization 已失效")
return False, "签到失败Authorization 已失效"
elif res is not None:
logger.error(f"{site} 签到失败,状态码:{res.status_code}")
return False, f"签到失败,状态码:{res.status_code}"
else:
logger.error(f"{site} 签到失败,无法访问网站")
return False, "签到失败,无法访问网站"
def login(self, site_info: CommentedMap) -> Tuple[bool, str]:
"""
执行登录操作,访问签到统计接口更新站点最后活跃时间
:param site_info: 站点信息含有站点Url、站点Cookie、UA等信息
:return: 登录结果信息
"""
site = site_info.get("name")
ua = site_info.get("ua")
token = site_info.get("token")
timeout = site_info.get("timeout")
if not token or token.strip() == "":
logger.error(f"{site} 模拟登录失败,缺少 Authorization 信息")
return False, "模拟登录失败,缺少 Authorization 信息"
headers = {
"User-Agent": ua,
"Accept": "application/json, text/plain, */*",
"Authorization": token if token.startswith("Bearer ") else f"Bearer {token}"
}
res = RequestUtils(
headers=headers,
timeout=timeout,
proxies=settings.PROXY if site_info.get("proxy") else None,
).get_res(
url="https://rousi.pro/api/points/attendance/stats"
)
if res is not None and res.status_code == 200 and res.json().get("code", -1) == 0:
logger.info(f"{site} 模拟登录成功")
return True, "模拟登录成功"
elif res is not None and res.status_code == 401:
logger.error(f"{site} 模拟登录失败Authorization 已失效")
return False, "模拟登录失败Authorization 已失效"
elif res is not None:
logger.error(f"{site} 模拟登录失败,状态码:{res.status_code}")
return False, f"模拟登录失败,状态码:{res.status_code}"
else:
logger.error(f"{site} 模拟登录失败,无法访问网站")
return False, "模拟登录失败,无法访问网站"

View File

@@ -57,6 +57,7 @@ class Tjupt(_ISiteSigninHandler):
ua = site_info.get("ua")
proxy = site_info.get("proxy")
render = site_info.get("render")
timeout = site_info.get("timeout")
# 创建正确答案存储目录
if not os.path.exists(os.path.dirname(self._answer_file)):
@@ -67,7 +68,8 @@ class Tjupt(_ISiteSigninHandler):
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
# 获取签到后返回html判断是否签到成功
if not html_text:

View File

@@ -44,13 +44,15 @@ class TTG(_ISiteSigninHandler):
ua = site_info.get("ua")
proxy = site_info.get("proxy")
render = site_info.get("render")
timeout = site_info.get("timeout")
# 获取页面html
html_text = self.get_page_source(url="https://totheglory.im",
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,请检查站点连通性")
return False, '签到失败,请检查站点连通性'

View File

@@ -50,6 +50,7 @@ class U2(_ISiteSigninHandler):
ua = site_info.get("ua")
proxy = site_info.get("proxy")
render = site_info.get("render")
timeout = site_info.get("timeout")
now = datetime.datetime.now()
# 判断当前时间是否小于9点
@@ -62,7 +63,8 @@ class U2(_ISiteSigninHandler):
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 签到失败,请检查站点连通性")
return False, '签到失败,请检查站点连通性'

View File

@@ -37,7 +37,7 @@ class YemaPT(_ISiteSigninHandler):
}
# 获取用户信息,更新最后访问时间
res = (RequestUtils(headers=headers,
timeout=15,
timeout=site_info.get("timeout"),
cookies=site_info.get("cookie"),
proxies=settings.PROXY if site_info.get("proxy") else None,
referer=site_info.get('url')
@@ -64,7 +64,7 @@ class YemaPT(_ISiteSigninHandler):
}
# 获取用户信息,更新最后访问时间
res = (RequestUtils(headers=headers,
timeout=15,
timeout=site_info.get("timeout"),
cookies=site_info.get("cookie"),
proxies=settings.PROXY if site_info.get("proxy") else None,
referer=site_info.get('url')

View File

@@ -38,13 +38,15 @@ class ZhuQue(_ISiteSigninHandler):
ua = site_info.get("ua")
proxy = site_info.get("proxy")
render = site_info.get("render")
timeout = site_info.get("timeout")
# 获取页面html
html_text = self.get_page_source(url="https://zhuque.in",
cookie=site_cookie,
ua=ua,
proxy=proxy,
render=render)
render=render,
timeout=timeout)
if not html_text:
logger.error(f"{site} 模拟登录失败,请检查站点连通性")
return False, '模拟登录失败,请检查站点连通性'
@@ -73,7 +75,8 @@ class ZhuQue(_ISiteSigninHandler):
}
skill_res = RequestUtils(cookies=site_cookie,
headers=headers,
proxies=settings.PROXY if proxy else None
proxies=settings.PROXY if proxy else None,
timeout=timeout
).post_res(url="https://zhuque.in/api/gaming/fireGenshinCharacterMagic", json=data)
if not skill_res or skill_res.status_code != 200:
logger.error(f"模拟登录失败,释放技能失败")

View File

@@ -79,6 +79,7 @@ class BrushConfig:
self.qb_category = config.get("qb_category")
self.site_hr_active = config.get("site_hr_active", False)
self.site_skip_tips = config.get("site_skip_tips", False)
self.rss_support = config.get("rss_support", False)
self.brush_tag = "刷流"
# 站点独立配置
@@ -123,7 +124,8 @@ class BrushConfig:
"qb_category",
"site_hr_active",
"site_skip_tips",
"del_no_free"
"del_no_free",
"rss_support"
# 当新增支持字段时,仅在此处添加字段名
}
try:
@@ -193,7 +195,8 @@ class BrushConfig:
"del_no_free": false,
"qb_category": "刷流",
"site_hr_active": true,
"site_skip_tips": true
"site_skip_tips": true,
"rss_support": true
}]"""
return desc + config
@@ -259,9 +262,9 @@ class BrushFlow(_PluginBase):
# 插件图标
plugin_icon = "brush.jpg"
# 插件版本
plugin_version = "4.3.3"
plugin_version = "4.3.5"
# 插件作者
plugin_author = "jxxghp,InfinityPacer"
plugin_author = "jxxghp,InfinityPacer,Seed680"
# 作者主页
author_url = "https://github.com/InfinityPacer"
# 插件配置项ID前缀
@@ -1638,6 +1641,22 @@ class BrushFlow(_PluginBase):
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'rss_support',
'label': '启用RSS支持',
}
}
]
}
]
}
@@ -1817,7 +1836,8 @@ class BrushFlow(_PluginBase):
"freeleech": "free",
"hr": "yes",
"enable_site_config": False,
"site_config": BrushConfig.get_demo_site_config()
"site_config": BrushConfig.get_demo_site_config(),
"rss_support": False
}
def get_page(self) -> List[dict]:
@@ -2002,7 +2022,14 @@ class BrushFlow(_PluginBase):
return True
logger.info(f"开始获取站点 {siteinfo.name} 的新种子 ...")
torrents = TorrentsChain().browse(domain=siteinfo.domain)
# 根据rss_support配置决定使用browse还是rss方法获取种子
brush_config = self.__get_brush_config(sitename=siteinfo.name)
if brush_config.rss_support:
torrents = TorrentsChain().rss(domain=siteinfo.domain)
else:
torrents = TorrentsChain().browse(domain=siteinfo.domain)
if not torrents:
logger.info(f"站点 {siteinfo.name} 没有获取到种子")
return True
@@ -2219,16 +2246,34 @@ class BrushFlow(_PluginBase):
return False, "存在H&R"
# 包含规则
if brush_config.include and not (
re.search(brush_config.include, torrent.title, re.I) or re.search(brush_config.include,
torrent.description, re.I)):
return False, "不符合包含规则"
if brush_config.include:
try:
include_match = False
if torrent.title and re.search(brush_config.include, torrent.title, re.I):
include_match = True
elif torrent.description and re.search(brush_config.include, torrent.description, re.I):
include_match = True
if not include_match:
return False, "不符合包含规则"
except re.error:
logger.warning(f"包含规则正则表达式错误: {brush_config.include}")
return False, "包含规则正则表达式错误"
# 排除规则
if brush_config.exclude and (
re.search(brush_config.exclude, torrent.title, re.I) or re.search(brush_config.exclude,
torrent.description, re.I)):
return False, "符合排除规则"
if brush_config.exclude:
try:
exclude_match = False
if torrent.title and re.search(brush_config.exclude, torrent.title, re.I):
exclude_match = True
elif torrent.description and re.search(brush_config.exclude, torrent.description, re.I):
exclude_match = True
if exclude_match:
return False, "符合排除规则"
except re.error:
logger.warning(f"排除规则正则表达式错误: {brush_config.exclude}")
return False, "排除规则正则表达式错误"
# 种子大小GB
if brush_config.size:
@@ -3048,6 +3093,7 @@ class BrushFlow(_PluginBase):
"enable_site_config": brush_config.enable_site_config,
"site_config": brush_config.site_config,
"del_no_free": brush_config.del_no_free,
"rss_support": brush_config.rss_support,
"_tabs": self._tabs
}

View File

@@ -0,0 +1,263 @@
import re
from typing import Any, Dict
from typing import List, Tuple
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
import sentry_sdk
from app.plugins import _PluginBase
from version import APP_VERSION
class SentrySanitizer:
# 常见敏感字段名(可自行扩展)
SENSITIVE_KEYS = {
"password", "passwd", "pwd",
"secret", "token", "access_token", "refresh_token",
"authorization", "api_key", "apikey",
"cookie", "set-cookie", "passkey",
"key", "credential", "auth", "login", "user", "username",
"email", "phone", "address", "ip", "host", "domain"
}
# 匹配包含敏感关键词的正则
SENSITIVE_PATTERN = re.compile(
"|".join(re.escape(key) for key in SENSITIVE_KEYS), re.IGNORECASE
)
# 网络连接错误类异常(不上报)
NETWORK_ERRORS = {
"ConnectionError", "ConnectionRefusedError", "ConnectionAbortedError",
"ConnectionResetError", "TimeoutError", "socket.timeout", "socket.error",
"ssl.SSLError", "ssl.SSLCertVerificationError", "ssl.SSLWantReadError",
"ssl.SSLWantWriteError", "ssl.SSLZeroReturnError", "ssl.SSLSyscallError",
"urllib.error.URLError", "urllib.error.HTTPError", "requests.exceptions.ConnectionError",
"requests.exceptions.Timeout", "requests.exceptions.ConnectTimeout",
"requests.exceptions.ReadTimeout", "requests.exceptions.SSLError",
"aiohttp.ClientConnectionError", "aiohttp.ClientTimeout", "aiohttp.ServerTimeoutError",
"aiohttp.ServerDisconnectedError", "aiohttp.ClientOSError"
}
# 网络连接错误关键词
NETWORK_ERROR_KEYWORDS = [
"connection", "timeout", "network", "dns", "ssl", "certificate",
"refused", "reset", "aborted", "unreachable", "no route to host",
"name or service not known", "temporary failure", "network is unreachable",
"SOCKSHTTPSConnectionPool", "ERR_HTTP_RESPONSE_CODE_FAILURE", "HTTPSConnectionPool",
"网络连接", "无法连接", "请求失败", "下载失败", "请求返回空值", "图片失败", "未获取到返回数据",
"请求返回空值", "返回空响应", "连接出错", "请求错误", "未获取到"
]
@classmethod
def scrub_dict(cls, data: Dict[str, Any]) -> Dict[str, Any]:
"""
递归清洗字典中的敏感信息
"""
if not isinstance(data, dict):
return data
sanitized = {}
for key, value in data.items():
if isinstance(value, dict):
sanitized[key] = cls.scrub_dict(value)
elif isinstance(value, list):
sanitized[key] = [cls.scrub_dict(v) if isinstance(v, dict) else v for v in value]
else:
if cls.SENSITIVE_PATTERN.search(str(key)):
sanitized[key] = "[Filtered]"
else:
sanitized[key] = value
return sanitized
@classmethod
def scrub_url(cls, url: str) -> str:
"""
清理 URL 中的敏感 query 参数
"""
try:
parsed = urlparse(url)
query = parse_qs(parsed.query, keep_blank_values=True)
for key in query:
if cls.SENSITIVE_PATTERN.search(key):
query[key] = ["[Filtered]"]
new_query = urlencode(query, doseq=True)
return urlunparse(parsed._replace(query=new_query))
except Exception as err:
print(str(err))
return url
@classmethod
def is_network_error(cls, event) -> bool:
"""
判断是否为网络连接错误类异常
"""
# 检查异常类型
if "exception" in event:
for exc in event["exception"].get("values", []):
if "type" in exc:
exc_type = exc["type"]
if exc_type in cls.NETWORK_ERRORS:
return True
# 检查异常消息是否包含网络错误关键词
if "value" in exc:
exc_value = exc["value"].lower()
for keyword in cls.NETWORK_ERROR_KEYWORDS:
if keyword in exc_value:
return True
# 检查日志消息
if "message" in event:
message = event["message"].lower()
for keyword in cls.NETWORK_ERROR_KEYWORDS:
if keyword in message:
return True
return False
@classmethod
def before_send(cls, event, hint):
"""
在发送到 Sentry 之前脱敏和过滤
"""
# 如果是网络连接错误,直接返回 None 不上报
if cls.is_network_error(event):
return None
# 处理 request 数据
request = event.get("request", {})
if "url" in request:
request["url"] = cls.scrub_url(request["url"])
if "headers" in request:
request["headers"] = cls.scrub_dict(request["headers"])
if "data" in request:
request["data"] = cls.scrub_dict(request["data"])
if "cookies" in request:
request["cookies"] = cls.scrub_dict(request["cookies"])
# 处理 user 数据
if "user" in event:
event["user"] = cls.scrub_dict(event["user"])
# 处理 extra 数据
if "extra" in event:
event["extra"] = cls.scrub_dict(event["extra"])
# 处理异常信息(避免敏感数据出现在 message 中)
if "exception" in event:
for exc in event["exception"].get("values", []):
if "value" in exc and cls.SENSITIVE_PATTERN.search(exc["value"]):
exc["value"] = "[Filtered Exception Message]"
# 清理异常堆栈中的敏感信息
if "stacktrace" in exc and "frames" in exc["stacktrace"]:
for frame in exc["stacktrace"]["frames"]:
if "vars" in frame:
frame["vars"] = cls.scrub_dict(frame["vars"])
if "context_line" in frame and cls.SENSITIVE_PATTERN.search(frame["context_line"]):
frame["context_line"] = "[Filtered]"
# 清理消息中的敏感信息
if "message" in event and cls.SENSITIVE_PATTERN.search(event["message"]):
event["message"] = "[Filtered Message]"
return event
class BugReporter(_PluginBase):
# 插件名称
plugin_name = "Bug反馈"
# 插件描述
plugin_desc = "自动上报异常,协助开发者发现和解决问题。"
# 插件图标
plugin_icon = "Alist_encrypt_A.png"
# 插件版本
plugin_version = "1.3"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "bugreporter_"
# 加载顺序
plugin_order = 99
# 可使用的用户级别
auth_level = 1
_enable: bool = False
def init_plugin(self, config: dict = None):
self._enable = config.get("enable")
if self._enable:
sentry_sdk.init("https://88da01ad33b4423cb0380620de53efa8@glitchtip.movie-pilot.org/1",
before_send=SentrySanitizer.before_send,
release=APP_VERSION,
send_default_pii=False)
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enable',
'label': '启用插件',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'warning',
'variant': 'tonal',
'text': '注意开启插件即代表你同意将部分异常信息自动发送给开发者以帮助改进软件如果你不希望自动发送任何数据请关闭或卸载此插件仅上报系统异常信息不会包含任何个人隐私信息或敏感数据网络连接错误类异常不会上报异常信息采集为使用开源项目解决方案GlitchTip。',
}
}
]
}
]
}
]
}
], {
"enable": self._enable,
}
def get_page(self) -> List[dict]:
pass
def get_state(self) -> bool:
return self._enable
def stop_service(self):
pass

View File

@@ -0,0 +1 @@
sentry_sdk~=2.44.0

View File

@@ -17,7 +17,7 @@ class ChatGPT(_PluginBase):
# 插件图标
plugin_icon = "Chatgpt_A.png"
# 插件版本
plugin_version = "2.1.7"
plugin_version = "2.1.9"
# 插件作者
plugin_author = "jxxghp"
# 作者主页

View File

@@ -14,22 +14,33 @@ class OpenAi:
_api_url: str = None
_model: str = "gpt-3.5-turbo"
_prompt: str = '接下来我会给你一个电影或电视剧的文件名你需要识别文件名中的名称、版本、分段、年份、分瓣率、季集等信息并按以下JSON格式返回{"name":string,"version":string,"part":string,"year":string,"resolution":string,"season":number|null,"episode":number|null}特别注意返回结果需要严格附合JSON格式不需要有任何其它的字符。如果中文电影或电视剧的文件名中存在谐音字或字母替代的情况请还原最有可能的结果。'
_client: openai.OpenAI = None
def __init__(self, api_key: str = None, api_url: str = None, proxy: dict = None, model: str = None, compatible:
bool = False, customize_prompt: str = None):
def __init__(self, api_key: str = None, api_url: str = None,
proxy: dict = None, model: str = None,
compatible: bool = False, customize_prompt: str = None):
self._api_key = api_key
self._api_url = api_url
if compatible:
openai.api_base = self._api_url
else:
openai.api_base = self._api_url + "/v1"
openai.api_key = self._api_key
if proxy and proxy.get("https"):
openai.proxy = proxy.get("https")
if model:
self._model = model
if customize_prompt:
self._prompt = customize_prompt
# 初始化 OpenAI 客户端
if self._api_key and self._api_url:
base_url = self._api_url if compatible else self._api_url + "/v1"
http_client = None
if proxy and proxy.get("https"):
import httpx
proxy_url = proxy.get("https")
# httpx 支持字符串格式的代理 URL
http_client = httpx.Client(proxies=proxy_url, timeout=60.0)
self._client = openai.OpenAI(
api_key=self._api_key,
base_url=base_url,
http_client=http_client
)
def get_state(self) -> bool:
return True if self._api_key else False
@@ -82,6 +93,8 @@ class OpenAi:
"""
获取模型
"""
if not self._client:
raise ValueError("OpenAI client not initialized. Please check API key and API URL.")
if not isinstance(message, list):
if prompt:
message = [
@@ -101,9 +114,10 @@ class OpenAi:
"content": message
}
]
return openai.ChatCompletion.create(
# 新版本 API 不支持 user 参数,需要从 kwargs 中移除
kwargs.pop('user', None)
return self._client.chat.completions.create(
model=self._model,
user=user,
messages=message,
**kwargs
)
@@ -170,11 +184,11 @@ class OpenAi:
if result:
self.__save_session(userid, text)
return result
except openai.error.RateLimitError as e:
except openai.RateLimitError as e:
return f"请求被ChatGPT拒绝了{str(e)}"
except openai.error.APIConnectionError as e:
except openai.APIConnectionError as e:
return f"ChatGPT网络连接失败{str(e)}"
except openai.error.Timeout as e:
except openai.APITimeoutError as e:
return f"没有接收到ChatGPT的返回消息{str(e)}"
except Exception as e:
return f"请求ChatGPT出现错误{str(e)}"

View File

@@ -1 +1 @@
openai~=0.27.2
cacheout~=0.16.0

View File

@@ -1,10 +1,10 @@
# Clash Rule Provider
**Clash Rule Provider** 生成适用于 [Meta Kernel](https://github.com/MetaCubeX/mihomo/tree/Meta) 定制配置,便于增加、修改和删除规则。
**Clash Rule Provider** 是一个[MoviePilot](https://github.com/jxxghp/MoviePilot)插件,用于生成适用于 [Meta Kernel](https://github.com/MetaCubeX/mihomo/tree/Meta) 定制配置,便于增加、修改和删除规则,基于 Meta 内核丰富的代理组配置,提供灵活的路由功能
- 即时通知 Clash 刷新规则集合
- 基于 Meta 内核丰富的代理组配置,提供灵活的路由功能
- 支持按大洲分组节点
- 支持按大洲和国家分组节点
- 支持覆写出站代理
- GEO 规则输入提示
- 支持 [ACL4SSR](https://github.com/ACL4SSR/ACL4SSR) 规则集合
@@ -12,7 +12,7 @@
### 规则集规则
用于添加能够在 Clash 中即时生效的规则Clash Rule Provider 会根据每条规则的**出站**生成相应的**规则集合** `📂<-` + `出站`
用于添加能够在 Clash 中即时生效的规则Clash Rule Provider 会根据每条规则的**出站**生成相应的**规则集合**。
### 置顶规则
@@ -40,4 +40,28 @@
### Hosts
如果需要自动更新此处使用的 Cloudflare IP, 可以通过其它[插件](https://github.com/wumode/MoviePilot-Addons)实现。
如果需要自动更新此处使用的 Cloudflare IP, 可以通过其它[插件](https://github.com/wumode/MoviePilot-Addons)实现。
### 配置隐藏
如果希望某些代理组、规则或是代理节点仅在特定条件下可见,可以使用可见性限制功能。例如,可以设置某些规则集仅在特定网络环境下可见。
自定义表达式是个返回`bool`值的Python表达式可以使用以下变量:
```python
# 请求 URL
url: str
# 客户端的IP地址
client_host: str
# 请求的标识符
identifier: str | None = None
# User-Agent
user_agent : str | None = None
```
表达式示例:
- `client_host == '192.168.1.1'`
- `identifier == 'office-laptop' and 'Mobile' in user_agent`
## 远程组件
[ClashRuleProvider-Remote](https://github.com/wumode/ClashRuleProvider-Remote)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,320 @@
import asyncio
import json
import secrets
from typing import Any, Dict, List, Callable, Optional, Literal
import websockets
import yaml
from fastapi import HTTPException, Request, status, Response, Body
from fastapi.responses import PlainTextResponse
from sse_starlette.sse import EventSourceResponse
from app import schemas
from app.core.config import settings
from app.log import logger
from .config import PluginConfig
from .models import ProxyGroup, Proxy, HostData, RuleData, RuleProvider, RuleProviderData
from .models.api import Connectivity, SubscriptionSetting, ConfigRequest
from .models.metadata import Metadata
from .models.types import RuleSet, DataSource
from .services import ClashRuleProviderService
class ApiCollection:
def __init__(self):
self.route_definitions = []
def register(self, path: str,
methods: List[Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD', 'TRACE']],
allow_anonymous: Optional[bool] = None,
auth: Optional[str] = None,
summary: Optional[str] = '',
**kwargs):
def decorator(func: Callable):
route_meta: Dict[str, Any] = {
'path': path,
'methods': methods,
'summary': summary,
'endpoint': func,
**kwargs
}
if allow_anonymous is not None:
route_meta['allow_anonymous'] = allow_anonymous
if auth is not None:
route_meta['auth'] = auth
self.route_definitions.append(route_meta)
return func
return decorator
def get_routes(self, instance: Any) -> List[Dict[str, Any]]:
bound_routes = []
for route in self.route_definitions:
func_name = route['endpoint'].__name__
bound_method = getattr(instance, func_name)
bound_routes.append({**route, 'endpoint': bound_method})
return bound_routes
apis = ApiCollection()
class ClashRuleProviderApi:
def __init__(self, services: ClashRuleProviderService, config: PluginConfig):
self.services: ClashRuleProviderService = services
self.config = config
@apis.register(path="/connectivity", methods=["POST"], auth="bear", summary="测试连接")
async def test_connectivity(self, item: Connectivity) -> schemas.Response:
success, message = await self.services.test_connectivity(item.clash_apis, item.sub_links)
return schemas.Response(success=success, message=message)
@apis.register(path="/clash-outbound", methods=["GET"], auth="bear", summary="获取所有出站")
def get_clash_outbound(self) -> schemas.Response:
outbound = self.services.clash_outbound()
return schemas.Response(success=True, data=outbound)
@apis.register(path="/status", methods=["GET"], auth="bear", summary="插件状态")
def get_status(self) -> schemas.Response:
data = self.services.get_status()
return schemas.Response(success=True, data=data)
@apis.register(path="/rules/{ruleset}", methods=["GET"], auth="bear", summary="获取指定集合中的规则")
def get_rules(self, ruleset: RuleSet) -> schemas.Response:
data = self.services.get_rules(ruleset)
return schemas.Response(success=True, data=data)
@apis.register(path="/reorder-rules/{ruleset}/{target}", methods=["PUT"], auth="bear", summary="重新排序规则")
def reorder_rules(self, ruleset: RuleSet, target: int,
moved_priority: int = Body(..., embed=True)) -> schemas.Response:
success, message = self.services.reorder_rules(ruleset, moved_priority, target)
return schemas.Response(success=success, message=message)
@apis.register(path="/rules/{ruleset}/{priority}", methods=["PATCH"], auth="bear", summary="更新规则")
def update_rule(self, ruleset: RuleSet, priority: int, rule_data: RuleData) -> schemas.Response:
success, message = self.services.update_rule(ruleset, priority, rule_data)
return schemas.Response(success=success, message=message)
@apis.register(path="/rules/{ruleset}", methods=["POST"], auth="bear", summary="添加规则")
def add_rule(self, ruleset: RuleSet, rule_data: RuleData = Body(...)) -> schemas.Response:
success, message = self.services.add_rule(ruleset, rule_data)
return schemas.Response(success=success, message=message)
@apis.register(path="/rules/{ruleset}/{priority}/meta", methods=["PATCH"], auth="bear", summary="更新规则元数据")
def update_rule_meta(self, ruleset: RuleSet, priority: int, meta: Metadata = Body(...)) -> schemas.Response:
success, message = self.services.update_rule_meta(ruleset, priority, meta)
return schemas.Response(success=success, message=message)
@apis.register(path="/rules/{ruleset}/metadata/disabled", methods=["POST"], auth="bear", summary="设置规则状态")
def set_rules_status(self, ruleset: RuleSet, priorities: dict[int, bool] = Body(...)):
self.services.set_rules_status(ruleset, priorities)
@apis.register(path="/rules/{ruleset}/{priority}", methods=["DELETE"], auth="bear", summary="删除规则")
def delete_rule(self, ruleset: RuleSet, priority: int) -> schemas.Response:
self.services.delete_rule(ruleset, priority)
return schemas.Response(success=True)
@apis.register(path="/rules/{ruleset}", methods=["DELETE"], auth="bear", summary="批量删除规则")
def delete_rules(self, ruleset: RuleSet, priority: list[int] = Body(...)) -> schemas.Response:
self.services.delete_rules(ruleset, priority)
return schemas.Response(success=True)
@apis.register(path="/refresh", methods=["PUT"], auth="bear", summary="更新订阅")
async def refresh_subscription(self, url: str = Body(..., embed=True)) -> schemas.Response:
success, message = await self.services.refresh_subscription(url)
return schemas.Response(success=success, message=message)
@apis.register(path="/rule-providers", methods=["GET"], auth="bear", summary="获取规则集合",
response_model=schemas.Response, response_model_exclude_none=True)
def get_rule_providers(self) -> schemas.Response:
return schemas.Response(success=True, data=self.services.state.all_rule_providers)
@apis.register(path="/rule-providers/{name}", methods=["POST"], auth="bear", summary="添加规则集合")
def add_rule_provider(self, name: str, item: RuleProvider):
success, message = self.services.add_rule_provider(name, item)
return schemas.Response(success=success, message=message)
@apis.register(path="/rule-providers/{name}", methods=["PATCH"], auth="bear", summary="更新规则集合")
def update_rule_provider(self, name: str, item: RuleProviderData):
success, message = self.services.update_rule_provider(name, item)
return schemas.Response(success=success, message=message)
@apis.register(path="/rule-providers/{name}/meta", methods=["PATCH"], auth="bear", summary="更新规则集元数据")
def update_rule_providers_meta(self, name: str, meta: Metadata):
success, message = self.services.update_rule_providers_meta(name, meta)
return schemas.Response(success=success, message=message)
@apis.register(path="/rule-providers/{name}", methods=["DELETE"], auth="bear", summary="删除规则集合")
def delete_rule_provider(self, name: str):
self.services.delete_rule_provider(name)
return schemas.Response(success=True)
@apis.register(path="/proxies", methods=["GET"], auth="bear", summary="获取代理",
response_model=schemas.Response, response_model_exclude_none=True)
def get_proxies(self):
proxies = self.services.get_proxies()
return schemas.Response(success=True, data=proxies)
@apis.register(path="/proxies/{name}", methods=["DELETE"], auth="bear", summary="删除出站代理")
def delete_proxy(self, name: str):
self.services.delete_proxy(name)
return schemas.Response(success=True)
@apis.register(path="/proxies", methods=["PUT"], auth="bear", summary="添加出站代理")
def import_proxies(self, vehicle: Literal["YAML", "LINK"] = Body(...), payload: str = Body(...)):
success, message = self.services.import_proxies(vehicle, payload)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxies/{name}", methods=["PATCH"], auth="bear", summary="更新出站代理")
def update_proxy(self, name: str, source: DataSource = Body(...), proxy: Proxy = Body(...)) -> schemas.Response:
success, message = self.services.update_proxy(name, source, proxy)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxies/{name}/meta", methods=["PATCH"], auth="bear", summary="更新代理组元数据")
def update_proxy_meta(self, name: str, meta: Metadata):
success, message = self.services.update_proxy_meta(name, meta)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxies/{name}/patch", methods=["DELETE"], auth="bear", summary="删除代理补丁")
def delete_proxy_patch(self, name: str):
success, message = self.services.delete_proxy_patch(name)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxy-groups", methods=["GET"], auth="bear", summary="获取代理组",
response_model=schemas.Response, response_model_exclude_none=True)
def get_proxy_groups(self):
proxy_groups = self.services.get_proxy_groups()
return schemas.Response(success=True, data=proxy_groups)
@apis.register(path="/proxy-groups/{name}", methods=["DELETE"], auth="bear", summary="删除代理组")
def delete_proxy_group(self, name: str):
success, message = self.services.delete_proxy_group(name)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxy-groups/{name}/meta", methods=["PATCH"], auth="bear", summary="更新代理组元数据")
def update_proxy_group_meta(self, name: str, meta: Metadata):
success, message = self.services.update_proxy_group_meta(name, meta)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxy-groups/{name}/patch", methods=["DELETE"], auth="bear", summary="删除代理组补丁")
def delete_proxy_group_patch(self, name: str):
success, message = self.services.delete_proxy_group_patch(name)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxy-groups", methods=["POST"], auth="bear", summary="添加代理组")
def add_proxy_group(self, item: ProxyGroup):
success, message = self.services.add_proxy_group(item)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxy-groups/{name}", methods=["PATCH"], auth="bear", summary="更新代理组")
def update_proxy_group(self, name: str, source: DataSource = Body(...), proxy_group: ProxyGroup = Body(...)):
success, message = self.services.update_proxy_group(name, source, proxy_group)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxy-providers", methods=["GET"], auth="bear", summary="获取代理集合",
response_model=schemas.Response, response_model_exclude_none=True)
def get_proxy_providers(self):
proxy_providers = self.services.state.all_proxy_providers
return schemas.Response(success=True, data=proxy_providers)
@apis.register(path="/ruleset", methods=["GET"], allow_anonymous=True, summary="获取规则集规则")
def get_ruleset(self, name: str, apikey: str) -> PlainTextResponse:
_apikey = self.config.apikey or settings.API_TOKEN
if not secrets.compare_digest(_apikey, apikey):
raise HTTPException(status_code=403, detail="Invalid API Key")
res = self.services.get_ruleset(name)
if not res:
raise HTTPException(status_code=404, detail=f"Ruleset {name!r} not found")
return PlainTextResponse(content=res, media_type="application/x-yaml")
@apis.register(path="/import", methods=["POST"], auth="bear", summary="导入规则")
def import_rules(self, vehicle: Literal["YAML"] = Body(...), payload: str = Body(...)):
self.services.import_rules(vehicle, payload)
return schemas.Response(success=True)
@apis.register(path="/hosts", methods=["GET"], auth="bear", summary="获取 Hosts")
def get_hosts(self):
return schemas.Response(success=True, data=self.services.state.hosts.model_dump(mode='json'))
@apis.register(path="/hosts", methods=["POST"], auth="bear", summary="更新 Hosts")
def update_hosts(self, domain: str = Body(..., embed=True), host: HostData = Body(...)):
success, message = self.services.update_hosts(domain, host)
return schemas.Response(success=success, message=message)
@apis.register(path="/hosts/{domain}", methods=["DELETE"], auth="bear", summary="删除 Hosts")
def delete_host(self, domain: str):
success, message = self.services.delete_host(domain)
return schemas.Response(success=success, message=message)
@apis.register(path="/subscription-info", methods=["POST"], auth="bear", summary="更新订阅信息")
def update_subscription_info(self, sub_info: SubscriptionSetting):
self.services.update_subscription_info(sub_info)
return schemas.Response(success=True)
@apis.register(path="/config", methods=["GET"], allow_anonymous=bool(True), summary="获取 Clash 配置")
def get_clash_config(self, apikey: str, request: Request, identifier: str | None = None):
_apikey = self.config.apikey or settings.API_TOKEN
param = ConfigRequest(
url=str(request.url),
client_host=request.client.host,
identifier=identifier,
user_agent=request.headers.get("user-agent")
)
if not secrets.compare_digest(apikey, _apikey):
raise HTTPException(status_code=403, detail="Invalid API Key")
logger.info(f"{request.client.host} 正在获取配置")
config = self.services.build_clash_config(param=param)
if not config:
raise HTTPException(status_code=500, detail="配置不可用")
config_dict = config.model_dump(mode="json", by_alias=True, exclude_none=True)
res = yaml.dump(config_dict, allow_unicode=True, sort_keys=False)
sub_info = self.services.get_subscription_user_info()
headers = {'Subscription-Userinfo': sub_info.header}
return Response(headers=headers, content=res, media_type="text/yaml")
@apis.register(path="/clash/proxy/{path:path}", methods=["GET"], auth="bear", summary="转发 Clash API 请求")
async def clash_proxy(self, path: str):
return await self.services.fetch_clash_data(path)
@apis.register(path="/clash/ws/{endpoint}", methods=["GET"], allow_anonymous=True,
summary="转发 Clash API Websocket 请求")
async def clash_websocket(self, request: Request, endpoint: str, secret: str):
if not secrets.compare_digest(secret, self.config.dashboard_secret):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Secret 校验不通过")
if endpoint not in ['traffic', 'connections', 'memory']:
raise HTTPException(status_code=400, detail="Invalid endpoint")
# This logic is highly coupled with the web framework, so it stays here.
queue = asyncio.Queue()
ws_base = self.config.dashboard_url.replace(
'http://', 'ws://').replace('https://', 'wss://')
url = f"{ws_base}/{endpoint}?token={self.config.dashboard_secret}"
async def clash_ws_listener():
try:
async with websockets.connect(url, ping_interval=None) as ws:
async for message in ws:
await queue.put(json.loads(message))
except Exception as e:
await queue.put({"error": str(e)})
listener_task = asyncio.create_task(clash_ws_listener())
async def event_generator():
try:
while True:
if await request.is_disconnected():
break
try:
data = await queue.get()
yield {'event': endpoint, 'data': json.dumps(data)}
except asyncio.CancelledError:
break
finally:
listener_task.cancel()
return EventSourceResponse(event_generator())

View File

@@ -0,0 +1,8 @@
from typing import Final
class Constant:
PATCH_LIFESPAN: Final[int] = 10
ACL4SSR_API: Final[str] = "https://api.github.com/repos/ACL4SSR/ACL4SSR"
METACUBEX_RULE_DAT_API: Final[str] = "https://api.github.com/repos/MetaCubeX/meta-rules-dat"
MISFIRE_GRACE_TIME: Final[int] = 120

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
from .models.api import ClashApi
class SubscriptionConfig(BaseModel):
url: str
rules: Optional[bool] = True
rule_providers: Optional[bool] = Field(default=True, alias='rule-providers')
proxies: Optional[bool] = True
proxy_groups: Optional[bool] = Field(default=True, alias='proxy-groups')
proxy_providers: Optional[bool] = Field(default=True, alias='proxy-providers')
@field_validator('url')
@classmethod
def validate_url(cls, v: str) -> str:
return v.strip()
class PluginConfig(BaseModel):
"""
A dataclass to hold all the configuration of the ClashRuleProvider plugin.
"""
model_config = ConfigDict(
str_strip_whitespace=True,
)
enabled: bool = False
proxy: bool = False
notify: bool = False
subscriptions_config: list[SubscriptionConfig] = Field(default_factory=list)
movie_pilot_url: str = ''
cron_string: str = '30 12 * * *'
timeout: int = 10
retry_times: int = 3
filter_keywords: List[str] = Field(default_factory=list)
auto_update_subscriptions: bool = True
ruleset_prefix: str = '📂<='
acl4ssr_prefix: str = '🗂️=>'
group_by_region: bool = False
group_by_country: bool = False
refresh_delay: int = 5
enable_acl4ssr: bool = False
dashboard_components: List[str] = Field(default_factory=list)
clash_template: str = ''
hint_geo_dat: bool = False
best_cf_ip: List[str] = Field(default_factory=list)
apikey: Optional[str] = None
clash_dashboards: List[ClashApi] = Field(default_factory=list)
active_dashboard: Optional[int] = None
identifiers: list[str] = Field(default_factory=list)
cache_ttl: int = 3600
@field_validator('clash_dashboards')
@classmethod
def validate_clash_dashboards(cls, v: List[ClashApi]):
for item in v:
url = item.url.rstrip('/')
if not (url.startswith('http://') or url.startswith('https://')):
url = 'http://' + url
item.url = url
return v
@field_validator('movie_pilot_url')
@classmethod
def validate_movie_pilot_url(cls, v: str):
return v.rstrip('/')
@property
def sub_links(self) -> List[str]:
return [sub.url for sub in self.subscriptions_config]
@property
def dashboard_url(self) -> str:
dashboard_url = ''
if self.active_dashboard is not None and self.active_dashboard in range(len(self.clash_dashboards)):
dashboard_url = self.clash_dashboards[self.active_dashboard].url
return dashboard_url
@property
def dashboard_secret(self) -> str:
dashboard_secret = ''
if self.active_dashboard is not None and self.active_dashboard in range(len(self.clash_dashboards)):
dashboard_secret = self.clash_dashboards[self.active_dashboard].secret
return dashboard_secret
def get_sub_conf(self, url: str) -> SubscriptionConfig:
return next((conf for conf in self.subscriptions_config if conf.url == url), SubscriptionConfig(url=url))

5136
plugins.v2/clashruleprovider/countries.json Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
const MetaLogo = "/assets/Meta-uqWbsmWL.png";
export { MetaLogo as M };

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -1,4 +0,0 @@
.plugin-config[data-v-929102b8] {
margin: 0 auto;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
.plugin-config[data-v-3fef8398] {
margin: 0 auto;
}

View File

@@ -1,4 +1,4 @@
.dashboard-widget[data-v-de7a088e] {
.dashboard-widget[data-v-318a5020] {
font-family: 'Inter', sans-serif;
}

View File

@@ -1,50 +0,0 @@
.plugin-page[data-v-d6db167c] {
margin: 0 auto;
}
/* 使卡片等宽并适应移动端 */
.d-flex.flex-wrap[data-v-d6db167c] {
gap: 16px;
}
.url-display[data-v-d6db167c] {
word-break: break-all;
padding: 8px;
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
/* 移动端堆叠布局 */
@media (max-width: 768px) {
.d-flex.flex-wrap[data-v-d6db167c] {
flex-direction: column;
}
}
/* Add visual distinction between sections */
.ruleset-section[data-v-d6db167c] {
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 16px;
background-color: #f5f5f5;
}
.top-section[data-v-d6db167c] {
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 16px;
background-color: #f9f9f9;
}
/* Optional: Add different border colors to further distinguish */
.ruleset-section[data-v-d6db167c] {
border-left: 4px solid #2196F3; /* Blue accent */
}
.top-section[data-v-d6db167c] {
border-left: 4px solid #4CAF50; /* Green accent */
}
.drag-handle[data-v-d6db167c] {
cursor: move;
}
.gap-2[data-v-d6db167c] {
gap: 8px;
}

View File

@@ -0,0 +1,84 @@
.rule-card[data-v-5bf9d562]:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
}
.proxy-group-card[data-v-88bfc397]:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
}
.proxy-card[data-v-e80a10d3]:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
}
.subscription-card[data-v-b5b6e9bb] {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.subscription-card[data-v-b5b6e9bb]:hover {
transform: translateY(-4px);
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.1);
border-color: rgb(var(--v-theme-primary));
}
.card-header[data-v-b5b6e9bb] {
background: rgba(var(--v-theme-surface-variant), 0.05);
}
.bg-surface-variant-lighten[data-v-b5b6e9bb] {
background: rgba(var(--v-theme-surface-variant), 0.02);
}
.stats-grid[data-v-b5b6e9bb] {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.bounce[data-v-6a1d5a83] {
animation: bounce-6a1d5a83 2s infinite;
}
@keyframes bounce-6a1d5a83 {
0%,
20%,
50%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-20px);
}
60% {
transform: translateY(-10px);
}
}
.rule-provider-card[data-v-01e2e8ef]:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
}
.host-card[data-v-a5d6e0e6]:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
}
.search-field {
max-width: 25rem;
}
.clash-data-table {
max-height: 40rem;
overflow-y: auto;
}
.drag-handle {
cursor: move;
}
.dragging-item {
opacity: 0.5;
background-color: rgb(var(--v-theme-grey-200));
}
.drop-over {
background-color: rgba(var(--v-theme-primary), 0.1) !important;
}
.plugin-page[data-v-ab912b83] {
margin: 0 auto;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
const isValidUrl = (urlString) => {
if (!urlString) return false;
try {
const url = new URL(urlString);
return url.protocol === "http:" || url.protocol === "https:";
} catch (e) {
return false;
}
};
function isValidIP(ip) {
const ipv4Regex = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(([0-9a-fA-F]{1,4}:){1,7}|:):([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})$/;
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
}
function validateIPs(ips) {
if (ips.length === 0) {
return `至少需要一个 IP 地址`;
}
for (const ip of ips) {
if (!isValidIP(ip)) {
return `无效的 IP 地址: ${ip}`;
}
}
return true;
}
function getUsageColor(percentage) {
return percentage > 90 ? "error" : percentage > 70 ? "warning" : "success";
}
function getBehaviorColor(action) {
const colors = {
classical: "success",
domain: "error",
ipcidr: "error"
};
return colors[action] || "primary";
}
function getFormatColor(action) {
const colors = {
yaml: "success",
text: "warning",
mrs: "info"
};
return colors[action] || "secondary";
}
function getRuleTypeColor(type) {
const colors = {
DOMAIN: "primary",
"DOMAIN-SUFFIX": "primary",
"DOMAIN-KEYWORD": "primary",
"DOMAIN-REGEX": "primary",
"DOMAIN-WILDCARD": "primary",
GEOSITE: "info",
GEOIP: "info",
"IP-CIDR": "warning",
"IP-CIDR6": "warning",
"IP-SUFFIX": "warning",
"IP-ASN": "warning",
"SRC-GEOIP": "info",
"SRC-IP-ASN": "warning",
"SRC-IP-CIDR": "warning",
"SRC-IP-SUFFIX": "warning",
"DST-PORT": "success",
"SRC-PORT": "success",
"IN-PORT": "success",
"IN-TYPE": "success",
"IN-USER": "success",
"IN-NAME": "success",
"PROCESS-PATH": "error",
"PROCESS-PATH-REGEX": "error",
"PROCESS-NAME": "error",
"PROCESS-NAME-REGEX": "error",
UID: "secondary",
NETWORK: "secondary",
DSCP: "secondary",
"RULE-SET": "deep-purple",
AND: "deep-orange",
OR: "deep-orange",
NOT: "deep-orange",
"SUB-RULE": "deep-orange",
MATCH: "teal"
};
return colors[type] || "grey";
}
function getSourceColor(source) {
const colors = {
Auto: "success",
Manual: "info"
};
return colors[source] || "primary";
}
function getActionColor(action) {
const colors = {
DIRECT: "success",
REJECT: "error",
"REJECT-DROP": "error",
PASS: "warning",
COMPATIBLE: "info"
};
return colors[action] || "primary";
}
function getProxyGroupTypeColor(action) {
const colors = {
"url-test": "success",
fallback: "error",
"load-balance": "primary",
select: "info"
};
return colors[action] || "warning";
}
function getProxyColor(action) {
const colors = {
ss: "success",
ssr: "success",
trojan: "error",
vmess: "primary",
vless: "primary",
hysteria: "info",
hysteria2: "info",
anytls: "warning"
};
return colors[action] || "secondary";
}
function getBoolColor(value) {
if (value) {
return "primary";
}
return "success";
}
function isSystemRule(rule) {
return rule.meta.source?.startsWith("Auto");
}
function isManual(source) {
return source === "Manual";
}
function isInvalid(source) {
return source === "Invalid";
}
function isRegion(source) {
return source === "Auto";
}
function pageTitle(itemPerPageValue) {
if (itemPerPageValue < 0) {
return "♾️";
}
return `${itemPerPageValue}`;
}
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function formatTimestamp(timestamp) {
if (!timestamp) return "N/A";
const date = new Date(timestamp * 1e3);
return date.toLocaleDateString("zh-CN");
}
function timestampToDate(timestamp) {
if (!timestamp) return "N/A";
const date = new Date(timestamp * 1e3);
return date.toLocaleString("zh-CN", {
// 'en-GB' 表示使用英国格式YYYY-MM-DD HH:mm:ss
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
// 24小时制
});
}
function getExpireColor(timestamp) {
if (!timestamp) return "grey";
const secondsLeft = timestamp - Math.floor(Date.now() / 1e3);
const daysLeft = secondsLeft / 86400;
return daysLeft < 7 ? "error" : daysLeft < 30 ? "warning" : "success";
}
function extractDomain(url) {
try {
const hostname = new URL(url).hostname;
const parts = hostname.split(".");
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(":")) {
return hostname;
}
if (parts.length <= 2) {
return hostname;
}
return parts.slice(-2).join(".");
} catch {
return url;
}
}
function getUsedPercentageFloor(data) {
const used = data.upload + data.download;
return data.total > 0 ? Math.floor(used / data.total * 100) : 0;
}
const _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
};
export { _export_sfc as _, getActionColor as a, isManual as b, isRegion as c, getSourceColor as d, getProxyGroupTypeColor as e, isValidUrl as f, getRuleTypeColor as g, isInvalid as h, isSystemRule as i, getProxyColor as j, extractDomain as k, formatTimestamp as l, getExpireColor as m, formatBytes as n, getUsageColor as o, pageTitle as p, getUsedPercentageFloor as q, getFormatColor as r, getBehaviorColor as s, timestampToDate as t, getBoolColor as u, validateIPs as v };

View File

@@ -1,9 +0,0 @@
const _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
};
export { _export_sfc as _ };

View File

@@ -2,14 +2,14 @@ const currentImports = {};
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
let moduleMap = {
"./Page":()=>{
dynamicLoadingCss(["__federation_expose_Page-BOym_1fV.css"], false, './Page');
return __federation_import('./__federation_expose_Page-D5l2MyNA.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
dynamicLoadingCss(["__federation_expose_Page-BVPPK5SA.css"], false, './Page');
return __federation_import('./__federation_expose_Page-DfFWx370.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./Config":()=>{
dynamicLoadingCss(["__federation_expose_Config-BrXQaadr.css"], false, './Config');
return __federation_import('./__federation_expose_Config-NH09p1Am.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
dynamicLoadingCss(["__federation_expose_Config-CwbjkOP2.css"], false, './Config');
return __federation_import('./__federation_expose_Config-CY46uj5g.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./Dashboard":()=>{
dynamicLoadingCss(["__federation_expose_Dashboard-vS9Qm2ZB.css"], false, './Dashboard');
return __federation_import('./__federation_expose_Dashboard-BDSt5WaH.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
dynamicLoadingCss(["__federation_expose_Dashboard-CFBdUa27.css"], false, './Dashboard');
return __federation_import('./__federation_expose_Dashboard-CABqciWS.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
const seen = {};
const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => {
const metaUrl = import.meta.url;

View File

@@ -713,6 +713,37 @@ var ace$2 = {exports: {}};
exports.importCssStylsheet = function (uri, doc) {
exports.buildDom(["link", { rel: "stylesheet", href: uri }], exports.getDocumentHead(doc));
};
exports.$fixPositionBug = function (el) {
var rect = el.getBoundingClientRect();
if (el.style.left) {
var target = parseFloat(el.style.left);
var result = +rect.left;
if (Math.abs(target - result) > 1) {
el.style.left = 2 * target - result + "px";
}
}
if (el.style.right) {
var target = parseFloat(el.style.right);
var result = window.innerWidth - rect.right;
if (Math.abs(target - result) > 1) {
el.style.right = 2 * target - result + "px";
}
}
if (el.style.top) {
var target = parseFloat(el.style.top);
var result = +rect.top;
if (Math.abs(target - result) > 1) {
el.style.top = 2 * target - result + "px";
}
}
if (el.style.bottom) {
var target = parseFloat(el.style.bottom);
var result = window.innerHeight - rect.bottom;
if (Math.abs(target - result) > 1) {
el.style.bottom = 2 * target - result + "px";
}
}
};
exports.scrollbarWidth = function (doc) {
var inner = exports.createElement("ace_inner");
inner.style.width = "100%";
@@ -1319,7 +1350,7 @@ var ace$2 = {exports: {}};
reportErrorIfPathIsNotConfigured = function () { };
}
};
exports.version = "1.43.2";
exports.version = "1.43.5";
});
@@ -2072,6 +2103,7 @@ var ace$2 = {exports: {}};
this.text = dom.createElement("textarea");
this.text.className = "ace_text-input";
this.text.setAttribute("wrap", "off");
this.text.setAttribute("autocomplete", "off");
this.text.setAttribute("autocorrect", "off");
this.text.setAttribute("autocapitalize", "off");
this.text.setAttribute("spellcheck", "false");
@@ -2858,7 +2890,7 @@ var ace$2 = {exports: {}};
anchor = this.$clickSelection.start;
}
else {
var orientedRange = calcRangeOrientation(this.$clickSelection, cursor);
var orientedRange = calcRangeOrientation(this.$clickSelection, cursor, editor.session);
cursor = orientedRange.cursor;
anchor = orientedRange.anchor;
}
@@ -2889,7 +2921,7 @@ var ace$2 = {exports: {}};
anchor = range.start;
}
else {
var orientedRange = calcRangeOrientation(this.$clickSelection, cursor);
var orientedRange = calcRangeOrientation(this.$clickSelection, cursor, editor.session);
cursor = orientedRange.cursor;
anchor = orientedRange.anchor;
}
@@ -3003,11 +3035,11 @@ var ace$2 = {exports: {}};
function calcDistance(ax, ay, bx, by) {
return Math.sqrt(Math.pow(bx - ax, 2) + Math.pow(by - ay, 2));
}
function calcRangeOrientation(range, cursor) {
function calcRangeOrientation(range, cursor, session) {
if (range.start.row == range.end.row)
var cmp = 2 * cursor.column - range.start.column - range.end.column;
else if (range.start.row == range.end.row - 1 && !range.start.column && !range.end.column)
var cmp = cursor.column - 4;
var cmp = 3 * cursor.column - 2 * session.getLine(range.start.row).length;
else
var cmp = 2 * cursor.row - range.start.row - range.end.row;
if (cmp < 0)
@@ -3018,6 +3050,71 @@ var ace$2 = {exports: {}};
});
ace.define("ace/mouse/mouse_event",["require","exports","module","ace/lib/event","ace/lib/useragent"], function(require, exports, module){ var event = require("../lib/event");
var useragent = require("../lib/useragent");
var MouseEvent = /** @class */ (function () {
function MouseEvent(domEvent, editor) { this.speed; this.wheelX; this.wheelY;
this.domEvent = domEvent;
this.editor = editor;
this.x = this.clientX = domEvent.clientX;
this.y = this.clientY = domEvent.clientY;
this.$pos = null;
this.$inSelection = null;
this.propagationStopped = false;
this.defaultPrevented = false;
}
MouseEvent.prototype.stopPropagation = function () {
event.stopPropagation(this.domEvent);
this.propagationStopped = true;
};
MouseEvent.prototype.preventDefault = function () {
event.preventDefault(this.domEvent);
this.defaultPrevented = true;
};
MouseEvent.prototype.stop = function () {
this.stopPropagation();
this.preventDefault();
};
MouseEvent.prototype.getDocumentPosition = function () {
if (this.$pos)
return this.$pos;
this.$pos = this.editor.renderer.screenToTextCoordinates(this.clientX, this.clientY);
return this.$pos;
};
MouseEvent.prototype.getGutterRow = function () {
var documentRow = this.getDocumentPosition().row;
var screenRow = this.editor.session.documentToScreenRow(documentRow, 0);
var screenTopRow = this.editor.session.documentToScreenRow(this.editor.renderer.$gutterLayer.$lines.get(0).row, 0);
return screenRow - screenTopRow;
};
MouseEvent.prototype.inSelection = function () {
if (this.$inSelection !== null)
return this.$inSelection;
var editor = this.editor;
var selectionRange = editor.getSelectionRange();
if (selectionRange.isEmpty())
this.$inSelection = false;
else {
var pos = this.getDocumentPosition();
this.$inSelection = selectionRange.contains(pos.row, pos.column);
}
return this.$inSelection;
};
MouseEvent.prototype.getButton = function () {
return event.getButton(this.domEvent);
};
MouseEvent.prototype.getShiftKey = function () {
return this.domEvent.shiftKey;
};
MouseEvent.prototype.getAccelKey = function () {
return useragent.isMac ? this.domEvent.metaKey : this.domEvent.ctrlKey;
};
return MouseEvent;
}());
exports.MouseEvent = MouseEvent;
});
ace.define("ace/lib/scroll",["require","exports","module"], function(require, exports, module){exports.preventParentScroll = function preventParentScroll(event) {
event.stopPropagation();
var target = event.currentTarget;
@@ -3090,8 +3187,20 @@ var ace$2 = {exports: {}};
dom.addCssClass(this.getElement(), className);
};
Tooltip.prototype.setTheme = function (theme) {
this.$element.className = CLASSNAME + " " +
(theme.isDark ? "ace_dark " : "") + (theme.cssClass || "");
if (this.theme) {
this.theme.isDark && dom.removeCssClass(this.getElement(), "ace_dark");
this.theme.cssClass && dom.removeCssClass(this.getElement(), this.theme.cssClass);
}
if (theme.isDark) {
dom.addCssClass(this.getElement(), "ace_dark");
}
if (theme.cssClass) {
dom.addCssClass(this.getElement(), theme.cssClass);
}
this.theme = {
isDark: theme.isDark,
cssClass: theme.cssClass
};
};
Tooltip.prototype.show = function (text, x, y) {
if (text != null)
@@ -3218,12 +3327,18 @@ var ace$2 = {exports: {}};
HoverTooltip.prototype.addToEditor = function (editor) {
editor.on("mousemove", this.onMouseMove);
editor.on("mousedown", this.hide);
editor.renderer.getMouseEventTarget().addEventListener("mouseout", this.onMouseOut, true);
var target = editor.renderer.getMouseEventTarget();
if (target && typeof target.removeEventListener === "function") {
target.addEventListener("mouseout", this.onMouseOut, true);
}
};
HoverTooltip.prototype.removeFromEditor = function (editor) {
editor.off("mousemove", this.onMouseMove);
editor.off("mousedown", this.hide);
editor.renderer.getMouseEventTarget().removeEventListener("mouseout", this.onMouseOut, true);
var target = editor.renderer.getMouseEventTarget();
if (target && typeof target.removeEventListener === "function") {
target.removeEventListener("mouseout", this.onMouseOut, true);
}
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
@@ -3278,7 +3393,6 @@ var ace$2 = {exports: {}};
this.$gatherData = value;
};
HoverTooltip.prototype.showForRange = function (editor, range, domNode, startingEvent) {
var MARGIN = 10;
if (startingEvent && startingEvent != this.lastEvent)
return;
if (this.isOpen && document.activeElement == this.getElement())
@@ -3290,7 +3404,6 @@ var ace$2 = {exports: {}};
this.setTheme(renderer.theme);
}
this.isOpen = true;
this.addMarker(range, editor.session);
this.range = Range.fromPoints(range.start, range.end);
var position = renderer.textToScreenCoordinates(range.start.row, range.start.column);
var rect = renderer.scroller.getBoundingClientRect();
@@ -3301,17 +3414,27 @@ var ace$2 = {exports: {}};
element.appendChild(domNode);
element.style.maxHeight = "";
element.style.display = "block";
var labelHeight = element.clientHeight;
var labelWidth = element.clientWidth;
var spaceBelow = window.innerHeight - position.pageY - renderer.lineHeight;
var isAbove = true;
if (position.pageY - labelHeight < 0 && position.pageY < spaceBelow) {
isAbove = false;
}
element.style.maxHeight = (isAbove ? position.pageY : spaceBelow) - MARGIN + "px";
element.style.top = isAbove ? "" : position.pageY + renderer.lineHeight + "px";
element.style.bottom = isAbove ? window.innerHeight - position.pageY + "px" : "";
element.style.left = Math.min(position.pageX, window.innerWidth - labelWidth - MARGIN) + "px";
this.$setPosition(editor, position, true, range);
dom.$fixPositionBug(element);
};
HoverTooltip.prototype.$setPosition = function (editor, position, withMarker, range) {
var MARGIN = 10;
withMarker && this.addMarker(range, editor.session);
var renderer = editor.renderer;
var element = this.getElement();
var labelHeight = element.offsetHeight;
var labelWidth = element.offsetWidth;
var anchorTop = position.pageY;
var anchorLeft = position.pageX;
var spaceBelow = window.innerHeight - anchorTop - renderer.lineHeight;
var isAbove = this.$shouldPlaceAbove(labelHeight, anchorTop, spaceBelow - MARGIN);
element.style.maxHeight = (isAbove ? anchorTop : spaceBelow) - MARGIN + "px";
element.style.top = isAbove ? "" : anchorTop + renderer.lineHeight + "px";
element.style.bottom = isAbove ? window.innerHeight - anchorTop + "px" : "";
element.style.left = Math.min(anchorLeft, window.innerWidth - labelWidth - MARGIN) + "px";
};
HoverTooltip.prototype.$shouldPlaceAbove = function (labelHeight, anchorTop, spaceBelow) {
return !(anchorTop - labelHeight < 0 && anchorTop < spaceBelow);
};
HoverTooltip.prototype.addMarker = function (range, session) {
if (this.marker) {
@@ -3321,6 +3444,11 @@ var ace$2 = {exports: {}};
this.marker = session && session.addMarker(range, "ace_highlight-marker", "text");
};
HoverTooltip.prototype.hide = function (e) {
if (e && this.$fromKeyboard && e.type == "keydown") {
if (e.code == "Escape") {
return;
}
}
if (!e && document.activeElement == this.getElement())
return;
if (e && e.target && (e.type != "keydown" || e.ctrlKey || e.metaKey) && this.$element.contains(e.target))
@@ -3331,6 +3459,7 @@ var ace$2 = {exports: {}};
this.timeout = null;
this.addMarker(null);
if (this.isOpen) {
this.$fromKeyboard = false;
this.$removeCloseEvents();
this.getElement().style.display = "none";
this.isOpen = false;
@@ -3368,7 +3497,7 @@ var ace$2 = {exports: {}};
});
ace.define("ace/mouse/default_gutter_handler",["require","exports","module","ace/lib/dom","ace/lib/event","ace/tooltip","ace/config"], function(require, exports, module){ var __extends = (this && this.__extends) || (function () {
ace.define("ace/mouse/default_gutter_handler",["require","exports","module","ace/lib/dom","ace/mouse/mouse_event","ace/tooltip","ace/config","ace/range"], function(require, exports, module){ var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
@@ -3395,17 +3524,19 @@ var ace$2 = {exports: {}};
throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
};
var dom = require("../lib/dom");
var event = require("../lib/event");
var Tooltip = require("../tooltip").Tooltip;
var MouseEvent = require("./mouse_event").MouseEvent;
var HoverTooltip = require("../tooltip").HoverTooltip;
var nls = require("../config").nls;
var GUTTER_TOOLTIP_LEFT_OFFSET = 5;
var GUTTER_TOOLTIP_TOP_OFFSET = 3;
exports.GUTTER_TOOLTIP_LEFT_OFFSET = GUTTER_TOOLTIP_LEFT_OFFSET;
exports.GUTTER_TOOLTIP_TOP_OFFSET = GUTTER_TOOLTIP_TOP_OFFSET;
var Range = require("../range").Range;
function GutterHandler(mouseHandler) {
var editor = mouseHandler.editor;
var gutter = editor.renderer.$gutterLayer;
var tooltip = new GutterTooltip(editor, true);
mouseHandler.$tooltip = new GutterTooltip(editor);
mouseHandler.$tooltip.addToEditor(editor);
mouseHandler.$tooltip.setDataProvider(function (e, editor) {
var row = e.getDocumentPosition().row;
mouseHandler.$tooltip.showTooltip(row);
});
mouseHandler.editor.setDefaultHandler("guttermousedown", function (e) {
if (!editor.isFocused() || e.getButton() != 0)
return;
@@ -3427,87 +3558,11 @@ var ace$2 = {exports: {}};
mouseHandler.captureMouse(e);
return e.preventDefault();
});
var tooltipTimeout, mouseEvent;
function showTooltip() {
var row = mouseEvent.getDocumentPosition().row;
var maxRow = editor.session.getLength();
if (row == maxRow) {
var screenRow = editor.renderer.pixelToScreenCoordinates(0, mouseEvent.y).row;
var pos = mouseEvent.$pos;
if (screenRow > editor.session.documentToScreenRow(pos.row, pos.column))
return hideTooltip();
}
tooltip.showTooltip(row);
if (!tooltip.isOpen)
return;
editor.on("mousewheel", hideTooltip);
editor.on("changeSession", hideTooltip);
window.addEventListener("keydown", hideTooltip, true);
if (mouseHandler.$tooltipFollowsMouse) {
moveTooltip(mouseEvent);
}
else {
var gutterRow = mouseEvent.getGutterRow();
var gutterCell = gutter.$lines.get(gutterRow);
if (gutterCell) {
var gutterElement = gutterCell.element.querySelector(".ace_gutter_annotation");
var rect = gutterElement.getBoundingClientRect();
var style = tooltip.getElement().style;
style.left = (rect.right - GUTTER_TOOLTIP_LEFT_OFFSET) + "px";
style.top = (rect.bottom - GUTTER_TOOLTIP_TOP_OFFSET) + "px";
}
else {
moveTooltip(mouseEvent);
}
}
}
function hideTooltip(e) {
if (e && e.type === "keydown" && (e.ctrlKey || e.metaKey))
return;
if (e && e.type === "mouseout" && (!e.relatedTarget || tooltip.getElement().contains(e.relatedTarget)))
return;
if (tooltipTimeout)
tooltipTimeout = clearTimeout(tooltipTimeout);
if (tooltip.isOpen) {
tooltip.hideTooltip();
editor.off("mousewheel", hideTooltip);
editor.off("changeSession", hideTooltip);
window.removeEventListener("keydown", hideTooltip, true);
}
}
function moveTooltip(e) {
tooltip.setPosition(e.x, e.y);
}
mouseHandler.editor.setDefaultHandler("guttermousemove", function (e) {
var target = e.domEvent.target || e.domEvent.srcElement;
if (dom.hasCssClass(target, "ace_fold-widget") || dom.hasCssClass(target, "ace_custom-widget"))
return hideTooltip();
if (tooltip.isOpen && mouseHandler.$tooltipFollowsMouse)
moveTooltip(e);
mouseEvent = e;
if (tooltipTimeout)
return;
tooltipTimeout = setTimeout(function () {
tooltipTimeout = null;
if (mouseEvent && !mouseHandler.isMousePressed)
showTooltip();
}, 50);
});
event.addListener(editor.renderer.$gutter, "mouseout", function (e) {
mouseEvent = null;
if (!tooltip.isOpen)
return;
tooltipTimeout = setTimeout(function () {
tooltipTimeout = null;
hideTooltip(e);
}, 50);
}, editor);
}
exports.GutterHandler = GutterHandler;
var GutterTooltip = /** @class */ (function (_super) {
__extends(GutterTooltip, _super);
function GutterTooltip(editor, isHover) {
if (isHover === void 0) { isHover = false; }
function GutterTooltip(editor) {
var _this = _super.call(this, editor.container) || this;
_this.id = "gt" + (++GutterTooltip.$uid);
_this.editor = editor;
@@ -3516,35 +3571,37 @@ var ace$2 = {exports: {}};
el.setAttribute("role", "tooltip");
el.setAttribute("id", _this.id);
el.style.pointerEvents = "auto";
if (isHover) {
_this.onMouseOut = _this.onMouseOut.bind(_this);
el.addEventListener("mouseout", _this.onMouseOut);
}
_this.idleTime = 50;
_this.onDomMouseMove = _this.onDomMouseMove.bind(_this);
_this.onDomMouseOut = _this.onDomMouseOut.bind(_this);
_this.setClassName("ace_gutter-tooltip");
return _this;
}
GutterTooltip.prototype.onMouseOut = function (e) {
if (!this.isOpen)
return;
if (!e.relatedTarget || this.getElement().contains(e.relatedTarget))
return;
if (e && e.currentTarget.contains(e.relatedTarget))
return;
this.hideTooltip();
GutterTooltip.prototype.onDomMouseMove = function (domEvent) {
var aceEvent = new MouseEvent(domEvent, this.editor);
this.onMouseMove(aceEvent, this.editor);
};
GutterTooltip.prototype.setPosition = function (x, y) {
var windowWidth = window.innerWidth || document.documentElement.clientWidth;
var windowHeight = window.innerHeight || document.documentElement.clientHeight;
var width = this.getWidth();
var height = this.getHeight();
x += 15;
y += 15;
if (x + width > windowWidth) {
x -= (x + width) - windowWidth;
GutterTooltip.prototype.onDomMouseOut = function (domEvent) {
var aceEvent = new MouseEvent(domEvent, this.editor);
this.onMouseOut(aceEvent);
};
GutterTooltip.prototype.addToEditor = function (editor) {
var gutter = editor.renderer.$gutter;
gutter.addEventListener("mousemove", this.onDomMouseMove);
gutter.addEventListener("mouseout", this.onDomMouseOut);
_super.prototype.addToEditor.call(this, editor);
};
GutterTooltip.prototype.removeFromEditor = function (editor) {
var gutter = editor.renderer.$gutter;
gutter.removeEventListener("mousemove", this.onDomMouseMove);
gutter.removeEventListener("mouseout", this.onDomMouseOut);
_super.prototype.removeFromEditor.call(this, editor);
};
GutterTooltip.prototype.destroy = function () {
if (this.editor) {
this.removeFromEditor(this.editor);
}
if (y + height > windowHeight) {
y -= 20 + height;
}
Tooltip.prototype.setPosition.call(this, x, y);
_super.prototype.destroy.call(this);
};
Object.defineProperty(GutterTooltip, "annotationLabels", {
get: function () {
@@ -3610,7 +3667,7 @@ var ace$2 = {exports: {}};
}
}
if (annotation.displayText.length === 0)
return this.hideTooltip();
return this.hide();
var annotationMessages = { error: [], security: [], warning: [], info: [], hint: [] };
var iconClassName = gutter.$useSvgGutterIcons ? "ace_icon_svg" : "ace_icon";
for (var i = 0; i < annotation.displayText.length; i++) {
@@ -3625,26 +3682,42 @@ var ace$2 = {exports: {}};
lineElement.appendChild(dom.createElement("br"));
annotationMessages[annotation.type[i].replace("_fold", "")].push(lineElement);
}
var tooltipElement = this.getElement();
dom.removeChildren(tooltipElement);
var tooltipElement = dom.createElement("span");
annotationMessages.error.forEach(function (el) { return tooltipElement.appendChild(el); });
annotationMessages.security.forEach(function (el) { return tooltipElement.appendChild(el); });
annotationMessages.warning.forEach(function (el) { return tooltipElement.appendChild(el); });
annotationMessages.info.forEach(function (el) { return tooltipElement.appendChild(el); });
annotationMessages.hint.forEach(function (el) { return tooltipElement.appendChild(el); });
tooltipElement.setAttribute("aria-live", "polite");
if (!this.isOpen) {
this.setTheme(this.editor.renderer.theme);
this.setClassName("ace_gutter-tooltip");
}
var annotationNode = this.$findLinkedAnnotationNode(row);
if (annotationNode) {
annotationNode.setAttribute("aria-describedby", this.id);
}
this.show();
var range = Range.fromPoints({ row: row, column: 0 }, { row: row, column: 0 });
this.showForRange(this.editor, range, tooltipElement);
this.visibleTooltipRow = row;
this.editor._signal("showGutterTooltip", this);
};
GutterTooltip.prototype.$setPosition = function (editor, _ignoredPosition, _withMarker, range) {
var gutterCell = this.$findCellByRow(range.start.row);
if (!gutterCell)
return;
var el = gutterCell && gutterCell.element;
var anchorEl = el && (el.querySelector(".ace_gutter_annotation"));
if (!anchorEl)
return;
var r = anchorEl.getBoundingClientRect();
if (!r)
return;
var position = {
pageX: r.right,
pageY: r.top
};
return _super.prototype.$setPosition.call(this, editor, position, false, range);
};
GutterTooltip.prototype.$shouldPlaceAbove = function (labelHeight, anchorTop, spaceBelow) {
return spaceBelow < labelHeight;
};
GutterTooltip.prototype.$findLinkedAnnotationNode = function (row) {
var cell = this.$findCellByRow(row);
if (cell) {
@@ -3657,12 +3730,11 @@ var ace$2 = {exports: {}};
GutterTooltip.prototype.$findCellByRow = function (row) {
return this.editor.renderer.$gutterLayer.$lines.cells.find(function (el) { return el.row === row; });
};
GutterTooltip.prototype.hideTooltip = function () {
GutterTooltip.prototype.hide = function (e) {
if (!this.isOpen) {
return;
}
this.$element.removeAttribute("aria-live");
this.hide();
if (this.visibleTooltipRow != undefined) {
var annotationNode = this.$findLinkedAnnotationNode(this.visibleTooltipRow);
if (annotationNode) {
@@ -3671,6 +3743,7 @@ var ace$2 = {exports: {}};
}
this.visibleTooltipRow = undefined;
this.editor._signal("hideGutterTooltip", this);
_super.prototype.hide.call(this, e);
};
GutterTooltip.annotationsToSummaryString = function (annotations) {
var e_1, _a;
@@ -3694,78 +3767,19 @@ var ace$2 = {exports: {}};
}
return summary.join(", ");
};
GutterTooltip.prototype.isOutsideOfText = function (e) {
var editor = e.editor;
var rect = editor.renderer.$gutter.getBoundingClientRect();
return !(e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom);
};
return GutterTooltip;
}(Tooltip));
}(HoverTooltip));
GutterTooltip.$uid = 0;
exports.GutterTooltip = GutterTooltip;
});
ace.define("ace/mouse/mouse_event",["require","exports","module","ace/lib/event","ace/lib/useragent"], function(require, exports, module){ var event = require("../lib/event");
var useragent = require("../lib/useragent");
var MouseEvent = /** @class */ (function () {
function MouseEvent(domEvent, editor) { this.speed; this.wheelX; this.wheelY;
this.domEvent = domEvent;
this.editor = editor;
this.x = this.clientX = domEvent.clientX;
this.y = this.clientY = domEvent.clientY;
this.$pos = null;
this.$inSelection = null;
this.propagationStopped = false;
this.defaultPrevented = false;
}
MouseEvent.prototype.stopPropagation = function () {
event.stopPropagation(this.domEvent);
this.propagationStopped = true;
};
MouseEvent.prototype.preventDefault = function () {
event.preventDefault(this.domEvent);
this.defaultPrevented = true;
};
MouseEvent.prototype.stop = function () {
this.stopPropagation();
this.preventDefault();
};
MouseEvent.prototype.getDocumentPosition = function () {
if (this.$pos)
return this.$pos;
this.$pos = this.editor.renderer.screenToTextCoordinates(this.clientX, this.clientY);
return this.$pos;
};
MouseEvent.prototype.getGutterRow = function () {
var documentRow = this.getDocumentPosition().row;
var screenRow = this.editor.session.documentToScreenRow(documentRow, 0);
var screenTopRow = this.editor.session.documentToScreenRow(this.editor.renderer.$gutterLayer.$lines.get(0).row, 0);
return screenRow - screenTopRow;
};
MouseEvent.prototype.inSelection = function () {
if (this.$inSelection !== null)
return this.$inSelection;
var editor = this.editor;
var selectionRange = editor.getSelectionRange();
if (selectionRange.isEmpty())
this.$inSelection = false;
else {
var pos = this.getDocumentPosition();
this.$inSelection = selectionRange.contains(pos.row, pos.column);
}
return this.$inSelection;
};
MouseEvent.prototype.getButton = function () {
return event.getButton(this.domEvent);
};
MouseEvent.prototype.getShiftKey = function () {
return this.domEvent.shiftKey;
};
MouseEvent.prototype.getAccelKey = function () {
return useragent.isMac ? this.domEvent.metaKey : this.domEvent.ctrlKey;
};
return MouseEvent;
}());
exports.MouseEvent = MouseEvent;
});
ace.define("ace/mouse/dragdrop_handler",["require","exports","module","ace/lib/dom","ace/lib/event","ace/lib/useragent"], function(require, exports, module){ var dom = require("../lib/dom");
var event = require("../lib/event");
var useragent = require("../lib/useragent");
@@ -4574,6 +4588,8 @@ var ace$2 = {exports: {}};
MouseHandler.prototype.destroy = function () {
if (this.releaseMouse)
this.releaseMouse();
if (this.$tooltip)
this.$tooltip.destroy();
};
return MouseHandler;
}());
@@ -4583,7 +4599,6 @@ var ace$2 = {exports: {}};
dragDelay: { initialValue: (useragent.isMac ? 150 : 0) },
dragEnabled: { initialValue: true },
focusTimeout: { initialValue: 0 },
tooltipFollowsMouse: { initialValue: true }
});
exports.MouseHandler = MouseHandler;
@@ -13724,8 +13739,7 @@ var ace$2 = {exports: {}};
});
ace.define("ace/keyboard/gutter_handler",["require","exports","module","ace/lib/keys","ace/mouse/default_gutter_handler"], function(require, exports, module){ var keys = require('../lib/keys');
var GutterTooltip = require("../mouse/default_gutter_handler").GutterTooltip;
ace.define("ace/keyboard/gutter_handler",["require","exports","module","ace/lib/keys"], function(require, exports, module){ var keys = require('../lib/keys');
var GutterKeyboardHandler = /** @class */ (function () {
function GutterKeyboardHandler(editor) {
this.editor = editor;
@@ -13734,7 +13748,7 @@ var ace$2 = {exports: {}};
this.lines = editor.renderer.$gutterLayer.$lines;
this.activeRowIndex = null;
this.activeLane = null;
this.annotationTooltip = new GutterTooltip(this.editor);
this.annotationTooltip = this.editor.$mouseHandler.$tooltip;
}
GutterKeyboardHandler.prototype.addListener = function () {
this.element.addEventListener("keydown", this.$onGutterKeyDown.bind(this));
@@ -13750,7 +13764,7 @@ var ace$2 = {exports: {}};
if (this.annotationTooltip.isOpen) {
e.preventDefault();
if (e.keyCode === keys["escape"])
this.annotationTooltip.hideTooltip();
this.annotationTooltip.hide();
return;
}
if (e.target === this.element) {
@@ -13869,12 +13883,8 @@ var ace$2 = {exports: {}};
}
return;
case "annotation":
var gutterElement = this.lines.cells[this.activeRowIndex].element.childNodes[2];
var rect = gutterElement.getBoundingClientRect();
var style = this.annotationTooltip.getElement().style;
style.left = rect.right + "px";
style.top = rect.bottom + "px";
this.annotationTooltip.showTooltip(this.$rowIndexToRow(this.activeRowIndex));
this.annotationTooltip.$fromKeyboard = true;
break;
}
return;
@@ -13893,7 +13903,7 @@ var ace$2 = {exports: {}};
}
}
if (this.annotationTooltip.isOpen)
this.annotationTooltip.hideTooltip();
this.annotationTooltip.hide();
return;
};
GutterKeyboardHandler.prototype.$isFoldWidgetVisible = function (index) {
@@ -16178,7 +16188,6 @@ var ace$2 = {exports: {}};
dragDelay: "$mouseHandler",
dragEnabled: "$mouseHandler",
focusTimeout: "$mouseHandler",
tooltipFollowsMouse: "$mouseHandler",
firstLineNumber: "session",
overwrite: "session",
newLineMode: "session",
@@ -16328,6 +16337,7 @@ var ace$2 = {exports: {}};
var nls = require("../config").nls;
var Gutter = /** @class */ (function () {
function Gutter(parentEl) {
this.$showCursorMarker = null;
this.element = dom.createElement("div");
this.element.className = "ace_layer ace_gutter-layer";
parentEl.appendChild(this.element);
@@ -16448,6 +16458,8 @@ var ace$2 = {exports: {}};
}
this._signal("afterRender");
this.$updateGutterWidth(config);
if (this.$showCursorMarker && this.$highlightGutterLine)
this.$updateCursorMarker();
};
Gutter.prototype.$updateGutterWidth = function (config) {
var session = this.session;
@@ -16476,6 +16488,8 @@ var ace$2 = {exports: {}};
this.$cursorRow = position.row;
};
Gutter.prototype.updateLineHighlight = function () {
if (this.$showCursorMarker)
this.$updateCursorMarker();
if (!this.$highlightGutterLine)
return;
var row = this.session.selection.cursor.row;
@@ -16502,6 +16516,26 @@ var ace$2 = {exports: {}};
}
}
};
Gutter.prototype.$updateCursorMarker = function () {
if (!this.session)
return;
var session = this.session;
if (!this.$highlightElement) {
this.$highlightElement = dom.createElement("div");
this.$highlightElement.className = "ace_gutter-cursor";
this.$highlightElement.style.pointerEvents = "none";
this.element.appendChild(this.$highlightElement);
}
var pos = session.selection.cursor;
var config = this.config;
var lines = this.$lines;
var screenTop = config.firstRowScreen * config.lineHeight;
var screenPage = Math.floor(screenTop / lines.canvasHeight);
var lineTop = session.documentToScreenRow(pos) * config.lineHeight;
var top = lineTop - (screenPage * lines.canvasHeight);
dom.setStyle(this.$highlightElement.style, "height", config.lineHeight + "px");
dom.setStyle(this.$highlightElement.style, "top", top + "px");
};
Gutter.prototype.scrollLines = function (config) {
var oldConfig = this.config;
this.config = config;
@@ -16745,6 +16779,10 @@ var ace$2 = {exports: {}};
};
Gutter.prototype.setHighlightGutterLine = function (highlightGutterLine) {
this.$highlightGutterLine = highlightGutterLine;
if (!highlightGutterLine && this.$highlightElement) {
this.$highlightElement.remove();
this.$highlightElement = null;
}
};
Gutter.prototype.setShowLineNumbers = function (show) {
this.$renderer = !show && {
@@ -16786,8 +16824,24 @@ var ace$2 = {exports: {}};
};
Gutter.prototype.$getGutterCell = function (row) {
var cells = this.$lines.cells;
var visibileRow = this.session.documentToScreenRow(row, 0);
return cells[row - this.config.firstRowScreen - (row - visibileRow)];
var min = 0;
var max = cells.length - 1;
if (row < cells[0].row || row > cells[max].row)
return;
while (min <= max) {
var mid = Math.floor((min + max) / 2);
var cell = cells[mid];
if (cell.row > row) {
max = mid - 1;
}
else if (cell.row < row) {
min = mid + 1;
}
else {
return cell;
}
}
return cell;
};
Gutter.prototype.$addCustomWidget = function (row, _a, cell) {
var className = _a.className, label = _a.label, title = _a.title, callbacks = _a.callbacks;
@@ -16850,7 +16904,7 @@ var ace$2 = {exports: {}};
}());
Gutter.prototype.$fixedWidth = false;
Gutter.prototype.$highlightGutterLine = true;
Gutter.prototype.$renderer = "";
Gutter.prototype.$renderer = undefined;
Gutter.prototype.$showLineNumbers = true;
Gutter.prototype.$showFoldWidgets = true;
oop.implement(Gutter.prototype, EventEmitter);
@@ -19856,6 +19910,15 @@ var ace$2 = {exports: {}};
: "padding" in (_self.theme || {}) ? 4 : _self.$padding;
if (_self.$padding && padding != _self.$padding)
_self.setPadding(padding);
if (_self.$gutterLayer) {
var showGutterCursor = module["$showGutterCursorMarker"];
if (showGutterCursor && !_self.$gutterLayer.$showCursorMarker) {
_self.$gutterLayer.$showCursorMarker = "theme";
}
else if (!showGutterCursor && _self.$gutterLayer.$showCursorMarker == "theme") {
_self.$gutterLayer.$showCursorMarker = null;
}
}
_self.$theme = module.cssClass;
_self.theme = module;
dom.addCssClass(_self.container, module.cssClass);

View File

@@ -0,0 +1,144 @@
from typing import Any, Callable, Dict, List, Optional, Union, Iterator
from pydantic import TypeAdapter, ValidationError
from ..models.metadata import Metadata
from ..models.rule import Action, RoutingRuleType, MatchRule, ClashRule, LogicRule
from ..models.ruleitem import RuleItem, RuleData
class ClashRuleManager:
"""Clash rule manager"""
def __init__(self):
self.rules: List[RuleItem] = []
def import_rules(self, rules_list: List[Dict[str, Any]]):
self.rules.clear()
for r in rules_list:
try:
rule = RuleItem.model_validate(r)
except ValidationError:
continue
self.rules.append(rule)
def export_rules(self) -> List[Dict[str, str]]:
adapter = TypeAdapter(list[RuleItem])
return adapter.dump_python(self.rules, mode='json')
def append_rules(self, clash_rules: List[RuleItem]):
self.rules.extend(clash_rules)
def insert_rule_at_priority(self, clash_rule: RuleItem, priority: int):
self.rules.insert(priority, clash_rule)
def update_rule_at_priority(self, clash_rule: RuleItem, src_priority: int, dst_priority) -> bool:
if len(self.rules) > src_priority >= 0:
if src_priority == dst_priority:
self.rules[src_priority] = clash_rule
else:
self.remove_rule_at_priority(src_priority)
self.insert_rule_at_priority(clash_rule, dst_priority)
return True
return False
def get_rule_at_priority(self, priority: int) -> Optional[RuleItem]:
"""Get rule item by priority"""
if len(self.rules) > priority >= 0:
return self.rules[priority]
return None
def remove_rule_at_priority(self, priority: int) -> Optional[RuleItem]:
"""Remove rule at specific priority"""
if 0 <= priority < len(self.rules):
return self.rules.pop(priority)
return None
def remove_rules_at_priorities(self, priorities: list[int]) -> list[RuleItem]:
"""Remove rules at specific priorities"""
removed = []
# Sort priorities in descending order to avoid index shift issues during removal
for priority in sorted(priorities, reverse=True):
if 0 <= priority < len(self.rules):
removed.append(self.rules.pop(priority))
return removed
def remove_rules_by_lambda(self, condition: Callable[[RuleItem], bool]):
"""Remove rules by lambda"""
initial_count = len(self.rules)
i = 0
while i < len(self.rules):
if condition(self.rules[i]):
del self.rules[i]
else:
i += 1
return initial_count - len(self.rules)
def move_rule_priority(self, from_priority: int, to_priority: int) -> bool:
"""Move rule priority to priority"""
clash_rule = self.remove_rule_at_priority(from_priority)
if not clash_rule:
return False
self.insert_rule_at_priority(clash_rule, to_priority)
return True
def filter_rules_by_condition(self, condition: Callable[[RuleItem], bool]):
"""Filter rules by condition"""
return [clash_rule for clash_rule in self.rules if condition(clash_rule)]
def filter_rules_by_type(self, rule_type: RoutingRuleType) -> List[RuleItem]:
"""Filter rules by type"""
return [clash_rule for clash_rule in self.rules
if isinstance(clash_rule.rule, ClashRule) and clash_rule.rule.rule_type == rule_type]
def filter_rules_by_action(self, action: Union[Action, str]) -> List[RuleItem]:
"""Filter rules by action"""
return [clash_rule for clash_rule in self.rules if clash_rule.rule.action == action]
def has_rule(self, clash_rule: Union[ClashRule, LogicRule, MatchRule]) -> bool:
"""Check if there is an identical rule"""
return any(r.rule == clash_rule for r in self.rules)
def has_rule_item(self, clash_rule: RuleItem) -> bool:
return any(clash_rule.meta.source == r.meta.source and r.rule == clash_rule.rule for r in self.rules)
def reorder_rules(self, moved_priority: int, target_priority: int) -> RuleItem:
"""Reorder the rules"""
if not (0 <= moved_priority < len(self.rules)):
raise IndexError("moved_priority out of range")
if not (0 <= target_priority < len(self.rules)):
raise IndexError("target_priority out of range")
rule = self.rules.pop(moved_priority)
self.rules.insert(target_priority, rule)
return rule
def update_rules_at_priorities(self, priorities: dict[int, bool]) -> list[RuleItem]:
"""Disable rules"""
updated = []
for priority, disabled in priorities.items():
if 0 <= priority < len(self.rules):
self.rules[priority].meta.disabled = disabled
updated.append(self.rules[priority])
return updated
def update_rule_meta_at_priority(self, priority: int, meta: Metadata) -> bool:
"""Update rule metadata at priority"""
if 0 <= priority < len(self.rules):
self.rules[priority].meta = meta
return True
return False
def to_list(self) -> list[RuleData]:
"""Convert parsed rules to a list"""
result: list[RuleData] = []
for priority, rule_item in enumerate(self.rules):
result.append(RuleData.from_rule_item(rule_item, priority))
return result
def clear(self):
self.rules.clear()
def __len__(self) -> int:
return len(self.rules)
def __iter__(self) -> Iterator[RuleItem]:
return iter(self.rules)

View File

@@ -0,0 +1,333 @@
import re
from typing import List, Dict, Any, Optional, Union
from pydantic import ValidationError
from ..models.rule import RuleType, Action, RoutingRuleType, MatchRule, ClashRule, LogicRule, SubRule, AdditionalParam
class ClashRuleParser:
"""Parser for Clash routing rules"""
@staticmethod
def parse(line: str) -> RuleType:
"""Parse a single rule line"""
# Handle logic rules (AND, OR, NOT)
if line.startswith(('AND,', 'OR,', 'NOT,')):
return ClashRuleParser._parse_logic_rule(line)
elif line.startswith('MATCH'):
return ClashRuleParser._parse_match_rule(line)
elif line.startswith('SUB-RULE'):
return ClashRuleParser._parse_sub_rule(line)
# Handle regular rules
return ClashRuleParser._parse_regular_rule(line)
@staticmethod
def parse_rule_line(line: str) -> Optional[RuleType]:
"""Parse a single rule line"""
line = line.strip()
try:
return ClashRuleParser.parse(line)
except (ValidationError, TypeError, ValueError, RecursionError):
return None
@staticmethod
def parse_rule_dict(clash_rule: Dict[str, Any]) -> Optional[RuleType]:
if not clash_rule:
return None
try:
if clash_rule.get("type") in ('AND', 'OR', 'NOT'):
conditions = clash_rule.get("conditions", [])
if not conditions:
return None
conditions = [ClashRuleParser._remove_parenthesis(f"({c})") for c in conditions]
conditions_str = ','.join(conditions)
conditions_str = f"({conditions_str})"
raw_rule = f"{clash_rule.get('type')},{conditions_str},{clash_rule.get('action')}"
rule = ClashRuleParser._parse_logic_rule(raw_rule)
elif clash_rule.get("type") == 'MATCH':
raw_rule = f"{clash_rule.get('type')},{clash_rule.get('action')}"
rule = ClashRuleParser._parse_match_rule(raw_rule)
elif clash_rule.get("type") == 'SUB-RULE':
condition = clash_rule.get("condition")
if not condition:
return None
condition_str = f"({condition})"
condition_str = ClashRuleParser._remove_parenthesis(condition_str)
raw_rule = f"{clash_rule.get('type')},{condition_str},{clash_rule.get('action')}"
rule = ClashRuleParser._parse_sub_rule(raw_rule)
else:
raw_rule = f"{clash_rule.get('type')},{clash_rule.get('payload')},{clash_rule.get('action')}"
if clash_rule.get('additional_params'):
raw_rule += f',{clash_rule.get('additional_params')}'
rule = ClashRuleParser._parse_regular_rule(raw_rule)
except (ValidationError, TypeError, ValueError):
return None
return rule
@staticmethod
def _parse_match_rule(line: str) -> MatchRule:
parts = line.split(',')
if len(parts) < 2:
raise ValueError(f"Invalid rule format: {line}")
action = parts[1].strip()
# Validate rule type
try:
action_enum = Action(action.upper())
final_action = action_enum
except ValueError:
final_action = action
return MatchRule(
action=final_action,
raw_rule=line
)
@staticmethod
def _parse_regular_rule(line: str) -> ClashRule:
"""Parse a regular (non-logic) rule"""
parts = line.split(',')
if len(parts) < 3 or len(parts) > 4:
raise ValueError(f"Invalid rule format: {line}")
rule_type_str = parts[0].upper().strip()
payload = parts[1].strip()
action = parts[2].strip()
if not payload or not rule_type_str:
raise ValueError(f"Invalid rule format: {line}")
additional_params = parts[3].strip() if len(parts) > 3 else None
# Validate rule type
try:
rule_type = RoutingRuleType(rule_type_str)
except ValueError:
raise ValueError(f"Unknown rule type: {rule_type_str}")
# Try to convert action to enum, otherwise keep as string (custom proxy group)
if additional_params is not None:
additional_params = AdditionalParam(additional_params)
try:
action_enum = Action(action.upper())
final_action = action_enum
except ValueError:
final_action = action
return ClashRule(
rule_type=rule_type,
payload=payload,
action=final_action,
additional_params=additional_params,
raw_rule=line
)
@staticmethod
def _parenthesis_balance(s: str) -> Optional[int]:
"""Calculate the balance of parenthesis"""
balance = 0
for i, char in enumerate(s):
if char == '(':
balance += 1
elif char == ')':
balance -= 1
if balance < 0:
return None
return balance
@staticmethod
def _parse_logic_rule(line: str) -> LogicRule:
"""Parse a logic rule (AND, OR, NOT)"""
# Extract logic type
logic_type_str, rest = line.split(',', 1)
logic_type = RoutingRuleType(logic_type_str.upper().strip())
last_comma_index = rest.rfind(',')
if last_comma_index == -1:
raise ValueError(f"Invalid logic rule format: {line}")
action_str = rest[last_comma_index + 1:]
conditions_str = rest[:last_comma_index]
# Find the matching parenthesis for the conditions block to separate conditions from action
balance = ClashRuleParser._parenthesis_balance(conditions_str)
if balance != 0:
raise ValueError(f"Mismatched parentheses in logic rule: {line}")
action = action_str.strip()
# Try to convert action to enum
try:
action_enum = Action(action.upper())
final_action = action_enum
except ValueError:
final_action = action
conditions = ClashRuleParser._parse_logic_conditions(conditions_str)
return LogicRule(
rule_type=logic_type,
conditions=conditions,
action=final_action,
raw_rule=line
)
@staticmethod
def _parse_sub_rule(line: str) -> SubRule:
"""Parse a sub-rule"""
rule_type_str, rest = line.split(',', 1)
rule_type = RoutingRuleType(rule_type_str.upper().strip())
if rule_type != RoutingRuleType.SUB_RULE:
raise ValueError(f"{rule_type.value} is not a sub-rule")
last_comma_index = rest.rfind(',')
if last_comma_index == -1:
raise ValueError(f"Invalid sub-rule format: {line}")
condition_str = rest[:last_comma_index]
action_str = rest[last_comma_index + 1:]
balance = ClashRuleParser._parenthesis_balance(condition_str)
if balance != 0:
raise ValueError(f"Mismatched parentheses in sub-rule: {line}")
conditions = ClashRuleParser._parse_logic_conditions(condition_str)
if len(conditions) != 1:
raise ValueError(f"Invalid sub-rule condition: {condition_str}")
return SubRule(
condition=conditions[0],
action=action_str,
raw_rule=line
)
@staticmethod
def _remove_parenthesis(_con_str: str):
balance = 0
filed_list = []
field = ''
for i, char in enumerate(_con_str):
if char == '(':
balance += 1
elif char == ')':
balance -= 1
elif char == ',':
if balance == 1:
filed_list.append(field)
else:
if balance == 1 and char:
field = field + char
if not any(filed_list):
return ClashRuleParser._remove_parenthesis(_con_str[1:-1])
else:
return _con_str
@staticmethod
def _parse_logic_conditions(conditions_str: str) -> List[Union[ClashRule, LogicRule]]:
"""
Parse conditions within logic rules, supporting nested logic.
The examples of conditions_str:
- (DOMAIN,baidu.com)
- (AND,(DOMAIN,baidu.com),(NETWORK,TCP))
"""
def __extract_condition_strings(_con_str: str) -> List[str]:
# Split conditions string by top-level commas
_con_str = _con_str.replace(' ', '')
_con_str = ClashRuleParser._remove_parenthesis(_con_str)
_condition_strings = []
balance = 0
start = 0
for i, char in enumerate(_con_str):
if char == '(':
if balance == 0:
start = i
balance += 1
elif char == ')':
balance -= 1
if balance == 0:
_condition_strings.append(_con_str[start:i + 1])
return _condition_strings
conditions = []
if not conditions_str:
return conditions
condition_strings = __extract_condition_strings(conditions_str)
for cond_str in condition_strings:
cond_str = cond_str.strip()
if not cond_str.startswith('(') or not cond_str.endswith(')'):
raise ValueError(f"Invalid nested logic rule format: {cond_str}")
content = cond_str[1:-1] # remove parentheses
if content.upper().startswith(('AND,', 'OR,', 'NOT,')):
# This is a nested logic rule.
parts = content.split(',', 1)
logic_type_str = parts[0].strip().upper()
logic_type = RoutingRuleType(logic_type_str)
nested_conditions_str = parts[1]
nested_conditions = ClashRuleParser._parse_logic_conditions(f'({nested_conditions_str})')
condition = LogicRule(
rule_type=logic_type,
conditions=nested_conditions,
action=Action.COMPATIBLE, # No action for conditions
raw_rule=content
)
conditions.append(condition)
else:
# Simple rule
parts = content.split(',', 1)
if len(parts) == 2:
rule_type_str, payload = parts
try:
rule_type = RoutingRuleType(rule_type_str.upper().strip())
condition = ClashRule(
rule_type=rule_type,
payload=payload.strip(),
action=Action.COMPATIBLE, # Logic conditions don't have actions
raw_rule=content
)
conditions.append(condition)
except ValueError:
raise ValueError(f"Invalid rule format: {content}")
return conditions
@staticmethod
def parse_rules(rules_text: str) -> List[Union[ClashRule, LogicRule, MatchRule]]:
"""Parse multiple rules from text, preserving order and priority"""
rules = []
lines = rules_text.strip().split('\n')
for line in lines:
rule = ClashRuleParser.parse_rule_line(line)
if rule:
rules.append(rule)
return rules
@staticmethod
def validate_rule(rule: ClashRule) -> bool:
"""Validate a parsed rule"""
try:
# Basic validation based on the rule type
if rule.rule_type in [RoutingRuleType.IP_CIDR, RoutingRuleType.IP_CIDR6]:
# Validate CIDR format
return '/' in rule.payload
elif rule.rule_type == RoutingRuleType.DST_PORT or rule.rule_type == RoutingRuleType.SRC_PORT:
# Validate port number/range
return rule.payload.isdigit() or '-' in rule.payload
elif rule.rule_type == RoutingRuleType.NETWORK:
# Validate the network type
return rule.payload.lower() in ['tcp', 'udp']
elif rule.rule_type == RoutingRuleType.DOMAIN_REGEX or rule.rule_type == RoutingRuleType.PROCESS_PATH_REGEX:
# Try to compile regex
re.compile(rule.payload)
return True
return True
except Exception:
return False

View File

@@ -0,0 +1,301 @@
import base64
import importlib
import json
import os
from typing import Dict, Any, Optional, Union
from urllib.parse import quote
from .converters import BaseConverter
class Converter:
"""
A refactored converter for V2Ray subscriptions that uses a strategy pattern.
It dynamically loads protocol-specific converters from the 'converters' directory.
"""
def __init__(self):
self._converters: Dict[str, BaseConverter] = self._load_converters()
def _load_converters(self) -> Dict[str, BaseConverter]:
"""
Dynamically discovers and loads all converter classes from the .py files
in the 'converters' directory.
"""
converters: Dict[str, BaseConverter] = {}
converter_dir = os.path.dirname(__file__)
module_names = [f.replace('.py', '') for f in os.listdir(os.path.join(converter_dir, 'converters'))
if f.endswith('.py') and not f.startswith('__')]
for module_name in module_names:
try:
module = importlib.import_module(f".converters.{module_name}", package=__package__)
class_name = f"{module_name.capitalize()}Converter"
converter_class = getattr(module, class_name, None)
if converter_class and issubclass(converter_class, BaseConverter):
instance = converter_class()
# Determine the protocol scheme based on the module name
scheme = module_name
if scheme == 'http':
converters['http'] = instance
converters['https'] = instance
elif scheme == 'socks':
converters['socks'] = instance
converters['socks5'] = instance
converters['socks5h'] = instance
elif scheme == 'hysteria2':
converters['hysteria2'] = instance
converters['hy2'] = instance
else:
converters[scheme] = instance
except (ImportError, AttributeError) as e:
# Log this error appropriately in a real application
print(f"Could not load converter for {module_name}: {e}")
return converters
def convert_line(self, line: str, names: dict[str, int] | None = None, skip_exception: bool = True,
logger: Any = None) -> dict[str, Any] | None:
"""
Parses a single subscription link and converts it to a proxy dictionary.
"""
if names is None:
names = {}
if "://" not in line:
return None
scheme, _ = line.split("://", 1)
scheme = scheme.lower()
converter = self._converters.get(scheme)
if converter:
try:
return converter.convert(line, names)
except Exception as e:
if logger:
logger.error(f"Error converting line {line}: {e}")
if not skip_exception:
raise ValueError(f"{scheme.upper()} parse error: {e}") from e
return None
return None
def convert_v2ray(self, v2ray_link: Union[list, bytes], skip_exception: bool = True,
logger: Any = None) -> dict[str, dict[str, Any]]:
"""
Converts a base64 encoded V2Ray subscription content or a list of links
into a list of proxy dictionaries.
"""
if isinstance(v2ray_link, bytes):
decoded = BaseConverter.decode_base64(v2ray_link).decode("utf-8")
lines = decoded.strip().splitlines()
else:
lines = v2ray_link
proxies: dict[str, dict[str, Any]] = {}
names = {}
for line in lines:
line = line.strip()
if not line:
continue
proxy = self.convert_line(line, names, skip_exception=skip_exception, logger=logger)
if proxy:
proxies[line] = proxy
elif not skip_exception:
raise ValueError("Failed to convert one of the links in the subscription.")
return proxies
@staticmethod
def convert_to_share_link(proxy_config: Dict[str, Any]) -> Optional[str]:
proxy_type = proxy_config.get("type")
name = proxy_config.get("name", "proxy")
if proxy_type == "vmess":
vmess_config = {
"v": "2",
"ps": name,
"add": proxy_config.get("server", ""),
"port": str(proxy_config.get("port", "")),
"id": proxy_config.get("uuid", ""),
"aid": str(proxy_config.get("alterId", 0)),
"scy": proxy_config.get("cipher", "auto"),
"net": proxy_config.get("network", "tcp"),
"type": "none",
"tls": "tls" if proxy_config.get("tls") else "",
"host": "",
"path": "/",
}
if proxy_config.get("network") == "http":
vmess_config["type"] = "http"
network = proxy_config.get("network")
if network == "ws":
ws_opts = proxy_config.get("ws-opts", {})
vmess_config["host"] = ws_opts.get("headers", {}).get("Host", "")
vmess_config["path"] = ws_opts.get("path", "/")
elif network == "http":
http_opts = proxy_config.get("http-opts", {})
vmess_config["host"] = http_opts.get("headers", {}).get("Host", "")
vmess_config["path"] = http_opts.get("path", "/")
elif network == "h2":
h2_opts = proxy_config.get("h2-opts", {})
vmess_config["host"] = h2_opts.get("host")[0] if h2_opts.get("host") else ""
vmess_config["path"] = h2_opts.get("path", "/")
# Remove empty values to keep the JSON clean
vmess_config = {k: v for k, v in vmess_config.items() if v not in ["", None]}
encoded_str = base64.b64encode(json.dumps(vmess_config).encode("utf-8")).decode("utf-8")
return f"vmess://{encoded_str}"
elif proxy_type == "ss":
method = proxy_config.get("cipher")
password = proxy_config.get("password")
server = proxy_config.get("server")
port = proxy_config.get("port")
if not all([method, password, server, port]):
return None
credentials = f"{method}:{password}@{server}:{port}"
encoded_credentials = base64.b64encode(credentials.encode("utf-8")).decode("utf-8")
return f"ss://{encoded_credentials}#{quote(name)}"
elif proxy_type == "trojan":
password = proxy_config.get("password")
server = proxy_config.get("server")
port = proxy_config.get("port")
if not all([password, server, port]):
return None
query_params = {}
if proxy_config.get("sni"):
query_params["sni"] = proxy_config["sni"]
if proxy_config.get("alpn"):
query_params["alpn"] = ",".join(proxy_config["alpn"])
if proxy_config.get("skip-cert-verify"):
query_params["allowInsecure"] = "1"
network = proxy_config.get("network")
if network:
query_params["type"] = network
if network == "ws":
ws_opts = proxy_config.get("ws-opts", {})
path = ws_opts.get("path", "/")
host = ws_opts.get("headers", {}).get("Host", "")
# Always add path and host for ws if they exist, even if defaulted, for round-trip consistency
if path:
query_params["path"] = path
if host:
query_params["host"] = host
elif network == "grpc":
grpc_opts = proxy_config.get("grpc-opts", {})
service_name = grpc_opts.get("grpc-service-name", "")
if service_name:
query_params["serviceName"] = service_name
client_fingerprint = proxy_config.get("client-fingerprint")
# Always add fp if it exists, to ensure round-trip consistency, as convert_v2ray defaults to "chrome"
if client_fingerprint:
query_params["fp"] = client_fingerprint
query_string = "&".join([f"{k}={quote(str(v))}" for k, v in query_params.items()])
base_link = f"trojan://{password}@{server}:{port}"
if query_string:
return f"{base_link}?{query_string}#{quote(name)}"
else:
return f"{base_link}#{quote(name)}"
elif proxy_type == "vless":
uuid = proxy_config.get("uuid")
server = proxy_config.get("server")
port = proxy_config.get("port")
if not all([uuid, server, port]):
return None
query_params = {}
name = proxy_config.get("name", f"{server}:{port}")
tls = proxy_config.get("tls", False)
if tls:
if "reality-opts" in proxy_config:
query_params["security"] = "reality"
reality_opts = proxy_config["reality-opts"]
if reality_opts.get("public-key"):
query_params["pbk"] = reality_opts["public-key"]
if reality_opts.get("short-id"):
query_params["sid"] = reality_opts["short-id"]
else:
query_params["security"] = "tls"
if proxy_config.get("client-fingerprint"):
query_params["fp"] = proxy_config["client-fingerprint"]
if proxy_config.get("alpn"):
query_params["alpn"] = ",".join(proxy_config["alpn"])
if proxy_config.get("skip-cert-verify"):
query_params["allowInsecure"] = "1"
if proxy_config.get("servername"):
query_params["sni"] = proxy_config["servername"]
# Network settings
network = proxy_config.get("network", "tcp")
query_params["type"] = network
if network == "ws":
ws_opts = proxy_config.get("ws-opts", {})
path = ws_opts.get("path", "")
host = ws_opts.get("headers", {}).get("Host", "")
if path:
query_params["path"] = path
if host:
query_params["host"] = host
elif network == "grpc":
grpc_opts = proxy_config.get("grpc-opts", {})
service_name = grpc_opts.get("grpc-service-name", "")
if service_name:
query_params["serviceName"] = service_name
if proxy_config.get("flow"):
query_params["flow"] = proxy_config["flow"]
query_string = "&".join([f"{k}={quote(str(v))}" for k, v in query_params.items()])
base_link = f"vless://{uuid}@{server}:{port}"
if query_string:
return f"{base_link}?{query_string}#{quote(name)}"
else:
return f"{base_link}#{quote(name)}"
elif proxy_type == "ssr":
server = proxy_config.get("server")
port = proxy_config.get("port")
protocol = proxy_config.get("protocol", "origin")
cipher = proxy_config.get("cipher")
obfs = proxy_config.get("obfs", "plain")
password = proxy_config.get("password")
name = proxy_config.get("name", f"{server}:{port}")
if not all([server, port, protocol, cipher, obfs, password]):
return None
password_enc = base64.urlsafe_b64encode(password.encode("utf-8")).decode("utf-8").rstrip('=')
ssr_main_part = f"{server}:{port}:{protocol}:{cipher}:{obfs}:{password_enc}"
query_params = {}
if proxy_config.get("obfs-param"):
query_params["obfsparam"] = base64.urlsafe_b64encode(
proxy_config["obfs-param"].encode("utf-8")).decode("utf-8").rstrip('=')
if proxy_config.get("protocol-param"):
query_params["protoparam"] = base64.urlsafe_b64encode(
proxy_config["protocol-param"].encode("utf-8")).decode("utf-8").rstrip('=')
query_params["remarks"] = base64.urlsafe_b64encode(name.encode("utf-8")).decode("utf-8").rstrip('=')
query_params["group"] = base64.urlsafe_b64encode("MoviePilot".encode("utf-8")).decode("utf-8").rstrip('=')
query_string = "&".join([f"{k}={v}" for k, v in query_params.items()])
full_ssr_link_body = f"{ssr_main_part}/?{query_string}"
encoded_full_ssr_link_body = base64.urlsafe_b64encode(
full_ssr_link_body.encode("utf-8")).decode("utf-8").rstrip('=')
return f"ssr://{encoded_full_ssr_link_body}"
return None

View File

@@ -0,0 +1,163 @@
from abc import ABC, abstractmethod
import base64
import binascii
import json
from typing import Dict, Any, Optional
from urllib.parse import unquote, urlparse, parse_qsl
class BaseConverter(ABC):
"""
Abstract base class for all protocol converters.
It defines a common interface and provides shared utility methods.
"""
user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome'
@abstractmethod
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
"""
Converts a subscription link to a proxy configuration dictionary.
:param link: The subscription link string (e.g., "vmess://...").
:param names: A dictionary to track and ensure unique proxy names.
:return: A dictionary representing the proxy configuration, or None if conversion fails.
"""
raise NotImplementedError
@staticmethod
def decode_base64(data):
# Add fault tolerance for different padding
data = data.strip()
missing_padding = len(data) % 4
if missing_padding:
data += '=' * (4 - missing_padding)
return base64.b64decode(data)
@staticmethod
def decode_base64_urlsafe(data):
data = data.strip()
missing_padding = len(data) % 4
if missing_padding:
data += '=' * (4 - missing_padding)
return base64.urlsafe_b64decode(data)
@staticmethod
def try_decode_base64_json(data):
try:
return json.loads(BaseConverter.decode_base64(data).decode('utf-8'))
except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError, TypeError):
return None
@staticmethod
def unique_name(name_map: Dict[str, int], name: str) -> str:
index = name_map.get(name, 0)
name_map[name] = index + 1
if index > 0:
return f"{name}-{index:02d}"
return name
@staticmethod
def lower_string(string: Optional[str]) -> Optional[str]:
if isinstance(string, str):
return string.lower()
return string
@staticmethod
def handle_vshare_link(link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
try:
url_info = urlparse(link)
query = dict(parse_qsl(url_info.query))
scheme = url_info.scheme.lower()
if not url_info.hostname or not url_info.port:
return None
proxy: Dict[str, Any] = {
'name': BaseConverter.unique_name(names,
unquote(url_info.fragment or f"{url_info.hostname}:{url_info.port}")),
'type': scheme,
'server': url_info.hostname,
'port': url_info.port,
'uuid': url_info.username,
'udp': True
}
# TLS and Reality settings
tls_mode = BaseConverter.lower_string(query.get('security'))
if tls_mode in ['tls', 'reality']:
proxy['tls'] = True
proxy['client-fingerprint'] = query.get('fp', 'chrome')
if 'alpn' in query:
proxy['alpn'] = query['alpn'].split(',')
if 'sni' in query:
proxy['servername'] = query['sni']
if tls_mode == 'reality':
proxy['reality-opts'] = {
'public-key': query.get('pbk'),
'short-id': query.get('sid')
}
# Network settings
network = BaseConverter.lower_string(query.get('type', 'tcp'))
header_type = BaseConverter.lower_string(query.get('headerType'))
if header_type == 'http':
network = 'http'
elif network == 'http':
network = 'h2'
proxy['network'] = network
if network == 'tcp' and header_type == 'http':
proxy['http-opts'] = {
'method': query.get('method', 'GET'),
'path': [query.get('path', '/')],
'headers': {'Host': [query.get('host', url_info.hostname)]}
}
elif network == 'h2':
proxy["h2-opts"] = {
"path": query.get("path", "/"),
"host": [query.get("host", url_info.hostname)]
}
elif network in ['ws', 'httpupgrade']:
ws_opts: Dict[str, Any] = {
'path': query.get('path', '/'),
'headers': {
'Host': query.get('host', url_info.hostname),
'User-Agent': BaseConverter.user_agent
}
}
if 'ed' in query:
try:
med = int(query['ed'])
if network == 'ws':
ws_opts['max-early-data'] = med
ws_opts['early-data-header-name'] = query.get('eh', 'Sec-WebSocket-Protocol')
elif network == 'httpupgrade':
ws_opts['v2ray-http-upgrade-fast-open'] = True
except (ValueError, TypeError):
pass
proxy['ws-opts'] = ws_opts
elif network == 'grpc':
proxy['grpc-opts'] = {
'grpc-service-name': query.get('serviceName', '')
}
# Packet Encoding
packet_encoding = BaseConverter.lower_string(query.get('packetEncoding'))
if packet_encoding == 'packet':
proxy['packet-addr'] = True
elif packet_encoding != 'none':
proxy['xudp'] = True
# Encryption
if 'encryption' in query and query['encryption']:
proxy['encryption'] = query['encryption']
if 'flow' in query:
proxy['flow'] = query['flow']
return proxy
except Exception:
return None

View File

@@ -0,0 +1,36 @@
from typing import Dict, Any, Optional
from urllib.parse import urlparse, parse_qsl, unquote
from . import BaseConverter
class AnytlsConverter(BaseConverter):
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
try:
parsed = urlparse(link)
query = dict(parse_qsl(parsed.query))
username = parsed.username
password = parsed.password or username
server = parsed.hostname
port = parsed.port
insecure = query.get("insecure", "0") == "1"
sni = query.get("sni")
fingerprint = query.get("hpkp")
name = self.unique_name(names, unquote(parsed.fragment or f"{server}:{port}"))
proxy = {
"name": name,
"type": "anytls",
"server": server,
"port": port,
"username": username,
"password": password,
"sni": sni,
"fingerprint": fingerprint,
"skip-cert-verify": insecure,
"udp": True
}
return proxy
except Exception:
return None

View File

@@ -0,0 +1,46 @@
import binascii
from typing import Dict, Any, Optional
from urllib.parse import urlparse, unquote
from . import BaseConverter
class HttpConverter(BaseConverter):
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
try:
parsed = urlparse(link)
server = parsed.hostname
port = parsed.port
name = self.unique_name(names, unquote(parsed.fragment or f"{server}:{port}"))
username = None
password = None
if parsed.username:
try:
# The userinfo part might be base64 encoded
decoded_userinfo = self.decode_base64(parsed.username.encode('utf-8')).decode('utf-8')
if ":" in decoded_userinfo:
username, password = decoded_userinfo.split(":", 1)
else:
username = decoded_userinfo
except (binascii.Error, UnicodeDecodeError):
# If not base64 encoded, use directly
username = parsed.username
password = parsed.password if parsed.password else ""
proxy = {
"name": name,
"type": "http",
"server": server,
"port": port,
"username": username,
"password": password,
"skip-cert-verify": True
}
if parsed.scheme == "https":
proxy["tls"] = True
return proxy
except Exception:
return None

View File

@@ -0,0 +1,59 @@
from typing import Dict, Any, Optional
from urllib.parse import urlparse, parse_qsl, unquote
from app.utils.string import StringUtils
from . import BaseConverter
class HysteriaConverter(BaseConverter):
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
try:
parsed = urlparse(link)
query = dict(parse_qsl(parsed.query))
name = self.unique_name(names, unquote(parsed.fragment or f"{parsed.hostname}:{parsed.port}"))
hysteria: Dict[str, Any] = {
"name": name,
"type": "hysteria",
"server": parsed.hostname,
"port": parsed.port,
"udp": True
}
auth_str = query.get("auth")
if auth_str:
hysteria["auth_str"] = auth_str
obfs = query.get("obfs")
if obfs:
hysteria["obfs"] = obfs
sni = query.get("peer")
if sni:
hysteria["sni"] = sni
protocol = query.get("protocol")
if protocol:
hysteria["protocol"] = protocol
up = query.get("up")
if not up:
up = query.get("upmbps")
if up:
hysteria["up"] = up
down = query.get("down")
if not down:
down = query.get("downmbps")
if down:
hysteria["down"] = down
alpn = query.get("alpn", "")
if alpn:
hysteria["alpn"] = alpn.split(",")
# skip-cert-verify
insecure_str = query.get("insecure", "false")
try:
skip_cert_verify = StringUtils.to_bool(insecure_str)
if skip_cert_verify:
hysteria["skip-cert-verify"] = skip_cert_verify
except ValueError:
pass
return hysteria
except Exception:
return None

View File

@@ -0,0 +1,45 @@
from typing import Dict, Any, Optional
from urllib.parse import urlparse, parse_qsl, unquote
from app.utils.string import StringUtils
from . import BaseConverter
class Hysteria2Converter(BaseConverter):
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
try:
parsed = urlparse(link)
query = dict(parse_qsl(parsed.query))
user_info = ""
if parsed.username:
if parsed.password:
user_info = f"{parsed.username}:{parsed.password}"
else:
user_info = parsed.username
password = user_info
server = parsed.hostname
port = parsed.port or 443
name = self.unique_name(names, unquote(parsed.fragment or f"{server}:{port}"))
proxy = {
"name": name,
"type": "hysteria2",
"server": server,
"port": port,
"password": password,
"obfs": query.get("obfs"),
"obfs-password": query.get("obfs-password"),
"sni": query.get("sni"),
"skip-cert-verify": StringUtils.to_bool(query.get("insecure", "false")),
"down": query.get("down"),
"up": query.get("up"),
"udp": True
}
if "pinSHA256" in query:
proxy["fingerprint"] = query.get("pinSHA256")
if "alpn" in query:
proxy["alpn"] = query["alpn"].split(",")
return proxy
except Exception:
return None

View File

@@ -0,0 +1,42 @@
import binascii
from typing import Dict, Any, Optional
from urllib.parse import urlparse, unquote
from . import BaseConverter
class SocksConverter(BaseConverter):
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
try:
parsed = urlparse(link)
server = parsed.hostname
port = parsed.port
name = self.unique_name(names, unquote(parsed.fragment or f"{server}:{port}"))
username = None
password = None
if parsed.username:
try:
# The userinfo part might be base64 encoded
decoded_userinfo = self.decode_base64(parsed.username.encode('utf-8')).decode('utf-8')
if ":" in decoded_userinfo:
username, password = decoded_userinfo.split(":", 1)
else:
username = decoded_userinfo
except (binascii.Error, UnicodeDecodeError):
# If not base64 encoded, use directly
username = parsed.username
password = parsed.password if parsed.password else ""
proxy = {
"name": name,
"type": "socks5",
"server": server,
"port": port,
"username": username,
"password": password,
"skip-cert-verify": True
}
return proxy
except Exception:
return None

View File

@@ -0,0 +1,75 @@
import binascii
from typing import Dict, Any, Optional
from urllib.parse import urlparse, parse_qsl, unquote
from . import BaseConverter
class SsConverter(BaseConverter):
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
try:
parsed = urlparse(link)
if parsed.port is None and parsed.netloc:
base64_body = parsed.netloc
decoded_body = self.decode_base64_urlsafe(base64_body).decode('utf-8')
new_line = f"ss://{decoded_body}"
if parsed.fragment:
new_line += f"#{parsed.fragment}"
parsed = urlparse(new_line)
name = self.unique_name(names, unquote(parsed.fragment or f"{parsed.hostname}:{parsed.port}"))
cipher_raw = parsed.username
password = parsed.password
cipher = cipher_raw
if not password and cipher_raw:
try:
decoded_user = self.decode_base64_urlsafe(cipher_raw).decode('utf-8')
except (binascii.Error, UnicodeDecodeError):
decoded_user = self.decode_base64(cipher_raw).decode('utf-8')
if ":" in decoded_user:
cipher, password = decoded_user.split(":", 1)
else:
cipher = decoded_user
server = parsed.hostname
port = parsed.port
query = dict(parse_qsl(parsed.query))
proxy = {
"name": name,
"type": "ss",
"server": server,
"port": port,
"cipher": cipher,
"password": password,
"udp": True
}
if query.get("udp-over-tcp") == "true" or query.get("uot") == "1":
proxy["udp-over-tcp"] = True
plugin = query.get("plugin")
if plugin and ";" in plugin:
query_string = "pluginName=" + plugin.replace(";", "&")
plugin_info = dict(parse_qsl(query_string))
plugin_name = plugin_info.get("pluginName", "")
if "obfs" in plugin_name:
proxy["plugin"] = "obfs"
proxy["plugin-opts"] = {
"mode": plugin_info.get("obfs"),
"host": plugin_info.get("obfs-host"),
}
elif "v2ray-plugin" in plugin_name:
proxy["plugin"] = "v2ray-plugin"
proxy["plugin-opts"] = {
"mode": plugin_info.get("mode"),
"host": plugin_info.get("host"),
"path": plugin_info.get("path"),
"tls": "tls" in plugin,
}
return proxy
except Exception:
return None

View File

@@ -0,0 +1,64 @@
import binascii
from typing import Dict, Any, Optional
from urllib.parse import parse_qsl
from . import BaseConverter
class SsrConverter(BaseConverter):
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
try:
_, body = link.split("://", 1)
try:
decoded_body = self.decode_base64_urlsafe(body).decode('utf-8')
except (binascii.Error, UnicodeDecodeError):
decoded_body = self.decode_base64(body).decode('utf-8')
parts, _, params_str = decoded_body.partition("/?")
part_list = parts.split(":", 5)
if len(part_list) != 6:
raise ValueError("Invalid SSR link format: incorrect number of parts")
host, port_str, protocol, method, obfs, password_enc = part_list
try:
port = int(port_str)
except ValueError:
raise ValueError("Invalid port in SSR link")
password = self.decode_base64_urlsafe(password_enc).decode('utf-8')
params = dict(parse_qsl(params_str))
remarks_b64 = params.get("remarks", "")
remarks = self.decode_base64_urlsafe(remarks_b64).decode('utf-8') if remarks_b64 else ""
obfsparam_b64 = params.get("obfsparam", "")
obfsparam = self.decode_base64_urlsafe(obfsparam_b64).decode(
'utf-8') if obfsparam_b64 else ""
protoparam_b64 = params.get("protoparam", "")
protoparam = self.decode_base64_urlsafe(protoparam_b64).decode(
'utf-8') if protoparam_b64 else ""
name = self.unique_name(names, remarks or f"{host}:{port}")
proxy = {
"name": name,
"type": "ssr",
"server": host,
"port": port,
"cipher": method,
"password": password,
"obfs": obfs,
"protocol": protocol,
"udp": True
}
if obfsparam:
proxy["obfs-param"] = obfsparam
if protoparam:
proxy["protocol-param"] = protoparam
return proxy
except Exception:
return None

View File

@@ -0,0 +1,60 @@
from typing import Dict, Any, Optional
from urllib.parse import urlparse, parse_qsl, unquote
from app.utils.string import StringUtils
from . import BaseConverter
class TrojanConverter(BaseConverter):
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
try:
parsed = urlparse(link)
query = dict(parse_qsl(parsed.query))
name = self.unique_name(names, unquote(parsed.fragment or f"{parsed.hostname}:{parsed.port}"))
trojan: Dict[str, Any] = {
"name": name,
"type": "trojan",
"server": parsed.hostname,
"port": parsed.port or 443,
"password": parsed.username or "",
"udp": True,
"tls": True
}
# skip-cert-verify
try:
trojan["skip-cert-verify"] = StringUtils.to_bool(query.get("allowInsecure", "0"))
except ValueError:
trojan["skip-cert-verify"] = False
# optional fields
if "sni" in query:
trojan["sni"] = query["sni"]
alpn = query.get("alpn")
if alpn:
trojan["alpn"] = alpn.split(",")
network = query.get("type", "").lower()
if network:
trojan["network"] = network
if network == "ws":
headers = {"User-Agent": self.user_agent}
trojan["ws-opts"] = {
"path": query.get("path", "/"),
"headers": headers
}
elif network == "grpc":
trojan["grpc-opts"] = {
"grpc-service-name": query.get("serviceName")
}
fp = query.get("fp")
trojan["client-fingerprint"] = fp if fp else "chrome"
return trojan
except Exception:
return None

View File

@@ -0,0 +1,46 @@
from typing import Dict, Any, Optional
from urllib.parse import urlparse, parse_qsl, unquote
from . import BaseConverter
class TuicConverter(BaseConverter):
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
try:
parsed = urlparse(link)
query = dict(parse_qsl(parsed.query))
user = parsed.username
password = parsed.password
server = parsed.hostname
port = parsed.port
name = self.unique_name(names, unquote(parsed.fragment or f"{server}:{port}"))
proxy = {
"name": name,
"type": "tuic",
"server": server,
"port": port,
"udp": True
}
if password:
proxy["uuid"] = user
proxy["password"] = password
else:
proxy["token"] = user
if "congestion_control" in query:
proxy["congestion-controller"] = query["congestion_control"]
if "alpn" in query:
proxy["alpn"] = query["alpn"].split(",")
if "sni" in query:
proxy["sni"] = query["sni"]
if query.get("disable_sni", "0") == "1":
proxy["disable-sni"] = True
if "udp_relay_mode" in query:
proxy["udp-relay-mode"] = query["udp_relay_mode"]
return proxy
except Exception:
return None

View File

@@ -0,0 +1,11 @@
from typing import Dict, Any, Optional
from . import BaseConverter
class VlessConverter(BaseConverter):
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
try:
return self.handle_vshare_link(link, names)
except Exception:
return None

View File

@@ -0,0 +1,106 @@
from typing import Dict, Any, Optional
from . import BaseConverter
class VmessConverter(BaseConverter):
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
try:
_, body = link.split("://", 1)
vmess_data = self.try_decode_base64_json(body)
# Xray VMessAEAD share link
if vmess_data is None:
return self.handle_vshare_link(link, names)
name = self.unique_name(names, vmess_data.get("ps", "vmess"))
net = self.lower_string(vmess_data.get("net"))
fake_type = self.lower_string(vmess_data.get("type"))
tls_mode = self.lower_string(vmess_data.get("tls"))
cipher = vmess_data.get("scy", "auto") or "auto"
alter_id = vmess_data.get("aid", 0)
# Adjust network type
if fake_type == "http":
net = "http"
elif net == "http":
net = "h2"
proxy = {
"name": name,
"type": "vmess",
"server": vmess_data.get("add"),
"port": vmess_data.get("port"),
"uuid": vmess_data.get("id"),
"alterId": alter_id,
"cipher": cipher,
"tls": tls_mode.endswith("tls") or tls_mode == "reality",
"udp": True,
"xudp": True,
"skip-cert-verify": False,
"network": net
}
# TLS Reality extension
if proxy["tls"]:
proxy["client-fingerprint"] = vmess_data.get("fp", "chrome") or "chrome"
alpn = vmess_data.get("alpn")
if alpn:
proxy["alpn"] = alpn.split(",") if isinstance(alpn, str) else alpn
sni = vmess_data.get("sni")
if sni:
proxy["servername"] = sni
if tls_mode == "reality":
proxy["reality-opts"] = {
"public-key": vmess_data.get("pbk"),
"short-id": vmess_data.get("sid")
}
path = vmess_data.get("path", "/")
host = vmess_data.get("host")
# Extension fields for different networks
if net == "tcp":
if fake_type == "http":
proxy["http-opts"] = {
"path": path,
"headers": {"Host": host} if host else {}
}
elif net == "http":
headers = {}
if host:
headers["Host"] = [host]
proxy["http-opts"] = {"path": [path], "headers": headers}
elif net == "h2":
proxy["h2-opts"] = {
"path": path,
"host": [host] if host else []
}
elif net == "ws":
ws_headers = {"Host": host} if host else {}
ws_headers["User-Agent"] = self.user_agent
ws_opts = {
"path": path,
"headers": ws_headers
}
# Add early-data config
early_data = vmess_data.get("ed")
if early_data:
try:
ws_opts["max-early-data"] = int(early_data)
except ValueError:
pass
early_data_header = vmess_data.get("edh")
if early_data_header:
ws_opts["early-data-header-name"] = early_data_header
proxy["ws-opts"] = ws_opts
elif net == "grpc":
proxy["grpc-opts"] = {
"grpc-service-name": path
}
return proxy
except Exception:
return None

View File

@@ -0,0 +1,118 @@
import copy
import time
from typing import Any
import jsonpatch
from pydantic import ValidationError
from app.db.plugindata_oper import PluginDataOper
from app.log import logger
from ..configconverter import Converter
from ..utilsprovider import UtilsProvider
from ...models.proxygroups import ProxyGroupData
from ...models.proxy import Proxy, ProxyData
from ...models.ruleproviders import RuleProviderData
from ...models.types import DataSource, DataKey
from ...models.datapatch import PatchItem
from ...models.metadata import Metadata
def _overwrite_proxy(proxy: dict[str, Any], overwritten_proxies: dict[str, Any]) -> dict[str, Any]:
if proxy["name"] in overwritten_proxies:
for key in ['base', 'tls', 'network']:
if overlay := overwritten_proxies[proxy["name"]].get(key):
proxy.update(copy.deepcopy(overlay))
return proxy
def upgrade(plugin_id: str):
data_oper = PluginDataOper()
# Upgrade proxy groups
proxy_groups = data_oper.get_data(plugin_id, "proxy_groups") or []
new_pg, invalid_pg, names = [], [], set()
for pg in proxy_groups:
try:
obj = ProxyGroupData(meta=Metadata(source=DataSource.MANUAL), data=pg, name=pg["name"])
if obj.name not in names:
new_pg.append(obj.model_dump(by_alias=True, exclude_none=True))
names.add(obj.name)
except ValidationError:
logger.error(f"升级代理组失败: {pg}")
invalid_pg.append(pg)
data_oper.save(plugin_id, DataKey.PROXY_GROUPS, new_pg)
data_oper.save(plugin_id, "proxy_groups", invalid_pg)
# Upgrade rule providers
rule_providers = data_oper.get_data(plugin_id, "extra_rule_providers") or {}
new_rp, invalid_rp = [], []
for name, rp in rule_providers.items():
try:
obj = RuleProviderData(meta=Metadata(source=DataSource.MANUAL), name=name, data=rp)
new_rp.append(obj.model_dump(by_alias=True, exclude_none=True))
except ValidationError:
logger.error(f"升级规则集失败: {rp}")
invalid_rp.append(rp)
data_oper.save(plugin_id, DataKey.RULE_PROVIDERS, new_rp)
data_oper.save(plugin_id, "extra_rule_providers", invalid_rp)
# Upgrade proxies
proxies = data_oper.get_data(plugin_id, DataKey.PROXIES) or []
new_proxies, invalid_proxies = [], []
all_proxies = []
names = set()
converter = Converter()
for proxy in proxies:
try:
raw = None
if isinstance(proxy, str):
proxy_dict, raw = converter.convert_line(proxy), proxy
elif isinstance(proxy, dict):
proxy_dict = UtilsProvider.filter_empty(proxy, empty=['', None])
else:
continue
obj = Proxy.model_validate(proxy_dict)
if obj.name in names: continue
p_data = ProxyData(data=obj, name=obj.name, meta=Metadata(source=DataSource.MANUAL), raw=raw)
new_proxies.append(p_data.model_dump(by_alias=True, exclude_none=True))
all_proxies.append(p_data.data)
names.add(p_data.name)
except Exception:
logger.error(f"升级代理失败: {proxy}")
invalid_proxies.append(proxy)
data_oper.save(plugin_id, DataKey.PROXIES, new_proxies)
data_oper.save(plugin_id, "extra_proxies", invalid_proxies)
# Create proxy patches
data_patch = {}
overwritten = data_oper.get_data(plugin_id, "overwritten_proxies") or {}
for name in overwritten:
if proxy := next((p for p in all_proxies if p.name == name), None):
src = proxy.model_dump(by_alias=True)
# Create a deep copy for dst to avoid modifying src in place if _overwrite_proxy mutates
dst = _overwrite_proxy(copy.deepcopy(src), overwritten)
if patch := jsonpatch.make_patch(src, dst).to_string():
data_patch[name] = PatchItem(patch=patch).model_dump(by_alias=True, exclude_none=True)
data_oper.save(plugin_id, DataKey.PROXY_PATCH, data_patch)
data_oper.save(plugin_id, DataKey.ACL4SSR, [])
# Upgrade rules
for key in [DataKey.TOP_RULES, DataKey.RULESET_RULES]:
if rules := data_oper.get_data(plugin_id, key):
for rule in rules:
rule["meta"] = Metadata(
source=rule.get("remark") or DataSource.MANUAL,
time_modified=rule.get("time_modified") or time.time()
).model_dump()
data_oper.save(plugin_id, key, rules)
data_oper.save(plugin_id, DataKey.DATA_VERSION, "2.1.0")

View File

@@ -0,0 +1,73 @@
import math
import time
from typing import Any, Optional, List, Dict
from urllib.parse import urlparse
class UtilsProvider:
@staticmethod
def filter_empty(original_dict: dict, empty: Optional[List[Any]] = None) -> dict:
"""过滤字典中的空值"""
return {k: v for k, v in original_dict.items() if v not in (empty or [None, '', [], {}])}
@staticmethod
def get_url_domain(url: str) -> str:
"""从 url 中提取域名"""
if not url:
return ""
parsed = urlparse(url)
if not parsed.netloc:
parsed = urlparse("https://" + url)
return parsed.netloc
@staticmethod
def find_cycles(graph: Dict[Any, Any]) -> List[List[Any]]:
"""DFS 检测环,并记录路径"""
visited = set()
stack = []
cycles = []
def dfs(node):
if node in stack:
cycle_index = stack.index(node)
cycles.append(stack[cycle_index:] + [node])
return
if node in visited:
return
visited.add(node)
stack.append(node)
for nei in graph.get(node, []):
dfs(nei)
stack.pop()
for n in graph:
if n not in visited:
dfs(n)
return cycles
@staticmethod
def format_bytes(value_bytes):
if value_bytes == 0:
return '0 B'
k = 1024
sizes = ['B', 'KB', 'MB', 'GB', 'TB']
i = math.floor(math.log(value_bytes) / math.log(k)) if value_bytes > 0 else 0
return f"{value_bytes / math.pow(k, i):.2f} {sizes[i]}"
@staticmethod
def format_expire_time(timestamp):
seconds_left = timestamp - int(time.time())
days = seconds_left // 86400
return f"{days}天后过期" if days > 0 else "已过期"
@staticmethod
def update_with_checking(src_dict: Dict[str, Any], dst_dict: Dict[str, Any]) -> Dict[str, Any]:
"""
跳过存在的键合并字典
"""
for key, value in src_dict.items():
if key in dst_dict:
continue
dst_dict[key] = value
return dst_dict

View File

@@ -0,0 +1,6 @@
from .proxy import *
from .hosts import *
from .ruleitem import *
from .ruleproviders import *
from .proxygroups import *
from .proxyproviders import *

View File

@@ -0,0 +1,71 @@
from typing import List
from pydantic import BaseModel, Field, RootModel
from simpleeval import simple_eval
class ClashApi(BaseModel):
url: str
secret: str
class Connectivity(BaseModel):
clash_apis: List[ClashApi] = Field(default_factory=list)
sub_links: List[str] = Field(default_factory=list)
class SubscriptionSetting(BaseModel):
url: str
enabled: bool
class DataUsage(BaseModel):
upload: int = 0
download: int = 0
total: int = 0
expire: int = 0
@property
def header(self) -> str:
return f'upload={self.upload}; download={self.download}; total={self.total}; expire={self.expire};'
class SubscriptionInfo(DataUsage):
last_update: int = Field(default=0)
proxy_num: int = Field(default=0)
enabled: bool = True
def update(self, setting: SubscriptionSetting):
self.enabled = setting.enabled
class SubscriptionsInfo(RootModel[dict[str, SubscriptionInfo]]):
root: dict[str, SubscriptionInfo] = Field(default_factory=dict)
def update(self, urls: list[str]):
if not urls:
return
self.root.clear()
for url in urls:
self.root[url] = self.root.get(url, SubscriptionInfo())
def get(self, url: str) -> SubscriptionInfo:
return self.root.get(url, SubscriptionInfo())
def __setitem__(self, key: str, value: SubscriptionInfo):
self.root[key] = value
def set(self, setting: SubscriptionSetting):
if setting.url in self.root:
self.root[setting.url].update(setting)
class ConfigRequest(BaseModel):
url: str
client_host: str
identifier: str | None = None
user_agent : str | None = None
def resolve(self, expr) -> bool:
return bool(simple_eval(expr=expr, names=self.model_dump()))

View File

@@ -0,0 +1,233 @@
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator, field_validator, field_serializer, PrivateAttr
from app.log import logger
from .proxy import Proxy
from .proxygroups import ProxyGroup
from .proxyproviders import ProxyProvider
from .proxy.tlsmixin import ClientFingerprint
from .ruleproviders import RuleProvider
from .rule import RuleType, Action, RoutingRuleType
from ..helper.clashruleparser import ClashRuleParser
class ExternalControllerCors(BaseModel):
allow_origins: list[str] = Field(default_factory=lambda: ["*"], alias="allow-origins")
allow_credentials: bool = Field(default=True, alias="allow-credentials")
class Profile(BaseModel):
store_selected: bool = Field(default=False, alias="store-selected")
store_fake_ip: bool = Field(default=False, alias="store-fake-ip")
class NTP(BaseModel):
enable: bool = Field(default=False)
Server: str = Field(default="time.apple.com")
port: int = Field(default=123)
write_to_system: bool = Field(default=False, alias="write-to-system")
class Experimental(BaseModel):
quic_go_disable_gso: bool = Field(default=False, alias="quic-go-disable-gso")
quic_go_disable_ecn: bool = Field(default=True, alias="quic-go-disable-ecn")
dialer_ip4p_convert: bool = Field(default=False, alias="dialer-ip4p-convert")
class ClashConfig(BaseModel):
_raw_proxies: dict[str, str] = PrivateAttr(default_factory=dict)
dns: dict[str, Any] | None = Field(default=None)
hosts: dict[str, list[str] | str] | None = Field(default=None)
allow_lan: bool | None = Field(default=None, alias="allow-lan")
bind_address: str = Field(default="*", alias="bind-address")
lan_allowed_ips: list[str] = Field(default_factory=lambda: ["0.0.0.0/0", "::/0"], alias="lan-allowed-ips")
lan_disallowed_ips: list[str] = Field(default_factory=list, alias="lan-disallowed-ips")
authentication: list[str] = Field(default_factory=list)
skip_auth_prefixes: list[str] = Field(default_factory=list, alias="skip-auth-prefixes")
mode: Literal["rule", "global", "direct"] = Field(default="rule")
log_level: Literal["silent", "error", "warning", "info", "debug"] = Field(default="info", alias="log-level")
ipv6: bool = Field(default=True)
keep_alive_interval: int = Field(default=0, alias="keep-alive-interval")
keep_alive_idle: int = Field(default=0, alias="keep-alive-idle")
disable_keep_alive: bool = Field(default=False, alias="disable-keep-alive")
find_process_mode: Literal["strict", "always", "off"] = Field(default="strict", alias="find-process-mode")
external_controller: str | None = Field(default=None, alias="external-controller")
external_controller_cors: ExternalControllerCors = Field(default_factory=ExternalControllerCors,
alias="external-controller-cors")
external_controller_unix: str | None = Field(default=None, alias="external-controller-unix")
external_controller_pipe: str | None = Field(default=None, alias="external-controller-pipe")
external_controller_tls: str | None = Field(default=None, alias="external-controller-tls")
secret: str | None = Field(default=None)
external_ui: str | None = Field(default=None, alias="external-ui")
external_ui_name: str | None = Field(default=None, alias="external-ui-name")
external_ui_url: str | None = Field(default=None, alias="external-ui-url")
profile: Profile = Field(default_factory=Profile)
unified_delay: bool = Field(default=True, alias="unified-delay")
tcp_concurrent: bool = Field(default=True, alias="tcp-concurrent")
interface_name: str | None = Field(default=None, alias="interface-name")
routing_mark: int | None = Field(default=None, alias="routing-mark")
tls: dict[str, Any] | None = Field(default=None, alias="tls")
global_client_fingerprint: ClientFingerprint | None = Field(default=ClientFingerprint.chrome,
alias="global-client-fingerprint")
geodata_mode: bool | None = Field(default=None, alias="geodata-mode")
geodata_loader: Literal["memconservative", "standard"] = Field(default="memconservative", alias="geodata-loader")
geo_auto_update: bool = Field(default=False, alias="geo-auto-update")
geo_update_interval: int = Field(default=24, alias="geo-update-interval")
global_ua: str = Field(default="clash.meta", alias="global-ua")
etag_support: bool = Field(default=True, alias="etag-support")
sniffer: dict[str, Any] | None = None
listeners: list[dict[str, Any]] | None = Field(default=None)
port: int = Field(default=0, description="HTTP(S) proxy port")
socks_port: int = Field(default=0, alias="socks-port")
mixed_port: int = Field(default=0, alias="mixed-port")
redir_port: int = Field(default=0, alias="redir-port")
tproxy_port: int = Field(default=0, alias="tproxy-port")
tun: dict[str, Any] | None = Field(default=None)
sub_rules: dict[str, Any] | None = Field(default=None, alias="sub-rules")
tunnels: list[dict[str, Any] | str] | None = Field(default=None)
ntp: NTP | None = Field(default=None)
experimental: Experimental | None = Field(default=None)
proxies: list[Proxy] = Field(default_factory=list)
proxy_providers: dict[str, ProxyProvider] = Field(default_factory=dict, alias="proxy-providers")
proxy_groups: list[ProxyGroup] = Field(default_factory=list, alias="proxy-groups")
rules: list[RuleType] = Field(default_factory=list)
rule_providers: dict[str, RuleProvider] = Field(default_factory=dict, alias="rule-providers")
@model_validator(mode="before")
@classmethod
def fill_none_with_default(cls, values: dict):
fill_none_fields = {"proxies", "proxy_providers", "proxy_groups", "rules", "rule_providers"}
for field_name in fill_none_fields:
field = cls.model_fields[field_name]
factory = field.default_factory
if not factory:
continue
keys = {field_name}
if field.alias:
keys.add(field.alias)
for key in keys:
if key in values and values[key] is None:
values[key] = factory()
return values
@field_serializer("proxies")
def serialize_proxies(self, v: list[Proxy], _info):
serialized_proxies = []
seen_names = set()
for proxy in v:
if proxy.name in seen_names:
logger.warning(f"Skipping duplicate proxy: {proxy.name}")
continue
seen_names.add(proxy.name)
serialized_proxies.append(proxy.model_dump(by_alias=True, exclude_none=True, mode="json"))
return serialized_proxies
@field_serializer("proxy_groups")
def serialize_proxy_groups(self, v: list[ProxyGroup], _info):
valid_outbounds = {a.value for a in Action}
valid_outbounds.add("GLOBAL")
if self.proxies:
valid_outbounds.update(p.name for p in self.proxies)
if v:
valid_outbounds.update(pg.name for pg in v)
serialized_groups = []
seen_names = set()
for group in v:
if group.name in seen_names:
logger.warning(f"Skipping duplicate proxy group: {group.name}")
continue
seen_names.add(group.name)
group_data = group.model_dump(by_alias=True, exclude_none=True, mode="json")
if "proxies" in group_data and group_data["proxies"]:
original_proxies = group_data["proxies"]
group_data["proxies"] = [
p for p in original_proxies if p in valid_outbounds
]
removed = set(original_proxies) - set(group_data["proxies"])
if removed:
logger.warning(f"Proxy group {group.name} removed missing proxies: {removed}")
serialized_groups.append(group_data)
return serialized_groups
@field_validator("mode", mode="before")
@classmethod
def validate_mode(cls, v):
if isinstance(v, str):
return v.lower()
return v
@field_validator("rules", mode="before")
@classmethod
def validate_rules(cls, v):
if isinstance(v, list):
rules = []
for item in v:
if isinstance(item, str):
rules.append(ClashRuleParser.parse(item))
else:
rules.append(item)
return rules
return v
@field_serializer("rules")
def serialize_rules(self, v: list[RuleType], _info):
valid_rules = []
valid_outbounds = set(self.outbounds)
valid_actions = {a.value for a in Action}
for rule in v:
if rule.rule_type == RoutingRuleType.SUB_RULE:
if self.sub_rules and rule.action in self.sub_rules:
valid_rules.append(rule)
else:
logger.warning(f"Skipping rule with missing sub-rule action: {rule}")
continue
if rule.rule_type == RoutingRuleType.RULE_SET:
if rule.payload not in self.rule_providers:
logger.warning(f"Skipping rule with missing rule-provider: {rule}")
continue
action_str = str(rule.action)
if action_str in valid_actions or action_str in valid_outbounds:
valid_rules.append(rule)
else:
logger.warning(f"Skipping rule with invalid outbound: {rule}")
return [str(rule) for rule in valid_rules]
@property
def outbounds(self) -> list[str]:
outbounds = []
if self.proxies:
outbounds.extend(p.name for p in self.proxies)
if self.proxy_groups:
outbounds.extend(pg.name for pg in self.proxy_groups)
return outbounds
@property
def node_num(self) -> int:
return len(self.proxies)
@property
def raw_proxies(self) -> dict[str, str]:
return self._raw_proxies
@raw_proxies.setter
def raw_proxies(self, value: dict[str, str]):
self._raw_proxies = value
def merge(self, other: 'ClashConfig') -> 'ClashConfig':
self.proxies += other.proxies
self.proxy_groups += other.proxy_groups
self.rules += other.rules
self.rule_providers |= other.rule_providers
self.proxy_providers |= other.proxy_providers
return self

View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel, Field
from .api import SubscriptionsInfo
from .configuration import ClashConfig
from .datapatch import DataPatch
from .hosts import Hosts
from .proxy import Proxies
from .proxygroups import ProxyGroups
from .ruleproviders import RuleProviders, RuleProvider
from .types import DataKey
class GeoRules(BaseModel):
geoip: list[str] = Field(default_factory=list)
geosite: list[str] = Field(default_factory=list)
class PersistState(BaseModel):
proxies: Proxies = Field(alias=DataKey.PROXIES, default_factory=Proxies)
proxy_groups: ProxyGroups = Field(alias=DataKey.PROXY_GROUPS, default_factory=ProxyGroups)
subscription_info: SubscriptionsInfo = Field(alias=DataKey.SUB_INFO, default_factory=SubscriptionsInfo)
rule_provider: dict[str, RuleProvider] = Field(alias=DataKey.AUTO_RULE_PROVIDERS, default_factory=dict)
rule_providers: RuleProviders = Field(alias=DataKey.RULE_PROVIDERS, default_factory=RuleProviders)
ruleset_names: dict[str, str] = Field(alias=DataKey.RULESET_NAMES, default_factory=dict)
acl4ssr_providers: RuleProviders = Field(alias=DataKey.ACL4SSR, default_factory=RuleProviders)
sub_configs: dict[str, ClashConfig] = Field(alias=DataKey.SUB_CONFIGS, default_factory=dict)
hosts: Hosts = Field(alias=DataKey.HOSTS, default_factory=Hosts)
proxy_group_patch: DataPatch = Field(alias=DataKey.PROXY_GROUP_PATCH, default_factory=DataPatch)
proxy_patch: DataPatch = Field(alias=DataKey.PROXY_PATCH, default_factory=DataPatch)
geo_rules: GeoRules = Field(alias=DataKey.GEO_RULES, default_factory=GeoRules)
rule_provider_patch: DataPatch = Field(alias=DataKey.RULE_PROVIDER_PATCH, default_factory=DataPatch)

View File

@@ -0,0 +1,32 @@
from pydantic import BaseModel, Field, RootModel
class PatchItem(BaseModel):
lifecycle: int = Field(default=3)
patch: str
class DataPatch(RootModel[dict[str, PatchItem]]):
"""DataPatch model for storing patch items."""
root: dict[str, PatchItem] = Field(default_factory=dict, description="Dictionary of patch items.")
def update_patch(self, alive_keys: list[str] | set[str], lifespan: int = 3):
outdated_keys = []
for key in list(self.root.keys()):
if key not in alive_keys:
self.root[key].lifecycle -= 1
if self.root[key].lifecycle == 0:
outdated_keys.append(key)
else:
self.root[key].lifecycle = lifespan
for key in outdated_keys:
del self.root[key]
def __setitem__(self, key: str, value: PatchItem):
self.root[key] = value
def __contains__(self, key: str) -> bool:
return key in self.root
def __getitem__(self, key: str) -> PatchItem:
return self.root[key]

Some files were not shown because too many files have changed in this diff Show More