mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-06-14 07:26:48 +00:00
Compare commits
260 Commits
AutoSignIn
...
ClashRuleP
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9862c81477 | ||
|
|
9fb3e09042 | ||
|
|
1ad19a5b23 | ||
|
|
527327c6cb | ||
|
|
a398dcb0b8 | ||
|
|
f3232dba0a | ||
|
|
e78a371663 | ||
|
|
068838d013 | ||
|
|
57f2ad523c | ||
|
|
615f85f02b | ||
|
|
74f47c7131 | ||
|
|
87224308d6 | ||
|
|
4b413d93a8 | ||
|
|
34e72a7ae3 | ||
|
|
944af59468 | ||
|
|
0f898f283e | ||
|
|
07a4731feb | ||
|
|
d3faafe6ee | ||
|
|
8bff87f1c5 | ||
|
|
889f393d2a | ||
|
|
e008da0c2b | ||
|
|
f3d1aa1ea9 | ||
|
|
77f399ffa0 | ||
|
|
e101d5c2bd | ||
|
|
a0d25abe25 | ||
|
|
bd3f6fe2e5 | ||
|
|
7f41a8a5f2 | ||
|
|
c33e7fe9df | ||
|
|
20e18117ab | ||
|
|
750d5917a2 | ||
|
|
fc23e3639d | ||
|
|
8a5b01f58f | ||
|
|
72bb3320ac | ||
|
|
2a4002032d | ||
|
|
be12618b0f | ||
|
|
4d2bc309ac | ||
|
|
2f78083c7f | ||
|
|
f1355f3400 | ||
|
|
6a03f626be | ||
|
|
5cf62a221a | ||
|
|
9662a4c457 | ||
|
|
3ad3de299c | ||
|
|
e760cd6afa | ||
|
|
8d30ba5c69 | ||
|
|
a9b66c4f43 | ||
|
|
cdc062d681 | ||
|
|
437b2b05d4 | ||
|
|
944919fc34 | ||
|
|
1ae826cf14 | ||
|
|
f438490ca5 | ||
|
|
b938ca5bf3 | ||
|
|
028103b900 | ||
|
|
bb1f159198 | ||
|
|
6fa42abc17 | ||
|
|
95b952c27f | ||
|
|
6631d06a04 | ||
|
|
1afce8c607 | ||
|
|
82c825e349 | ||
|
|
ff7d7b1fa4 | ||
|
|
328ed9884a | ||
|
|
4d1b90abc8 | ||
|
|
c5afdfc2da | ||
|
|
fdbd5ad501 | ||
|
|
d66605ae99 | ||
|
|
145e9747a9 | ||
|
|
87e4dcd211 | ||
|
|
633c8bad97 | ||
|
|
0927d0388a | ||
|
|
323289aa74 | ||
|
|
1f80e3b078 | ||
|
|
0ac725383e | ||
|
|
659f4f2b0d | ||
|
|
d65979323e | ||
|
|
d2503648a9 | ||
|
|
fffad33cc5 | ||
|
|
ae99671190 | ||
|
|
528b938f0f | ||
|
|
722f8da96d | ||
|
|
c53a3dc152 | ||
|
|
e29f59c28c | ||
|
|
c2c1320b18 | ||
|
|
e15733b7de | ||
|
|
02a2518fce | ||
|
|
861f416aad | ||
|
|
17cf85c1c1 | ||
|
|
6dbf539d88 | ||
|
|
24b9c2ec29 | ||
|
|
9a8e939414 | ||
|
|
a6b5286bf9 | ||
|
|
490c740c54 | ||
|
|
39d64a1cf4 | ||
|
|
a0272dfcaf | ||
|
|
44d3db72b4 | ||
|
|
48b5d1018e | ||
|
|
738e224ba3 | ||
|
|
6f2a0b2213 | ||
|
|
c2ccdf2b8e | ||
|
|
adb6230eea | ||
|
|
aa89750d1f | ||
|
|
4ca2d14076 | ||
|
|
8bd590e1ea | ||
|
|
d7effcd625 | ||
|
|
a7b830e4fd | ||
|
|
5b8f5b406f | ||
|
|
69b430bdc3 | ||
|
|
00d3346dfc | ||
|
|
7452540a93 | ||
|
|
d98902e536 | ||
|
|
5ecefb4a41 | ||
|
|
814149e0f3 | ||
|
|
d306145a14 | ||
|
|
da72e1b252 | ||
|
|
b6fc76cdb7 | ||
|
|
7842375d11 | ||
|
|
f6d83a5d31 | ||
|
|
97b8e7028a | ||
|
|
cc6cc55ad0 | ||
|
|
52063367f8 | ||
|
|
0003e4382b | ||
|
|
e2cbe22e8d | ||
|
|
436983e49e | ||
|
|
8829414a47 | ||
|
|
9f46c829db | ||
|
|
0de6531aed | ||
|
|
a5a96b74e3 | ||
|
|
f7b1a027f5 | ||
|
|
bde04fd7e1 | ||
|
|
af38909f58 | ||
|
|
5ccd80c4f1 | ||
|
|
ebf407b8b2 | ||
|
|
d0be1feec5 | ||
|
|
02fbbc87b4 | ||
|
|
ce1804cd0f | ||
|
|
53da73f11e | ||
|
|
fb3d8e9c0d | ||
|
|
5039a94bbf | ||
|
|
3ae993050b | ||
|
|
0dddb4675f | ||
|
|
56abaaf31c | ||
|
|
900f4fec95 | ||
|
|
88688672db | ||
|
|
cc6b95e5a1 | ||
|
|
377808f3da | ||
|
|
1d5e44e02c | ||
|
|
ff9c35041e | ||
|
|
d9afb64d00 | ||
|
|
6d60123272 | ||
|
|
84fcc3762f | ||
|
|
77b34dba5c | ||
|
|
4d8f36f674 | ||
|
|
5ccbb412eb | ||
|
|
4a0c700e6b | ||
|
|
00c65a0983 | ||
|
|
b961a52440 | ||
|
|
707feedda2 | ||
|
|
07c6ee1341 | ||
|
|
fd360cf21d | ||
|
|
a267df9e5d | ||
|
|
8feecbcb42 | ||
|
|
4224939f30 | ||
|
|
234ceba60c | ||
|
|
5c8a6647e2 | ||
|
|
5b763dff42 | ||
|
|
ee453841df | ||
|
|
6768d2c244 | ||
|
|
cb14efcc68 | ||
|
|
7871dfd0b8 | ||
|
|
99d1bfe37e | ||
|
|
b65c1b8bf7 | ||
|
|
517a16f0a3 | ||
|
|
89bfb9750d | ||
|
|
01eac66a6a | ||
|
|
cd53b8d454 | ||
|
|
d986f45634 | ||
|
|
0ceb633d96 | ||
|
|
2965743cfe | ||
|
|
9fa02d62e2 | ||
|
|
b2bd0f3701 | ||
|
|
de0e83f830 | ||
|
|
94b6df246e | ||
|
|
6b895919a0 | ||
|
|
a9830202e8 | ||
|
|
e96eece117 | ||
|
|
107b8e408f | ||
|
|
6629aeadef | ||
|
|
b0e5680260 | ||
|
|
a322274d77 | ||
|
|
be2289739a | ||
|
|
7536a8782e | ||
|
|
4d71a24fbc | ||
|
|
85ac9dd393 | ||
|
|
75c65b96d4 | ||
|
|
7d8433b768 | ||
|
|
d66413dd7a | ||
|
|
a0c9afc3ed | ||
|
|
e0c39170e6 | ||
|
|
8e199afe24 | ||
|
|
e68d915f36 | ||
|
|
b3e78c3e5e | ||
|
|
f02b90552b | ||
|
|
e93bfc6667 | ||
|
|
131463cfbe | ||
|
|
b963398987 | ||
|
|
ed395a26a9 | ||
|
|
03a2b35930 | ||
|
|
5a642e1e51 | ||
|
|
a8813b0272 | ||
|
|
66ce816a31 | ||
|
|
241e3200f8 | ||
|
|
19f52d6217 | ||
|
|
884efaebbf | ||
|
|
b51ba3d92a | ||
|
|
ec74481160 | ||
|
|
c60a4f01aa | ||
|
|
e34cafd641 | ||
|
|
5f8bb72641 | ||
|
|
df3e42987a | ||
|
|
8a738b7684 | ||
|
|
491f40663b | ||
|
|
fe8a7c6cd2 | ||
|
|
6245940466 | ||
|
|
c86cbc473f | ||
|
|
d93665a572 | ||
|
|
250ee4ada8 | ||
|
|
dfe2247b25 | ||
|
|
858261ddcc | ||
|
|
47bf56afe4 | ||
|
|
af3956d86f | ||
|
|
a69feb73ca | ||
|
|
88b29169fc | ||
|
|
2c9e108ac4 | ||
|
|
73b2d778a0 | ||
|
|
bf67d6e567 | ||
|
|
5e9da0802d | ||
|
|
2811021996 | ||
|
|
8c0a05b2de | ||
|
|
bb070bf83e | ||
|
|
21aec36ea5 | ||
|
|
6019cf92ac | ||
|
|
42d5dd1e89 | ||
|
|
0b3313e078 | ||
|
|
5684ba056a | ||
|
|
44af7dbb78 | ||
|
|
2102a03740 | ||
|
|
0a9cadf7ab | ||
|
|
279efe8000 | ||
|
|
fd92e58f81 | ||
|
|
fe93e46e02 | ||
|
|
cbf541992f | ||
|
|
8e1d336250 | ||
|
|
12e0e2b9f5 | ||
|
|
ac914f70f3 | ||
|
|
a07b8a4f4a | ||
|
|
6960b3f7aa | ||
|
|
fe83ff1be8 | ||
|
|
6357dc8e4a | ||
|
|
f1d94d0aa3 | ||
|
|
53dd3bc796 | ||
|
|
a9d528fc05 | ||
|
|
0388c437b1 |
20
docs/FAQ.md
Normal file
20
docs/FAQ.md
Normal 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
264
docs/Repository_Guide.md
Normal 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
102
docs/faq/01-extend-notification-channel.md
Normal file
102
docs/faq/01-extend-notification-channel.md
Normal 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"
|
||||
```
|
||||
30
docs/faq/02-remote-command-handler.md
Normal file
30
docs/faq/02-remote-command-handler.md
Normal 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
|
||||
```
|
||||
17
docs/faq/03-expose-plugin-api.md
Normal file
17
docs/faq/03-expose-plugin-api.md
Normal 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文档和调试
|
||||
15
docs/faq/04-register-service.md
Normal file
15
docs/faq/04-register-service.md
Normal 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
|
||||
}]
|
||||
```
|
||||
33
docs/faq/05-enhance-recognition.md
Normal file
33
docs/faq/05-enhance-recognition.md
Normal 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 # 结果只含原标题,代表空识别结果事件
|
||||
}
|
||||
)
|
||||
```
|
||||
259
docs/faq/06-extend-indexer-sites.md
Normal file
259
docs/faq/06-extend-indexer-sites.md
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- 需要注意的是,如果你没有完成用户认证,通过插件配置进去的索引站点也是无法正常使用的。
|
||||
- **请不要添加对黄赌毒站点的支持,否则随时封闭接口。**
|
||||
23
docs/faq/07-call-api-from-plugin.md
Normal file
23
docs/faq/07-call-api-from-plugin.md
Normal 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调用完成后,均会自动刷新一次插件数据页。
|
||||
47
docs/faq/08-render-dashboard.md
Normal file
47
docs/faq/08-render-dashboard.md
Normal 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
|
||||
```
|
||||
61
docs/faq/09-extend-discovery-source.md
Normal file
61
docs/faq/09-extend-discovery-source.md
Normal 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请求。
|
||||
28
docs/faq/10-extend-recommend-source.md
Normal file
28
docs/faq/10-extend-recommend-source.md
Normal 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="额外媒体数据源")
|
||||
```
|
||||
19
docs/faq/11-override-system-module.md
Normal file
19
docs/faq/11-override-system-module.md
Normal 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`,将继续执行下一个插件或者系统模块的相同声明方法;如果对应的方法需要返回是的列表对象,则会执行所有插件和系统模块的方法后将结果组合返回。
|
||||
319
docs/faq/12-extend-storage-type.md
Normal file
319
docs/faq/12-extend-storage-type.md
Normal 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": "复制"
|
||||
}
|
||||
```
|
||||
24
docs/faq/13-integrate-workflow.md
Normal file
24
docs/faq/13-integrate-workflow.md
Normal 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
|
||||
```
|
||||
- 编辑工作流流程,添加`调用插件`组件,选择该插件的对应动作,将插件的功能串接到工作流程中
|
||||
162
docs/faq/14-message-interaction.md
Normal file
162
docs/faq/14-message-interaction.md
Normal 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)才能使用此功能
|
||||
- 建议在交互中保存用户状态数据,以支持复杂的多步骤操作
|
||||
- 可以结合插件数据存储功能保存用户的交互历史和偏好设置
|
||||
186
docs/faq/15-use-system-cache.md
Normal file
186
docs/faq/15-use-system-cache.md
Normal 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值
|
||||
- 大文件或二进制数据建议使用文件缓存后端
|
||||
- 在插件卸载时清理相关缓存,避免内存泄漏
|
||||
103
docs/faq/16-register-agent-tools.md
Normal file
103
docs/faq/16-register-agent-tools.md
Normal 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
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 |
67
package.json
67
package.json
@@ -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 解析记录,支持泛域名(* 记录)及 IPv6(AAAA)。",
|
||||
"labels": "网络",
|
||||
"version": "1.0",
|
||||
"icon": "AliDnsDDNS.png",
|
||||
"author": "dtzsghnr",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.0": "初始版本,支持 IPv4/IPv6、泛域名、多记录配置、更新历史详情页"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
177
package.v2.json
177
package.v2.json
@@ -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,15 @@
|
||||
"name": "站点自动签到",
|
||||
"description": "自动模拟登录、签到站点。",
|
||||
"labels": "站点",
|
||||
"version": "2.7",
|
||||
"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": "增加保号风险提示",
|
||||
@@ -61,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 版本下载任务分类与标签插件"
|
||||
}
|
||||
},
|
||||
@@ -88,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 版本媒体库服务器通知插件"
|
||||
@@ -102,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语法包裹的情况",
|
||||
@@ -123,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)",
|
||||
@@ -183,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 版本",
|
||||
@@ -241,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下载器分类复用配置",
|
||||
@@ -267,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 版本"
|
||||
}
|
||||
@@ -351,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"
|
||||
@@ -420,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",
|
||||
@@ -438,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": "改进媒体识别",
|
||||
@@ -454,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": "修复按日期排序错误",
|
||||
@@ -468,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": "改进导入界面",
|
||||
@@ -496,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"
|
||||
}
|
||||
@@ -517,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": "初始版本"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class AutoSignIn(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "signin.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.7"
|
||||
plugin_version = "2.8.2"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
|
||||
114
plugins.v2/autosignin/sites/rousipro.py
Normal file
114
plugins.v2/autosignin/sites/rousipro.py
Normal 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, "模拟登录失败,无法访问网站"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
263
plugins.v2/bugreporter/__init__.py
Normal file
263
plugins.v2/bugreporter/__init__.py
Normal 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
|
||||
1
plugins.v2/bugreporter/requirements.txt
Normal file
1
plugins.v2/bugreporter/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
sentry_sdk~=2.44.0
|
||||
@@ -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"
|
||||
# 作者主页
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -1 +1 @@
|
||||
openai~=0.27.2
|
||||
cacheout~=0.16.0
|
||||
@@ -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
320
plugins.v2/clashruleprovider/api.py
Normal file
320
plugins.v2/clashruleprovider/api.py
Normal 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())
|
||||
8
plugins.v2/clashruleprovider/base.py
Normal file
8
plugins.v2/clashruleprovider/base.py
Normal 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
90
plugins.v2/clashruleprovider/config.py
Normal file
90
plugins.v2/clashruleprovider/config.py
Normal 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
5136
plugins.v2/clashruleprovider/countries.json
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
3
plugins.v2/clashruleprovider/dist/assets/Meta-1zu2nKV2.js
vendored
Normal file
3
plugins.v2/clashruleprovider/dist/assets/Meta-1zu2nKV2.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
const MetaLogo = "/assets/Meta-uqWbsmWL.png";
|
||||
|
||||
export { MetaLogo as M };
|
||||
BIN
plugins.v2/clashruleprovider/dist/assets/Meta-uqWbsmWL.png
vendored
Normal file
BIN
plugins.v2/clashruleprovider/dist/assets/Meta-uqWbsmWL.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -1,4 +0,0 @@
|
||||
|
||||
.plugin-config[data-v-929102b8] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
1479
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CY46uj5g.js
vendored
Normal file
1479
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CY46uj5g.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
4
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CwbjkOP2.css
vendored
Normal file
4
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CwbjkOP2.css
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
.plugin-config[data-v-3fef8398] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
|
||||
.dashboard-widget[data-v-de7a088e] {
|
||||
.dashboard-widget[data-v-318a5020] {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
84
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-BVPPK5SA.css
vendored
Normal file
84
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-BVPPK5SA.css
vendored
Normal 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
14255
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-DfFWx370.js
vendored
Normal file
14255
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-DfFWx370.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
208
plugins.v2/clashruleprovider/dist/assets/_plugin-vue_export-helper-D32QZFxh.js
vendored
Normal file
208
plugins.v2/clashruleprovider/dist/assets/_plugin-vue_export-helper-D32QZFxh.js
vendored
Normal 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 };
|
||||
@@ -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 _ };
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
144
plugins.v2/clashruleprovider/helper/clashrulemanager.py
Normal file
144
plugins.v2/clashruleprovider/helper/clashrulemanager.py
Normal 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)
|
||||
333
plugins.v2/clashruleprovider/helper/clashruleparser.py
Normal file
333
plugins.v2/clashruleprovider/helper/clashruleparser.py
Normal 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
|
||||
301
plugins.v2/clashruleprovider/helper/configconverter.py
Normal file
301
plugins.v2/clashruleprovider/helper/configconverter.py
Normal 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
|
||||
163
plugins.v2/clashruleprovider/helper/converters/__init__.py
Normal file
163
plugins.v2/clashruleprovider/helper/converters/__init__.py
Normal 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
|
||||
36
plugins.v2/clashruleprovider/helper/converters/anytls.py
Normal file
36
plugins.v2/clashruleprovider/helper/converters/anytls.py
Normal 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
|
||||
46
plugins.v2/clashruleprovider/helper/converters/http.py
Normal file
46
plugins.v2/clashruleprovider/helper/converters/http.py
Normal 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
|
||||
59
plugins.v2/clashruleprovider/helper/converters/hysteria.py
Normal file
59
plugins.v2/clashruleprovider/helper/converters/hysteria.py
Normal 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
|
||||
45
plugins.v2/clashruleprovider/helper/converters/hysteria2.py
Normal file
45
plugins.v2/clashruleprovider/helper/converters/hysteria2.py
Normal 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
|
||||
42
plugins.v2/clashruleprovider/helper/converters/socks.py
Normal file
42
plugins.v2/clashruleprovider/helper/converters/socks.py
Normal 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
|
||||
75
plugins.v2/clashruleprovider/helper/converters/ss.py
Normal file
75
plugins.v2/clashruleprovider/helper/converters/ss.py
Normal 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
|
||||
64
plugins.v2/clashruleprovider/helper/converters/ssr.py
Normal file
64
plugins.v2/clashruleprovider/helper/converters/ssr.py
Normal 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
|
||||
60
plugins.v2/clashruleprovider/helper/converters/trojan.py
Normal file
60
plugins.v2/clashruleprovider/helper/converters/trojan.py
Normal 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
|
||||
46
plugins.v2/clashruleprovider/helper/converters/tuic.py
Normal file
46
plugins.v2/clashruleprovider/helper/converters/tuic.py
Normal 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
|
||||
11
plugins.v2/clashruleprovider/helper/converters/vless.py
Normal file
11
plugins.v2/clashruleprovider/helper/converters/vless.py
Normal 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
|
||||
106
plugins.v2/clashruleprovider/helper/converters/vmess.py
Normal file
106
plugins.v2/clashruleprovider/helper/converters/vmess.py
Normal 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
|
||||
118
plugins.v2/clashruleprovider/helper/dataupgrader/v_2_1_0.py
Normal file
118
plugins.v2/clashruleprovider/helper/dataupgrader/v_2_1_0.py
Normal 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")
|
||||
73
plugins.v2/clashruleprovider/helper/utilsprovider.py
Normal file
73
plugins.v2/clashruleprovider/helper/utilsprovider.py
Normal 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
|
||||
6
plugins.v2/clashruleprovider/models/__init__.py
Normal file
6
plugins.v2/clashruleprovider/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .proxy import *
|
||||
from .hosts import *
|
||||
from .ruleitem import *
|
||||
from .ruleproviders import *
|
||||
from .proxygroups import *
|
||||
from .proxyproviders import *
|
||||
71
plugins.v2/clashruleprovider/models/api.py
Normal file
71
plugins.v2/clashruleprovider/models/api.py
Normal 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()))
|
||||
233
plugins.v2/clashruleprovider/models/configuration.py
Normal file
233
plugins.v2/clashruleprovider/models/configuration.py
Normal 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
|
||||
31
plugins.v2/clashruleprovider/models/datamodel.py
Normal file
31
plugins.v2/clashruleprovider/models/datamodel.py
Normal 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)
|
||||
32
plugins.v2/clashruleprovider/models/datapatch.py
Normal file
32
plugins.v2/clashruleprovider/models/datapatch.py
Normal 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]
|
||||
93
plugins.v2/clashruleprovider/models/generics.py
Normal file
93
plugins.v2/clashruleprovider/models/generics.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from typing import TypeVar, Generic, Iterator, Any
|
||||
from pydantic import BaseModel, RootModel, Field, model_validator
|
||||
from .metadata import Metadata
|
||||
|
||||
|
||||
# Specific data payload model
|
||||
T = TypeVar("T")
|
||||
|
||||
class ResourceItem(BaseModel, Generic[T]):
|
||||
"""Generic resource item model"""
|
||||
name: str = Field(..., description="Resource name")
|
||||
data: T = Field(..., description="Resource data payload")
|
||||
meta: Metadata = Field(default_factory=Metadata, description="Resource metadata")
|
||||
|
||||
|
||||
# Subclasses of ResourceItem
|
||||
R = TypeVar("R", bound=ResourceItem)
|
||||
|
||||
class ResourceList(RootModel[list[R]], Generic[R]):
|
||||
"""
|
||||
Generic configuration list base class
|
||||
"""
|
||||
root: list[R] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_unique_names(self) -> 'ResourceList[R]':
|
||||
names = [item.name for item in self.root]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("names must be unique")
|
||||
return self
|
||||
|
||||
def __iter__(self) -> Iterator[R]:
|
||||
return iter(self.root)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.root)
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
"""Check if a configuration with the specified name exists"""
|
||||
return any(item.name == name for item in self.root)
|
||||
|
||||
def get(self, name: str) -> R | None:
|
||||
"""Get the configuration item by name"""
|
||||
for item in self.root:
|
||||
if item.name == name:
|
||||
return item
|
||||
return None
|
||||
|
||||
def add(self, item: R):
|
||||
"""Add a configuration item, raise an exception if the name is duplicated"""
|
||||
if item.name in self:
|
||||
raise ValueError(f"name {item.name!r} already exists")
|
||||
self.root.insert(0, item)
|
||||
|
||||
def remove(self, name: str):
|
||||
"""Remove the configuration item by name"""
|
||||
self.root = [item for item in self.root if item.name != name]
|
||||
|
||||
def pop(self, name: str) -> R | None:
|
||||
"""Remove and return the configuration item with the specified name"""
|
||||
for i, item in enumerate(self.root) :
|
||||
if item.name == name:
|
||||
return self.root.pop(i)
|
||||
return None
|
||||
|
||||
def update(self, name: str, item: R):
|
||||
"""Update the configuration item with the specified name"""
|
||||
for i, existing_item in enumerate(self.root):
|
||||
if existing_item.name == name:
|
||||
item.meta = self.root[i].meta
|
||||
self.root[i] = item
|
||||
return
|
||||
|
||||
def update_data(self, name: str, data: Any) -> bool:
|
||||
"""Update only the data payload of the configuration item with the specified name"""
|
||||
item = self.get(name)
|
||||
if item:
|
||||
item.data = data
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_meta(self, name: str, meta: Metadata) -> bool:
|
||||
"""Set metadata for the specified configuration item"""
|
||||
item = self.get(name)
|
||||
if item:
|
||||
item.meta = meta
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def names(self) -> list[str]:
|
||||
"""Return a list of names for all configuration items"""
|
||||
return [item.name for item in self.root]
|
||||
33
plugins.v2/clashruleprovider/models/hosts.py
Normal file
33
plugins.v2/clashruleprovider/models/hosts.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from pydantic import Field, RootModel, BaseModel
|
||||
|
||||
from .metadata import Metadata
|
||||
|
||||
|
||||
class HostData(BaseModel):
|
||||
domain: str
|
||||
value: list[str]
|
||||
using_cloudflare: bool
|
||||
meta: Metadata = Field(default_factory=Metadata)
|
||||
|
||||
|
||||
class Hosts(RootModel[list[HostData]]):
|
||||
root: list[HostData] = Field(default_factory=list)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.root)
|
||||
|
||||
def update(self, domain: str, data: HostData):
|
||||
self.root = [host for host in self.root if host.domain != domain]
|
||||
self.root.append(data)
|
||||
|
||||
def delete(self, domain: str):
|
||||
self.root = [host for host in self.root if host.domain != domain]
|
||||
|
||||
def to_dict(self, cloudflare: list[str]) -> dict[str, list[str]]:
|
||||
hosts = {}
|
||||
for host in self.root:
|
||||
if host.using_cloudflare:
|
||||
hosts[host.domain] = cloudflare
|
||||
else:
|
||||
hosts[host.domain] = host.value
|
||||
return hosts
|
||||
25
plugins.v2/clashruleprovider/models/metadata.py
Normal file
25
plugins.v2/clashruleprovider/models/metadata.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import time
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .api import ConfigRequest
|
||||
from .types import DataSource
|
||||
|
||||
|
||||
class Metadata(BaseModel):
|
||||
"""Metadata model for Clash items"""
|
||||
# source of the item
|
||||
source: DataSource = Field(default=DataSource.MANUAL)
|
||||
# whether the item is disabled
|
||||
disabled: bool = Field(default=False)
|
||||
# roles that cannot see the item
|
||||
invisible_to: list[str] = Field(default_factory=list)
|
||||
# additional remarks
|
||||
remark: str = Field(default="")
|
||||
# last modified time
|
||||
time_modified: float = Field(default_factory=lambda: time.time())
|
||||
# whether the item has been patched
|
||||
patched: bool = Field(default=False)
|
||||
|
||||
def available(self, param: ConfigRequest | None = None) -> bool:
|
||||
return not self.disabled and (param is None or not any(param.resolve(expr) for expr in self.invisible_to))
|
||||
78
plugins.v2/clashruleprovider/models/proxy/__init__.py
Normal file
78
plugins.v2/clashruleprovider/models/proxy/__init__.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import jsonpatch
|
||||
from typing import Union, Any
|
||||
|
||||
from pydantic import Field, RootModel, model_validator
|
||||
|
||||
from .anytlsproxy import AnyTLSProxy
|
||||
from .directproxy import DirectProxy
|
||||
from .dnsproxy import DnsProxy
|
||||
from .httpproxy import HttpProxy
|
||||
from .hysteriaproxy import HysteriaProxy
|
||||
from .hysteria2proxy import Hysteria2Proxy
|
||||
from .mieruproxy import MieruProxy
|
||||
from .networkmixin import NetworkMixin
|
||||
from .proxybase import ProxyBase
|
||||
from .shadowsocksproxy import ShadowsocksProxy
|
||||
from .shadowsocksrproxy import ShadowsocksRProxy
|
||||
from .snellproxy import SnellProxy
|
||||
from .socks5proxy import Socks5Proxy
|
||||
from .sshproxy import SshProxy
|
||||
from .tlsmixin import TLSMixin
|
||||
from .trojanproxy import TrojanProxy
|
||||
from .tuicproxy import TuicProxy
|
||||
from .vlessproxy import VlessProxy
|
||||
from .vmessproxy import VmessProxy
|
||||
from .wireguardproxy import WireGuardProxy
|
||||
from ..generics import ResourceItem, ResourceList
|
||||
|
||||
ProxyType = Union[
|
||||
AnyTLSProxy,
|
||||
DirectProxy,
|
||||
DnsProxy,
|
||||
HttpProxy,
|
||||
HysteriaProxy,
|
||||
Hysteria2Proxy,
|
||||
MieruProxy,
|
||||
ShadowsocksProxy,
|
||||
ShadowsocksRProxy,
|
||||
SnellProxy,
|
||||
Socks5Proxy,
|
||||
SshProxy,
|
||||
TrojanProxy,
|
||||
TuicProxy,
|
||||
VlessProxy,
|
||||
VmessProxy,
|
||||
WireGuardProxy,
|
||||
]
|
||||
|
||||
|
||||
class Proxy(RootModel[ProxyType]):
|
||||
root: ProxyType = Field(..., discriminator="type")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.root.name
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self.root, item)
|
||||
|
||||
def patch(self, patch: str) -> 'Proxy':
|
||||
src = self.model_dump(mode='json', by_alias=True)
|
||||
patched = jsonpatch.apply_patch(src, patch=patch, in_place=True)
|
||||
return Proxy.model_validate(patched)
|
||||
|
||||
|
||||
class ProxyData(ResourceItem[Proxy]):
|
||||
raw: Union[str, dict[str, Any], None] = None
|
||||
v2ray_link: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_name_consistency(self):
|
||||
if self.name != self.data.name:
|
||||
raise ValueError(f"name ({self.name}) must equal data.name ({self.data.name})")
|
||||
return self
|
||||
|
||||
|
||||
class Proxies(ResourceList[ProxyData]):
|
||||
"""Proxies Collection"""
|
||||
pass
|
||||
15
plugins.v2/clashruleprovider/models/proxy/anytlsproxy.py
Normal file
15
plugins.v2/clashruleprovider/models/proxy/anytlsproxy.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
from .tlsmixin import TLSMixin
|
||||
from .networkmixin import NetworkMixin
|
||||
|
||||
|
||||
class AnyTLSProxy(ProxyBase, TLSMixin, NetworkMixin):
|
||||
type: Literal['anytls'] = 'anytls'
|
||||
password: str
|
||||
idle_session_check_interval: Optional[int] = Field(30, alias='idle-session-check-interval')
|
||||
idle_session_timeout: Optional[int] = Field(30, alias='idle-session-timeout')
|
||||
min_idle_session: Optional[int] = Field(0, alias='min-idle-session')
|
||||
7
plugins.v2/clashruleprovider/models/proxy/directproxy.py
Normal file
7
plugins.v2/clashruleprovider/models/proxy/directproxy.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from typing import Literal
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class DirectProxy(ProxyBase):
|
||||
type: Literal['direct'] = 'direct'
|
||||
7
plugins.v2/clashruleprovider/models/proxy/dnsproxy.py
Normal file
7
plugins.v2/clashruleprovider/models/proxy/dnsproxy.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from typing import Literal
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class DnsProxy(ProxyBase):
|
||||
type: Literal['dns'] = 'dns'
|
||||
11
plugins.v2/clashruleprovider/models/proxy/httpproxy.py
Normal file
11
plugins.v2/clashruleprovider/models/proxy/httpproxy.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from typing import Optional, Dict, Literal
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
from .tlsmixin import TLSMixin
|
||||
|
||||
|
||||
class HttpProxy(ProxyBase, TLSMixin):
|
||||
type: Literal['http'] = 'http'
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
26
plugins.v2/clashruleprovider/models/proxy/hysteria2proxy.py
Normal file
26
plugins.v2/clashruleprovider/models/proxy/hysteria2proxy.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class Hysteria2Proxy(ProxyBase):
|
||||
type: Literal['hysteria2'] = 'hysteria2'
|
||||
password: Optional[str] = None
|
||||
obfs: Optional[Literal['salamander']] = None
|
||||
obfs_password: Optional[str] = Field(None, alias='obfs-password')
|
||||
up: Optional[int | str] = None
|
||||
down: Optional[int | str] = None
|
||||
hop_interval: Optional[int] = Field(None, alias='hop-interval')
|
||||
ca: Optional[str] = None
|
||||
ca_str: Optional[str] = Field(None, alias='ca-str')
|
||||
cwnd: Optional[int] = None
|
||||
udp_mtu: Optional[int] = Field(None, alias='udp-mtu')
|
||||
ports: Optional[str] = None
|
||||
|
||||
# QUIC-GO 特殊配置
|
||||
initial_stream_receive_window: Optional[int] = Field(None, alias='initial-stream-receive-window')
|
||||
max_stream_receive_window: Optional[int] = Field(None, alias='max-stream-receive-window')
|
||||
initial_connection_receive_window: Optional[int] = Field(None, alias='initial-connection-receive-window')
|
||||
max_connection_receive_window: Optional[int] = Field(None, alias='max-connection-receive-window')
|
||||
24
plugins.v2/clashruleprovider/models/proxy/hysteriaproxy.py
Normal file
24
plugins.v2/clashruleprovider/models/proxy/hysteriaproxy.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class HysteriaProxy(ProxyBase):
|
||||
type: Literal['hysteria'] = 'hysteria'
|
||||
auth_str: Optional[str] = Field(None, alias='auth-str')
|
||||
auth: Optional[str] = None
|
||||
protocol: Optional[Literal['udp','wechat-video', 'faketcp']] = None
|
||||
up: Optional[int | str] = None
|
||||
down: Optional[int | str] = None
|
||||
obfs: Optional[str] = None
|
||||
obfs_protocol: Optional[str] = Field(None, alias='obfs-protocol')
|
||||
recv_window_conn: Optional[int] = Field(None, alias='recv-window-conn')
|
||||
recv_window: Optional[int] = Field(None, alias='recv-window')
|
||||
disable_mtu_discovery: Optional[bool] = Field(None, alias='disable-mtu-discovery')
|
||||
fast_open: Optional[bool] = Field(None, alias='fast-open')
|
||||
hop_interval: Optional[int] = Field(None, alias='hop-interval')
|
||||
ca: Optional[str] = None
|
||||
ca_str: Optional[str] = Field(None, alias='ca-str')
|
||||
ports: Optional[str] = None
|
||||
25
plugins.v2/clashruleprovider/models/proxy/mieruproxy.py
Normal file
25
plugins.v2/clashruleprovider/models/proxy/mieruproxy.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class MieruProxy(ProxyBase):
|
||||
type: Literal['mieru'] = 'mieru'
|
||||
username: str
|
||||
password: str
|
||||
port_range: Optional[str] = Field(None, alias='port-range')
|
||||
transport: Literal['TCP'] = 'TCP'
|
||||
multiplexing: Optional[Literal[
|
||||
'MULTIPLEXING_OFF', 'MULTIPLEXING_LOW', 'MULTIPLEXING_MIDDLE', 'MULTIPLEXING_HIGH']] = 'MULTIPLEXING_LOW'
|
||||
handshake_mode: Optional[Literal['HANDSHAKE_STANDARD', 'HANDSHAKE_NO_WAIT']] = 'HANDSHAKE_STANDARD'
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_port_config(self):
|
||||
"""Pydantic v2 style model-level validation."""
|
||||
if not getattr(self, 'port', None) and not getattr(self, 'port_range', None):
|
||||
raise ValueError("either 'port' or 'port-range' must be set")
|
||||
if getattr(self, 'port', None) and getattr(self, 'port_range', None):
|
||||
raise ValueError("'port' and 'port-range' cannot be set at the same time")
|
||||
return self
|
||||
109
plugins.v2/clashruleprovider/models/proxy/networkmixin.py
Normal file
109
plugins.v2/clashruleprovider/models/proxy/networkmixin.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from typing import List, Optional, Dict, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HttpOpts(BaseModel):
|
||||
method: Optional[str] = None
|
||||
path: List[str] = ['/']
|
||||
headers: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
|
||||
class H2Opts(BaseModel):
|
||||
host: List[str]
|
||||
path: str = '/'
|
||||
|
||||
|
||||
class GrpcOpts(BaseModel):
|
||||
grpc_service_name: str = Field(..., alias='grpc-service-name')
|
||||
|
||||
|
||||
class WsOpts(BaseModel):
|
||||
path: str = '/'
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
max_early_data: Optional[int] = Field(None, alias='max-early-data')
|
||||
early_data_header_name: Optional[str] = Field(None, alias='early-data-header-name')
|
||||
v2ray_http_upgrade: Optional[bool] = Field(None, alias='v2ray-http-upgrade')
|
||||
v2ray_http_upgrade_fast_open: Optional[bool] = Field(None, alias='v2ray-http-upgrade-fast-open')
|
||||
|
||||
|
||||
class XhttpReuseSettings(BaseModel):
|
||||
max_concurrency: Optional[str] = Field(None, alias='max-concurrency')
|
||||
max_connections: Optional[str] = Field(None, alias='max-connections')
|
||||
c_max_reuse_times: Optional[str] = Field(None, alias='c-max-reuse-times')
|
||||
h_max_request_times: Optional[str] = Field(None, alias='h-max-request-times')
|
||||
h_max_reusable_secs: Optional[str] = Field(None, alias='h-max-reusable-secs')
|
||||
h_keep_alive_period: Optional[int] = Field(None, alias='h-keep-alive-period')
|
||||
|
||||
|
||||
class XhttpDownloadSettings(BaseModel):
|
||||
# xhttp part
|
||||
path: Optional[str] = None
|
||||
host: Optional[str] = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
no_grpc_header: Optional[bool] = Field(None, alias='no-grpc-header')
|
||||
x_padding_bytes: Optional[str] = Field(None, alias='x-padding-bytes')
|
||||
x_padding_obfs_mode: Optional[bool] = Field(None, alias='x-padding-obfs-mode')
|
||||
x_padding_key: Optional[str] = Field(None, alias='x-padding-key')
|
||||
x_padding_header: Optional[str] = Field(None, alias='x-padding-header')
|
||||
x_padding_placement: Optional[str] = Field(None, alias='x-padding-placement')
|
||||
x_padding_method: Optional[str] = Field(None, alias='x-padding-method')
|
||||
uplink_http_method: Optional[str] = Field(None, alias='uplink-http-method')
|
||||
session_placement: Optional[str] = Field(None, alias='session-placement')
|
||||
session_key: Optional[str] = Field(None, alias='session-key')
|
||||
seq_placement: Optional[str] = Field(None, alias='seq-placement')
|
||||
seq_key: Optional[str] = Field(None, alias='seq-key')
|
||||
uplink_data_placement: Optional[str] = Field(None, alias='uplink-data-placement')
|
||||
uplink_data_key: Optional[str] = Field(None, alias='uplink-data-key')
|
||||
uplink_chunk_size: Optional[int] = Field(None, alias='uplink-chunk-size')
|
||||
sc_max_each_post_bytes: Optional[int] = Field(None, alias='sc-max-each-post-bytes')
|
||||
sc_min_posts_interval_ms: Optional[int] = Field(None, alias='sc-min-posts-interval-ms')
|
||||
reuse_settings: Optional[XhttpReuseSettings] = Field(None, alias='reuse-settings')
|
||||
|
||||
# proxy part
|
||||
server: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
tls: Optional[bool] = None
|
||||
alpn: Optional[List[str]] = None
|
||||
skip_cert_verify: Optional[bool] = Field(None, alias='skip-cert-verify')
|
||||
fingerprint: Optional[str] = None
|
||||
certificate: Optional[str] = None
|
||||
private_key: Optional[str] = Field(None, alias='private-key')
|
||||
servername: Optional[str] = None
|
||||
client_fingerprint: Optional[str] = Field(None, alias='client-fingerprint')
|
||||
|
||||
|
||||
class XhttpOpts(BaseModel):
|
||||
host: Optional[str] = None
|
||||
path: str = '/'
|
||||
mode: Literal["auto", "stream-one", "stream-up", "packet-up"] | None = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
no_grpc_header: Optional[bool] = Field(None, alias='no-grpc-header')
|
||||
x_padding_bytes: Optional[str] = Field(None, alias='x-padding-bytes')
|
||||
x_padding_obfs_mode: Optional[bool] = Field(None, alias='x-padding-obfs-mode')
|
||||
x_padding_key: Optional[str] = Field(None, alias='x-padding-key')
|
||||
x_padding_header: Optional[str] = Field(None, alias='x-padding-header')
|
||||
x_padding_placement: Optional[str] = Field(None, alias='x-padding-placement')
|
||||
x_padding_method: Optional[str] = Field(None, alias='x-padding-method')
|
||||
uplink_http_method: Optional[str] = Field(None, alias='uplink-http-method')
|
||||
session_placement: Optional[str] = Field(None, alias='session-placement')
|
||||
session_key: Optional[str] = Field(None, alias='session-key')
|
||||
seq_placement: Optional[str] = Field(None, alias='seq-placement')
|
||||
seq_key: Optional[str] = Field(None, alias='seq-key')
|
||||
uplink_data_placement: Optional[str] = Field(None, alias='uplink-data-placement')
|
||||
uplink_data_key: Optional[str] = Field(None, alias='uplink-data-key')
|
||||
uplink_chunk_size: Optional[int] = Field(None, alias='uplink-chunk-size')
|
||||
sc_max_each_post_bytes: Optional[int] = Field(None, alias='sc-max-each-post-bytes')
|
||||
sc_min_posts_interval_ms: Optional[int] = Field(None, alias='sc-min-posts-interval-ms')
|
||||
reuse_settings: Optional[XhttpReuseSettings] = Field(None, alias='reuse-settings')
|
||||
download_settings: Optional[XhttpDownloadSettings] = Field(None, alias='download-settings')
|
||||
|
||||
|
||||
class NetworkMixin(BaseModel):
|
||||
# Transport settings
|
||||
network: Optional[Literal['tcp', 'http', 'h2', 'grpc', 'ws', 'kcp', 'xhttp']] = None
|
||||
http_opts: Optional[HttpOpts] = Field(None, alias='http-opts')
|
||||
h2_opts: Optional[H2Opts] = Field(None, alias='h2-opts')
|
||||
grpc_opts: Optional[GrpcOpts] = Field(None, alias='grpc-opts')
|
||||
ws_opts: Optional[WsOpts] = Field(None, alias='ws-opts')
|
||||
xhttp_opts: Optional[XhttpOpts] = Field(None, alias='xhttp-opts')
|
||||
38
plugins.v2/clashruleprovider/models/proxy/proxybase.py
Normal file
38
plugins.v2/clashruleprovider/models/proxy/proxybase.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SmuxBrutalOpts(BaseModel):
|
||||
enabled: bool = False
|
||||
up: Optional[str] = None
|
||||
down: Optional[str] = None
|
||||
|
||||
|
||||
class Smux(BaseModel):
|
||||
enabled: bool = False
|
||||
protocol: Literal['smux', 'yamux', 'h2mux'] = 'h2mux'
|
||||
max_connections: Optional[int] = Field(None, alias='max-connections')
|
||||
min_streams: Optional[int] = Field(None, alias='min-streams')
|
||||
max_streams: Optional[int] = Field(None, alias='max-streams')
|
||||
statistic: Optional[bool] = None
|
||||
only_tcp: Optional[bool] = Field(None, alias='only-tcp')
|
||||
padding: Optional[bool] = None
|
||||
brutal_opts: Optional[SmuxBrutalOpts] = Field(None, alias='brutal-opts')
|
||||
|
||||
|
||||
class ProxyBase(BaseModel):
|
||||
name: str
|
||||
type: Literal['direct', 'dns', 'http', 'ss', 'ssr', 'mieru', 'snell', 'vmess', 'vless', 'trojan', 'anytls',
|
||||
'hysteria','hysteria2', 'tuic', 'wireguard', 'ssh', 'socks5']
|
||||
server: str
|
||||
port: int
|
||||
ip_version: Optional[Literal['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer']] = Field(None,
|
||||
alias='ip-version')
|
||||
udp: bool = False
|
||||
interface_name: Optional[str] = Field(None, alias='interface-name')
|
||||
routing_mark: Optional[int] = Field(None, alias='routing-mark')
|
||||
tfo: Optional[bool] = None
|
||||
mptcp: Optional[bool] = None
|
||||
dialer_proxy: Optional[str] = Field(None, alias='dialer-proxy')
|
||||
smux: Optional[Smux] = None
|
||||
110
plugins.v2/clashruleprovider/models/proxy/shadowsocksproxy.py
Normal file
110
plugins.v2/clashruleprovider/models/proxy/shadowsocksproxy.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from typing import Optional, Dict, Literal, List, Union
|
||||
|
||||
from pydantic import Field, BaseModel, field_validator, ValidationInfo
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
ShadowsocksCipherType = Literal[
|
||||
# AES 相关
|
||||
'aes-128-ctr', 'aes-192-ctr', 'aes-256-ctr',
|
||||
'aes-128-cfb', 'aes-192-cfb', 'aes-256-cfb',
|
||||
'aes-128-gcm', 'aes-192-gcm', 'aes-256-gcm',
|
||||
'aes-128-com', 'aes-192-com', 'aes-256-com',
|
||||
'aes-128-gcm-siv', 'aes-256-gcm-siv',
|
||||
# CHACHA 相关
|
||||
'chacha20-ietf', 'chacha20', 'xchacha20',
|
||||
'chacha20-ietf-poly1305', 'xchacha20-ietf-poly1305',
|
||||
'chacha8-ietf-poly1305', 'xchacha8-ietf-poly1305',
|
||||
# 2022 Blake3 相关
|
||||
'2022-blake3-aes-128-gcm', '2022-blake3-aes-256-gcm', '2022-blake3-chacha20-poly1305',
|
||||
# LEA 相关
|
||||
'lea-128-gcm', 'lea-192-gcm', 'lea-256-gcm',
|
||||
# 其他
|
||||
'rabbit128-poly1305', 'aegis-128l', 'aegis-256', 'aez-384', 'deoxys-ii-256-128', 'rc4-md5', 'none'
|
||||
]
|
||||
|
||||
|
||||
class ObfsPluginOpts(BaseModel):
|
||||
mode: Literal['tls', 'http']
|
||||
host: Optional[str] = Field(default="bing.com")
|
||||
|
||||
|
||||
class V2rayPluginOpts(BaseModel):
|
||||
mode: Literal['websocket'] = 'websocket'
|
||||
host: Optional[str] = Field(default="bing.com")
|
||||
path: Optional[str] = None
|
||||
tls: Optional[bool] = False
|
||||
fingerprint: Optional[str] = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
skip_cert_verify: Optional[bool] = Field(False, alias='skip-cert-verify')
|
||||
mux: Optional[bool] = True
|
||||
v2ray_http_upgrade: Optional[bool] = Field(False, alias='v2ray-http-upgrade')
|
||||
v2ray_http_upgrade_fast_open: Optional[bool] = Field(False, alias='v2ray-http-upgrade-fast-open')
|
||||
|
||||
|
||||
class GostPluginOpts(BaseModel):
|
||||
mode: Literal['websocket'] = 'websocket'
|
||||
host: Optional[str] = Field(default="bing.com")
|
||||
path: Optional[str] = None
|
||||
tls: Optional[bool] = False
|
||||
fingerprint: Optional[str] = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
skip_cert_verify: Optional[bool] = Field(False, alias='skip-cert-verify')
|
||||
mux: Optional[bool] = True
|
||||
|
||||
|
||||
class ShadowTlsPluginOpts(BaseModel):
|
||||
password: Optional[str] = None
|
||||
host: str
|
||||
fingerprint: Optional[str] = None
|
||||
skip_cert_verify: Optional[bool] = Field(False, alias='skip-cert-verify')
|
||||
version: Optional[Literal[1, 2, 3]] = 2
|
||||
alpn: Optional[List[str]] = None
|
||||
|
||||
|
||||
class RestlsPluginOpts(BaseModel):
|
||||
password: str
|
||||
host: str
|
||||
version_hint: str = Field(alias='version-hint')
|
||||
restls_script: Optional[str] = Field(None, alias='restls-script')
|
||||
|
||||
|
||||
class ShadowsocksProxy(ProxyBase):
|
||||
type: Literal['ss'] = 'ss'
|
||||
cipher: ShadowsocksCipherType
|
||||
password: str
|
||||
udp_over_tcp: Optional[bool] = Field(None, alias='udp-over-tcp')
|
||||
udp_over_tcp_version: Optional[Literal[1, 2]] = Field(1, alias='udp-over-tcp-version')
|
||||
client_fingerprint: Optional[Literal['chrome', 'ios', 'firefox', 'safari']] = Field(None,
|
||||
alias='client-fingerprint')
|
||||
plugin: Optional[Literal['obfs', 'v2ray-plugin', 'shadow-tls', 'restls', 'gost-plugin']] = None
|
||||
plugin_opts: Optional[Union[
|
||||
ObfsPluginOpts,
|
||||
V2rayPluginOpts,
|
||||
GostPluginOpts,
|
||||
ShadowTlsPluginOpts,
|
||||
RestlsPluginOpts,
|
||||
]] = Field(None, alias='plugin-opts')
|
||||
|
||||
|
||||
@field_validator("plugin_opts")
|
||||
@classmethod
|
||||
def validate_plugin_opts(cls, v, info: ValidationInfo):
|
||||
plugin = info.data.get("plugin")
|
||||
if plugin and v:
|
||||
if not isinstance(plugin, str):
|
||||
raise ValueError("plugin must be a string")
|
||||
plugin_model_map = {
|
||||
"obfs": "ObfsPluginOpts",
|
||||
"v2ray-plugin": "V2rayPluginOpts",
|
||||
"gost-plugin": "GostPluginOpts",
|
||||
"shadow-tls": "ShadowTlsPluginOpts",
|
||||
"restls": "RestlsPluginOpts",
|
||||
}
|
||||
|
||||
expected_model = plugin_model_map.get(plugin)
|
||||
if expected_model and v.__class__.__name__ != expected_model:
|
||||
raise ValueError(f"{plugin} plugin requires {expected_model}")
|
||||
|
||||
return v
|
||||
@@ -0,0 +1,14 @@
|
||||
from pydantic import Field
|
||||
from typing import Optional, Literal
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class ShadowsocksRProxy(ProxyBase):
|
||||
type: Literal['ssr'] = 'ssr'
|
||||
cipher: str
|
||||
password: str
|
||||
obfs: Literal['plain', 'http_simple', 'http_post', 'random_head', 'tls1.2_ticket_auth', 'tls1.2_ticket_fastauth']
|
||||
obfs_param: Optional[str] = Field(None, alias='obfs-param')
|
||||
protocol: Literal['origin', 'auth_sha1_v4', 'auth_aes128_md5', 'auth_aes128_sha1', 'auth_chain_a', 'auth_chain_b']
|
||||
protocol_param: Optional[str] = Field(None, alias='protocol-param')
|
||||
17
plugins.v2/clashruleprovider/models/proxy/snellproxy.py
Normal file
17
plugins.v2/clashruleprovider/models/proxy/snellproxy.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class SnellObfsOpts(BaseModel):
|
||||
mode: Optional[Literal['http', 'tls']] = None
|
||||
host: Optional[str] = None
|
||||
|
||||
|
||||
class SnellProxy(ProxyBase):
|
||||
type: Literal['snell'] = 'snell'
|
||||
psk: str
|
||||
version: Optional[Literal[1,2,3]] = 1
|
||||
obfs_opts: Optional[SnellObfsOpts] = Field(None, alias='obfs-opts')
|
||||
10
plugins.v2/clashruleprovider/models/proxy/socks5proxy.py
Normal file
10
plugins.v2/clashruleprovider/models/proxy/socks5proxy.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
from .tlsmixin import TLSMixin
|
||||
|
||||
|
||||
class Socks5Proxy(ProxyBase, TLSMixin):
|
||||
type: Literal['socks5'] = 'socks5'
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
15
plugins.v2/clashruleprovider/models/proxy/sshproxy.py
Normal file
15
plugins.v2/clashruleprovider/models/proxy/sshproxy.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from typing import List, Optional, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class SshProxy(ProxyBase):
|
||||
type: Literal['ssh'] = 'ssh'
|
||||
username: str
|
||||
password: Optional[str] = None
|
||||
private_key: Optional[str] = Field(None, alias='privateKey')
|
||||
private_key_passphrase: Optional[str] = Field(None, alias='private-key-passphrase')
|
||||
host_key: Optional[List[str]] = Field(None, alias='host-key')
|
||||
host_key_algorithms: Optional[List[str]] = Field(None, alias='host-key-algorithms')
|
||||
41
plugins.v2/clashruleprovider/models/proxy/tlsmixin.py
Normal file
41
plugins.v2/clashruleprovider/models/proxy/tlsmixin.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from enum import StrEnum
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ClientFingerprint(StrEnum):
|
||||
chrome = 'chrome'
|
||||
firefox = 'firefox'
|
||||
safari = 'safari'
|
||||
ios = 'ios'
|
||||
android = 'android'
|
||||
edge = 'edge'
|
||||
n360 = '360'
|
||||
qq = 'qq'
|
||||
random = 'random'
|
||||
|
||||
|
||||
class RealityOpts(BaseModel):
|
||||
public_key: str = Field(..., alias='public-key')
|
||||
short_id: Optional[str] = Field(None, alias='short-id')
|
||||
support_x25519mlkem768: Optional[bool] = Field(None, alias='support-x25519mlkem768')
|
||||
|
||||
|
||||
class EchOpts(BaseModel):
|
||||
enable: bool = False
|
||||
config: str
|
||||
|
||||
|
||||
class TLSMixin(BaseModel):
|
||||
"""TLS 配置混入类"""
|
||||
# TLS settings
|
||||
tls: Optional[bool] = None
|
||||
sni: Optional[str] = None
|
||||
servername: Optional[str] = None
|
||||
fingerprint: Optional[str] = None
|
||||
alpn: Optional[List[str]] = None
|
||||
skip_cert_verify: Optional[bool] = Field(None, alias='skip-cert-verify')
|
||||
client_fingerprint: Optional[ClientFingerprint] = Field(None, alias='client-fingerprint')
|
||||
reality_opts: Optional[RealityOpts] = Field(None, alias='reality-opts')
|
||||
ech_opts: Optional[EchOpts] = Field(None, alias='ech-opts')
|
||||
21
plugins.v2/clashruleprovider/models/proxy/trojanproxy.py
Normal file
21
plugins.v2/clashruleprovider/models/proxy/trojanproxy.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
from .tlsmixin import TLSMixin
|
||||
from .networkmixin import NetworkMixin
|
||||
|
||||
|
||||
class TrojanSSOption(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
method: Optional[Literal['aes-128-gcm', 'aes-256-gcm', 'chacha20-ietf-poly1305']] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class TrojanProxy(ProxyBase, TLSMixin, NetworkMixin):
|
||||
type: Literal['trojan'] = 'trojan'
|
||||
password: str
|
||||
ss_opts: Optional[TrojanSSOption] = Field(None, alias='ss-opts')
|
||||
network: Optional[Literal['tcp', 'grpc', 'ws']] = None
|
||||
tls: Optional[bool] = True
|
||||
41
plugins.v2/clashruleprovider/models/proxy/tuicproxy.py
Normal file
41
plugins.v2/clashruleprovider/models/proxy/tuicproxy.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
from .tlsmixin import TLSMixin
|
||||
|
||||
|
||||
class TuicProxy(ProxyBase, TLSMixin):
|
||||
type: Literal['tuic'] = 'tuic'
|
||||
# TUIC v4/v5 认证
|
||||
token: Optional[str] = None
|
||||
uuid: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
# 连接配置
|
||||
ip: Optional[str] = None
|
||||
heartbeat_interval: Optional[int] = Field(None, alias='heartbeat-interval')
|
||||
reduce_rtt: Optional[bool] = Field(None, alias='reduce-rtt')
|
||||
request_timeout: Optional[int] = Field(None, alias='request-timeout')
|
||||
udp_relay_mode: Optional[Literal['native', 'quic']] = Field(None, alias='udp-relay-mode')
|
||||
congestion_controller: Optional[Literal['cubic', 'new_reno', 'bbr']] = Field(None, alias='congestion-controller')
|
||||
disable_sni: Optional[bool] = Field(None, alias='disable-sni')
|
||||
max_udp_relay_packet_size: Optional[int] = Field(None, alias='max-udp-relay-packet-size')
|
||||
|
||||
# 性能配置
|
||||
fast_open: Optional[bool] = Field(None, alias='fast-open')
|
||||
max_open_streams: Optional[int] = Field(None, alias='max-open-streams')
|
||||
cwnd: Optional[int] = None
|
||||
recv_window_conn: Optional[int] = Field(None, alias='recv-window-conn')
|
||||
recv_window: Optional[int] = Field(None, alias='recv-window')
|
||||
disable_mtu_discovery: Optional[bool] = Field(None, alias='disable-mtu-discovery')
|
||||
max_datagram_frame_size: Optional[int] = Field(None, alias='max-datagram-frame-size')
|
||||
|
||||
# TLS 证书配置
|
||||
ca: Optional[str] = None
|
||||
ca_str: Optional[str] = Field(None, alias='ca-str')
|
||||
|
||||
# UDP over Stream 扩展
|
||||
udp_over_stream: Optional[bool] = Field(None, alias='udp-over-stream')
|
||||
udp_over_stream_version: Optional[int] = Field(None, alias='udp-over-stream-version')
|
||||
16
plugins.v2/clashruleprovider/models/proxy/vlessproxy.py
Normal file
16
plugins.v2/clashruleprovider/models/proxy/vlessproxy.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from pydantic import Field
|
||||
from typing import Optional, Literal
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
from .tlsmixin import TLSMixin
|
||||
from .networkmixin import NetworkMixin
|
||||
|
||||
|
||||
class VlessProxy(ProxyBase, TLSMixin, NetworkMixin):
|
||||
type: Literal['vless'] = 'vless'
|
||||
uuid: str
|
||||
flow: Optional[str] = None
|
||||
packet_addr: Optional[bool] = Field(None, alias='packet-addr')
|
||||
xudp: Optional[bool] = None
|
||||
packet_encoding: Optional[Literal['packetaddr', 'xudp']] = Field(None, alias='packet-encoding')
|
||||
encryption: Optional[str] = None
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user