mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-06-13 23:16:49 +00:00
Compare commits
156 Commits
ClashRuleP
...
AutoSignIn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
102
README.md
102
README.md
@@ -24,6 +24,7 @@ MoviePilot官方插件市场:https://github.com/jxxghp/MoviePilot-Plugins
|
||||
- [13. 如何将插件功能集成到工作流?](#13-如何将插件功能集成到工作流)
|
||||
- [14. 如何在插件中通过消息持续与用户交互?](#14-如何在插件中通过消息持续与用户交互)
|
||||
- [15. 如何在插件中使用系统级统一缓存?](#15-如何在插件中使用系统级统一缓存)
|
||||
- [16. 如何在插件中注册智能体工具?](#16-如何在插件中注册智能体工具)
|
||||
- [版本发布](#版本发布)
|
||||
- [1. 如何发布插件版本?](#1-如何发布插件版本)
|
||||
- [2. 如何开发V2版本的插件以及实现插件多版本兼容?](#2-如何开发v2版本的插件以及实现插件多版本兼容)
|
||||
@@ -1352,6 +1353,107 @@ def get_actions(self) -> List[Dict[str, Any]]:
|
||||
- 大文件或二进制数据建议使用文件缓存后端
|
||||
- 在插件卸载时清理相关缓存,避免内存泄漏
|
||||
|
||||
### 16. 如何在插件中注册智能体工具?
|
||||
**(仅支持 `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` 获取
|
||||
- 工具执行时间应该尽量短,避免阻塞智能体的响应
|
||||
- 建议在工具执行过程中添加适当的错误处理和日志记录
|
||||
|
||||
|
||||
## 版本发布
|
||||
|
||||
|
||||
31
package.json
31
package.json
@@ -174,11 +174,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": "修复删除辅种",
|
||||
@@ -217,12 +218,13 @@
|
||||
"name": "Cloudflare IP优选",
|
||||
"description": "🌩 测试 Cloudflare CDN 延迟和速度,自动优选IP。",
|
||||
"labels": "网络,站点",
|
||||
"version": "1.4",
|
||||
"version": "1.5",
|
||||
"icon": "cloudflare.jpg",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.5": "适配CloudflareSpeedTest新版名称",
|
||||
"v1.4": "修复立即运行一次",
|
||||
"v1.3": "调整插件开启状态判断条件",
|
||||
"v1.2": "增强API安全性"
|
||||
@@ -319,11 +321,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 +466,16 @@
|
||||
"name": "药丸签到",
|
||||
"description": "药丸论坛签到。",
|
||||
"labels": "站点",
|
||||
"version": "1.4.1",
|
||||
"version": "2.0.2",
|
||||
"icon": "invites.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
|
||||
"v2.0.1": "尝试修复签到失败问题,新增使用代理、Cookie自动更新功能",
|
||||
"v2.0.0": "修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI",
|
||||
"v1.4.1": "更新签到域名前缀",
|
||||
"v1.4": "自定义保留消息天数"
|
||||
}
|
||||
@@ -477,11 +484,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 +498,12 @@
|
||||
"name": "MoviePilot更新推送",
|
||||
"description": "MoviePilot推送release更新通知、自动重启。",
|
||||
"labels": "消息通知,自动更新",
|
||||
"version": "1.4",
|
||||
"version": "1.5",
|
||||
"icon": "Moviepilot_A.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.5": "修复版本描述为空时的报错",
|
||||
"v1.4": "兼容更新内容带版本号的情况",
|
||||
"v1.3": "增加前端版本更新检查,需要主程序升级至v1.8.4+版本"
|
||||
}
|
||||
@@ -560,12 +569,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版本无法读取媒体库的问题",
|
||||
@@ -801,13 +811,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 +850,6 @@
|
||||
"icon": "Macos_Sierra.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.4.1": "修复Bing壁纸命名问题",
|
||||
"v1.3": "适配MoviePilot v2.5.3+版本",
|
||||
|
||||
@@ -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,13 @@
|
||||
"name": "站点自动签到",
|
||||
"description": "自动模拟登录、签到站点。",
|
||||
"labels": "站点",
|
||||
"version": "2.7",
|
||||
"version": "2.8",
|
||||
"icon": "signin.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.8": "适配站点 Rousi Pro",
|
||||
"v2.7": "站点请求使用站点设置的超时时间",
|
||||
"v2.6": "感谢madrays佬提供的UI!",
|
||||
"v2.5.4": "增加保号风险提示",
|
||||
@@ -61,11 +64,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 +95,16 @@
|
||||
"name": "媒体库服务器通知",
|
||||
"description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。",
|
||||
"labels": "消息通知,媒体库",
|
||||
"version": "1.6",
|
||||
"version": "1.8.2.1",
|
||||
"icon": "mediaplay.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"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 +114,12 @@
|
||||
"name": "ChatGPT",
|
||||
"description": "消息交互支持与ChatGPT对话。",
|
||||
"labels": "消息通知,识别",
|
||||
"version": "2.1.7",
|
||||
"version": "2.1.8",
|
||||
"icon": "Chatgpt_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.1.8": "修复 OpenAI API >=1.0.0 兼容性问题",
|
||||
"v2.1.7":"独立安装OpenAi SDK依赖",
|
||||
"v2.1.6": "支持自定义辅助识别提示词",
|
||||
"v2.1.5": "兼容一些模型返回json数据信息用markdown语法包裹的情况",
|
||||
@@ -183,11 +196,12 @@
|
||||
"name": "演职人员刮削",
|
||||
"description": "刮削演职人员图片以及中文名称。",
|
||||
"labels": "媒体库,刮削",
|
||||
"version": "2.2.1",
|
||||
"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+",
|
||||
@@ -242,11 +256,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下载器分类复用配置",
|
||||
@@ -368,11 +383,12 @@
|
||||
"name": "MoviePilot更新推送",
|
||||
"description": "MoviePilot推送release更新通知、自动重启。",
|
||||
"labels": "消息通知,自动更新",
|
||||
"version": "2.2",
|
||||
"version": "2.3",
|
||||
"icon": "Moviepilot_A.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.3": "修复版本描述为空时的报错",
|
||||
"v2.2": "支持 MoviePilot v2.5.0+",
|
||||
"v2.1": "优化执行周期输入,需要MoviePilot v2.2.1+",
|
||||
"v2.0": "兼容MoviePilot V2"
|
||||
@@ -433,11 +449,14 @@
|
||||
"name": "绕过Trackers",
|
||||
"description": "提供tracker服务器IP地址列表,帮助IPv6连接绕过OpenClash。",
|
||||
"labels": "工具",
|
||||
"version": "1.4.3",
|
||||
"version": "1.5.2",
|
||||
"icon": "Clash_A.png",
|
||||
"author": "wumode",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"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": "修复通知类型错误",
|
||||
@@ -452,11 +471,17 @@
|
||||
"name": "IMDb源",
|
||||
"description": "让探索,推荐和媒体识别支持IMDb数据源。",
|
||||
"labels": "探索",
|
||||
"version": "1.5.8",
|
||||
"version": "1.6.6",
|
||||
"icon": "IMDb_IOS-OSX_App.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"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",
|
||||
@@ -484,12 +509,25 @@
|
||||
"name": "Clash Rule Provider",
|
||||
"description": "随时为Clash添加一些额外的规则。",
|
||||
"labels": "工具",
|
||||
"version": "1.4.1",
|
||||
"version": "2.1.2",
|
||||
"icon": "Mihomo_Meta_A.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"release": true,
|
||||
"history": {
|
||||
"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格式支持",
|
||||
@@ -515,11 +553,16 @@
|
||||
"name": "美剧生词标注",
|
||||
"description": "根据CEFR等级,为英语影视剧标注高级词汇。",
|
||||
"labels": "英语",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.3",
|
||||
"icon": "LexiAnnot.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"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": "支持考试词汇标注; 优化分词处理; 修复错误",
|
||||
@@ -544,13 +587,27 @@
|
||||
"name": "Bug反馈",
|
||||
"description": "自动上报异常,协助开发者发现和解决问题。",
|
||||
"labels": "开发",
|
||||
"version": "1.2",
|
||||
"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 版本登录壁纸本地化插件"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class AutoSignIn(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "signin.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.7"
|
||||
plugin_version = "2.8"
|
||||
# 插件作者
|
||||
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 "签到成功" in res.json().get("message", ""):
|
||||
logger.info(f"{site} 签到成功")
|
||||
return True, "签到成功"
|
||||
elif res is not None and res.status_code == 400 and res.json().get("error", "") == "今日已签到":
|
||||
logger.info(f"{site} 今日已签到")
|
||||
return True, "今日已签到"
|
||||
elif res is not None and res.status_code == 401:
|
||||
logger.error(f"{site} 签到失败,登录状态无效")
|
||||
return False, "签到失败,登录状态无效"
|
||||
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 "attended_dates" in res.json():
|
||||
logger.info(f"{site} 模拟登录成功")
|
||||
return True, "模拟登录成功"
|
||||
elif res is not None and res.status_code == 401:
|
||||
logger.error(f"{site} 模拟登录失败,登录状态无效")
|
||||
return False, "模拟登录失败,登录状态无效"
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
from typing import Any, Dict
|
||||
from typing import List, Tuple
|
||||
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
||||
@@ -43,7 +41,10 @@ class SentrySanitizer:
|
||||
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"
|
||||
"name or service not known", "temporary failure", "network is unreachable",
|
||||
"SOCKSHTTPSConnectionPool", "ERR_HTTP_RESPONSE_CODE_FAILURE", "HTTPSConnectionPool",
|
||||
"网络连接", "无法连接", "请求失败", "下载失败", "请求返回空值", "图片失败", "未获取到返回数据",
|
||||
"请求返回空值", "返回空响应", "连接出错", "请求错误", "未获取到"
|
||||
]
|
||||
|
||||
@classmethod
|
||||
@@ -170,7 +171,7 @@ class BugReporter(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Alist_encrypt_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.2"
|
||||
plugin_version = "1.3"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
|
||||
@@ -1 +1 @@
|
||||
sentry_sdk~=2.35.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.8"
|
||||
# 插件作者
|
||||
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 +0,0 @@
|
||||
openai~=0.27.2
|
||||
@@ -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))
|
||||
0
plugins.v2/clashruleprovider/countries.json
Executable file → Normal file
0
plugins.v2/clashruleprovider/countries.json
Executable file → Normal file
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 |
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
@@ -1,4 +0,0 @@
|
||||
|
||||
.plugin-config[data-v-3939efa1] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
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
@@ -1,4 +1,4 @@
|
||||
|
||||
.dashboard-widget[data-v-de7a088e] {
|
||||
.dashboard-widget[data-v-318a5020] {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
84
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-CJILOVp4.css
vendored
Normal file
84
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-CJILOVp4.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-97c0f367] {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.subscription-card[data-v-97c0f367]: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-97c0f367] {
|
||||
background: rgba(var(--v-theme-surface-variant), 0.05);
|
||||
}
|
||||
.bg-surface-variant-lighten[data-v-97c0f367] {
|
||||
background: rgba(var(--v-theme-surface-variant), 0.02);
|
||||
}
|
||||
.stats-grid[data-v-97c0f367] {
|
||||
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
14231
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-DeAFYy3o.js
vendored
Normal file
14231
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-DeAFYy3o.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
||||
|
||||
.plugin-page[data-v-ad6ce99d] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 使卡片等宽并适应移动端 */
|
||||
.d-flex.flex-wrap[data-v-ad6ce99d] {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 移动端堆叠布局 */
|
||||
@media (max-width: 768px) {
|
||||
.d-flex.flex-wrap[data-v-ad6ce99d] {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.drag-handle[data-v-ad6ce99d] {
|
||||
cursor: move;
|
||||
}
|
||||
.toggle-container[data-v-ad6ce99d] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem;
|
||||
margin-left: 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
.subscription-card[data-v-ad6ce99d] {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
background: white;
|
||||
}
|
||||
.subscription-card[data-v-ad6ce99d]:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.card-title[data-v-ad6ce99d] {
|
||||
color: whitesmoke;
|
||||
}
|
||||
.card-header[data-v-ad6ce99d] {
|
||||
padding: 0.625rem;
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 1) 0%, rgba(var(--v-theme-primary), 0.7) 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.card-refresh-button[data-v-ad6ce99d] {
|
||||
background-color: rgba(var(--v-theme-primary), 0.9);
|
||||
color: whitesmoke;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.625rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
.search-field[data-v-ad6ce99d] {
|
||||
max-width: 25rem;
|
||||
}
|
||||
.clash-data-table[data-v-ad6ce99d] {
|
||||
max-height: 40rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
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-efdkIdKV.css"], false, './Page');
|
||||
return __federation_import('./__federation_expose_Page-DEabfqvu.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
dynamicLoadingCss(["__federation_expose_Page-CJILOVp4.css"], false, './Page');
|
||||
return __federation_import('./__federation_expose_Page-DeAFYy3o.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Config":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Config-CkZHWVJE.css"], false, './Config');
|
||||
return __federation_import('./__federation_expose_Config-yV-dGVgm.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-CybypqLB.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
|
||||
36
plugins.v2/clashruleprovider/models/proxy/networkmixin.py
Normal file
36
plugins.v2/clashruleprovider/models/proxy/networkmixin.py
Normal file
@@ -0,0 +1,36 @@
|
||||
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 NetworkMixin(BaseModel):
|
||||
# Transport settings
|
||||
network: Optional[Literal['tcp', 'http', 'h2', 'grpc', 'ws', 'kcp']] = 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')
|
||||
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
|
||||
18
plugins.v2/clashruleprovider/models/proxy/vmessproxy.py
Normal file
18
plugins.v2/clashruleprovider/models/proxy/vmessproxy.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
from .tlsmixin import TLSMixin
|
||||
from .networkmixin import NetworkMixin
|
||||
|
||||
class VmessProxy(ProxyBase, TLSMixin, NetworkMixin):
|
||||
type: Literal['vmess'] = 'vmess'
|
||||
uuid: str
|
||||
alter_id: int = Field(0, alias='alterId')
|
||||
cipher: Literal['auto', 'zero', 'aes-128-gcm', 'chacha20-poly1305', 'none'] = 'auto'
|
||||
packet_addr: Optional[bool] = Field(None, alias='packet-addr')
|
||||
xudp: Optional[bool] = None
|
||||
packet_encoding: Optional[Literal['packetaddr', 'xudp']] = Field(None, alias='packet-encoding')
|
||||
global_padding: Optional[bool] = Field(None, alias='global-padding')
|
||||
authenticated_length: Optional[bool] = Field(None, alias='authenticated-length')
|
||||
60
plugins.v2/clashruleprovider/models/proxy/wireguardproxy.py
Normal file
60
plugins.v2/clashruleprovider/models/proxy/wireguardproxy.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from typing import List, Optional, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class WireGuardPeerOption(BaseModel):
|
||||
server: str
|
||||
port: int
|
||||
public_key: str = Field(..., alias='public-key')
|
||||
pre_shared_key: Optional[str] = Field(None, alias='pre-shared-key')
|
||||
reserved: Optional[List[int]] = None
|
||||
allowed_ips: Optional[List[str]] = Field(None, alias='allowed-ips')
|
||||
|
||||
|
||||
class AmneziaWGOption(BaseModel):
|
||||
jc: Optional[int] = None
|
||||
jmin: Optional[int] = None
|
||||
jmax: Optional[int] = None
|
||||
s1: Optional[int] = None
|
||||
s2: Optional[int] = None
|
||||
h1: Optional[int] = None
|
||||
h2: Optional[int] = None
|
||||
h3: Optional[int] = None
|
||||
h4: Optional[int] = None
|
||||
# AmneziaWG v1.5
|
||||
i1: Optional[str] = None
|
||||
i2: Optional[str] = None
|
||||
i3: Optional[str] = None
|
||||
i4: Optional[str] = None
|
||||
i5: Optional[str] = None
|
||||
j1: Optional[str] = None
|
||||
j2: Optional[str] = None
|
||||
j3: Optional[str] = None
|
||||
itime: Optional[int] = None
|
||||
|
||||
|
||||
class WireGuardProxy(ProxyBase):
|
||||
type: Literal['wireguard'] = 'wireguard'
|
||||
ip: Optional[str] = None
|
||||
ipv6: Optional[str] = None
|
||||
private_key: str = Field(..., alias='private-key')
|
||||
public_key: str = Field(..., alias='public-key')
|
||||
pre_shared_key: Optional[str] = Field(None, alias='pre-shared-key')
|
||||
reserved: Optional[List[int]] = None
|
||||
workers: Optional[int] = None
|
||||
mtu: Optional[int] = None
|
||||
persistent_keepalive: Optional[int] = Field(None, alias='persistent-keepalive')
|
||||
|
||||
# 多 peer 配置
|
||||
peers: Optional[List[WireGuardPeerOption]] = None
|
||||
|
||||
# DNS 配置
|
||||
remote_dns_resolve: Optional[bool] = Field(None, alias='remote-dns-resolve')
|
||||
dns: Optional[List[str]] = None
|
||||
refresh_server_ip_interval: Optional[int] = Field(None, alias='refresh-server-ip-interval')
|
||||
|
||||
# AmneziaWG 扩展
|
||||
amnezia_wg_option: Optional[AmneziaWGOption] = Field(None, alias='amnezia-wg-option')
|
||||
163
plugins.v2/clashruleprovider/models/proxygroups.py
Normal file
163
plugins.v2/clashruleprovider/models/proxygroups.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import jsonpatch
|
||||
import re
|
||||
from typing import List, Optional, Union, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, RootModel, model_validator
|
||||
|
||||
from .generics import ResourceItem, ResourceList
|
||||
|
||||
|
||||
class ProxyGroupBase(BaseModel):
|
||||
"""
|
||||
包含所有代理组类型共有的通用字段。
|
||||
"""
|
||||
# Required field
|
||||
name: str = Field(..., description="The name of the proxy group.")
|
||||
|
||||
# Proxy and provider references
|
||||
proxies: Optional[List[str]] = Field(default=None,
|
||||
description="References to outbound proxies or other proxy groups.")
|
||||
use: Optional[List[str]] = Field(default=None, description="References to proxy provider sets.")
|
||||
|
||||
# Health check fields
|
||||
url: Optional[str] = Field(default="https://www.gstatic.com/generate_204", description="Health check test address.")
|
||||
interval: Optional[int] = Field(default=300, description="Health check interval in seconds.")
|
||||
lazy: Optional[bool] = Field(default=True, description="If not selected, no health checks are performed.")
|
||||
timeout: Optional[int] = Field(default=5000, description="Health check timeout in milliseconds.")
|
||||
max_failed_times: Optional[int] = Field(default=5, alias="max-failed-times",
|
||||
description="Maximum number of failures before a forced health check.")
|
||||
expected_status: Optional[str] = Field(default='*', alias="expected-status",
|
||||
description="Expected HTTP response status code for health checks.")
|
||||
|
||||
# Network and routing fields
|
||||
disable_udp: Optional[bool] = Field(default=False, description="Disables UDP for this proxy group.",
|
||||
alias="disable-udp")
|
||||
interface_name: Optional[str] = Field(default=None, description="DEPRECATED. Specifies the outbound interface.",
|
||||
alias="interface-name")
|
||||
routing_mark: Optional[int] = Field(default=None, alias="routing-mark",
|
||||
description="DEPRECATED. The routing mark for outbound connections.")
|
||||
|
||||
# Dynamic proxy inclusion
|
||||
include_all: Optional[bool] = Field(default=False, description="Includes all outbound proxies and proxy sets.",
|
||||
alias="include-all")
|
||||
include_all_proxies: Optional[bool] = Field(default=False, description="Includes all outbound proxies.",
|
||||
alias="include-all-proxies")
|
||||
include_all_providers: Optional[bool] = Field(default=False, description="Includes all proxy provider sets.",
|
||||
alias="include-all-providers")
|
||||
|
||||
# Filtering
|
||||
filter: Optional[str] = Field(default=None, description="Regex to filter nodes from providers.")
|
||||
exclude_filter: Optional[str] = Field(default=None, description="Regex to exclude nodes.", alias="exclude-filter")
|
||||
exclude_type: Optional[str] = Field(default=None, description="Exclude nodes by adapter type, separated by '|'.",
|
||||
alias="exclude-type")
|
||||
|
||||
# UI fields
|
||||
hidden: Optional[bool] = Field(default=False, description="Hides the proxy group in the API.")
|
||||
icon: Optional[str] = Field(default=None, description="Icon string for the proxy group, for UI use.")
|
||||
|
||||
@field_validator('expected_status')
|
||||
@classmethod
|
||||
def validate_expected_status(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None or v == '*':
|
||||
return v
|
||||
pattern = re.compile(r'^\d{3}([-/]\d{3})*$')
|
||||
if not pattern.match(v):
|
||||
raise ValueError("Invalid format for expected-status.")
|
||||
parts = re.split(r'[/]', v)
|
||||
for part in parts:
|
||||
if '-' in part:
|
||||
start, end = part.split('-')
|
||||
if not (start.isdigit() and end.isdigit() and 100 <= int(start) < 600 and 100 <= int(end) < 600 and int(
|
||||
start) <= int(end)):
|
||||
raise ValueError(f"Invalid status code range: {part}")
|
||||
elif not (part.isdigit() and 100 <= int(part) < 600):
|
||||
raise ValueError(f"Invalid status code: {part}")
|
||||
return v
|
||||
|
||||
|
||||
class SelectGroup(ProxyGroupBase):
|
||||
type: Literal['select'] = "select"
|
||||
|
||||
|
||||
class RelayGroup(ProxyGroupBase):
|
||||
type: Literal['relay'] = "relay"
|
||||
|
||||
|
||||
class FallbackGroup(ProxyGroupBase):
|
||||
type: Literal['fallback'] = "fallback"
|
||||
|
||||
|
||||
class UrlTestGroup(ProxyGroupBase):
|
||||
type: Literal['url-test'] = "url-test"
|
||||
tolerance: Optional[int] = Field(default=None, description="proxies switch tolerance, measured in milliseconds (ms).")
|
||||
|
||||
|
||||
class LoadBalanceGroup(ProxyGroupBase):
|
||||
type: Literal['load-balance'] = "load-balance"
|
||||
strategy: Optional[Literal['round-robin', 'consistent-hashing', 'sticky-sessions']] = Field(
|
||||
default='round-robin', description="Load balancing strategy."
|
||||
)
|
||||
|
||||
|
||||
class SmartGroup(ProxyGroupBase):
|
||||
type: Literal['smart'] = "smart"
|
||||
uselightgbm: bool = Field(default=False, description="Use LightGBM model predict weight.")
|
||||
collectdata: bool = Field(default=False, description="Collect datas for model training.")
|
||||
policy_priority: Optional[str] = Field(default=None,
|
||||
description="<1 means lower priority, >1 means higher priority, "
|
||||
"the default is 1, pattern support regex and string.",
|
||||
alias="policy-priority")
|
||||
strategy: Optional[Literal['round-robin', 'sticky-sessions']] = Field(
|
||||
default='sticky-sessions', description="Load balancing strategy."
|
||||
)
|
||||
sample_rate: Optional[int] = Field(default=1, description="Data acquisition rate.", alias="sample-rate")
|
||||
|
||||
@field_validator('policy_priority', mode='before')
|
||||
@classmethod
|
||||
def validate_policy_priority(cls, v):
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if not isinstance(v, str):
|
||||
raise ValueError('policy_priority must be a string')
|
||||
return v
|
||||
|
||||
# Discriminated Union
|
||||
ProxyGroupType = Union[SelectGroup, RelayGroup, FallbackGroup, UrlTestGroup, LoadBalanceGroup, SmartGroup]
|
||||
|
||||
|
||||
class ProxyGroup(RootModel[ProxyGroupType]):
|
||||
root: ProxyGroupType = Field(..., discriminator='type')
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.root.name
|
||||
|
||||
@property
|
||||
def proxies(self) -> list[str]:
|
||||
if self.root.proxies:
|
||||
return self.root.proxies
|
||||
return []
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self.root, item)
|
||||
|
||||
def patch(self, patch: str) -> 'ProxyGroup':
|
||||
src = self.model_dump(mode="json", by_alias=True)
|
||||
patched = jsonpatch.apply_patch(src, patch=patch, in_place=True)
|
||||
return ProxyGroup.model_validate(patched)
|
||||
|
||||
|
||||
class ProxyGroupData(ResourceItem[ProxyGroup]):
|
||||
"""Proxy Group Data"""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_name_consistency(self):
|
||||
data_name = self.data.name
|
||||
if self.name != data_name:
|
||||
raise ValueError(f"name ({self.name}) must equal data.name ({data_name})")
|
||||
return self
|
||||
|
||||
|
||||
class ProxyGroups(ResourceList[ProxyGroupData]):
|
||||
"""Proxy Groups Collection"""
|
||||
pass
|
||||
130
plugins.v2/clashruleprovider/models/proxyproviders.py
Normal file
130
plugins.v2/clashruleprovider/models/proxyproviders.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, ConfigDict
|
||||
|
||||
from .generics import ResourceItem, ResourceList
|
||||
from .types import VehicleType
|
||||
|
||||
|
||||
class OverrideProxyName(BaseModel):
|
||||
"""代理名称覆盖配置"""
|
||||
pattern: str | None = Field(None, description="正则表达式模式")
|
||||
target: str = Field(..., description="替换目标")
|
||||
|
||||
|
||||
class Override(BaseModel):
|
||||
"""代理配置覆盖"""
|
||||
tfo: bool | None = Field(None, description="TCP Fast Open")
|
||||
mptcp: bool | None = Field(None, description="Multipath TCP")
|
||||
udp: bool | None = Field(None, description="UDP支持")
|
||||
udp_over_tcp: bool | None = Field(None, alias="udp-over-tcp", description="UDP over TCP")
|
||||
up: str | None = Field(None, description="上传速度限制")
|
||||
dialer_proxy: str | None = Field(None, alias="dialer-proxy", description="拨号代理")
|
||||
skip_cert_verify: bool | None = Field(None, alias="skip-cert-verify", description="跳过证书验证")
|
||||
interface_name: Optional[str] = Field(None, alias="interface-name", description="网络接口名称")
|
||||
routing_mark: int | None = Field(None, alias="routing-mark", description="路由标记")
|
||||
ip_version: str | None = Field(None, alias="ip-version", description="IP版本偏好")
|
||||
additional_prefix: str | None = Field(None, alias="additional-prefix", description="名称前缀")
|
||||
additional_suffix: str | None = Field(None, alias="additional-suffix", description="名称后缀")
|
||||
proxy_name: list[OverrideProxyName] | None = Field(None, alias="proxy-name", description="代理名称替换规则")
|
||||
|
||||
|
||||
class HealthCheck(BaseModel):
|
||||
"""健康检查配置"""
|
||||
enable: bool = Field(..., description="启用健康检查")
|
||||
url: str = Field(..., description="健康检查URL")
|
||||
interval: int = Field(300, description="检查间隔(秒)")
|
||||
timeout: int | None = Field(None, description="超时时间(毫秒)")
|
||||
lazy: bool = Field(True, description="懒加载模式")
|
||||
expected_status: str | None = Field(None, alias="expected-status", description="期望的HTTP状态码")
|
||||
|
||||
@field_validator('interval')
|
||||
@classmethod
|
||||
def validate_interval(cls, v):
|
||||
if v <= 0:
|
||||
raise ValueError("间隔时间必须大于0")
|
||||
return v
|
||||
|
||||
@field_validator('timeout')
|
||||
@classmethod
|
||||
def validate_timeout(cls, v):
|
||||
if v is not None and v <= 0:
|
||||
raise ValueError("超时时间必须大于0")
|
||||
return v
|
||||
|
||||
|
||||
class ProxyProvider(BaseModel):
|
||||
"""Proxy Provider"""
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
type: VehicleType = Field(..., description="Provider类型")
|
||||
path: str | None = Field(default=None, description="本地文件路径")
|
||||
url: str | None = Field(default=None, description="远程URL")
|
||||
proxy: str | None = Field(default=None, description="使用的代理")
|
||||
interval: int | None = Field(default=None, description="更新间隔(秒)")
|
||||
filter: str | None = Field(default=None, description="过滤正则表达式")
|
||||
exclude_filter: str | None = Field(default=None, alias="exclude-filter", description="排除过滤正则表达式")
|
||||
exclude_type: str | None = Field(default=None, alias="exclude-type", description="排除的代理类型")
|
||||
dialer_proxy: str | None = Field(default=None, alias="dialer-proxy", description="拨号代理")
|
||||
size_limit: int | None = Field(default=None, alias="size-limit", description="文件大小限制(字节)")
|
||||
payload: list[dict[str, Any]] | None = Field(default=None, description="内联代理配置")
|
||||
health_check: HealthCheck | None = Field(default=None, alias="health-check", description="健康检查配置")
|
||||
override: Override | None = Field(default=None, description="配置覆盖")
|
||||
header: dict[str, list[str]] | None = Field(default=None, description="HTTP请求头")
|
||||
|
||||
@field_validator('interval')
|
||||
@classmethod
|
||||
def validate_interval(cls, v):
|
||||
if v is not None and v <= 0:
|
||||
raise ValueError("间隔时间必须大于0")
|
||||
return v
|
||||
|
||||
@field_validator('size_limit')
|
||||
@classmethod
|
||||
def validate_size_limit(cls, v):
|
||||
if v is not None and v < 0:
|
||||
raise ValueError("文件大小限制不能为负数")
|
||||
return v
|
||||
|
||||
@field_validator('exclude_type')
|
||||
@classmethod
|
||||
def validate_exclude_type(cls, v):
|
||||
if v is not None:
|
||||
types = [t.strip() for t in v.split('|')]
|
||||
if not all(types):
|
||||
raise ValueError("排除类型不能为空")
|
||||
return v
|
||||
|
||||
@field_validator('url')
|
||||
@classmethod
|
||||
def validate_url_dependency(cls, v, info):
|
||||
if info.data.get('type') == VehicleType.HTTP and not v:
|
||||
raise ValueError("HTTP类型的provider必须提供URL")
|
||||
return v
|
||||
|
||||
@field_validator('path')
|
||||
@classmethod
|
||||
def validate_path_dependency(cls, v, info):
|
||||
if info.data.get('type') == VehicleType.FILE and not v:
|
||||
raise ValueError("FILE类型的provider必须提供路径")
|
||||
return v
|
||||
|
||||
@field_validator('payload')
|
||||
@classmethod
|
||||
def validate_payload_dependency(cls, v, info):
|
||||
if info.data.get('type') == VehicleType.INLINE and not v:
|
||||
raise ValueError("INLINE类型的provider必须提供payload")
|
||||
return v
|
||||
|
||||
|
||||
class ProxyProviderData(ResourceItem[ProxyProvider]):
|
||||
"""Proxy Provider Data"""
|
||||
pass
|
||||
|
||||
|
||||
class ProxyProviders(ResourceList[ProxyProviderData]):
|
||||
"""Proxy Provider Collection"""
|
||||
pass
|
||||
192
plugins.v2/clashruleprovider/models/rule/__init__.py
Normal file
192
plugins.v2/clashruleprovider/models/rule/__init__.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from enum import Enum, StrEnum
|
||||
from typing import Any, List, Optional, Union, Dict, Literal
|
||||
|
||||
from pydantic import BaseModel, field_validator, ValidationInfo
|
||||
|
||||
|
||||
class AdditionalParam(Enum):
|
||||
NO_RESOLVE = 'no-resolve'
|
||||
SRC = 'src'
|
||||
|
||||
|
||||
class RoutingRuleType(Enum):
|
||||
"""Enumeration of all supported Clash rule types"""
|
||||
DOMAIN = "DOMAIN"
|
||||
DOMAIN_SUFFIX = "DOMAIN-SUFFIX"
|
||||
DOMAIN_KEYWORD = "DOMAIN-KEYWORD"
|
||||
DOMAIN_REGEX = "DOMAIN-REGEX"
|
||||
DOMAIN_WILDCARD = "DOMAIN-WILDCARD"
|
||||
|
||||
GEOSITE = "GEOSITE"
|
||||
GEOIP = "GEOIP"
|
||||
|
||||
IP_CIDR = "IP-CIDR"
|
||||
IP_CIDR6 = "IP-CIDR6"
|
||||
IP_SUFFIX = "IP-SUFFIX"
|
||||
IP_ASN = "IP-ASN"
|
||||
|
||||
|
||||
SRC_GEOIP = "SRC-GEOIP"
|
||||
SRC_IP_ASN = "SRC-IP-ASN"
|
||||
SRC_IP_CIDR = "SRC-IP-CIDR"
|
||||
SRC_IP_SUFFIX = "SRC-IP-SUFFIX"
|
||||
|
||||
DST_PORT = "DST-PORT"
|
||||
SRC_PORT = "SRC-PORT"
|
||||
|
||||
IN_PORT = "IN-PORT"
|
||||
IN_TYPE = "IN-TYPE"
|
||||
IN_USER = "IN-USER"
|
||||
IN_NAME = "IN-NAME"
|
||||
|
||||
PROCESS_PATH = "PROCESS-PATH"
|
||||
PROCESS_PATH_REGEX = "PROCESS-PATH-REGEX"
|
||||
PROCESS_NAME = "PROCESS-NAME"
|
||||
PROCESS_NAME_REGEX = "PROCESS-NAME-REGEX"
|
||||
|
||||
UID = "UID"
|
||||
NETWORK = "NETWORK"
|
||||
DSCP = "DSCP"
|
||||
|
||||
RULE_SET = "RULE-SET"
|
||||
AND = "AND"
|
||||
OR = "OR"
|
||||
NOT = "NOT"
|
||||
SUB_RULE = "SUB-RULE"
|
||||
|
||||
MATCH = "MATCH"
|
||||
|
||||
|
||||
class Action(StrEnum):
|
||||
"""Enumeration of rule actions"""
|
||||
DIRECT = "DIRECT"
|
||||
REJECT = "REJECT"
|
||||
REJECT_DROP = "REJECT-DROP"
|
||||
PASS = "PASS"
|
||||
COMPATIBLE = "COMPATIBLE"
|
||||
|
||||
|
||||
class RuleBase(BaseModel):
|
||||
rule_type: RoutingRuleType
|
||||
action: Union[Action, str] # Can be Action enum or custom proxy group name
|
||||
raw_rule: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
pass
|
||||
|
||||
def __str__(self) -> str:
|
||||
pass
|
||||
|
||||
def __eq__(self, other: 'RuleBase') -> bool:
|
||||
if not isinstance(other, RuleBase):
|
||||
return NotImplemented
|
||||
return self.__str__() == other.__str__()
|
||||
|
||||
|
||||
class ClashRule(RuleBase):
|
||||
"""Represents a parsed Clash routing rule"""
|
||||
rule_type: RoutingRuleType
|
||||
payload: str
|
||||
additional_params: Optional[AdditionalParam] = None
|
||||
|
||||
def condition_string(self) -> str:
|
||||
return f"{self.rule_type.value},{self.payload}"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'type': self.rule_type.value,
|
||||
'payload': self.payload,
|
||||
'action': self.action.value if isinstance(self.action, Action) else self.action,
|
||||
'additional_params': self.additional_params.value if self.additional_params else None,
|
||||
'rule_string': str(self)
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
rule_str = f"{self.condition_string()},{self.action}"
|
||||
if self.additional_params:
|
||||
rule_str += f",{self.additional_params.value}"
|
||||
return rule_str
|
||||
|
||||
@field_validator('payload', mode='after')
|
||||
@classmethod
|
||||
def validate_payload(cls, v: Optional[str], info: ValidationInfo) -> Optional[str]:
|
||||
# 获取其他字段的值
|
||||
rule_type = info.data['rule_type']
|
||||
|
||||
if rule_type == RoutingRuleType.NETWORK and v is not None and v.upper() not in ('TCP', 'UDP'):
|
||||
raise ValueError('Payload must be TCP or UDP')
|
||||
return v
|
||||
|
||||
|
||||
class LogicRule(RuleBase):
|
||||
"""Represents a logic rule (AND, OR, NOT)"""
|
||||
rule_type: Literal[RoutingRuleType.AND, RoutingRuleType.OR, RoutingRuleType.NOT]
|
||||
conditions: List[Union[ClashRule, 'LogicRule']]
|
||||
|
||||
def condition_string(self) -> str:
|
||||
conditions_str = ','.join([f"({c.condition_string()})" for c in self.conditions])
|
||||
return f"{self.rule_type.value},({conditions_str})"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
conditions: list[str] = []
|
||||
for condition in self.conditions:
|
||||
conditions.append(condition.condition_string())
|
||||
|
||||
return {
|
||||
'type': self.rule_type.value,
|
||||
'conditions': conditions,
|
||||
'action': self.action.value if isinstance(self.action, Action) else self.action,
|
||||
'rule_string': str(self)
|
||||
}
|
||||
|
||||
@field_validator('conditions', mode='after')
|
||||
@classmethod
|
||||
def validate_conditions(cls, v: List[Union[ClashRule, 'LogicRule']]) -> List[Union[ClashRule, 'LogicRule']]:
|
||||
if not v:
|
||||
raise ValueError('A condition list must be provided')
|
||||
return v
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.condition_string()},{self.action}"
|
||||
|
||||
|
||||
class SubRule(RuleBase):
|
||||
rule_type: Literal[RoutingRuleType.SUB_RULE] = RoutingRuleType.SUB_RULE
|
||||
condition: Union[ClashRule, LogicRule]
|
||||
action: str
|
||||
|
||||
def condition_string(self) -> str:
|
||||
return f"{self.rule_type.value},({self.condition.condition_string()})"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'type': self.rule_type.value,
|
||||
'condition': f"({self.condition.condition_string()})",
|
||||
'action': self.action,
|
||||
'rule_string': str(self)
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.condition_string()},{self.action}"
|
||||
|
||||
|
||||
class MatchRule(RuleBase):
|
||||
"""Represents a match rule"""
|
||||
rule_type: Literal[RoutingRuleType.MATCH] = RoutingRuleType.MATCH
|
||||
|
||||
@staticmethod
|
||||
def condition_string() -> str:
|
||||
return "MATCH"
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
return {
|
||||
'type': 'MATCH',
|
||||
'action': self.action.value if isinstance(self.action, Action) else self.action,
|
||||
'rule_string': str(self)
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.condition_string()},{self.action}"
|
||||
|
||||
|
||||
RuleType = Union[ClashRule, LogicRule, SubRule, MatchRule]
|
||||
40
plugins.v2/clashruleprovider/models/ruleitem.py
Normal file
40
plugins.v2/clashruleprovider/models/ruleitem.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from pydantic import BaseModel, Field, field_validator, field_serializer
|
||||
|
||||
from .metadata import Metadata
|
||||
from .rule import RuleType
|
||||
from .rule import RoutingRuleType, Action, AdditionalParam
|
||||
from ..helper.clashruleparser import ClashRuleParser
|
||||
|
||||
|
||||
class RuleItem(BaseModel):
|
||||
"""Clash rule item"""
|
||||
rule: RuleType
|
||||
meta: Metadata = Field(default_factory=Metadata)
|
||||
|
||||
@field_serializer("rule")
|
||||
def serialize_rule(self, v: RuleType, _info):
|
||||
return str(v)
|
||||
|
||||
@field_validator("rule", mode="before")
|
||||
@classmethod
|
||||
def validate_rule(cls, v):
|
||||
if isinstance(v, str):
|
||||
return ClashRuleParser.parse(v)
|
||||
return v
|
||||
|
||||
|
||||
class RuleData(BaseModel):
|
||||
priority: int
|
||||
rule_string: str
|
||||
type: RoutingRuleType
|
||||
payload: str | None = None
|
||||
action: Action | str
|
||||
additional_params: AdditionalParam | None = None
|
||||
conditions: list[str] | None = None
|
||||
condition: str | None = None
|
||||
meta: Metadata = Field(default_factory=Metadata)
|
||||
|
||||
@classmethod
|
||||
def from_rule_item(cls, item: RuleItem, priority: int) -> 'RuleData':
|
||||
fields = item.rule.to_dict()
|
||||
return cls(priority=priority, meta=item.meta, **fields)
|
||||
76
plugins.v2/clashruleprovider/models/ruleproviders.py
Normal file
76
plugins.v2/clashruleprovider/models/ruleproviders.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from typing import Annotated, List, Optional, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from .generics import ResourceItem, ResourceList
|
||||
from .types import VehicleType
|
||||
|
||||
|
||||
class RuleProvider(BaseModel):
|
||||
"""Rule Provider"""
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
type: VehicleType = Field(..., description="Provider type")
|
||||
url: Optional[str] = Field(default=None, description="Must be configured if the type is http")
|
||||
path: Optional[str] = Field(default=None, description="Optional, file path, must be unique.")
|
||||
interval: Optional[int] = Field(default=None, ge=0, description="The update interval for the provider, in seconds.")
|
||||
proxy: Optional[str] = Field(default=None, description="Download/update through the specified proxy.")
|
||||
behavior: Optional[Literal["domain", "ipcidr", "classical"]] = Field(None,
|
||||
description="Behavior of the rule provider")
|
||||
format: Literal["yaml", "text", "mrs"] = Field("yaml", description="Format of the rule provider file")
|
||||
size_limit: Annotated[int, Field(
|
||||
default=0, ge=0, validation_alias="size-limit", serialization_alias="size-limit",
|
||||
description="The maximum size of downloadable files in bytes (0 for no limit)")
|
||||
] = 0
|
||||
payload: Optional[List[str]] = Field(default=None, description="Content, only effective when type is inline")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def validate_type_relationships(cls, values):
|
||||
"""Perform cross-field validation before the model is created."""
|
||||
type_ = values.get('type')
|
||||
url = values.get('url')
|
||||
path = values.get('path')
|
||||
payload = values.get('payload')
|
||||
format_ = values.get('format', 'yaml')
|
||||
behavior = values.get('behavior')
|
||||
|
||||
# url check
|
||||
if type_ == "http" and url is None:
|
||||
raise ValueError("url must be configured if the type is 'http'")
|
||||
if type_ != "http" and 'url' in values:
|
||||
values['url'] = None
|
||||
|
||||
# path check
|
||||
if type_ == "file" and path is None:
|
||||
raise ValueError("path must be configured if the type is 'file'")
|
||||
if type_ != "file" and 'path' in values:
|
||||
values['path'] = None
|
||||
|
||||
# payload handling
|
||||
if type_ == "inline":
|
||||
if payload is None:
|
||||
raise ValueError("payload must be configured if the type is 'inline'")
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("payload must be a list of strings when type is 'inline'")
|
||||
elif 'payload' in values:
|
||||
values['payload'] = None
|
||||
|
||||
# format-behavior rule
|
||||
if format_ == "mrs" and behavior not in {"domain", "ipcidr"}:
|
||||
raise ValueError("mrs format only supports 'domain' or 'ipcidr' behavior")
|
||||
|
||||
return values
|
||||
|
||||
|
||||
class RuleProviderData(ResourceItem[RuleProvider]):
|
||||
"""Rule Provider Data"""
|
||||
pass
|
||||
|
||||
|
||||
class RuleProviders(ResourceList[RuleProviderData]):
|
||||
"""Rule Providers Collection"""
|
||||
pass
|
||||
57
plugins.v2/clashruleprovider/models/types.py
Normal file
57
plugins.v2/clashruleprovider/models/types.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from enum import StrEnum
|
||||
from typing import TypeVar, Protocol
|
||||
|
||||
|
||||
class DataSource(StrEnum):
|
||||
MANUAL = "Manual"
|
||||
ACL4SSR = "Acl4SSR"
|
||||
TEMPLATE = "Template"
|
||||
SUB = "Subscription"
|
||||
AUTO = "Auto"
|
||||
|
||||
|
||||
class VehicleType(StrEnum):
|
||||
FILE = "file"
|
||||
HTTP = "http"
|
||||
INLINE = "inline"
|
||||
|
||||
|
||||
class DataKey(StrEnum):
|
||||
"""Plugin data key"""
|
||||
PROXY_PATCH = "proxy_patch"
|
||||
PROXY_GROUPS = "proxy-groups"
|
||||
PROXIES = "proxies"
|
||||
INVALID_PROXIES = "extra_proxies"
|
||||
SUB_INFO = "subscription_info"
|
||||
HOSTS = "hosts"
|
||||
ACL4SSR = "acl4ssr_providers"
|
||||
RULE_PROVIDERS = "rule-providers"
|
||||
DATA_VERSION = "data_version"
|
||||
SUB_CONFIGS = "clash_configs"
|
||||
PROXY_GROUP_PATCH = "proxy_group_patch"
|
||||
RULESET_NAMES = "ruleset_names"
|
||||
AUTO_RULE_PROVIDERS = "rule_provider"
|
||||
GEO_RULES = "geo_rules"
|
||||
TOP_RULES = "top_rules"
|
||||
RULESET_RULES = "ruleset_rules"
|
||||
RULE_PROVIDER_PATCH = "rule_provider_patch"
|
||||
RAW_PROXIES = "raw_proxies"
|
||||
|
||||
|
||||
class RuleSet(StrEnum):
|
||||
TOP = "top"
|
||||
RULESET = "ruleset"
|
||||
|
||||
|
||||
class ClashKey(StrEnum):
|
||||
PROXIES = "proxies"
|
||||
PROXY_GROUPS = "proxy-groups"
|
||||
NAME = "name"
|
||||
RULES = "rules"
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
class SupportsPatch(Protocol[T]):
|
||||
def patch(self, patch: str) -> T:
|
||||
...
|
||||
@@ -1,2 +1,5 @@
|
||||
websockets
|
||||
sse_starlette~=2.3.6
|
||||
websockets
|
||||
sse_starlette~=3.1.1
|
||||
PyYAML~=6.0.2
|
||||
jsonpatch~=1.33
|
||||
simpleeval~=1.0.3
|
||||
973
plugins.v2/clashruleprovider/services.py
Normal file
973
plugins.v2/clashruleprovider/services.py
Normal file
@@ -0,0 +1,973 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import pytz
|
||||
import re
|
||||
import time
|
||||
import yaml
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple, Iterable, TypeVar
|
||||
|
||||
import jsonpatch
|
||||
from fastapi import HTTPException
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.utils.http import AsyncRequestUtils
|
||||
|
||||
from .base import Constant
|
||||
from .helper.clashruleparser import ClashRuleParser, RoutingRuleType, Action
|
||||
from .helper.configconverter import Converter
|
||||
from .helper.utilsprovider import UtilsProvider
|
||||
from .models import ProxyGroup, Proxy, RuleProvider, RuleProviderData, ProxyData, HostData, VehicleType, SelectGroup, \
|
||||
ProxyGroupData, RuleItem, RuleData, Metadata, RuleProviders
|
||||
from .models.api import ClashApi, SubscriptionSetting, DataUsage, SubscriptionInfo, ConfigRequest
|
||||
from .models.configuration import ClashConfig
|
||||
from .models.datapatch import PatchItem, DataPatch
|
||||
from .models.rule import RuleType
|
||||
from .models.types import DataSource, DataKey, RuleSet, ClashKey, SupportsPatch
|
||||
from .state import PluginState
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
class ClashRuleProviderService:
|
||||
|
||||
def __init__(
|
||||
self, plugin_id: str,
|
||||
state: PluginState,
|
||||
scheduler: Optional[AsyncIOScheduler] = None
|
||||
):
|
||||
self.plugin_id = plugin_id
|
||||
self.state = state
|
||||
self.scheduler = scheduler
|
||||
|
||||
def save_rules(self):
|
||||
self.state.save_data(DataKey.TOP_RULES, self.state.top_rules_manager.export_rules())
|
||||
self.state.save_data(DataKey.RULESET_RULES, self.state.ruleset_rules_manager.export_rules())
|
||||
|
||||
def load_rules(self):
|
||||
self.state.top_rules_manager.import_rules(self.state.get_data(DataKey.TOP_RULES) or [])
|
||||
self.state.ruleset_rules_manager.import_rules(self.state.get_data(DataKey.RULESET_RULES) or [])
|
||||
|
||||
def _make_proxy_patch(self, src: Proxy, dst: Proxy):
|
||||
src_dict = src.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
dst_dict = dst.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
patch = jsonpatch.make_patch(src_dict, dst_dict)
|
||||
patches = self.state.proxy_patch
|
||||
patches[src.name] = PatchItem(patch=patch.to_string(), lifecycle=Constant.PATCH_LIFESPAN)
|
||||
self.state.proxy_patch = patches
|
||||
|
||||
def _apply_patch(self, item: SupportsPatch[T], name: str, patch: DataPatch) -> T:
|
||||
try:
|
||||
if name in patch:
|
||||
return item.patch(patch[name].patch)
|
||||
except Exception as err:
|
||||
logger.error(f"Failed to apply patch for {name}: {repr(err)}")
|
||||
return item
|
||||
|
||||
def _apply_patches(self, items: list[Any], patches: DataPatch) -> list[Any]:
|
||||
for item in items:
|
||||
item.data = self._apply_patch(item.data, item.name, patches)
|
||||
item.meta.patched = item.name in patches
|
||||
return items
|
||||
|
||||
def _apply_patch_to_config(self, conf: ClashConfig) -> ClashConfig:
|
||||
conf.proxies = [self._apply_patch(proxy, proxy.name, self.state.proxy_patch) for proxy in conf.proxies]
|
||||
conf.proxy_groups = [self._apply_patch(pg, pg.name, self.state.proxy_group_patch) for pg in conf.proxy_groups]
|
||||
return conf
|
||||
|
||||
def _merge_subscriptions(self, config: ClashConfig):
|
||||
subscriptions_config = self.state.config.subscriptions_config
|
||||
subscription_info = self.state.subscription_info
|
||||
|
||||
for conf in subscriptions_config:
|
||||
if not subscription_info.get(conf.url).enabled:
|
||||
continue
|
||||
sub_config = self.state.get_sub_config(conf.url)
|
||||
config.merge(sub_config)
|
||||
|
||||
def _filter_available_items(self, items: Iterable[Any], param: ConfigRequest) -> list[Any]:
|
||||
return [item.data for item in items if item.meta.available(param)]
|
||||
|
||||
def _process_auto_rule_providers(self, config: ClashConfig):
|
||||
auto_rule_provider = {}
|
||||
ruleset_names = self.state.ruleset_names
|
||||
|
||||
for r in self.state.ruleset_rules_manager.rules:
|
||||
rule = r.rule
|
||||
rule_provider_name = f'{self.state.config.ruleset_prefix}{rule.action}'
|
||||
if rule_provider_name not in auto_rule_provider:
|
||||
path_name = hashlib.sha256(rule.action.encode('utf-8')).hexdigest()[:10]
|
||||
ruleset_names[path_name] = rule_provider_name
|
||||
sub_url = (f"{self.state.config.movie_pilot_url}/api/v1/plugin/{self.plugin_id}/ruleset?"
|
||||
f"name={path_name}&apikey={self.state.config.apikey or settings.API_TOKEN}")
|
||||
auto_rule_provider[rule_provider_name] = RuleProvider(
|
||||
type=VehicleType.HTTP, behavior="classical", url=sub_url, path=f"./CRP/{path_name}.yaml",
|
||||
interval=3600, format="yaml"
|
||||
)
|
||||
config.rule_providers = config.rule_providers | auto_rule_provider
|
||||
self.state.rule_provider = auto_rule_provider
|
||||
self.state.ruleset_names = ruleset_names
|
||||
|
||||
def _process_rules(self, config: ClashConfig, param: ConfigRequest):
|
||||
top_rules: list[RuleType] = []
|
||||
acl4ssr_providers_map: dict[str, RuleProvider] = {}
|
||||
acl4ssr_data = self.state.acl4ssr_providers
|
||||
|
||||
for r in self.state.top_rules_manager:
|
||||
if not r.meta.available(param):
|
||||
continue
|
||||
rule = r.rule
|
||||
if rule.rule_type == RoutingRuleType.RULE_SET:
|
||||
if rule.payload in acl4ssr_data:
|
||||
acl4ssr_providers_map[rule.payload] = acl4ssr_data.get(rule.payload).data
|
||||
top_rules.append(rule)
|
||||
config.rule_providers = config.rule_providers | acl4ssr_providers_map
|
||||
config.rules = top_rules + config.rules
|
||||
|
||||
def _cleanup_ruleset_names(self, config: ClashConfig):
|
||||
ruleset_names = self.state.ruleset_names
|
||||
key_to_delete = [key for key, item in ruleset_names.items() if item not in config.rule_providers]
|
||||
for key in key_to_delete:
|
||||
del ruleset_names[key]
|
||||
self.state.ruleset_names = ruleset_names
|
||||
|
||||
def _check_cycles(self, config: ClashConfig):
|
||||
proxy_graph = self._build_graph(config)
|
||||
cycles = UtilsProvider.find_cycles(proxy_graph)
|
||||
if cycles:
|
||||
logger.warn("发现代理组回环:")
|
||||
for cycle in cycles:
|
||||
logger.warn(" -> ".join(cycle))
|
||||
|
||||
def _make_proxy_group_patch(self, src: ProxyGroup, dst: ProxyGroup):
|
||||
src_dict = src.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
dst_dict = dst.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
patch = jsonpatch.make_patch(src_dict, dst_dict)
|
||||
|
||||
# Flatten list patches to full replace to avoid index shift issues
|
||||
new_ops = []
|
||||
replaced_paths = set()
|
||||
list_fields = ["/proxies", "/use"]
|
||||
|
||||
for op in patch.patch:
|
||||
path = op["path"]
|
||||
matched_list = next((f for f in list_fields if path == f or path.startswith(f + '/')), None)
|
||||
if matched_list:
|
||||
if matched_list not in replaced_paths:
|
||||
field_name = matched_list.strip('/')
|
||||
val = dst_dict.get(field_name)
|
||||
if val is None:
|
||||
# Removed in dst
|
||||
new_ops.append({"op": "remove", "path": matched_list})
|
||||
elif field_name not in src_dict:
|
||||
# Not in src, added in dst
|
||||
new_ops.append({"op": "add", "path": matched_list, "value": val})
|
||||
else:
|
||||
# In src and dst, replacing
|
||||
new_ops.append({"op": "replace", "path": matched_list, "value": val})
|
||||
replaced_paths.add(matched_list)
|
||||
else:
|
||||
new_ops.append(op)
|
||||
|
||||
patch.patch = new_ops
|
||||
pg_patches = self.state.proxy_group_patch
|
||||
pg_patches[src.name] = PatchItem(patch=patch.to_string(), lifecycle=Constant.PATCH_LIFESPAN)
|
||||
self.state.proxy_group_patch = pg_patches
|
||||
|
||||
def organize_and_save_rules(self):
|
||||
self.sync_ruleset()
|
||||
self.save_rules()
|
||||
|
||||
def ruleset(self, ruleset: str) -> List[str]:
|
||||
if not ruleset.startswith(self.state.config.ruleset_prefix):
|
||||
return []
|
||||
action = ruleset[len(self.state.config.ruleset_prefix):]
|
||||
try:
|
||||
final_action = Action(action.upper())
|
||||
except ValueError:
|
||||
final_action = action
|
||||
rules = self.state.ruleset_rules_manager.filter_rules_by_action(final_action)
|
||||
return [rule.rule.condition_string() for rule in rules]
|
||||
|
||||
def sync_ruleset(self):
|
||||
outbounds = set()
|
||||
new_outbounds = set()
|
||||
manager = self.state.top_rules_manager
|
||||
|
||||
manager.remove_rules_by_lambda(
|
||||
lambda r: r.rule.rule_type == RoutingRuleType.RULE_SET and
|
||||
r.meta.source == DataSource.AUTO and
|
||||
r.rule.payload != f"{self.state.config.ruleset_prefix}{r.rule.action}"
|
||||
)
|
||||
rules_existed = manager.filter_rules_by_condition(
|
||||
lambda r: r.meta.source == DataSource.AUTO and r.rule.rule_type == RoutingRuleType.RULE_SET
|
||||
)
|
||||
actions_existed = {r.rule.action for r in rules_existed}
|
||||
|
||||
for r in self.state.ruleset_rules_manager:
|
||||
if r.meta.disabled:
|
||||
continue
|
||||
outbounds.add(r.rule.action)
|
||||
if r.rule.action not in actions_existed:
|
||||
new_outbounds.add(r.rule.action)
|
||||
|
||||
manager.remove_rules_by_lambda(
|
||||
lambda r: r.rule.rule_type == RoutingRuleType.RULE_SET and
|
||||
r.meta.source == DataSource.AUTO and
|
||||
(r.rule.action not in outbounds)
|
||||
)
|
||||
|
||||
for outbound in new_outbounds:
|
||||
clash_rule = ClashRuleParser.parse_rule_line(
|
||||
f"RULE-SET,{self.state.config.ruleset_prefix}{outbound},{outbound}")
|
||||
if clash_rule is None:
|
||||
continue
|
||||
rule = RuleItem(rule=clash_rule, meta=Metadata(source=DataSource.AUTO))
|
||||
if not manager.has_rule_item(rule):
|
||||
manager.insert_rule_at_priority(rule, 0)
|
||||
|
||||
def append_top_rules(self, rules: List[str]):
|
||||
clash_rules = []
|
||||
for rule in rules:
|
||||
clash_rule = ClashRuleParser.parse_rule_line(rule)
|
||||
if clash_rule:
|
||||
clash_rules.append(RuleItem(rule=clash_rule, meta=Metadata(source=DataSource.MANUAL)))
|
||||
self.state.top_rules_manager.append_rules(clash_rules)
|
||||
self.state.save_data(DataKey.TOP_RULES, self.state.top_rules_manager.export_rules())
|
||||
|
||||
def clash_outbound(self) -> list[str]:
|
||||
outbound = [pg_data.data.name for pg_data in self.state.proxy_groups_from_subs()]
|
||||
if self.state.clash_template:
|
||||
outbound.extend(pg.name for pg in self.state.clash_template.proxy_groups)
|
||||
if self.state.config.group_by_region or self.state.config.group_by_country:
|
||||
outbound.extend(pg.name for pg in self.proxy_groups_by_region())
|
||||
outbound.extend(pg.data.name for pg in self.state.proxy_groups)
|
||||
outbound.extend(pg.data.name for pg in self.get_proxies())
|
||||
return outbound
|
||||
|
||||
def delete_proxy(self, name: str) -> Tuple[bool, str]:
|
||||
proxies = self.state.proxies
|
||||
deleted = proxies.pop(name)
|
||||
if deleted:
|
||||
self.state.proxies = proxies
|
||||
return True, "代理删除成功"
|
||||
return False, f"代理 {name!r} 不存在"
|
||||
|
||||
def delete_proxy_patch(self, name: str) -> tuple[bool, str]:
|
||||
patches = self.state.proxy_patch
|
||||
if name in patches:
|
||||
del patches.root[name]
|
||||
self.state.proxy_patch = patches
|
||||
return True, "补丁已删除"
|
||||
return False, "补丁不存在"
|
||||
|
||||
def import_proxies(self, vehicle: str, payload: str) -> tuple[bool, str]:
|
||||
proxies = []
|
||||
if vehicle == 'LINK':
|
||||
links = payload.strip().splitlines()
|
||||
proxies = list(Converter().convert_v2ray(links, skip_exception=True, logger=logger).items())
|
||||
elif vehicle == 'YAML':
|
||||
try:
|
||||
imported = yaml.load(payload, Loader=yaml.SafeLoader)
|
||||
if not isinstance(imported, dict):
|
||||
return False, "无效的输入"
|
||||
except yaml.YAMLError as err:
|
||||
logger.error(f"Failed to import rules: {repr(err)}")
|
||||
return False, 'YAML 格式错误'
|
||||
proxies = [(None, p) for p in (imported.get(DataKey.PROXIES) or [])]
|
||||
if not proxies:
|
||||
return False, "无可用节点"
|
||||
success_count = 0
|
||||
error_messages = ''
|
||||
success = True
|
||||
ps = self.state.proxies
|
||||
for item in proxies:
|
||||
try:
|
||||
proxy = Proxy.model_validate(item[1])
|
||||
meta = Metadata(source=DataSource.MANUAL)
|
||||
pd = ProxyData(data=proxy, name=proxy.name, meta=meta, raw=item[0], v2ray_link=item[0])
|
||||
if not pd.v2ray_link:
|
||||
try:
|
||||
pd.v2ray_link = Converter.convert_to_share_link(item[1])
|
||||
except Exception as err:
|
||||
logger.debug(f"Failed to convert proxy link: {repr(err)}")
|
||||
ps.add(pd)
|
||||
success_count += 1
|
||||
except Exception as err:
|
||||
success = False
|
||||
error_messages += f"{err}\n"
|
||||
message = f"导入 {success_count}/{len(proxies)} 个代理节点. \n{error_messages}"
|
||||
self.state.proxies = ps
|
||||
return success, message
|
||||
|
||||
def update_proxy(self, previous_name: str, source: str, proxy: Proxy) -> tuple[bool, str]:
|
||||
if source == DataSource.MANUAL:
|
||||
proxies = self.state.proxies
|
||||
proxies.update(previous_name, ProxyData(data=proxy, name=proxy.name, meta=Metadata()))
|
||||
self.state.proxies = proxies
|
||||
return True, "代理更新成功"
|
||||
if previous_name != proxy.name:
|
||||
return False, "请勿修改代理名称"
|
||||
proxies = list(self.state.proxies_from_subs())
|
||||
src = next((g for g in proxies if g.name == previous_name), None)
|
||||
if src is None:
|
||||
return False, f"代理组 {previous_name!r} ({source}) 不存在"
|
||||
self._make_proxy_patch(src.data, proxy)
|
||||
return True, "代理更新成功"
|
||||
|
||||
def update_proxy_meta(self, name: str, meta: Metadata) -> tuple[bool, str]:
|
||||
proxies = self.state.proxies
|
||||
if name not in proxies:
|
||||
return False, f"The proxy name {name} does not exist"
|
||||
proxies.set_meta(name, meta)
|
||||
self.state.proxies = proxies
|
||||
return True, ''
|
||||
|
||||
def get_proxies(self, patched: bool = True) -> list[ProxyData]:
|
||||
proxies = self.state.all_proxies
|
||||
proxies = list(
|
||||
filter(lambda p: not any(keyword in p.data.name for keyword in self.state.config.filter_keywords), proxies)
|
||||
)
|
||||
if not patched:
|
||||
return proxies
|
||||
return self._apply_patches(proxies, self.state.proxy_patch)
|
||||
|
||||
@cached(maxsize=1, ttl=86400, skip_empty=True)
|
||||
def _get_countries_data(self) -> List[Dict[str, str]]:
|
||||
file_path = settings.ROOT_PATH / 'app' / 'plugins' / self.plugin_id.lower() / 'countries.json'
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"加载国家/地区文件错误:{e}")
|
||||
return []
|
||||
|
||||
def proxy_groups_by_region(self) -> list[ProxyGroupData]:
|
||||
countries = self._get_countries_data()
|
||||
proxies = self.get_proxies()
|
||||
return self._group_by_region(
|
||||
countries, proxies, self.state.config.group_by_region, self.state.config.group_by_country
|
||||
)
|
||||
|
||||
@cached(maxsize=2, ttl=86400)
|
||||
def _group_by_region(self, countries: list[dict[str, str]], proxies: list[ProxyData], group_by_continent: bool,
|
||||
group_by_country: bool) -> list[ProxyGroupData]:
|
||||
continent_groups = {}
|
||||
country_groups = {}
|
||||
continent_map = {
|
||||
'欧洲': 'Europe', '亚洲': 'Asia', '大洋洲': 'Oceania', '非洲': 'Africa',
|
||||
'北美洲': 'NorthAmerica', '南美洲': 'SouthAmerica'
|
||||
}
|
||||
proxy_groups: list[ProxyGroup] = []
|
||||
hk = next((c for c in countries if c['abbr'] == 'HK'), {})
|
||||
tw = next((c for c in countries if c['abbr'] == 'TW'), {})
|
||||
|
||||
for proxy_data in proxies:
|
||||
proxy_node = proxy_data.data
|
||||
country = ClashRuleProviderService._country_from_node(countries, proxy_node.name)
|
||||
if not country:
|
||||
continue
|
||||
if country.get("abbr") == "CN":
|
||||
if any(key in proxy_node.name for key in ("🇭🇰", "HK", "香港")):
|
||||
country = hk
|
||||
if any(key in proxy_node.name for key in ("🇹🇼", "TW", "台湾")):
|
||||
country = tw
|
||||
continent = continent_map.get(country["continent"])
|
||||
if continent and group_by_continent:
|
||||
continent_groups.setdefault(continent, []).append(proxy_node.name)
|
||||
if group_by_country:
|
||||
country_groups.setdefault(f"{country.get('emoji')} {country.get('chinese')}", []).append(
|
||||
proxy_node.name)
|
||||
for continent, nodes in continent_groups.items():
|
||||
if nodes:
|
||||
proxy_groups.append(ProxyGroup(root=SelectGroup(name=continent, proxies=nodes)))
|
||||
|
||||
excluded = ('中国', '香港', 'CN', 'HK', '🇨🇳', '🇭🇰')
|
||||
for continent_node in continent_groups.get('Asia', []):
|
||||
if any(x in continent_node for x in excluded):
|
||||
continue
|
||||
continent_groups.setdefault('AsiaExceptChina', []).append(continent_node)
|
||||
if continent_groups.get('AsiaExceptChina'):
|
||||
pg = SelectGroup(name="AsiaExceptChina", proxies=continent_groups['AsiaExceptChina'])
|
||||
proxy_groups.append(ProxyGroup(root=pg))
|
||||
for country, nodes in country_groups.items():
|
||||
if len(nodes):
|
||||
proxy_groups.append(ProxyGroup(root=SelectGroup(name=country, proxies=nodes)))
|
||||
country_group = list(country_groups.keys())
|
||||
if country_group:
|
||||
proxy_groups.append(ProxyGroup(root=SelectGroup(name="🏴☠️国家分组", proxies=country_group)))
|
||||
ret = [ProxyGroupData(name=p.name, data=p, meta=Metadata(source=DataSource.AUTO)) for p in proxy_groups]
|
||||
return ret
|
||||
|
||||
@staticmethod
|
||||
def _country_from_node(countries: List[Dict[str, str]], node_name: str) -> Optional[Dict[str, str]]:
|
||||
node_name_lower = node_name.lower()
|
||||
for country in countries:
|
||||
if country.get('emoji') and country['emoji'] in node_name:
|
||||
return country
|
||||
if (
|
||||
(country.get('chinese') and country['chinese'] in node_name) or
|
||||
(country.get('english') and country['english'].lower() in node_name_lower)
|
||||
):
|
||||
return country
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_graph(config: ClashConfig) -> Dict[str, Any]:
|
||||
"""构建代理组有向图"""
|
||||
graph = {}
|
||||
groups = config.proxy_groups
|
||||
group_names = {g.name for g in groups}
|
||||
for group in groups:
|
||||
proxies = group.proxies
|
||||
graph[group.name] = [p for p in proxies if p in group_names]
|
||||
return graph
|
||||
|
||||
async def fetch_clash_data(self, endpoint: str) -> Dict:
|
||||
headers = {"Authorization": f"Bearer {self.state.config.dashboard_secret}"}
|
||||
url = f"{self.state.config.dashboard_url}/{endpoint}"
|
||||
response = await AsyncRequestUtils().get_json(url, headers=headers, timeout=10)
|
||||
if response is None:
|
||||
raise HTTPException(status_code=502, detail=f"Failed to fetch {endpoint}")
|
||||
return response
|
||||
|
||||
def get_subscription_user_info(self) -> DataUsage:
|
||||
sub_info = DataUsage()
|
||||
for info in self.state.subscription_info.root.values():
|
||||
sub_info.upload += info.upload
|
||||
sub_info.download += info.download
|
||||
sub_info.total += info.total
|
||||
sub_info.expire = max(sub_info.expire, info.expire)
|
||||
return sub_info
|
||||
|
||||
@staticmethod
|
||||
async def async_notify_clash(ruleset: str, api_url: str, api_secret: str):
|
||||
"""
|
||||
通知 Clash 刷新规则集
|
||||
"""
|
||||
logger.info(f"正在刷新 [{ruleset}] {api_url} ...")
|
||||
url = f'{api_url}/providers/rules/{ruleset}'
|
||||
resp = await AsyncRequestUtils(content_type="application/json",
|
||||
headers={"authorization": f"Bearer {api_secret}"}
|
||||
).put_res(url)
|
||||
if resp and resp.status_code == 204:
|
||||
logger.info(f"[{ruleset}] {api_url} 刷新完成")
|
||||
else:
|
||||
logger.warn(f"[{ruleset}] {api_url} 刷新失败")
|
||||
|
||||
def add_notification_job(self, ruleset_names: List[str]):
|
||||
if not self.state.config.enabled or not self.scheduler:
|
||||
return
|
||||
for ruleset in ruleset_names:
|
||||
if ruleset in self.state.rule_provider:
|
||||
self.scheduler.add_job(
|
||||
ClashRuleProviderService.async_notify_clash, "date",
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) +
|
||||
timedelta(seconds=self.state.config.refresh_delay),
|
||||
args=(ruleset, self.state.config.dashboard_url,
|
||||
self.state.config.dashboard_secret),
|
||||
id=f'CRP-notify-clash{ruleset}', replace_existing=True,
|
||||
misfire_grace_time=Constant.MISFIRE_GRACE_TIME
|
||||
)
|
||||
|
||||
def build_clash_config(self, param: ConfigRequest) -> ClashConfig | None:
|
||||
if not self.state.clash_template:
|
||||
config = ClashConfig()
|
||||
else:
|
||||
config = self.state.clash_template.model_copy(deep=True)
|
||||
|
||||
# Merge subscriptions
|
||||
self._merge_subscriptions(config)
|
||||
|
||||
# Add proxies
|
||||
config.proxies += self._filter_available_items(self.state.proxies, param)
|
||||
config.proxies = list(
|
||||
filter(lambda p: not any(kw in p.name for kw in self.state.config.filter_keywords), config.proxies)
|
||||
)
|
||||
# Add proxy groups
|
||||
config.proxy_groups += self._filter_available_items(self.state.proxy_groups, param)
|
||||
|
||||
# Add region groups
|
||||
if self.state.config.group_by_region or self.state.config.group_by_country:
|
||||
config.proxy_groups += [pg.data for pg in self.proxy_groups_by_region()]
|
||||
|
||||
# Add rule providers (Load once)
|
||||
current_rule_providers = self.state.rule_providers
|
||||
rule_providers = {}
|
||||
for rp_data in current_rule_providers:
|
||||
if rp_data.meta.available(param):
|
||||
rule_providers[rp_data.name] = rp_data.data
|
||||
config.rule_providers = config.rule_providers | rule_providers
|
||||
|
||||
# Apply patches
|
||||
config = self._apply_patch_to_config(config)
|
||||
|
||||
# Sync and add auto rule providers
|
||||
self.sync_ruleset()
|
||||
self._process_auto_rule_providers(config)
|
||||
|
||||
# Add rules (including ACL4SSR)
|
||||
self._process_rules(config, param)
|
||||
|
||||
# Add Hosts
|
||||
hosts = self.state.hosts.to_dict(self.state.config.best_cf_ip)
|
||||
if hosts:
|
||||
config.hosts = config.hosts or {}
|
||||
config.hosts = config.hosts | hosts
|
||||
|
||||
# Cleanup ruleset names
|
||||
self._cleanup_ruleset_names(config)
|
||||
|
||||
# Cycle check
|
||||
self._check_cycles(config)
|
||||
|
||||
return config
|
||||
|
||||
def delete_proxy_group(self, name: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Deletes a proxy group by name and saves the state.
|
||||
Returns True if a group was deleted, False otherwise.
|
||||
"""
|
||||
pgs = self.state.proxy_groups
|
||||
deleted = pgs.pop(name)
|
||||
if deleted:
|
||||
self.state.proxy_groups = pgs
|
||||
return True, "代理组删除成功"
|
||||
return False, f"代理组 {name!r} 不存在"
|
||||
|
||||
def delete_proxy_group_patch(self, name: str) -> tuple[bool, str]:
|
||||
patches = self.state.proxy_group_patch
|
||||
if name in patches:
|
||||
del patches.root[name]
|
||||
self.state.proxy_group_patch = patches
|
||||
return True, "补丁已删除"
|
||||
return False, "补丁不存在"
|
||||
|
||||
def update_proxy_group_meta(self, name: str, meta: Metadata) -> tuple[bool, str]:
|
||||
pgs = self.state.proxy_groups
|
||||
res = pgs.set_meta(name, meta)
|
||||
if res:
|
||||
self.state.proxy_groups = pgs
|
||||
return True, ""
|
||||
return False, f"代理组 {name!r} 不存在"
|
||||
|
||||
def add_proxy_group(self, proxy_group: ProxyGroup) -> tuple[bool, str]:
|
||||
"""
|
||||
Adds a new proxy group, saves the state, and returns status.
|
||||
"""
|
||||
try:
|
||||
pgs = self.state.proxy_groups
|
||||
pgs.add(ProxyGroupData(data=proxy_group, name=proxy_group.name, meta=Metadata(source=DataSource.MANUAL)))
|
||||
self.state.proxy_groups = pgs
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add proxy group: {repr(e)}")
|
||||
return False, "代理组添加失败"
|
||||
return True, "代理组添加成功"
|
||||
|
||||
def get_proxy_groups(self, patched = True) -> list[ProxyGroupData]:
|
||||
pgs = self.state.all_proxy_groups
|
||||
pgs += self.proxy_groups_by_region()
|
||||
if not patched:
|
||||
return pgs
|
||||
return self._apply_patches(pgs, self.state.proxy_group_patch)
|
||||
|
||||
def update_proxy_group(self, previous_name: str, source: str, proxy_group: ProxyGroup) -> tuple[bool, str]:
|
||||
if source == DataSource.MANUAL:
|
||||
pgs = self.state.proxy_groups
|
||||
pgs.update(previous_name, ProxyGroupData(data=proxy_group, name=proxy_group.name, meta=Metadata()))
|
||||
self.state.proxy_groups = pgs
|
||||
return True, "代理组更新成功"
|
||||
if previous_name != proxy_group.name:
|
||||
return False, "请勿修改代理组名称"
|
||||
pgs = self.proxy_groups_by_region()
|
||||
src = next((g for g in pgs if g.name == previous_name), None)
|
||||
if src is None:
|
||||
return False, f"代理组 {previous_name!r} ({source}) 不存在"
|
||||
self._make_proxy_group_patch(src.data, proxy_group)
|
||||
return True, "代理组更新成功"
|
||||
|
||||
def update_rule_provider(self, name: str, rule_provider: RuleProviderData) -> Tuple[bool, str]:
|
||||
"""
|
||||
Updates a rule provider.
|
||||
"""
|
||||
rps = self.state.rule_providers
|
||||
if name not in rps:
|
||||
return False, f"规则集 {name!r} 不存在"
|
||||
rps.update(name, rule_provider)
|
||||
self.state.rule_providers = rps
|
||||
return True, "规则集更新成功"
|
||||
|
||||
def update_rule_providers_meta(self, name: str, meta: Metadata) -> tuple[bool, str]:
|
||||
rps = self.state.rule_providers
|
||||
if name in rps:
|
||||
res = rps.set_meta(name, meta)
|
||||
if res:
|
||||
self.state.rule_providers = rps
|
||||
return True, ""
|
||||
|
||||
arps = self.state.acl4ssr_providers
|
||||
if name in arps:
|
||||
res = arps.set_meta(name, meta)
|
||||
if res:
|
||||
self.state.acl4ssr_providers = arps
|
||||
return True, ""
|
||||
return False, f"规则集 {name!r} 不存在"
|
||||
|
||||
def update_rule_meta(self, rule_type: RuleSet, priority: int, meta: Metadata) -> tuple[bool, str]:
|
||||
manager = self.state.get_rule_manager(rule_type)
|
||||
rule = manager.get_rule_at_priority(priority)
|
||||
if not rule:
|
||||
return False, "规则不存在"
|
||||
res = manager.update_rule_meta_at_priority(priority, meta)
|
||||
if res:
|
||||
if rule_type == RuleSet.RULESET:
|
||||
self.add_notification_job([f"{self.state.config.ruleset_prefix}{rule.rule.action}"])
|
||||
self.organize_and_save_rules()
|
||||
return True, ""
|
||||
return False, "更新规则元数据失败"
|
||||
|
||||
def delete_rule_provider(self, name: str) -> tuple[bool, str]:
|
||||
rps = self.state.rule_providers
|
||||
deleted = rps.pop(name)
|
||||
if deleted:
|
||||
self.state.rule_providers = rps
|
||||
return True, f"规则集删除成功"
|
||||
return False, f"规则集 {name!r} 不存在"
|
||||
|
||||
def add_rule_provider(self, name: str, rule_provider: RuleProvider) -> tuple[bool, str]:
|
||||
try:
|
||||
rps = self.state.rule_providers
|
||||
rps.add(RuleProviderData(data=rule_provider, name=name, meta=Metadata(source=DataSource.MANUAL)))
|
||||
self.state.rule_providers = rps
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add rule provider: {repr(e)}")
|
||||
return False, "规则集添加失败"
|
||||
return True, "规则集添加成功"
|
||||
|
||||
async def test_connectivity(self, clash_apis: List[ClashApi], sub_links: List[str]) -> Tuple[bool, str]:
|
||||
tasks = []
|
||||
urls = []
|
||||
for d in clash_apis:
|
||||
headers = {"authorization": f"Bearer {d.secret}"}
|
||||
url = f"{d.url}/version"
|
||||
task = asyncio.create_task(
|
||||
AsyncRequestUtils(accept_type="application/json", headers=headers, timeout=5).get_res(url)
|
||||
)
|
||||
urls.append(url)
|
||||
tasks.append(task)
|
||||
for sub_link in sub_links:
|
||||
task = asyncio.create_task(
|
||||
AsyncRequestUtils(
|
||||
accept_type="text/html", proxies=settings.PROXY if self.state.config.proxy else None,
|
||||
timeout=5).get(sub_link)
|
||||
)
|
||||
urls.append(sub_link)
|
||||
tasks.append(task)
|
||||
results = await asyncio.gather(*tasks)
|
||||
for i, result in enumerate(results):
|
||||
if not result:
|
||||
return False, f"无法连接到 {urls[i]}"
|
||||
return True, ""
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
data = {
|
||||
"state": self.state.config.enabled,
|
||||
"ruleset_prefix": self.state.config.ruleset_prefix,
|
||||
"preset_identifiers": self.state.config.identifiers,
|
||||
"best_cf_ip": self.state.config.best_cf_ip,
|
||||
"geoRules": self.state.geo_rules,
|
||||
"subscription_info": self.state.subscription_info,
|
||||
"sub_url": f"{self.state.config.movie_pilot_url}/api/v1/plugin/{self.plugin_id}/config?"
|
||||
f"apikey={self.state.config.apikey or settings.API_TOKEN}"
|
||||
}
|
||||
return data
|
||||
|
||||
def get_rules(self, ruleset: RuleSet) -> list[RuleData]:
|
||||
manager = self.state.get_rule_manager(ruleset)
|
||||
return manager.to_list()
|
||||
|
||||
def reorder_rules(self, rule_type: RuleSet, moved_priority: int, target_priority: int) -> tuple[bool, str]:
|
||||
manager = self.state.get_rule_manager(rule_type)
|
||||
try:
|
||||
rule = manager.reorder_rules(moved_priority, target_priority)
|
||||
if rule_type == RuleSet.RULESET:
|
||||
self.add_notification_job(
|
||||
[f"{self.state.config.ruleset_prefix}{rule.rule.action}"])
|
||||
except Exception as e:
|
||||
logger.info(f"Failed to reorder rules: {repr(e)}")
|
||||
return False, "规则移动失败"
|
||||
self.organize_and_save_rules()
|
||||
return True, ""
|
||||
|
||||
def update_rule(self, rule_type: RuleSet, priority: int, rule_data: RuleData) -> tuple[bool, str]:
|
||||
try:
|
||||
dst_priority = rule_data.priority
|
||||
src_priority = priority
|
||||
clash_rule = ClashRuleParser.parse_rule_dict(rule_data.model_dump(mode='json', exclude_none=True))
|
||||
if not clash_rule:
|
||||
return False, f"无效的规则: {rule_data!r}"
|
||||
manager = self.state.get_rule_manager(rule_type)
|
||||
original_rule = manager.get_rule_at_priority(src_priority)
|
||||
meta = Metadata(source=original_rule.meta.source, time_modified=time.time())
|
||||
rule_item = RuleItem(rule=clash_rule, meta=meta)
|
||||
if rule_type == RuleSet.RULESET:
|
||||
res = manager.update_rule_at_priority(rule_item, src_priority, dst_priority)
|
||||
if res:
|
||||
ruleset_to_notify = [f"{self.state.config.ruleset_prefix}{clash_rule.action}"]
|
||||
if rule_data.action != original_rule.rule.action:
|
||||
ruleset_to_notify.append(f"{self.state.config.ruleset_prefix}{original_rule.rule.action}")
|
||||
self.add_notification_job(ruleset_to_notify)
|
||||
else:
|
||||
res = manager.update_rule_at_priority(rule_item, src_priority, dst_priority)
|
||||
except Exception as err:
|
||||
logger.info(f"Failed to update rules: {repr(err)}")
|
||||
return False, "更新规则出错"
|
||||
self.organize_and_save_rules()
|
||||
return res, ""
|
||||
|
||||
def add_rule(self, rule_type: RuleSet, rule_data: RuleData) -> tuple[bool, str]:
|
||||
try:
|
||||
priority = rule_data.priority
|
||||
clash_rule = ClashRuleParser.parse_rule_dict(rule_data.model_dump(mode='json', exclude_none=True))
|
||||
if not clash_rule:
|
||||
return False, f"无效的输入规则: {rule_data.model_dump(mode='json', exclude_none=True)}"
|
||||
meta = Metadata(source=DataSource.MANUAL, time_modified=time.time())
|
||||
rule_item = RuleItem(rule=clash_rule, meta=meta)
|
||||
if rule_type == RuleSet.RULESET:
|
||||
self.state.ruleset_rules_manager.insert_rule_at_priority(rule_item, priority)
|
||||
self.add_notification_job([f"{self.state.config.ruleset_prefix}{clash_rule.action}"])
|
||||
else:
|
||||
self.state.top_rules_manager.insert_rule_at_priority(rule_item, priority)
|
||||
except Exception as err:
|
||||
logger.info(f"Failed to add rule: {repr(err)}")
|
||||
return False, "添加规则出错"
|
||||
self.organize_and_save_rules()
|
||||
return True, ""
|
||||
|
||||
def delete_rule(self, ruleset: RuleSet, priority: int):
|
||||
manager = self.state.get_rule_manager(ruleset)
|
||||
res = manager.remove_rule_at_priority(priority)
|
||||
if ruleset == RuleSet.RULESET:
|
||||
if res:
|
||||
self.add_notification_job([f"{self.state.config.ruleset_prefix}{res.rule.action}"])
|
||||
self.organize_and_save_rules()
|
||||
|
||||
def delete_rules(self, ruleset: RuleSet, priorities: list[int]):
|
||||
manager = self.state.get_rule_manager(ruleset)
|
||||
removed = manager.remove_rules_at_priorities(priorities)
|
||||
if ruleset == RuleSet.RULESET:
|
||||
if removed:
|
||||
actions = {r.rule.action for r in removed}
|
||||
self.add_notification_job([f"{self.state.config.ruleset_prefix}{action}" for action in actions])
|
||||
self.organize_and_save_rules()
|
||||
|
||||
def set_rules_status(self, ruleset: RuleSet, priorities: dict[int, bool]):
|
||||
manager = self.state.get_rule_manager(ruleset)
|
||||
updated = manager.update_rules_at_priorities(priorities)
|
||||
if ruleset == RuleSet.RULESET:
|
||||
if updated:
|
||||
actions = {r.rule.action for r in updated}
|
||||
self.add_notification_job([f"{self.state.config.ruleset_prefix}{action}" for action in actions])
|
||||
self.organize_and_save_rules()
|
||||
|
||||
def import_rules(self, vehicle: str, payload: str) -> tuple[bool, str]:
|
||||
rules: List[str] = []
|
||||
if vehicle == 'YAML':
|
||||
try:
|
||||
imported_rules = yaml.load(payload, Loader=yaml.SafeLoader)
|
||||
if not isinstance(imported_rules, dict):
|
||||
return False, "无效的输入"
|
||||
except yaml.YAMLError as err:
|
||||
logger.error(f"Failed to import rules: {repr(err)}")
|
||||
return False, 'YAML 格式错误'
|
||||
rules = imported_rules.get(ClashKey.RULES, [])
|
||||
self.append_top_rules(rules)
|
||||
return True, ""
|
||||
|
||||
def get_ruleset(self, name: str) -> Optional[str]:
|
||||
ruleset_name = self.state.ruleset_names.get(name)
|
||||
if ruleset_name is None:
|
||||
return None
|
||||
rules = self.ruleset(ruleset_name)
|
||||
res = yaml.dump({"payload": rules}, allow_unicode=True)
|
||||
return res
|
||||
|
||||
def update_hosts(self, domain: str, host: HostData) -> tuple[bool, str]:
|
||||
hosts = self.state.hosts
|
||||
hosts.update(domain, host)
|
||||
self.state.hosts = hosts
|
||||
return True, f"Host for domain {host.domain} updated successfully."
|
||||
|
||||
def delete_host(self, domain: str) -> tuple[bool, str]:
|
||||
hosts = self.state.hosts
|
||||
original_len = len(hosts)
|
||||
hosts.delete(domain)
|
||||
if len(hosts) < original_len:
|
||||
self.state.hosts = hosts
|
||||
return True, ''
|
||||
else:
|
||||
return False, f'Host for domain {domain} not found.'
|
||||
|
||||
async def refresh_subscription(self, url: str) -> Tuple[bool, str]:
|
||||
sub_conf = next((conf for conf in self.state.config.subscriptions_config if conf.url == url), None)
|
||||
if not sub_conf:
|
||||
return False, f"Configuration for {url} not found."
|
||||
config, info = await self.async_get_subscription(url)
|
||||
if not config:
|
||||
return False, f"订阅链接 {url} 更新失败"
|
||||
|
||||
sub_configs = self.state.sub_configs
|
||||
sub_configs[url] = config
|
||||
self.state.sub_configs = sub_configs
|
||||
|
||||
sub_info_map = self.state.subscription_info
|
||||
info.enabled = sub_info_map.get(url).enabled
|
||||
sub_info_map[url] = info
|
||||
self.state.subscription_info = sub_info_map
|
||||
return True, "订阅更新成功"
|
||||
|
||||
def update_subscription_info(self, sub_setting: SubscriptionSetting):
|
||||
sub_info = self.state.subscription_info
|
||||
sub_info.set(sub_setting)
|
||||
self.state.subscription_info = sub_info
|
||||
|
||||
async def async_get_subscription(self, url: str) -> tuple[ClashConfig | None, SubscriptionInfo | None]:
|
||||
if not url:
|
||||
return None, None
|
||||
logger.info(f"正在刷新 {UtilsProvider.get_url_domain(url)} ...")
|
||||
ret = None
|
||||
raw_proxies = {}
|
||||
for _ in range(self.state.config.retry_times):
|
||||
ret = await AsyncRequestUtils(
|
||||
accept_type="text/html", timeout=self.state.config.timeout, ua="clash.meta",
|
||||
proxies=settings.PROXY if self.state.config.proxy else None
|
||||
).get_res(url)
|
||||
if ret:
|
||||
break
|
||||
if not ret:
|
||||
logger.warning(f"{UtilsProvider.get_url_domain(url)} 刷新失败.")
|
||||
return None, None
|
||||
try:
|
||||
content = ret.content
|
||||
rs = yaml.safe_load(content)
|
||||
if isinstance(rs, str):
|
||||
proxies = Converter().convert_v2ray(content)
|
||||
if not proxies:
|
||||
raise ValueError("Unknown content type")
|
||||
rs = {
|
||||
ClashKey.PROXIES: proxies.values(),
|
||||
ClashKey.PROXY_GROUPS: [
|
||||
{ClashKey.NAME: "All Proxies", 'type': 'select', 'include-all-proxies': True}
|
||||
]
|
||||
}
|
||||
raw_proxies = {p['name']: link for link, p in proxies.items()}
|
||||
if not isinstance(rs, dict):
|
||||
raise ValueError("Subscription content is not a valid dictionary.")
|
||||
rs: dict[str, Any] = rs
|
||||
logger.info(f"已刷新: {UtilsProvider.get_url_domain(url)}. 节点数量: {len(rs.get(ClashKey.PROXIES, []))}")
|
||||
conf = ClashConfig.model_validate(rs)
|
||||
except Exception as e:
|
||||
logger.error(f"解析配置出错: {e}")
|
||||
return None, None
|
||||
info = {"last_update": int(time.time()), "proxy_num": conf.node_num}
|
||||
if 'Subscription-Userinfo' in ret.headers:
|
||||
matches = re.findall(r'(\w+)=(\d+)', ret.headers['Subscription-Userinfo'])
|
||||
variables = {key: int(value) for key, value in matches}
|
||||
info.update(variables)
|
||||
sub_info = SubscriptionInfo(**info)
|
||||
conf.raw_proxies = raw_proxies
|
||||
return conf, sub_info
|
||||
|
||||
async def async_refresh_subscriptions(self) -> Dict[str, bool]:
|
||||
res = {}
|
||||
sub_info_map = self.state.subscription_info
|
||||
sub_configs_map = self.state.sub_configs
|
||||
|
||||
for sub_conf in self.state.config.subscriptions_config:
|
||||
url = sub_conf.url
|
||||
if not sub_info_map.get(url).enabled:
|
||||
continue
|
||||
conf, sub_info = await self.async_get_subscription(url)
|
||||
if not conf:
|
||||
res[url] = False
|
||||
continue
|
||||
sub_info_map[url] = sub_info
|
||||
res[url] = True
|
||||
sub_configs_map[url] = conf
|
||||
self.state.subscription_info = sub_info_map
|
||||
self.state.sub_configs = sub_configs_map
|
||||
return res
|
||||
|
||||
async def async_refresh_acl4ssr(self):
|
||||
logger.info("正在刷新 ACL4SSR ...")
|
||||
paths = ['Clash/Providers', 'Clash/Providers/Ruleset']
|
||||
api_url = f"{Constant.ACL4SSR_API}/contents/%s"
|
||||
branch = 'master'
|
||||
new_providers = []
|
||||
names = set()
|
||||
for path in paths:
|
||||
response = await AsyncRequestUtils().get_res(api_url % path, headers=settings.GITHUB_HEADERS,
|
||||
params={'ref': branch})
|
||||
if not response:
|
||||
continue
|
||||
files = response.json()
|
||||
yaml_files = [f for f in files if f["type"] == "file" and f[ClashKey.NAME].endswith((".yaml", ".yml"))]
|
||||
for f in yaml_files:
|
||||
name = f"{self.state.config.acl4ssr_prefix}{f[ClashKey.NAME][:f[ClashKey.NAME].rfind('.')]}"
|
||||
if name in names:
|
||||
continue
|
||||
file_path = f"./ACL4SSR/{f['name']}"
|
||||
provider = RuleProvider(
|
||||
type=VehicleType.HTTP, path=file_path, url=f["download_url"], interval=600, behavior="classical",
|
||||
format="yaml"
|
||||
)
|
||||
meta = Metadata(source=DataSource.ACL4SSR)
|
||||
new_providers.append(RuleProviderData(name=name, data=provider, meta=meta))
|
||||
names.add(name)
|
||||
|
||||
self.state.acl4ssr_providers = RuleProviders.model_validate(new_providers)
|
||||
logger.info(f"ACL4SSR 规则集刷新完成. 规则集数量: {len(self.state.acl4ssr_providers)}")
|
||||
|
||||
async def async_refresh_geo_dat(self):
|
||||
logger.info("正在刷新 Geo Rules ...")
|
||||
branch = 'meta'
|
||||
api_url = f"{Constant.METACUBEX_RULE_DAT_API}/contents/geo"
|
||||
resp = await AsyncRequestUtils().get_res(api_url, headers=settings.GITHUB_HEADERS, params={'ref': branch})
|
||||
if not resp:
|
||||
return
|
||||
|
||||
geo_rules = self.state.geo_rules
|
||||
for path in resp.json():
|
||||
if path["type"] == "dir" and path["name"] in geo_rules.model_fields:
|
||||
tree_sha = path["sha"]
|
||||
url = f"{Constant.METACUBEX_RULE_DAT_API}/git/trees/{tree_sha}"
|
||||
res = await AsyncRequestUtils().get_res(url, headers=settings.GITHUB_HEADERS, params={'ref': branch})
|
||||
if not res:
|
||||
continue
|
||||
tree = res.json()
|
||||
yaml_files = [item["path"][:item["path"].rfind('.')] for item in tree["tree"] if
|
||||
item["type"] == "blob" and item['path'].endswith((".yaml", ".yml"))]
|
||||
setattr(geo_rules, path["name"], yaml_files)
|
||||
self.state.geo_rules = geo_rules
|
||||
logger.info(f"Geo Rules 更新完成. 规则数量: "
|
||||
f"geoip({len(self.state.geo_rules.geoip)}), geosite({len(self.state.geo_rules.geosite)})")
|
||||
|
||||
def check_patch_lifetime(self):
|
||||
pp = self.state.proxy_patch
|
||||
proxies = self.state.all_proxies
|
||||
pp.update_patch({g.name for g in proxies}, lifespan=Constant.PATCH_LIFESPAN)
|
||||
self.state.proxy_patch = pp
|
||||
|
||||
groups = self.proxy_groups_by_region() + self.state.all_proxy_groups
|
||||
pgp = self.state.proxy_group_patch
|
||||
pgp.update_patch({g.name for g in groups}, lifespan=Constant.PATCH_LIFESPAN)
|
||||
self.state.proxy_group_patch = pgp
|
||||
|
||||
rpp = self.state.rule_provider_patch
|
||||
rule_providers = self.state.all_rule_providers
|
||||
rpp.update_patch({g.name for g in rule_providers}, lifespan=Constant.PATCH_LIFESPAN)
|
||||
self.state.rule_provider_patch = rpp
|
||||
283
plugins.v2/clashruleprovider/state.py
Normal file
283
plugins.v2/clashruleprovider/state.py
Normal file
@@ -0,0 +1,283 @@
|
||||
from itertools import chain
|
||||
from typing import Any, Generator, Callable
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from app.core.cache import Cache
|
||||
from app.db.plugindata_oper import PluginDataOper
|
||||
|
||||
from .config import PluginConfig
|
||||
from .helper.clashrulemanager import ClashRuleManager
|
||||
from .helper.utilsprovider import UtilsProvider
|
||||
from .models import RuleProviderData, ProxyProviderData, ProxyGroupData, Hosts, ProxyGroups, RuleProviders, \
|
||||
RuleProvider, Metadata, Proxies, ProxyData
|
||||
from .models.configuration import ClashConfig
|
||||
from .models.types import DataSource, RuleSet, DataKey
|
||||
from .models.datapatch import DataPatch
|
||||
from .models.api import SubscriptionsInfo
|
||||
from .models.datamodel import GeoRules, PersistState
|
||||
|
||||
|
||||
class PluginState:
|
||||
"""
|
||||
A DAL to manage the runtime state of ClashRuleProvider.
|
||||
"""
|
||||
def __init__(self, plugin_id: str, config: PluginConfig = None):
|
||||
self.plugin_id = plugin_id
|
||||
self.config = config or PluginConfig()
|
||||
self.plugin_data = PluginDataOper()
|
||||
self.cache = Cache(maxsize=256, ttl=self.config.cache_ttl)
|
||||
self.cache_region = f"app.plugins.{self.plugin_id.lower()}"
|
||||
|
||||
# Build schemas from PersistState model
|
||||
self._schemas: dict[str, tuple[TypeAdapter, Callable[[], Any]]] = {}
|
||||
for _, field in PersistState.model_fields.items():
|
||||
alias = field.alias
|
||||
if alias:
|
||||
self._schemas[alias] = (TypeAdapter(field.annotation), field.default_factory)
|
||||
|
||||
# Rule and Proxy Managers (Runtime)
|
||||
self.top_rules_manager: ClashRuleManager = ClashRuleManager()
|
||||
self.ruleset_rules_manager: ClashRuleManager = ClashRuleManager()
|
||||
|
||||
# Runtime variables (not persisted directly or persisted via config)
|
||||
self.clash_template: ClashConfig = ClashConfig()
|
||||
|
||||
def _get_val(self, key: str) -> Any:
|
||||
# Check cache
|
||||
if self.cache.exists(key, region=self.cache_region):
|
||||
return self.cache.get(key, region=self.cache_region)
|
||||
|
||||
data = self.plugin_data.get_data(self.plugin_id, key)
|
||||
adapter, default_factory = self._schemas.get(key, (None, None))
|
||||
|
||||
if data is None:
|
||||
if default_factory:
|
||||
val = default_factory()
|
||||
self.cache.set(key, val, region=self.cache_region)
|
||||
return val
|
||||
return None
|
||||
|
||||
if adapter:
|
||||
val = adapter.validate_python(data)
|
||||
else:
|
||||
val = data
|
||||
|
||||
self.cache.set(key, val, region=self.cache_region)
|
||||
return val
|
||||
|
||||
def _set_val(self, key: str, value: Any):
|
||||
adapter, _ = self._schemas.get(key, (None, None))
|
||||
if adapter:
|
||||
data = adapter.dump_python(value, mode="json", by_alias=True, exclude_none=True)
|
||||
else:
|
||||
data = value
|
||||
self.plugin_data.save(self.plugin_id, key, data)
|
||||
self.cache.set(key, value, region=self.cache_region)
|
||||
|
||||
@property
|
||||
def proxies(self) -> Proxies:
|
||||
return self._get_val(DataKey.PROXIES)
|
||||
|
||||
@proxies.setter
|
||||
def proxies(self, value: Proxies):
|
||||
self._set_val(DataKey.PROXIES, value)
|
||||
|
||||
@property
|
||||
def proxy_groups(self) -> ProxyGroups:
|
||||
return self._get_val(DataKey.PROXY_GROUPS)
|
||||
|
||||
@proxy_groups.setter
|
||||
def proxy_groups(self, value: ProxyGroups):
|
||||
self._set_val(DataKey.PROXY_GROUPS, value)
|
||||
|
||||
@property
|
||||
def subscription_info(self) -> SubscriptionsInfo:
|
||||
return self._get_val(DataKey.SUB_INFO)
|
||||
|
||||
@subscription_info.setter
|
||||
def subscription_info(self, value: SubscriptionsInfo):
|
||||
self._set_val(DataKey.SUB_INFO, value)
|
||||
|
||||
@property
|
||||
def rule_provider(self) -> dict[str, RuleProvider]:
|
||||
return self._get_val(DataKey.AUTO_RULE_PROVIDERS)
|
||||
|
||||
@rule_provider.setter
|
||||
def rule_provider(self, value: dict[str, RuleProvider]):
|
||||
self._set_val(DataKey.AUTO_RULE_PROVIDERS, value)
|
||||
|
||||
@property
|
||||
def rule_providers(self) -> RuleProviders:
|
||||
return self._get_val(DataKey.RULE_PROVIDERS)
|
||||
|
||||
@rule_providers.setter
|
||||
def rule_providers(self, value: RuleProviders):
|
||||
self._set_val(DataKey.RULE_PROVIDERS, value)
|
||||
|
||||
@property
|
||||
def ruleset_names(self) -> dict[str, str]:
|
||||
return self._get_val(DataKey.RULESET_NAMES)
|
||||
|
||||
@ruleset_names.setter
|
||||
def ruleset_names(self, value: dict[str, str]):
|
||||
self._set_val(DataKey.RULESET_NAMES, value)
|
||||
|
||||
@property
|
||||
def acl4ssr_providers(self) -> RuleProviders:
|
||||
return self._get_val(DataKey.ACL4SSR)
|
||||
|
||||
@acl4ssr_providers.setter
|
||||
def acl4ssr_providers(self, value: RuleProviders):
|
||||
self._set_val(DataKey.ACL4SSR, value)
|
||||
|
||||
@property
|
||||
def sub_configs(self) -> dict[str, ClashConfig]:
|
||||
sub_conf = self._get_val(DataKey.SUB_CONFIGS)
|
||||
return sub_conf
|
||||
|
||||
@sub_configs.setter
|
||||
def sub_configs(self, value: dict[str, ClashConfig]):
|
||||
self._set_val(DataKey.SUB_CONFIGS, value)
|
||||
|
||||
@property
|
||||
def hosts(self) -> Hosts:
|
||||
return self._get_val(DataKey.HOSTS)
|
||||
|
||||
@hosts.setter
|
||||
def hosts(self, value: Hosts):
|
||||
self._set_val(DataKey.HOSTS, value)
|
||||
|
||||
@property
|
||||
def proxy_group_patch(self) -> DataPatch:
|
||||
return self._get_val(DataKey.PROXY_GROUP_PATCH)
|
||||
|
||||
@proxy_group_patch.setter
|
||||
def proxy_group_patch(self, value: DataPatch):
|
||||
self._set_val(DataKey.PROXY_GROUP_PATCH, value)
|
||||
|
||||
@property
|
||||
def proxy_patch(self) -> DataPatch:
|
||||
return self._get_val(DataKey.PROXY_PATCH)
|
||||
|
||||
@proxy_patch.setter
|
||||
def proxy_patch(self, value: DataPatch):
|
||||
self._set_val(DataKey.PROXY_PATCH, value)
|
||||
|
||||
@property
|
||||
def rule_provider_patch(self) -> DataPatch:
|
||||
return self._get_val(DataKey.RULE_PROVIDER_PATCH)
|
||||
|
||||
@rule_provider_patch.setter
|
||||
def rule_provider_patch(self, value: DataPatch):
|
||||
self._set_val(DataKey.RULE_PROVIDER_PATCH, value)
|
||||
|
||||
@property
|
||||
def geo_rules(self) -> GeoRules:
|
||||
return self._get_val(DataKey.GEO_RULES)
|
||||
|
||||
@geo_rules.setter
|
||||
def geo_rules(self, value: GeoRules):
|
||||
self._set_val(DataKey.GEO_RULES, value)
|
||||
|
||||
def get_data(self, key: str) -> Any:
|
||||
return self.plugin_data.get_data(self.plugin_id, key)
|
||||
|
||||
def save_data(self, key: str, value: Any):
|
||||
self.plugin_data.save(self.plugin_id, key, value)
|
||||
|
||||
def get_rule_manager(self, ruleset: RuleSet) -> ClashRuleManager:
|
||||
if ruleset == RuleSet.RULESET:
|
||||
return self.ruleset_rules_manager
|
||||
return self.top_rules_manager
|
||||
|
||||
def get_sub_config(self, url: str) -> ClashConfig:
|
||||
conf = self.sub_configs.get(url)
|
||||
if conf is None:
|
||||
return ClashConfig()
|
||||
ret = ClashConfig()
|
||||
sub_options = self.config.get_sub_conf(url)
|
||||
for field_name in sub_options.model_fields.keys():
|
||||
if getattr(sub_options, field_name) is True and field_name in ret.model_fields:
|
||||
setattr(ret, field_name, getattr(conf, field_name))
|
||||
return ret
|
||||
|
||||
def set_rule_providers(self, rule_providers: dict[str, dict[str, Any]]):
|
||||
self.rule_provider.clear()
|
||||
for name, rp in rule_providers.items():
|
||||
self.rule_providers[name] = RuleProvider(**rp)
|
||||
|
||||
def rule_providers_from_subs(self) -> Generator[RuleProviderData, None, None]:
|
||||
for url, conf in self.sub_configs.items():
|
||||
if self.config.get_sub_conf(url).rule_providers:
|
||||
for name, rp in conf.rule_providers.items():
|
||||
meta = Metadata(source=DataSource.SUB, remark=UtilsProvider.get_url_domain(url))
|
||||
yield RuleProviderData(name=name, data=rp, meta=meta)
|
||||
|
||||
def rule_providers_from_template(self) -> Generator[RuleProviderData, None, None]:
|
||||
for name, rp in self.clash_template.rule_providers.items():
|
||||
yield RuleProviderData(meta=Metadata(source=DataSource.TEMPLATE), name=name, data=rp)
|
||||
|
||||
def proxy_providers_from_subs(self) -> Generator[ProxyProviderData, None, None]:
|
||||
for url, conf in self.sub_configs.items():
|
||||
if self.config.get_sub_conf(url).proxy_providers:
|
||||
for name, pp in conf.proxy_providers.items():
|
||||
meta = Metadata(source=DataSource.SUB, remark=UtilsProvider.get_url_domain(url))
|
||||
yield ProxyProviderData(meta=meta, name=name, data=pp)
|
||||
|
||||
def proxy_providers_from_template(self) -> Generator[ProxyProviderData, None, None]:
|
||||
for name, pp in self.clash_template.proxy_providers.items():
|
||||
yield ProxyProviderData(meta=Metadata(source=DataSource.TEMPLATE), name=name, data=pp)
|
||||
|
||||
def proxy_groups_from_subs(self) -> Generator[ProxyGroupData, None, None]:
|
||||
for url, conf in self.sub_configs.items():
|
||||
if self.config.get_sub_conf(url).proxy_groups:
|
||||
for pg in conf.proxy_groups:
|
||||
meta = Metadata(source=DataSource.SUB, remark=UtilsProvider.get_url_domain(url))
|
||||
yield ProxyGroupData(meta=meta, data=pg, name=pg.name)
|
||||
|
||||
def proxy_groups_from_template(self) -> Generator[ProxyGroupData, None, None]:
|
||||
for pg in self.clash_template.proxy_groups:
|
||||
yield ProxyGroupData(meta=Metadata(source=DataSource.TEMPLATE), data=pg, name=pg.name)
|
||||
|
||||
def proxies_from_subs(self) -> Generator[ProxyData, None, None]:
|
||||
for url, conf in self.sub_configs.items():
|
||||
for p in conf.proxies:
|
||||
meta = Metadata(source=DataSource.SUB, remark=UtilsProvider.get_url_domain(url))
|
||||
yield ProxyData(meta=meta, data=p, name=p.name, v2ray_link=conf.raw_proxies.get(p.name))
|
||||
|
||||
def proxies_from_template(self) -> Generator[ProxyData, None, None]:
|
||||
for p in self.clash_template.proxies:
|
||||
yield ProxyData(meta=Metadata(source=DataSource.TEMPLATE), data=p, name=p.name)
|
||||
|
||||
@property
|
||||
def all_rule_providers(self) -> list[RuleProviderData]:
|
||||
return list(chain(
|
||||
self.rule_providers,
|
||||
self.rule_providers_from_template(),
|
||||
self.rule_providers_from_subs(),
|
||||
self.acl4ssr_providers
|
||||
))
|
||||
|
||||
@property
|
||||
def all_proxy_providers(self) -> list[ProxyProviderData]:
|
||||
return list(chain(
|
||||
self.proxy_providers_from_subs(),
|
||||
self.proxy_providers_from_template()
|
||||
))
|
||||
|
||||
@property
|
||||
def all_proxy_groups(self) -> list[ProxyGroupData]:
|
||||
return list(chain(
|
||||
self.proxy_groups,
|
||||
self.proxy_groups_from_subs(),
|
||||
self.proxy_groups_from_template()
|
||||
))
|
||||
|
||||
@property
|
||||
def all_proxies(self) -> list[ProxyData]:
|
||||
return list(chain(
|
||||
self.proxies,
|
||||
self.proxies_from_subs(),
|
||||
self.proxies_from_template()
|
||||
))
|
||||
@@ -47,14 +47,14 @@ class DoubanRank(_PluginBase):
|
||||
# 私有属性
|
||||
_scheduler = None
|
||||
_douban_address = {
|
||||
'movie-ustop': 'https://rsshub.app/douban/movie/ustop',
|
||||
'movie-weekly': 'https://rsshub.app/douban/movie/weekly',
|
||||
'movie-real-time': 'https://rsshub.app/douban/movie/weekly/movie_real_time_hotest',
|
||||
'show-domestic': 'https://rsshub.app/douban/movie/weekly/show_domestic',
|
||||
'movie-hot-gaia': 'https://rsshub.app/douban/movie/weekly/movie_hot_gaia',
|
||||
'tv-hot': 'https://rsshub.app/douban/movie/weekly/tv_hot',
|
||||
'movie-top250': 'https://rsshub.app/douban/movie/weekly/movie_top250',
|
||||
'movie-top250-full': 'https://rsshub.app/douban/list/movie_top250',
|
||||
'movie-ustop': '/douban/movie/ustop',
|
||||
'movie-weekly': '/douban/movie/weekly',
|
||||
'movie-real-time': '/douban/movie/weekly/movie_real_time_hotest',
|
||||
'show-domestic': '/douban/movie/weekly/show_domestic',
|
||||
'movie-hot-gaia': '/douban/movie/weekly/movie_hot_gaia',
|
||||
'tv-hot': '/douban/movie/weekly/tv_hot',
|
||||
'movie-top250': '/douban/movie/weekly/movie_top250',
|
||||
'movie-top250-full': '/douban/list/movie_top250',
|
||||
}
|
||||
_enabled = False
|
||||
_cron = ""
|
||||
@@ -65,6 +65,7 @@ class DoubanRank(_PluginBase):
|
||||
_clear = False
|
||||
_clearflag = False
|
||||
_proxy = False
|
||||
_rsshub = "https://rsshub.app"
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
|
||||
@@ -74,6 +75,7 @@ class DoubanRank(_PluginBase):
|
||||
self._proxy = config.get("proxy")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._vote = float(config.get("vote")) if config.get("vote") else 0
|
||||
self._rsshub = config.get("rsshub") or "https://rsshub.app"
|
||||
rss_addrs = config.get("rss_addrs")
|
||||
if rss_addrs:
|
||||
if isinstance(rss_addrs, str):
|
||||
@@ -237,7 +239,7 @@ class DoubanRank(_PluginBase):
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -254,7 +256,7 @@ class DoubanRank(_PluginBase):
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -266,6 +268,23 @@ class DoubanRank(_PluginBase):
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'rsshub',
|
||||
'label': 'RSSHub地址',
|
||||
'placeholder': 'https://rsshub.app'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -345,6 +364,7 @@ class DoubanRank(_PluginBase):
|
||||
"proxy": False,
|
||||
"onlyonce": False,
|
||||
"vote": "",
|
||||
"rsshub": "https://rsshub.app",
|
||||
"ranks": [],
|
||||
"rss_addrs": "",
|
||||
"clear": False
|
||||
@@ -508,6 +528,7 @@ class DoubanRank(_PluginBase):
|
||||
"cron": self._cron,
|
||||
"onlyonce": self._onlyonce,
|
||||
"vote": self._vote,
|
||||
"rsshub": self._rsshub,
|
||||
"ranks": self._ranks,
|
||||
"rss_addrs": '\n'.join(map(str, self._rss_addrs)),
|
||||
"clear": self._clear
|
||||
@@ -518,7 +539,10 @@ class DoubanRank(_PluginBase):
|
||||
刷新RSS
|
||||
"""
|
||||
logger.info(f"开始刷新豆瓣榜单 ...")
|
||||
addr_list = self._rss_addrs + [self._douban_address.get(rank) for rank in self._ranks]
|
||||
# 构建完整的RSS地址
|
||||
rsshub_base = self._rsshub.rstrip('/')
|
||||
rank_addrs = [f"{rsshub_base}{self._douban_address.get(rank)}" for rank in self._ranks if self._douban_address.get(rank)]
|
||||
addr_list = self._rss_addrs + rank_addrs
|
||||
if not addr_list:
|
||||
logger.info(f"未设置榜单RSS地址")
|
||||
return
|
||||
|
||||
@@ -28,7 +28,7 @@ class DownloadSiteTag(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Youtube-dl_B.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.2"
|
||||
plugin_version = "2.6"
|
||||
# 插件作者
|
||||
plugin_author = "叮叮当"
|
||||
# 作者主页
|
||||
@@ -55,12 +55,33 @@ class DownloadSiteTag(_PluginBase):
|
||||
_enabled_media_tag = False
|
||||
_enabled_tag = True
|
||||
_enabled_category = False
|
||||
_enabled_del_tags = False
|
||||
_category_movie = None
|
||||
_category_tv = None
|
||||
_category_anime = None
|
||||
_downloaders = None
|
||||
# 默认的tracker映射字符串(用于显示在界面上)
|
||||
_tracker_mappings_default = "\n".join([
|
||||
"chdbits.xyz -> ptchdbits.co",
|
||||
"agsvpt.trackers.work -> agsvpt.com",
|
||||
"tracker.cinefiles.info -> audiences.me",
|
||||
"# 格式说明:tracker域名 -> 映射域名",
|
||||
"# 使用 -> 作为分隔符",
|
||||
"# 每行一个映射规则,空行和以#开头的行会被忽略",
|
||||
"# 站点管理中必须存在对应的域名才能生效"
|
||||
])
|
||||
_tracker_mappings_str = ""
|
||||
_tracker_mappings = {}
|
||||
_del_tags_task_rid = {}
|
||||
# 前缀配置
|
||||
_site_prefix = ""
|
||||
_media_prefix = ""
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 初始化删除标签任务rid映射
|
||||
self._del_tags_task_rid = {}
|
||||
# 初始化默认的tracker映射
|
||||
self._tracker_mappings = self._parse_tracker_mappings(self._tracker_mappings_default)
|
||||
# 读取配置
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
@@ -72,10 +93,29 @@ class DownloadSiteTag(_PluginBase):
|
||||
self._enabled_media_tag = config.get("enabled_media_tag")
|
||||
self._enabled_tag = config.get("enabled_tag")
|
||||
self._enabled_category = config.get("enabled_category")
|
||||
self._enabled_del_tags = config.get("enabled_del_tags")
|
||||
self._category_movie = config.get("category_movie") or "电影"
|
||||
self._category_tv = config.get("category_tv") or "电视"
|
||||
self._category_anime = config.get("category_anime") or "动漫"
|
||||
self._downloaders = config.get("downloaders")
|
||||
self._tracker_mappings_str = config.get("tracker_mappings_str", "")
|
||||
# 读取前缀配置
|
||||
self._site_prefix = config.get("site_prefix", "")
|
||||
self._media_prefix = config.get("media_prefix", "")
|
||||
|
||||
# 此设置对于老用户来说缺乏具体说明,因此如果为空,表示用户首次更新,则使用默认配置起到提示作用
|
||||
if not ("tracker_mappings_str" in config):
|
||||
config["tracker_mappings_str"] = self._tracker_mappings_default
|
||||
self.update_config(config)
|
||||
# 如果用户有配置,解析并合并到默认映射中
|
||||
elif self._tracker_mappings_str:
|
||||
user_mappings = self._parse_tracker_mappings(self._tracker_mappings_str)
|
||||
# 将用户映射合并到默认映射中,用户映射会覆盖默认映射中相同的key
|
||||
self._tracker_mappings.update(user_mappings)
|
||||
|
||||
# 首次运行时,从下载器初始化rid映射
|
||||
if self._enabled_del_tags:
|
||||
self._task_del_unused_tags()
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
@@ -146,7 +186,20 @@ class DownloadSiteTag(_PluginBase):
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
# 初始化公共服务列表
|
||||
tasks = []
|
||||
if self._enabled:
|
||||
if self._enabled_del_tags:
|
||||
# 添加 删除所有未被任何种子使用的标签 任务 每5分钟执行一次
|
||||
tasks.append({
|
||||
"id": "DeleteUnusedTags",
|
||||
"name": "删除下载器中未被使用的标签",
|
||||
"trigger": "interval",
|
||||
"func": self._task_del_unused_tags,
|
||||
"kwargs": {
|
||||
"minutes": 5
|
||||
}
|
||||
})
|
||||
if self._interval == "计划任务" or self._interval == "固定间隔":
|
||||
if self._interval == "固定间隔":
|
||||
if self._interval_unit == "小时":
|
||||
@@ -163,7 +216,7 @@ class DownloadSiteTag(_PluginBase):
|
||||
if self._interval_time < 5:
|
||||
self._interval_time = 5
|
||||
logger.info(f"{self.LOG_TAG}启动定时服务: 最小不少于5分钟, 防止执行间隔太短任务冲突")
|
||||
return [{
|
||||
tasks.append({
|
||||
"id": "DownloadSiteTag",
|
||||
"name": "补全下载历史的标签与分类",
|
||||
"trigger": "interval",
|
||||
@@ -171,16 +224,16 @@ class DownloadSiteTag(_PluginBase):
|
||||
"kwargs": {
|
||||
"minutes": self._interval_time
|
||||
}
|
||||
}]
|
||||
})
|
||||
else:
|
||||
return [{
|
||||
tasks.append({
|
||||
"id": "DownloadSiteTag",
|
||||
"name": "补全下载历史的标签与分类",
|
||||
"trigger": CronTrigger.from_crontab(self._interval_cron),
|
||||
"func": self._complemented_history,
|
||||
"kwargs": {}
|
||||
}]
|
||||
return []
|
||||
})
|
||||
return tasks
|
||||
|
||||
@staticmethod
|
||||
def str_to_number(s: str, i: int) -> int:
|
||||
@@ -188,10 +241,21 @@ class DownloadSiteTag(_PluginBase):
|
||||
return int(s)
|
||||
except ValueError:
|
||||
return i
|
||||
|
||||
@staticmethod
|
||||
def custom_intersection(indexers, tags):
|
||||
"""
|
||||
自定义交集算法, 用于处理标签与分类的交集(后缀匹配模式)
|
||||
"""
|
||||
return {
|
||||
idx for idx in set(indexers)
|
||||
for tag in set(tags)
|
||||
if idx == tag or tag.endswith(idx)
|
||||
}
|
||||
|
||||
def _complemented_history(self):
|
||||
"""
|
||||
补全下载历史的标签与分类
|
||||
补全下载历史的标签与分类,且执行清理未使用的标签
|
||||
"""
|
||||
if not self.service_infos:
|
||||
return
|
||||
@@ -199,15 +263,10 @@ class DownloadSiteTag(_PluginBase):
|
||||
# 记录处理的种子, 供辅种(无下载历史)使用
|
||||
dispose_history = {}
|
||||
# 所有站点索引
|
||||
indexers = [indexer.get("name") for indexer in SitesHelper().get_indexers()]
|
||||
indexers = [self._generate_site_tag(i.get("name")) if self._site_prefix else i.get("name") for i in SitesHelper().get_indexers()]
|
||||
# JackettIndexers索引器支持多个站点, 如果不存在历史记录, 则通过tracker会再次附加其他站点名称
|
||||
indexers.append("JackettIndexers")
|
||||
indexers.append(self._generate_site_tag("JackettIndexers"))
|
||||
indexers = set(indexers)
|
||||
tracker_mappings = {
|
||||
"chdbits.xyz": "ptchdbits.co",
|
||||
"agsvpt.trackers.work": "agsvpt.com",
|
||||
"tracker.cinefiles.info": "audiences.me",
|
||||
}
|
||||
for service in self.service_infos.values():
|
||||
downloader = service.name
|
||||
downloader_obj = service.instance
|
||||
@@ -263,7 +322,7 @@ class DownloadSiteTag(_PluginBase):
|
||||
trackers = self._get_trackers(torrent=torrent, dl_type=service.type)
|
||||
for tracker in trackers:
|
||||
# 检查tracker是否包含特定的关键字,并进行相应的映射
|
||||
for key, mapped_domain in tracker_mappings.items():
|
||||
for key, mapped_domain in self._tracker_mappings.items():
|
||||
if key in tracker:
|
||||
domain = mapped_domain
|
||||
break
|
||||
@@ -279,12 +338,17 @@ class DownloadSiteTag(_PluginBase):
|
||||
# 按设置生成需要写入的标签与分类
|
||||
_tags = []
|
||||
_cat = None
|
||||
|
||||
# 站点标签, 如果勾选开关的话 因允许torrent_site为空时运行到此, 因此需要判断torrent_site不为空
|
||||
if self._enabled_tag and history.torrent_site:
|
||||
_tags.append(history.torrent_site)
|
||||
site_tag = self._generate_site_tag(history.torrent_site)
|
||||
_tags.append(site_tag)
|
||||
|
||||
# 媒体标题标签, 如果勾选开关的话 因允许title为空时运行到此, 因此需要判断title不为空
|
||||
if self._enabled_media_tag and history.title:
|
||||
_tags.append(history.title)
|
||||
media_tag = self._generate_media_tag(history.title)
|
||||
_tags.append(media_tag)
|
||||
|
||||
# 分类, 如果勾选开关的话 <tr暂不支持> 因允许mtype为空时运行到此, 因此需要判断mtype不为空。为防止不必要的识别, 种子已经存在分类torrent_cat时 也不执行
|
||||
if service.type == "qbittorrent" and self._enabled_category and not torrent_cat and history.type:
|
||||
# 如果是电视剧 需要区分是否动漫
|
||||
@@ -298,24 +362,92 @@ class DownloadSiteTag(_PluginBase):
|
||||
genre_ids = tmdb_info.get("genre_ids")
|
||||
_cat = self._genre_ids_get_cat(history.type, genre_ids)
|
||||
|
||||
# 识别并清理历史标签
|
||||
tags_to_remove = []
|
||||
if torrent_tags:
|
||||
# 站点标签处理
|
||||
if history.torrent_site:
|
||||
# 如果配置了站点前缀
|
||||
if self._site_prefix:
|
||||
# 清理无前缀的站点标签
|
||||
if history.torrent_site in torrent_tags:
|
||||
tags_to_remove.append(history.torrent_site)
|
||||
# 清理带旧前缀的站点标签(除了当前前缀)
|
||||
for tag in torrent_tags:
|
||||
if tag.endswith(history.torrent_site) and tag != f"{self._site_prefix}{history.torrent_site}":
|
||||
tags_to_remove.append(tag)
|
||||
# 如果没有配置站点前缀
|
||||
else:
|
||||
# 清理所有带前缀的站点标签
|
||||
for tag in torrent_tags:
|
||||
if tag.endswith(history.torrent_site) and tag != history.torrent_site:
|
||||
tags_to_remove.append(tag)
|
||||
|
||||
# 剧名标签处理
|
||||
if history.title:
|
||||
# 如果配置了剧名前缀
|
||||
if self._media_prefix:
|
||||
# 清理无前缀的剧名标签
|
||||
if history.title in torrent_tags:
|
||||
tags_to_remove.append(history.title)
|
||||
# 清理带旧前缀的剧名标签(除了当前前缀)
|
||||
for tag in torrent_tags:
|
||||
if tag.endswith(history.title) and tag != f"{self._media_prefix}{history.title}":
|
||||
tags_to_remove.append(tag)
|
||||
# 如果没有配置剧名前缀
|
||||
else:
|
||||
# 清理所有带前缀的剧名标签
|
||||
for tag in torrent_tags:
|
||||
if tag.endswith(history.title) and tag != history.title:
|
||||
tags_to_remove.append(tag)
|
||||
|
||||
# 去除种子已经存在的标签
|
||||
if _tags and torrent_tags:
|
||||
_tags = list(set(_tags) - set(torrent_tags))
|
||||
|
||||
# 如果分类一样, 那么不需要修改
|
||||
if _cat == torrent_cat:
|
||||
_cat = None
|
||||
|
||||
# 判断当前种子是否不需要修改
|
||||
if not _cat and not _tags:
|
||||
if not _cat and not _tags and not tags_to_remove:
|
||||
continue
|
||||
|
||||
# 执行通用方法, 设置种子标签与分类
|
||||
self._set_torrent_info(service=service, _hash=_hash, _torrent=torrent, _tags=_tags, _cat=_cat,
|
||||
_original_tags=torrent_tags)
|
||||
_original_tags=torrent_tags, _tags_to_remove=tags_to_remove)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self.LOG_TAG}分析种子信息时发生了错误: {str(e)}")
|
||||
|
||||
# 执行清理未使用标签
|
||||
if self._enabled_del_tags:
|
||||
self._del_unused_tags(service=service)
|
||||
|
||||
logger.info(f"{self.LOG_TAG}执行完成")
|
||||
|
||||
def _generate_site_tag(self, site_name):
|
||||
"""
|
||||
生成带前缀的站点标签
|
||||
"""
|
||||
if not site_name:
|
||||
return ""
|
||||
if self._site_prefix:
|
||||
return f"{self._site_prefix}{site_name}"
|
||||
else:
|
||||
return site_name
|
||||
|
||||
def _generate_media_tag(self, media_title):
|
||||
"""
|
||||
生成带前缀的剧名标签
|
||||
"""
|
||||
if not media_title:
|
||||
return ""
|
||||
if self._media_prefix:
|
||||
return f"{self._media_prefix}{media_title}"
|
||||
else:
|
||||
return media_title
|
||||
|
||||
def _genre_ids_get_cat(self, mtype, genre_ids=None):
|
||||
"""
|
||||
根据genre_ids判断是否<动漫>分类
|
||||
@@ -335,6 +467,47 @@ class DownloadSiteTag(_PluginBase):
|
||||
_cat = self._category_tv
|
||||
return _cat
|
||||
|
||||
@staticmethod
|
||||
def _parse_tracker_mappings(mapping_str: str) -> dict:
|
||||
"""
|
||||
解析tracker映射规则字符串为字典
|
||||
格式:tracker域名 -> 映射域名
|
||||
例如:chdbits.xyz -> ptchdbits.co
|
||||
使用"->"作为分隔符
|
||||
"""
|
||||
tracker_mappings = {}
|
||||
if not mapping_str:
|
||||
return tracker_mappings
|
||||
|
||||
lines = mapping_str.strip().split('\n')
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue # 跳过空行和注释行
|
||||
|
||||
# 支持多种分隔符
|
||||
separators = ['->', '→', ':', ':']
|
||||
separator = None
|
||||
for sep in separators:
|
||||
if sep in line:
|
||||
separator = sep
|
||||
break
|
||||
|
||||
if separator:
|
||||
parts = line.split(separator, 1)
|
||||
if len(parts) == 2:
|
||||
key = parts[0].strip()
|
||||
value = parts[1].strip()
|
||||
if key and value:
|
||||
tracker_mappings[key] = value
|
||||
else:
|
||||
# 如果没有找到分隔符,尝试按空格分割
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
tracker_mappings[parts[0].strip()] = parts[1].strip()
|
||||
|
||||
return tracker_mappings
|
||||
|
||||
@staticmethod
|
||||
def _torrent_key(torrent: Any, dl_type: str) -> Optional[Tuple[int, str]]:
|
||||
"""
|
||||
@@ -423,8 +596,12 @@ class DownloadSiteTag(_PluginBase):
|
||||
获取种子标签
|
||||
"""
|
||||
try:
|
||||
return [str(tag).strip() for tag in torrent.get("tags", "").split(',')] \
|
||||
if dl_type == "qbittorrent" else torrent.labels or []
|
||||
if dl_type == "qbittorrent":
|
||||
tags_str = torrent.get("tags", "")
|
||||
# 处理空字符串情况,并过滤掉空白标签
|
||||
return [tag.strip() for tag in tags_str.split(',') if tag.strip()] if tags_str else []
|
||||
else:
|
||||
return torrent.labels or []
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return []
|
||||
@@ -441,7 +618,7 @@ class DownloadSiteTag(_PluginBase):
|
||||
return None
|
||||
|
||||
def _set_torrent_info(self, service: ServiceInfo, _hash: str, _torrent: Any = None, _tags=None, _cat: str = None,
|
||||
_original_tags: list = None):
|
||||
_original_tags: list = None, _tags_to_remove: list = []):
|
||||
"""
|
||||
设置种子标签与分类
|
||||
"""
|
||||
@@ -463,7 +640,10 @@ class DownloadSiteTag(_PluginBase):
|
||||
if _hash and _torrent:
|
||||
# 下载器api不通用, 因此需分开处理
|
||||
if service.type == "qbittorrent":
|
||||
# 设置标签
|
||||
# 先移除需要删除的标签
|
||||
if _tags_to_remove:
|
||||
downloader_obj.remove_torrents_tag(ids=_hash, tag=_tags_to_remove)
|
||||
# 再添加新标签
|
||||
if _tags:
|
||||
downloader_obj.set_torrents_tag(ids=_hash, tags=_tags)
|
||||
# 设置分类 <tr暂不支持>
|
||||
@@ -478,16 +658,102 @@ class DownloadSiteTag(_PluginBase):
|
||||
_torrent.setCategory(category=_cat)
|
||||
else:
|
||||
# 设置标签
|
||||
if _tags:
|
||||
if _tags or _tags_to_remove:
|
||||
# _original_tags = None表示未指定, 因此需要获取原始标签
|
||||
if _original_tags is None:
|
||||
_original_tags = self._get_label(torrent=_torrent, dl_type=service.type)
|
||||
# 如果原始标签不是空的, 那么合并原始标签
|
||||
if _original_tags:
|
||||
_tags = list(set(_original_tags).union(set(_tags)))
|
||||
# 移除需要删除的标签
|
||||
if _tags_to_remove:
|
||||
_tags = list(set(_tags) - set(_tags_to_remove))
|
||||
downloader_obj.set_torrent_tag(ids=_hash, tags=_tags)
|
||||
logger.warn(
|
||||
f"{self.LOG_TAG}下载器: {service.name} 种子id: {_hash} {(' 标签: ' + ','.join(_tags)) if _tags else ''} {(' 分类: ' + _cat) if _cat else ''}")
|
||||
|
||||
def _task_del_unused_tags(self):
|
||||
"""
|
||||
公共服务:删除所有未被任何种子使用的标签,遍历全部下载器
|
||||
"""
|
||||
if not self.service_infos:
|
||||
return
|
||||
for service in self.service_infos.values():
|
||||
# 仅qb支持删除未使用标签
|
||||
if service.type != "qbittorrent":
|
||||
continue
|
||||
downloader = service.name
|
||||
downloader_obj = service.instance
|
||||
if not downloader_obj:
|
||||
logger.error(f"{self.LOG_TAG} 删除未使用标签公共服务,获取下载器失败 {downloader}")
|
||||
continue
|
||||
try:
|
||||
# 初始化下载器 获取全量数据
|
||||
if downloader not in self._del_tags_task_rid:
|
||||
data = downloader_obj.qbc.sync_maindata(rid=0)
|
||||
logger.info(f"{self.LOG_TAG}初始化删除未使用标签任务 RID for {downloader} full_update: {data.get('full_update', False)}")
|
||||
self._del_tags_task_rid[downloader] = data.get("rid", 0)
|
||||
else:
|
||||
# 提取上次返回的 rid
|
||||
last_rid = self._del_tags_task_rid[downloader]
|
||||
data = downloader_obj.qbc.sync_maindata(rid=last_rid)
|
||||
# 更新 rid 用于下次访问
|
||||
self._del_tags_task_rid[downloader] = data.get("rid", last_rid)
|
||||
# 可能服务器重启,或其他原因导致 rid 状态已被重置
|
||||
if data.get("full_update", False):
|
||||
logger.info(f"{self.LOG_TAG}重置删除未使用标签任务 RID for {downloader} full_update: {data.get('full_update', False)}")
|
||||
continue
|
||||
if data.get('torrents_removed', []):
|
||||
logger.info(f"{self.LOG_TAG}删除未使用标签任务 RID for {downloader} 发现删除种子,即将执行清理未使用标签操作!")
|
||||
# 指定下载器服务,执行删除未使用标签
|
||||
self._del_unused_tags(service=service)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self.LOG_TAG}删除未使用标签公共服务,下载器:{downloader} 发生了错误: {str(e)}")
|
||||
|
||||
def _del_unused_tags(self, service: ServiceInfo, torrents: Any = None):
|
||||
"""
|
||||
删除所有未被任何种子使用的标签, 可指定下载器与种子列表
|
||||
"""
|
||||
# 只有qb下载器才需要删除未使用的标签,TR下载器未使用标签会自动移除
|
||||
if not service or not service.instance or service.type != "qbittorrent":
|
||||
return
|
||||
|
||||
downloader_obj = service.instance
|
||||
try:
|
||||
# 获取所有现有的标签 调用内部qbc的API
|
||||
all_tags = downloader_obj.qbc.torrents_tags()
|
||||
if not all_tags:
|
||||
logger.info(
|
||||
f"{self.LOG_TAG}下载器: {service.name} 当前没有任何标签,跳过删除未使用标签操作")
|
||||
return
|
||||
# 获取下载器中的种子
|
||||
if not torrents:
|
||||
torrents, error = downloader_obj.get_torrents()
|
||||
# 如果下载器获取种子发生错误 或 没有种子 则跳过
|
||||
if error or not torrents:
|
||||
logger.warn(
|
||||
f"{self.LOG_TAG}删除所有未被任何种子使用的标签时发生了错误或查询不到任何种子!")
|
||||
return
|
||||
logger.info(
|
||||
f"{self.LOG_TAG}删除所有未被任何种子使用的标签: {service.name} 查询到 {len(torrents)} 个种子")
|
||||
# 收集所有正在被使用的标签
|
||||
used_tags_set = set()
|
||||
for torrent in torrents:
|
||||
tag = self._get_label(torrent=torrent, dl_type=service.type)
|
||||
if tag: # 确保种子有标签
|
||||
used_tags_set.update(tag)
|
||||
# 计算未使用的标签(在全部标签中但不在使用集合中)
|
||||
unused_tags = [tag for tag in all_tags if tag not in used_tags_set]
|
||||
# 删除未使用的标签
|
||||
if unused_tags:
|
||||
downloader_obj.delete_torrents_tag(ids=None, tag=unused_tags)
|
||||
logger.info(
|
||||
f"{self.LOG_TAG}删除所有未被任何种子使用的标签: {",".join(unused_tags)}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self.LOG_TAG}删除所有未被任何种子使用的标签时发生了错误: {str(e)}")
|
||||
|
||||
|
||||
@eventmanager.register(EventType.DownloadAdded)
|
||||
def download_added(self, event: Event):
|
||||
@@ -510,7 +776,7 @@ class DownloadSiteTag(_PluginBase):
|
||||
if not service:
|
||||
logger.info(f"触发添加下载事件,但没有监听下载器 {downloader},跳过后续处理")
|
||||
return
|
||||
|
||||
|
||||
context: Context = event.event_data.get("context")
|
||||
_hash = event.event_data.get("hash")
|
||||
_torrent = context.torrent_info
|
||||
@@ -519,10 +785,12 @@ class DownloadSiteTag(_PluginBase):
|
||||
_cat = None
|
||||
# 站点标签, 如果勾选开关的话
|
||||
if self._enabled_tag and _torrent.site_name:
|
||||
_tags.append(_torrent.site_name)
|
||||
site_tag = self._generate_site_tag(_torrent.site_name)
|
||||
_tags.append(site_tag)
|
||||
# 媒体标题标签, 如果勾选开关的话
|
||||
if self._enabled_media_tag and _media.title:
|
||||
_tags.append(_media.title)
|
||||
media_tag = self._generate_media_tag(_media.title)
|
||||
_tags.append(media_tag)
|
||||
# 分类, 如果勾选开关的话 <tr暂不支持>
|
||||
if self._enabled_category and _media.type:
|
||||
_cat = self._genre_ids_get_cat(_media.type, _media.genre_ids)
|
||||
@@ -531,12 +799,13 @@ class DownloadSiteTag(_PluginBase):
|
||||
self._set_torrent_info(service=service, _hash=_hash, _tags=_tags, _cat=_cat)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self.LOG_TAG}分析下载事件时发生了错误: {str(e)}")
|
||||
f"{self.LOG_TAG}分析添加下载事件时发生了错误: {str(e)}")
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
@@ -616,7 +885,8 @@ class DownloadSiteTag(_PluginBase):
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -627,6 +897,61 @@ class DownloadSiteTag(_PluginBase):
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCheckboxBtn',
|
||||
'props': {
|
||||
'model': 'enabled_del_tags',
|
||||
'label': '自动删除未使用标签',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'site_prefix',
|
||||
'label': '站点标签前缀',
|
||||
'placeholder': '留空表示不使用前缀,自动识别历史标签并更新',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'media_prefix',
|
||||
'label': '剧名标签前缀',
|
||||
'placeholder': '留空表示不使用前缀,自动识别历史标签并更新',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -735,6 +1060,27 @@ class DownloadSiteTag(_PluginBase):
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '定时任务:支持两种定时方式,主要针对辅种刷流等种子补全站点信息。如没有对应的需求建议切换为禁用。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
@@ -749,7 +1095,8 @@ class DownloadSiteTag(_PluginBase):
|
||||
'props': {
|
||||
'model': 'category_movie',
|
||||
'label': '电影分类名称(默认: 电影)',
|
||||
'placeholder': '电影'
|
||||
'placeholder': '电影',
|
||||
'hint': '请填写下载器里已创建的电影分类名称'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -770,7 +1117,8 @@ class DownloadSiteTag(_PluginBase):
|
||||
'props': {
|
||||
'model': 'category_tv',
|
||||
'label': '电视分类名称(默认: 电视)',
|
||||
'placeholder': '电视'
|
||||
'placeholder': '电视',
|
||||
'hint': '请填写下载器里已创建的电视分类名称'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -791,7 +1139,8 @@ class DownloadSiteTag(_PluginBase):
|
||||
'props': {
|
||||
'model': 'category_anime',
|
||||
'label': '动漫分类名称(默认: 动漫)',
|
||||
'placeholder': '动漫'
|
||||
'placeholder': '动漫',
|
||||
'hint': '请填写下载器里已创建的动漫分类名称'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -804,7 +1153,7 @@ class DownloadSiteTag(_PluginBase):
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -812,7 +1161,30 @@ class DownloadSiteTag(_PluginBase):
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '定时任务:支持两种定时方式,主要针对辅种刷流等种子补全站点信息。如没有对应的需求建议切换为禁用。'
|
||||
'text': '以下为tracker映射规则,您可以根据需要修改或添加新的规则。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'tracker_mappings_str',
|
||||
'label': 'Tracker域名映射规则',
|
||||
'rows': 8,
|
||||
'placeholder': '每行一个映射,格式:tracker域名 -> 映射域名\n例如:chdbits.xyz -> ptchdbits.co',
|
||||
'hint': '支持的分隔符:->, →, :, :,空格'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -827,13 +1199,17 @@ class DownloadSiteTag(_PluginBase):
|
||||
"enabled_tag": True,
|
||||
"enabled_media_tag": False,
|
||||
"enabled_category": False,
|
||||
"enabled_del_tags": False,
|
||||
"category_movie": "电影",
|
||||
"category_tv": "电视",
|
||||
"category_anime": "动漫",
|
||||
"interval": "计划任务",
|
||||
"interval_cron": "5 4 * * *",
|
||||
"interval_time": "6",
|
||||
"interval_unit": "小时"
|
||||
"interval_unit": "小时",
|
||||
"tracker_mappings_str": self._tracker_mappings_default, # 添加默认的映射规则字符串
|
||||
"site_prefix": "",
|
||||
"media_prefix": ""
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
@@ -852,4 +1228,4 @@ class DownloadSiteTag(_PluginBase):
|
||||
self._event.clear()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
print(str(e))
|
||||
File diff suppressed because it is too large
Load Diff
771
plugins.v2/imdbsource/imdbapi.py
Normal file
771
plugins.v2/imdbsource/imdbapi.py
Normal file
@@ -0,0 +1,771 @@
|
||||
from typing import Any, AsyncGenerator, Dict, Generator, List, Optional, Final
|
||||
|
||||
import requests
|
||||
import httpx
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.log import logger
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils, AsyncRequestUtils
|
||||
|
||||
from .schema.imdbapi import ImdbApiTitle, ImdbApiEpisode, ImdbApiCredit, ImdbapiImage
|
||||
from .schema.imdbapi import (ImdbApiSearchTitlesResponse, ImdbApiListTitlesResponse, ImdbApiListTitleEpisodesResponse,
|
||||
ImdbApiListTitleSeasonsResponse, ImdbApiListTitleCreditsResponse,
|
||||
ImdbapiListTitleAKAsResponse, ImdbApiTitleImagesResponse)
|
||||
from .schema.imdbtypes import ImdbType
|
||||
|
||||
|
||||
CACHE_LIFESPAN: Final[int] = 86400
|
||||
|
||||
|
||||
class ImdbApiClient:
|
||||
BASE_URL = 'https://api.imdbapi.dev'
|
||||
|
||||
def __init__(self, proxies: Optional[Dict[str, str]] = None, ua: Optional[str] = None) -> None:
|
||||
self._req = RequestUtils(ua=ua, accept_type="application/json",
|
||||
proxies=proxies, session=requests.Session())
|
||||
if proxies:
|
||||
proxy_url = proxies.get("https") or proxies.get("http")
|
||||
else:
|
||||
proxy_url = None
|
||||
self._free_api_client = httpx.AsyncClient(timeout=10, proxy=proxy_url)
|
||||
|
||||
self._async_req = AsyncRequestUtils(
|
||||
ua=ua,
|
||||
accept_type="application/json",
|
||||
client=self._free_api_client
|
||||
)
|
||||
|
||||
@retry(Exception, logger=logger, delay=1)
|
||||
@cached(maxsize=4096, ttl=CACHE_LIFESPAN)
|
||||
def _free_imdb_api(self, path: str, params: Optional[dict] = None) -> Optional[dict]:
|
||||
r = self._req.get_res(url=f"{self.BASE_URL}{path}", params=params, raise_exception=True)
|
||||
if r is None:
|
||||
return None
|
||||
if r.status_code != 200:
|
||||
try:
|
||||
logger.warning(
|
||||
f"Free IMDb API returned non-200 status: {r.status_code} for path={path} params={params}"
|
||||
)
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
return r.json()
|
||||
|
||||
@retry(Exception, logger=logger, delay=1)
|
||||
@cached(maxsize=4096, ttl=CACHE_LIFESPAN)
|
||||
async def _async_free_imdb_api(self, path: str, params: Optional[dict] = None) -> Optional[dict]:
|
||||
r = await self._async_req.get_res(url=f"{self.BASE_URL}{path}", params=params, raise_exception=True)
|
||||
if r is None:
|
||||
return None
|
||||
if r.status_code != 200:
|
||||
try:
|
||||
logger.warning(
|
||||
f"Free IMDb API returned non-200 status: {r.status_code} for path={path} params={params}"
|
||||
)
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
return r.json()
|
||||
|
||||
def search_titles(self, query: str, limit: Optional[int] = None) -> Optional[ImdbApiSearchTitlesResponse]:
|
||||
"""
|
||||
Search for titles using a query string.
|
||||
|
||||
:param query: Required. The search query for titles.
|
||||
:param limit: Optional. Limit the number of results returned. The maximum is 50.
|
||||
:return: Search results.
|
||||
"""
|
||||
path = '/search/titles'
|
||||
params: Dict[str, Any] = {'query': query}
|
||||
if limit:
|
||||
params['limit'] = limit
|
||||
try:
|
||||
r = self._free_imdb_api(path=path, params=params)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbApiSearchTitlesResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while searching for titles: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
async def async_search_titles(self, query: str, limit: Optional[int] = None
|
||||
) -> Optional[ImdbApiSearchTitlesResponse]:
|
||||
endpoint = '/search/titles'
|
||||
params: Dict[str, Any] = {'query': query}
|
||||
if limit:
|
||||
params['limit'] = limit
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=endpoint, params=params)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbApiSearchTitlesResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while searching for titles: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
def advanced_search(self, query: str, limit: Optional[int] = None,
|
||||
media_types: Optional[List[ImdbType]] = None,
|
||||
year: Optional[int] = None) -> Optional[List[ImdbApiTitle]]:
|
||||
"""
|
||||
Perform an advanced search for titles using a query string with additional filters.
|
||||
|
||||
:param query: The search query for titles.
|
||||
:param limit: The maximum number of results to return.
|
||||
:param media_types: The type of titles to filter by.
|
||||
:param year: The start year for filtering titles.
|
||||
:return: Search results.
|
||||
"""
|
||||
|
||||
data = self.search_titles(query=query, limit=limit)
|
||||
if data is None:
|
||||
return None
|
||||
ret = data.titles
|
||||
if year:
|
||||
ret = [title for title in ret if title.start_year == year]
|
||||
if media_types:
|
||||
ret = [title for title in ret if title.type in media_types]
|
||||
return ret
|
||||
|
||||
async def async_advanced_search(self, query: str, limit: Optional[int] = None,
|
||||
media_types: Optional[List[ImdbType]] = None,
|
||||
year: Optional[int] = None) -> Optional[List[ImdbApiTitle]]:
|
||||
"""
|
||||
Perform an advanced search for titles using a query string with additional filters.
|
||||
|
||||
:param query: The search query for titles.
|
||||
:param limit: The maximum number of results to return.
|
||||
:param media_types: The type of titles to filter by.
|
||||
:param year: The start year for filtering titles.
|
||||
:return: Search results.
|
||||
"""
|
||||
|
||||
res = await self.async_search_titles(query=query, limit=limit)
|
||||
if res is None:
|
||||
return None
|
||||
data = res.titles
|
||||
if year:
|
||||
data = [title for title in res.titles if title.start_year == year]
|
||||
if media_types:
|
||||
data = [title for title in res.titles if title.type in media_types]
|
||||
return data
|
||||
|
||||
def titles(self,
|
||||
types: Optional[List[ImdbType]] = None,
|
||||
genres: Optional[List[str]] = None,
|
||||
country_codes: Optional[List[str]] = None,
|
||||
language_codes: Optional[List[str]] = None,
|
||||
name_ids: Optional[List[str]] = None,
|
||||
interest_ids: Optional[List[str]] = None,
|
||||
start_year: Optional[int] = None,
|
||||
end_year: Optional[int] = None,
|
||||
min_vote_count: Optional[int] = None,
|
||||
max_vote_count: Optional[int] = None,
|
||||
min_aggregate_rating: Optional[float] = None,
|
||||
max_aggregate_rating: Optional[float] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
page_token: Optional[str] = None) -> Optional[ImdbApiListTitlesResponse]:
|
||||
"""
|
||||
Retrieve a list of titles with optional filters.
|
||||
|
||||
:param types: Optional. The type of titles to filter by. If not specified,
|
||||
all types are returned.
|
||||
- MOVIE: Represents a movie title.
|
||||
- TV_SERIES: Represents a TV series title.
|
||||
- TV_MINI_SERIES: Represents a TV miniseries title.
|
||||
- TV_SPECIAL: Represents a TV special title.
|
||||
- TV_MOVIE: Represents a TV movie title.
|
||||
- SHORT: Represents a short title.
|
||||
- VIDEO: Represents a video title.
|
||||
- VIDEO_GAME: Represents a video game title.
|
||||
:param genres: Optional. The genres to filter titles by. If not specified,
|
||||
titles from all genres are returned.
|
||||
:param country_codes: Optional. The ISO 3166-1 alpha-2 country codes to
|
||||
filter titles by. If not specified, titles from all countries are
|
||||
returned. Example: "US" for the United States, "GB" for the United
|
||||
Kingdom.
|
||||
:param language_codes: Optional. The ISO 639-1 or ISO 639-2 language codes
|
||||
to filter titles by. If not specified, titles in all languages are
|
||||
returned.
|
||||
:param name_ids: Optional. The IDs of names to filter titles by.
|
||||
:param interest_ids: Optional. The IDs of interests to filter titles by.
|
||||
If not specified, titles associated with all interests are returned.
|
||||
:param start_year: Optional. The start year for filtering titles.
|
||||
:param end_year: Optional. The end year for filtering titles.
|
||||
:param min_vote_count: Optional. The minimum number of votes a title must
|
||||
have to be included. If not specified, titles with any number of votes
|
||||
are included. The value must be between 0 and 1,000,000,000. Default is 0.
|
||||
:param max_vote_count: Optional. The maximum number of votes a title can
|
||||
have to be included. If not specified, titles with any number of votes
|
||||
are included. The value must be between 0 and 1,000,000,000.
|
||||
:param min_aggregate_rating: Optional. The minimum rating a title must have
|
||||
to be included. If not specified, titles with any rating are included.
|
||||
The value must be between 0.0 and 10.0.
|
||||
:param max_aggregate_rating: Optional. The maximum rating a title can have
|
||||
to be included. If not specified, titles with any rating are included.
|
||||
The value must be between 0.0 and 10.0.
|
||||
:param sort_by: Optional. The sorting order for the titles. If not
|
||||
specified, titles are sorted by popularity.
|
||||
- SORT_BY_POPULARITY: Sort by popularity. Used to rank titles based on
|
||||
viewership, ratings, or cultural impact.
|
||||
- SORT_BY_RELEASE_DATE: Sort by release date. Newer titles typically
|
||||
appear before older ones.
|
||||
- SORT_BY_USER_RATING: Sort by average user rating, reflecting audience
|
||||
reception.
|
||||
- SORT_BY_USER_RATING_COUNT: Sort by number of user ratings, indicating
|
||||
engagement or popularity.
|
||||
- SORT_BY_YEAR: Sort by release year, with newer titles typically first.
|
||||
:param sort_order: Optional. The sorting order for the titles. If not
|
||||
specified, titles are sorted in ascending order.
|
||||
- ASC: Sort in ascending order.
|
||||
- DESC: Sort in descending order.
|
||||
:param page_token: Optional. Token for pagination, if applicable.
|
||||
:return: A dictionary containing the list of titles and pagination info.
|
||||
"""
|
||||
|
||||
path = '/titles'
|
||||
params: Dict[str, Any] = {}
|
||||
if types:
|
||||
params['types'] = [t.value for t in types]
|
||||
if genres:
|
||||
params['genres'] = genres
|
||||
if country_codes:
|
||||
params['countryCodes'] = country_codes
|
||||
if language_codes:
|
||||
params['languageCodes'] = language_codes
|
||||
if name_ids:
|
||||
params['nameIds'] = name_ids
|
||||
if interest_ids:
|
||||
params['interestIds'] = interest_ids
|
||||
if start_year:
|
||||
params['startYear'] = start_year
|
||||
if end_year:
|
||||
params['endYear'] = end_year
|
||||
if min_vote_count:
|
||||
params['minVoteCount'] = min_vote_count
|
||||
if max_vote_count:
|
||||
params['maxVoteCount'] = max_vote_count
|
||||
if min_aggregate_rating:
|
||||
params['minAggregateRating'] = min_aggregate_rating
|
||||
if max_aggregate_rating:
|
||||
params['maxAggregateRating'] = max_aggregate_rating
|
||||
if sort_by:
|
||||
params['sortBy'] = sort_by
|
||||
if sort_order:
|
||||
params['sortOrder'] = sort_order
|
||||
if page_token:
|
||||
params['pageToken'] = page_token
|
||||
|
||||
try:
|
||||
return ImdbApiListTitlesResponse.model_validate(self._free_imdb_api(path=path, params=params))
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while listing titles: {e}")
|
||||
return None
|
||||
|
||||
async def async_titles(self,
|
||||
types: Optional[List[ImdbType]] = None,
|
||||
genres: Optional[List[str]] = None,
|
||||
country_codes: Optional[List[str]] = None,
|
||||
language_codes: Optional[List[str]] = None,
|
||||
name_ids: Optional[List[str]] = None,
|
||||
interest_ids: Optional[List[str]] = None,
|
||||
start_year: Optional[int] = None,
|
||||
end_year: Optional[int] = None,
|
||||
min_vote_count: Optional[int] = None,
|
||||
max_vote_count: Optional[int] = None,
|
||||
min_aggregate_rating: Optional[float] = None,
|
||||
max_aggregate_rating: Optional[float] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
page_token: Optional[str] = None) -> Optional[ImdbApiListTitlesResponse]:
|
||||
path = '/titles'
|
||||
params: Dict[str, Any] = {}
|
||||
if types:
|
||||
params['types'] = [t.value for t in types]
|
||||
if genres:
|
||||
params['genres'] = genres
|
||||
if country_codes:
|
||||
params['countryCodes'] = country_codes
|
||||
if language_codes:
|
||||
params['languageCodes'] = language_codes
|
||||
if name_ids:
|
||||
params['nameIds'] = name_ids
|
||||
if interest_ids:
|
||||
params['interestIds'] = interest_ids
|
||||
if start_year:
|
||||
params['startYear'] = start_year
|
||||
if end_year:
|
||||
params['endYear'] = end_year
|
||||
if min_vote_count:
|
||||
params['minVoteCount'] = min_vote_count
|
||||
if max_vote_count:
|
||||
params['maxVoteCount'] = max_vote_count
|
||||
if min_aggregate_rating:
|
||||
params['minAggregateRating'] = min_aggregate_rating
|
||||
if max_aggregate_rating:
|
||||
params['maxAggregateRating'] = max_aggregate_rating
|
||||
if sort_by:
|
||||
params['sortBy'] = sort_by
|
||||
if sort_order:
|
||||
params['sortOrder'] = sort_order
|
||||
if page_token:
|
||||
params['pageToken'] = page_token
|
||||
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=path, params=params)
|
||||
if r is None:
|
||||
return None
|
||||
return ImdbApiListTitlesResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while listing titles: {e}")
|
||||
return None
|
||||
|
||||
def titles_generator(self,
|
||||
types: Optional[List[ImdbType]] = None,
|
||||
genres: Optional[List[str]] = None,
|
||||
country_codes: Optional[List[str]] = None,
|
||||
language_codes: Optional[List[str]] = None,
|
||||
name_ids: Optional[List[str]] = None,
|
||||
interest_ids: Optional[List[str]] = None,
|
||||
start_year: Optional[int] = None,
|
||||
end_year: Optional[int] = None,
|
||||
min_vote_count: Optional[int] = None,
|
||||
max_vote_count: Optional[int] = None,
|
||||
min_aggregate_rating: Optional[float] = None,
|
||||
max_aggregate_rating: Optional[float] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
) -> Generator[ImdbApiTitle, None, None]:
|
||||
page_token = None
|
||||
while True:
|
||||
response = self.titles(
|
||||
types=types,
|
||||
genres=genres,
|
||||
country_codes=country_codes,
|
||||
language_codes=language_codes,
|
||||
name_ids=name_ids,
|
||||
interest_ids=interest_ids,
|
||||
start_year=start_year,
|
||||
end_year=end_year,
|
||||
min_vote_count=min_vote_count,
|
||||
max_vote_count=max_vote_count,
|
||||
min_aggregate_rating=min_aggregate_rating,
|
||||
max_aggregate_rating=max_aggregate_rating,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
for title in response.titles:
|
||||
yield title
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
async def async_titles_generator(self,
|
||||
types: Optional[List[ImdbType]] = None,
|
||||
genres: Optional[List[str]] = None,
|
||||
country_codes: Optional[List[str]] = None,
|
||||
language_codes: Optional[List[str]] = None,
|
||||
name_ids: Optional[List[str]] = None,
|
||||
interest_ids: Optional[List[str]] = None,
|
||||
start_year: Optional[int] = None,
|
||||
end_year: Optional[int] = None,
|
||||
min_vote_count: Optional[int] = None,
|
||||
max_vote_count: Optional[int] = None,
|
||||
min_aggregate_rating: Optional[float] = None,
|
||||
max_aggregate_rating: Optional[float] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
) -> AsyncGenerator[ImdbApiTitle, None]:
|
||||
|
||||
page_token = None
|
||||
while True:
|
||||
response = await self.async_titles(
|
||||
types=types,
|
||||
genres=genres,
|
||||
country_codes=country_codes,
|
||||
language_codes=language_codes,
|
||||
name_ids=name_ids,
|
||||
interest_ids=interest_ids,
|
||||
start_year=start_year,
|
||||
end_year=end_year,
|
||||
min_vote_count=min_vote_count,
|
||||
max_vote_count=max_vote_count,
|
||||
min_aggregate_rating=min_aggregate_rating,
|
||||
max_aggregate_rating=max_aggregate_rating,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
|
||||
for title in response.titles:
|
||||
yield title
|
||||
|
||||
page_token = response.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
def title(self, title_id: str) -> Optional[ImdbApiTitle]:
|
||||
"""
|
||||
Retrieve a title's details using its IMDb ID.
|
||||
|
||||
:param title_id: The IMDb title ID in the format 'tt1234567'.
|
||||
:return: Details.
|
||||
"""
|
||||
path = '/titles/%s'
|
||||
try:
|
||||
r = self._free_imdb_api(path=path % title_id)
|
||||
ret = ImdbApiTitle.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving details: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
async def async_title(self, title_id: str) -> Optional[ImdbApiTitle]:
|
||||
path = '/titles/%s'
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=path % title_id)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbApiTitle.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving details: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
def episodes(self, title_id: str, season: Optional[str] = None,
|
||||
page_size: Optional[int] = None, page_token: Optional[str] = None) -> Optional[
|
||||
ImdbApiListTitleEpisodesResponse]:
|
||||
"""
|
||||
Retrieve the episodes associated with a specific title.
|
||||
|
||||
:param title_id: Required. IMDb title ID in the format "tt1234567".
|
||||
:param season: Optional. The season number to filter episodes by.
|
||||
:param page_size: Optional. The maximum number of episodes to return per page.
|
||||
The value must be between 1 and 50. The default is 20.
|
||||
:param page_token: Optional. Token for pagination, if applicable.
|
||||
:return: Episodes.
|
||||
|
||||
"""
|
||||
path = '/titles/%s/episodes'
|
||||
param: Dict[str, Any] = {}
|
||||
if season is not None:
|
||||
param['season'] = season
|
||||
if page_size is not None:
|
||||
param['pageSize'] = page_size
|
||||
if page_token is not None:
|
||||
param['pageToken'] = page_token
|
||||
try:
|
||||
r = self._free_imdb_api(path=path % title_id, params=param)
|
||||
ret = ImdbApiListTitleEpisodesResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving episodes: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
async def async_episodes(self, title_id: str, season: Optional[str] = None,
|
||||
page_size: Optional[int] = None, page_token: Optional[str] = None
|
||||
) -> Optional[ImdbApiListTitleEpisodesResponse]:
|
||||
|
||||
path = '/titles/%s/episodes'
|
||||
param: Dict[str, Any] = {}
|
||||
if season is not None:
|
||||
param['season'] = season
|
||||
if page_size is not None:
|
||||
param['pageSize'] = page_size
|
||||
if page_token is not None:
|
||||
param['pageToken'] = page_token
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=path % title_id, params=param)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbApiListTitleEpisodesResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving episodes: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
def episodes_generator(self, title_id: str, season: Optional[str] = None) -> Generator[ImdbApiEpisode, None, None]:
|
||||
page_token = None
|
||||
while True:
|
||||
response = self.episodes(
|
||||
title_id=title_id,
|
||||
season=season,
|
||||
page_size=50,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
|
||||
for episode in response.episodes:
|
||||
yield episode
|
||||
|
||||
page_token = response.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
async def async_episodes_generator(self, title_id: str, season: Optional[str] = None
|
||||
) -> AsyncGenerator[ImdbApiEpisode, None]:
|
||||
page_token = None
|
||||
while True:
|
||||
response = await self.async_episodes(
|
||||
title_id=title_id,
|
||||
season=season,
|
||||
page_size=50,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
|
||||
for episode in response.episodes:
|
||||
yield episode
|
||||
|
||||
page_token = response.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
def seasons(self, title_id: str) -> Optional[ImdbApiListTitleSeasonsResponse]:
|
||||
"""
|
||||
Retrieve the seasons associated with a specific title.
|
||||
|
||||
:param title_id: Required. IMDb title ID in the format "tt1234567".
|
||||
:return: Seasons.
|
||||
"""
|
||||
path = '/titles/%s/seasons'
|
||||
try:
|
||||
r = self._free_imdb_api(path=path % title_id)
|
||||
ret = ImdbApiListTitleSeasonsResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving seasons: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
async def async_seasons(self, title_id: str) -> Optional[ImdbApiListTitleSeasonsResponse]:
|
||||
path = '/titles/%s/seasons'
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=path % title_id)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbApiListTitleSeasonsResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving seasons: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
def credits(self, title_id: str, categories: Optional[List[str]] = None,
|
||||
page_size: Optional[int] = None, page_token: Optional[str] = None
|
||||
) -> Optional[ImdbApiListTitleCreditsResponse]:
|
||||
"""
|
||||
Retrieve the credits associated with a specific title.
|
||||
|
||||
:param title_id: Required. IMDb title ID in the format "tt1234567".
|
||||
:param categories: Optional. The categories of credits to filter by.
|
||||
:param page_size: Optional. The maximum number of credits to return per page.
|
||||
The value must be between 1 and 50. The default is 20.
|
||||
:param page_token: Optional. Token for pagination, if applicable.
|
||||
:return: Credits.
|
||||
|
||||
"""
|
||||
path = '/titles/%s/credits'
|
||||
param: Dict[str, Any] = {}
|
||||
if categories:
|
||||
param['categories'] = categories
|
||||
if page_size is not None:
|
||||
param['pageSize'] = page_size
|
||||
if page_token is not None:
|
||||
param['pageToken'] = page_token
|
||||
try:
|
||||
r = self._free_imdb_api(path=path % title_id, params=param)
|
||||
ret = ImdbApiListTitleCreditsResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving credits: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
async def async_credits(self, title_id: str, categories: Optional[List[str]] = None,
|
||||
page_size: Optional[int] = None, page_token: Optional[str] = None) -> Optional[
|
||||
ImdbApiListTitleCreditsResponse]:
|
||||
|
||||
path = '/titles/%s/credits'
|
||||
param: Dict[str, Any] = {}
|
||||
if categories:
|
||||
param['categories'] = categories
|
||||
if page_size is not None:
|
||||
param['pageSize'] = page_size
|
||||
if page_token is not None:
|
||||
param['pageToken'] = page_token
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=path % title_id, params=param)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbApiListTitleCreditsResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving credits: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
def credits_generator(self, title_id: str, categories: Optional[List[str]] = None
|
||||
) -> Generator[ImdbApiCredit, None, None]:
|
||||
page_token = None
|
||||
while True:
|
||||
response = self.credits(
|
||||
title_id=title_id,
|
||||
categories=categories,
|
||||
page_size=50,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
|
||||
for credit in response.credits:
|
||||
yield credit
|
||||
|
||||
page_token = response.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
async def async_credits_generator(self, title_id: str, categories: Optional[List[str]] = None
|
||||
) -> AsyncGenerator[ImdbApiCredit, None]:
|
||||
page_token = None
|
||||
while True:
|
||||
response = await self.async_credits(
|
||||
title_id=title_id,
|
||||
categories=categories,
|
||||
page_size=50,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
|
||||
for credit in response.credits:
|
||||
yield credit
|
||||
|
||||
page_token = response.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
def akas(self, title_id: str) -> Optional[ImdbapiListTitleAKAsResponse]:
|
||||
"""
|
||||
Retrieve the alternative titles (AKAs) associated with a specific title.
|
||||
|
||||
:param title_id: Required. IMDb title ID in the format "tt1234567".
|
||||
:return: AKAs.
|
||||
"""
|
||||
path = '/titles/%s/akas'
|
||||
try:
|
||||
r = self._free_imdb_api(path=path % title_id)
|
||||
ret = ImdbapiListTitleAKAsResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving alternative titles: {e}")
|
||||
return None
|
||||
if r is None:
|
||||
return None
|
||||
return ret
|
||||
|
||||
async def async_akas(self, title_id: str) -> Optional[ImdbapiListTitleAKAsResponse]:
|
||||
path = '/titles/%s/akas'
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=path % title_id)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbapiListTitleAKAsResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving alternative titles: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
def images(self, title_id: str, types: list[str] | None = None, page_size: int | None = None,
|
||||
page_token: str | None = None) -> ImdbApiTitleImagesResponse | None:
|
||||
"""
|
||||
Retrieve the images associated with a specific title.
|
||||
|
||||
:param title_id: Required. IMDb title ID in the format "tt1234567".
|
||||
:param types: Optional. The types of images to filter by.
|
||||
- 'poster'
|
||||
- 'behind_the_scenes'
|
||||
- 'still_frame'
|
||||
:param page_size: Optional. The maximum number of images to return per page.
|
||||
The value must be between 1 and 50. The default is 20.
|
||||
:param page_token: Optional. Token for pagination, if applicable.
|
||||
"""
|
||||
path = '/titles/%s/images'
|
||||
param: Dict[str, Any] = {}
|
||||
if types:
|
||||
param['types'] = types
|
||||
if page_size is not None:
|
||||
param['pageSize'] = page_size
|
||||
if page_token is not None:
|
||||
param['pageToken'] = page_token
|
||||
try:
|
||||
r = self._free_imdb_api(path=path % title_id, params=param)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbApiTitleImagesResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving images: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
async def async_images(self, title_id: str, types: list[str] | None = None, page_size: int = 20,
|
||||
page_token: str | None = None) -> ImdbApiTitleImagesResponse | None:
|
||||
path = '/titles/%s/images'
|
||||
param: Dict[str, Any] = {}
|
||||
if types:
|
||||
param['types'] = types
|
||||
if page_size is not None:
|
||||
param['pageSize'] = page_size
|
||||
if page_token is not None:
|
||||
param['pageToken'] = page_token
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=path % title_id, params=param)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbApiTitleImagesResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving images: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
def images_generator(self, title_id: str, types: list[str] | None = None
|
||||
) -> Generator[ImdbapiImage, None, None]:
|
||||
page_token = None
|
||||
while True:
|
||||
response = self.images(
|
||||
title_id=title_id,
|
||||
types=types,
|
||||
page_size=50,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
for image in response.images:
|
||||
yield image
|
||||
|
||||
page_token = response.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
async def async_images_generator(self, title_id: str, types: list[str] | None = None
|
||||
) -> AsyncGenerator[ImdbapiImage, None]:
|
||||
page_token = None
|
||||
while True:
|
||||
response = await self.async_images(
|
||||
title_id=title_id,
|
||||
types=types,
|
||||
page_size=50,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
for image in response.images:
|
||||
yield image
|
||||
|
||||
page_token = response.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
File diff suppressed because one or more lines are too long
582
plugins.v2/imdbsource/officialapi.py
Normal file
582
plugins.v2/imdbsource/officialapi.py
Normal file
@@ -0,0 +1,582 @@
|
||||
import re
|
||||
from textwrap import dedent
|
||||
from typing import Any, Dict, List, Optional, Final, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.log import logger
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils, AsyncRequestUtils
|
||||
|
||||
from .schema.imdbtypes import ImdbType
|
||||
from .schema import VerticalList, AdvancedTitleSearchResponse, AdvancedTitleSearch, TitleEdge, SearchParams
|
||||
|
||||
INTERESTS_ID: Final[Dict[str, Dict[str, str]]] = {
|
||||
"Action": {
|
||||
"Action": "in0000001",
|
||||
"Action Epic": "in0000002",
|
||||
"B-Action": "in0000003",
|
||||
"Car Action": "in0000004",
|
||||
"Disaster": "in0000005",
|
||||
"Gun Fu": "in0000197",
|
||||
"Kung Fu": "in0000198",
|
||||
"Martial Arts": "in0000006",
|
||||
"One-Person Army Action": "in0000007",
|
||||
"Samurai": "in0000199",
|
||||
"Superhero": "in0000008",
|
||||
"Sword & Sandal": "in0000009",
|
||||
"War": "in0000010",
|
||||
"War Epic": "in0000011",
|
||||
"Wuxia": "in0000200"
|
||||
},
|
||||
"Adventure": {
|
||||
"Adventure": "in0000012",
|
||||
"Adventure Epic": "in0000015",
|
||||
"Desert Adventure": "in0000013",
|
||||
"Dinosaur Adventure": "in0000014",
|
||||
"Globetrotting Adventure": "in0000016",
|
||||
"Jungle Adventure": "in0000017",
|
||||
"Mountain Adventure": "in0000018",
|
||||
"Quest": "in0000019",
|
||||
"Road Trip": "in0000020",
|
||||
"Sea Adventure": "in0000021",
|
||||
"Swashbuckler": "in0000022",
|
||||
"Teen Adventure": "in0000023",
|
||||
"Urban Adventure": "in0000024"
|
||||
},
|
||||
"Animation": {
|
||||
"Adult Animation": "in0000025",
|
||||
"Animation": "in0000026",
|
||||
"Computer Animation": "in0000028",
|
||||
"Hand-Drawn Animation": "in0000029",
|
||||
"Stop Motion Animation": "in0000030"
|
||||
},
|
||||
"Anime": {
|
||||
"Anime": "in0000027",
|
||||
"Isekai": "in0000201",
|
||||
"Iyashikei": "in0000202",
|
||||
"Josei": "in0000203",
|
||||
"Mecha": "in0000204",
|
||||
"Seinen": "in0000205",
|
||||
"Shōjo": "in0000207",
|
||||
"Shōnen": "in0000206",
|
||||
"Slice of Life": "in0000208"
|
||||
},
|
||||
"Comedy": {
|
||||
"Body Swap Comedy": "in0000031",
|
||||
"Buddy Comedy": "in0000032",
|
||||
"Buddy Cop": "in0000033",
|
||||
"Comedy": "in0000034",
|
||||
"Dark Comedy": "in0000035",
|
||||
"Farce": "in0000036",
|
||||
"High-Concept Comedy": "in0000037",
|
||||
"Mockumentary": "in0000038",
|
||||
"Parody": "in0000039",
|
||||
"Quirky Comedy": "in0000040",
|
||||
"Raunchy Comedy": "in0000041",
|
||||
"Satire": "in0000042",
|
||||
"Screwball Comedy": "in0000043",
|
||||
"Sitcom": "in0000044",
|
||||
"Sketch Comedy": "in0000045",
|
||||
"Slapstick": "in0000046",
|
||||
"Stand-Up": "in0000047",
|
||||
"Stoner Comedy": "in0000048",
|
||||
"Teen Comedy": "in0000049"
|
||||
},
|
||||
"Crime": {
|
||||
"Caper": "in0000050",
|
||||
"Cop Drama": "in0000051",
|
||||
"Crime": "in0000052",
|
||||
"Drug Crime": "in0000053",
|
||||
"Film Noir": "in0000054",
|
||||
"Gangster": "in0000055",
|
||||
"Heist": "in0000056",
|
||||
"Police Procedural": "in0000057",
|
||||
"True Crime": "in0000058"
|
||||
},
|
||||
"Documentary": {
|
||||
"Crime Documentary": "in0000059",
|
||||
"Documentary": "in0000060",
|
||||
"Docuseries": "in0000061",
|
||||
"Faith & Spirituality Documentary": "in0000062",
|
||||
"Food Documentary": "in0000063",
|
||||
"History Documentary": "in0000064",
|
||||
"Military Documentary": "in0000065",
|
||||
"Music Documentary": "in0000066",
|
||||
"Nature Documentary": "in0000067",
|
||||
"Political Documentary": "in0000068",
|
||||
"Science & Technology Documentary": "in0000069",
|
||||
"Sports Documentary": "in0000070",
|
||||
"Travel Documentary": "in0000071"
|
||||
},
|
||||
"Drama": {
|
||||
"Biography": "in0000072",
|
||||
"Coming-of-Age": "in0000073",
|
||||
"Costume Drama": "in0000074",
|
||||
"Docudrama": "in0000075",
|
||||
"Drama": "in0000076",
|
||||
"Epic": "in0000077",
|
||||
"Financial Drama": "in0000078",
|
||||
"Historical Epic": "in0000079",
|
||||
"History": "in0000080",
|
||||
"Korean Drama": "in0000209",
|
||||
"Legal Drama": "in0000081",
|
||||
"Medical Drama": "in0000082",
|
||||
"Period Drama": "in0000083",
|
||||
"Political Drama": "in0000084",
|
||||
"Prison Drama": "in0000085",
|
||||
"Psychological Drama": "in0000086",
|
||||
"Showbiz Drama": "in0000087",
|
||||
"Soap Opera": "in0000088",
|
||||
"Teen Drama": "in0000089",
|
||||
"Telenovela": "in0000210",
|
||||
"Tragedy": "in0000090",
|
||||
"Workplace Drama": "in0000091"
|
||||
},
|
||||
"Family": {
|
||||
"Animal Adventure": "in0000092",
|
||||
"Family": "in0000093"
|
||||
},
|
||||
"Fantasy": {
|
||||
"Dark Fantasy": "in0000095",
|
||||
"Fairy Tale": "in0000097",
|
||||
"Fantasy": "in0000098",
|
||||
"Fantasy Epic": "in0000096",
|
||||
"Supernatural Fantasy": "in0000099",
|
||||
"Sword & Sorcery": "in0000100",
|
||||
"Teen Fantasy": "in0000101"
|
||||
},
|
||||
"Game Show": {
|
||||
"Beauty Competition": "in0000102",
|
||||
"Cooking Competition": "in0000103",
|
||||
"Game Show": "in0000105",
|
||||
"Quiz Show": "in0000104",
|
||||
"Survival Competition": "in0000106",
|
||||
"Talent Competition": "in0000107"
|
||||
},
|
||||
"Horror": {
|
||||
"B-Horror": "in0000108",
|
||||
"Body Horror": "in0000109",
|
||||
"Folk Horror": "in0000110",
|
||||
"Found Footage Horror": "in0000111",
|
||||
"Horror": "in0000112",
|
||||
"Monster Horror": "in0000113",
|
||||
"Psychological Horror": "in0000114",
|
||||
"Slasher Horror": "in0000115",
|
||||
"Splatter Horror": "in0000116",
|
||||
"Supernatural Horror": "in0000117",
|
||||
"Teen Horror": "in0000118",
|
||||
"Vampire Horror": "in0000119",
|
||||
"Werewolf Horror": "in0000120",
|
||||
"Witch Horror": "in0000121",
|
||||
"Zombie Horror": "in0000122"
|
||||
},
|
||||
"Lifestyle": {
|
||||
"Beauty Makeover": "in0000123",
|
||||
"Cooking & Food": "in0000124",
|
||||
"Home Improvement": "in0000125",
|
||||
"Lifestyle": "in0000126",
|
||||
"News": "in0000211",
|
||||
"Talk Show": "in0000127",
|
||||
"Travel": "in0000128"
|
||||
},
|
||||
"Music": {
|
||||
"Concert": "in0000129",
|
||||
"Music": "in0000130"
|
||||
},
|
||||
"Musical": {
|
||||
"Classic Musical": "in0000131",
|
||||
"Jukebox Musical": "in0000132",
|
||||
"Musical": "in0000133",
|
||||
"Pop Musical": "in0000134",
|
||||
"Rock Musical": "in0000135"
|
||||
},
|
||||
"Mystery": {
|
||||
"Bumbling Detective": "in0000136",
|
||||
"Cozy Mystery": "in0000137",
|
||||
"Hard-boiled Detective": "in0000138",
|
||||
"Mystery": "in0000139",
|
||||
"Suspense Mystery": "in0000140",
|
||||
"Whodunnit": "in0000141"
|
||||
},
|
||||
"Reality TV": {
|
||||
"Business Reality TV": "in0000142",
|
||||
"Crime Reality TV": "in0000143",
|
||||
"Dating Reality TV": "in0000144",
|
||||
"Docusoap Reality TV": "in0000145",
|
||||
"Hidden Camera": "in0000146",
|
||||
"Paranormal Reality TV": "in0000147",
|
||||
"Reality TV": "in0000148"
|
||||
},
|
||||
"Romance": {
|
||||
"Dark Romance": "in0000149",
|
||||
"Feel-Good Romance": "in0000151",
|
||||
"Romance": "in0000152",
|
||||
"Romantic Comedy": "in0000153",
|
||||
"Romantic Epic": "in0000150",
|
||||
"Steamy Romance": "in0000154",
|
||||
"Teen Romance": "in0000155",
|
||||
"Tragic Romance": "in0000156"
|
||||
},
|
||||
"Sci-Fi": {
|
||||
"Alien Invasion": "in0000157",
|
||||
"Artificial Intelligence": "in0000158",
|
||||
"Cyberpunk": "in0000159",
|
||||
"Dystopian Sci-Fi": "in0000160",
|
||||
"Kaiju": "in0000161",
|
||||
"Sci-Fi": "in0000162",
|
||||
"Sci-Fi Epic": "in0000163",
|
||||
"Space Sci-Fi": "in0000164",
|
||||
"Steampunk": "in0000165",
|
||||
"Time Travel": "in0000166"
|
||||
},
|
||||
"Seasonal": {
|
||||
"Holiday": "in0000192",
|
||||
"Holiday Animation": "in0000193",
|
||||
"Holiday Comedy": "in0000194",
|
||||
"Holiday Family": "in0000195",
|
||||
"Holiday Romance": "in0000196"
|
||||
},
|
||||
"Short": {
|
||||
"Short": "in0000212"
|
||||
},
|
||||
"Sport": {
|
||||
"Baseball": "in0000167",
|
||||
"Basketball": "in0000168",
|
||||
"Boxing": "in0000169",
|
||||
"Extreme Sport": "in0000170",
|
||||
"Football": "in0000171",
|
||||
"Motorsport": "in0000172",
|
||||
"Soccer": "in0000173",
|
||||
"Sport": "in0000174",
|
||||
"Water Sport": "in0000175"
|
||||
},
|
||||
"Thriller": {
|
||||
"Conspiracy Thriller": "in0000176",
|
||||
"Cyber Thriller": "in0000177",
|
||||
"Erotic Thriller": "in0000178",
|
||||
"Giallo": "in0000179",
|
||||
"Legal Thriller": "in0000180",
|
||||
"Political Thriller": "in0000181",
|
||||
"Psychological Thriller": "in0000182",
|
||||
"Serial Killer": "in0000183",
|
||||
"Spy": "in0000184",
|
||||
"Survival": "in0000185",
|
||||
"Thriller": "in0000186"
|
||||
},
|
||||
"Western": {
|
||||
"Classical Western": "in0000187",
|
||||
"Contemporary Western": "in0000188",
|
||||
"Spaghetti Western": "in0000190",
|
||||
"Western": "in0000191",
|
||||
"Western Epic": "in0000189"
|
||||
}
|
||||
}
|
||||
CACHE_LIFETIME: Final[int] = 86400
|
||||
IMDB_GRAPHQL_QUERY: Final[str] = dedent("""
|
||||
query VerticalListPageItems( $titles: [ID!]! $names: [ID!]! $images: [ID!]! $videos: [ID!]!) {
|
||||
titles(ids: $titles) { ...TitleParts meterRanking { currentRank meterType rankChange {changeDirection difference} } ratingsSummary { aggregateRating } }
|
||||
names(ids: $names) { ...NameParts }
|
||||
videos(ids: $videos) { ...VideoParts }
|
||||
images(ids: $images) { ...ImageParts }
|
||||
}
|
||||
fragment TitleParts on Title {
|
||||
id
|
||||
titleText { text }
|
||||
titleType { id }
|
||||
releaseYear { year }
|
||||
akas(first: 50) { edges { node { text country { id text } language { text } } } }
|
||||
plot { plotText {plainText}}
|
||||
primaryImage { id url width height }
|
||||
releaseDate {day month year}
|
||||
titleGenres {genres {genre { text }}}
|
||||
certificate { rating }
|
||||
originalTitleText{ text }
|
||||
runtime { seconds }
|
||||
}
|
||||
fragment NameParts on Name {
|
||||
id
|
||||
nameText { text }
|
||||
primaryImage { id url width height }
|
||||
}
|
||||
fragment ImageParts on Image {
|
||||
id
|
||||
height
|
||||
width
|
||||
url
|
||||
}
|
||||
fragment VideoParts on Video {
|
||||
id
|
||||
name { value }
|
||||
contentType { displayName { value } id }
|
||||
previewURLs { displayName { value } url videoDefinition videoMimeType }
|
||||
playbackURLs { displayName { value } url videoDefinition videoMimeType }
|
||||
thumbnail { height url width }
|
||||
}
|
||||
""")
|
||||
|
||||
|
||||
class PersistedQueryNotFound(Exception):
|
||||
def __init__(self, message: str, code: int = None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
class OfficialApiClient:
|
||||
BASE_URL = "https://caching.graphql.imdb.com/"
|
||||
|
||||
def __init__(self, proxies: Optional[Dict[str, str]] = None,
|
||||
ua: Optional[str] = None):
|
||||
self._req = RequestUtils(accept_type="application/json",
|
||||
content_type="application/json",
|
||||
timeout=10,
|
||||
ua=ua,
|
||||
proxies=proxies,
|
||||
session=requests.Session())
|
||||
if proxies:
|
||||
proxy_url = proxies.get("https") or proxies.get("http")
|
||||
else:
|
||||
proxy_url = None
|
||||
self._client = httpx.AsyncClient(timeout=10, proxy=proxy_url)
|
||||
self._async_req = AsyncRequestUtils(accept_type="application/json", content_type="application/json",
|
||||
client=self._client, ua=ua)
|
||||
self.flat_interest_id = {}
|
||||
for category, value in INTERESTS_ID.items():
|
||||
for name, in_id in value.items():
|
||||
self.flat_interest_id[name] = in_id
|
||||
|
||||
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
|
||||
async def _async_request(self, params: Dict[str, Any], sha256: str) -> Optional[Dict]:
|
||||
params["extensions"] = {"persistedQuery": {"sha256Hash": sha256, "version": 1}}
|
||||
data = await self._async_req.post_json(f"{self.BASE_URL}", json=params, raise_exception=True)
|
||||
if not data:
|
||||
return None
|
||||
if "errors" in data:
|
||||
error = data.get("errors")[0] if data.get("errors") else {}
|
||||
return {'error': error}
|
||||
return data.get("data")
|
||||
|
||||
@retry(Exception, logger=logger, delay=1)
|
||||
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
|
||||
def _query_graphql(self, query: str, variables: Dict[str, Any]) -> Optional[dict]:
|
||||
params = {'query': query, 'variables': variables}
|
||||
data = self._req.post_json(f"{self.BASE_URL}", json=params, raise_exception=True)
|
||||
if not data:
|
||||
return {'error': 'Query failed.'}
|
||||
if "errors" in data:
|
||||
error = data.get("errors")[0] if data.get("errors") else {}
|
||||
return {'error': error}
|
||||
return data.get("data")
|
||||
|
||||
@retry(Exception, logger=logger, delay=1)
|
||||
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
|
||||
async def _async_query_graphql(self, query: str, variables: Dict[str, Any]) -> Optional[Dict]:
|
||||
params = {'query': query, 'variables': variables}
|
||||
data = await self._async_req.post_json(f"{self.BASE_URL}", json=params, raise_exception=True)
|
||||
if not data:
|
||||
return None
|
||||
if "errors" in data:
|
||||
error = data.get("errors")[0] if data.get("errors") else {}
|
||||
return {'error': error}
|
||||
return data.get("data")
|
||||
|
||||
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
|
||||
def vertical_list_page_items(self,
|
||||
titles: Optional[List[str]] = None,
|
||||
names: Optional[List[str]] = None,
|
||||
images: Optional[List[str]] = None,
|
||||
videos: Optional[List[str]] = None,
|
||||
is_registered: bool = False
|
||||
) -> Optional[VerticalList]:
|
||||
variables = {'images': images or [],
|
||||
'titles': titles or [],
|
||||
'names': names or [],
|
||||
'videos': videos or [],
|
||||
'isRegistered': is_registered,
|
||||
}
|
||||
try:
|
||||
data = self._query_graphql(IMDB_GRAPHQL_QUERY, variables)
|
||||
if 'error' in data:
|
||||
error = data['error']
|
||||
if error:
|
||||
logger.error(f"Error querying VerticalListPageItems: {error}")
|
||||
return None
|
||||
ret = VerticalList.model_validate(data)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while querying VerticalListPageItems: {e}")
|
||||
return None
|
||||
|
||||
return ret
|
||||
|
||||
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
|
||||
async def async_vertical_list_page_items(self,
|
||||
titles: Optional[List[str]] = None,
|
||||
names: Optional[List[str]] = None,
|
||||
images: Optional[List[str]] = None,
|
||||
videos: Optional[List[str]] = None,
|
||||
is_registered: bool = False
|
||||
) -> Optional[VerticalList]:
|
||||
variables = {'images': images or [],
|
||||
'titles': titles or [],
|
||||
'names': names or [],
|
||||
'videos': videos or [],
|
||||
'isRegistered': is_registered,
|
||||
}
|
||||
try:
|
||||
data = await self._async_query_graphql(IMDB_GRAPHQL_QUERY, variables)
|
||||
if 'error' in data:
|
||||
error = data['error']
|
||||
if error:
|
||||
logger.error(f"Error querying VerticalListPageItems: {error}")
|
||||
return None
|
||||
ret = VerticalList.model_validate(data)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while querying VerticalListPageItems: {e}")
|
||||
return None
|
||||
|
||||
return ret
|
||||
|
||||
@retry(Exception, logger=logger, delay=1)
|
||||
async def async_advanced_title_search(self,
|
||||
params: SearchParams,
|
||||
sha256: str,
|
||||
last_cursor: Optional[str] = None,
|
||||
) -> Optional[AdvancedTitleSearch]:
|
||||
|
||||
variables: Dict[str, Any] = {"first": 50,
|
||||
"locale": "en-US",
|
||||
"sortBy": params.sort_by,
|
||||
"sortOrder": params.sort_order,
|
||||
}
|
||||
operation_name = 'AdvancedTitleSearch'
|
||||
if params.title_types:
|
||||
title_type_ids = []
|
||||
for title_type in params.title_types:
|
||||
if title_type in ImdbType._value2member_map_:
|
||||
title_type_ids.append(title_type)
|
||||
if len(title_type_ids):
|
||||
variables["titleTypeConstraint"] = {"anyTitleTypeIds": title_type_ids}
|
||||
if params.genres:
|
||||
variables["genreConstraint"] = {"allGenreIds": params.genres, "excludeGenreIds": []}
|
||||
if params.countries:
|
||||
variables["originCountryConstraint"] = {"allCountries": params.countries}
|
||||
if params.languages:
|
||||
variables["languageConstraint"] = {"anyPrimaryLanguages": params.languages}
|
||||
if params.rating_min or params.rating_max:
|
||||
rating_min = params.rating_min if params.rating_min else 1
|
||||
rating_min = max(rating_min, 1)
|
||||
rating_max = params.rating_max if params.rating_max else 10
|
||||
rating_max = min(rating_max, 10)
|
||||
variables["userRatingsConstraint"] = {"aggregateRatingRange": {"max": rating_max, "min": rating_min}}
|
||||
if params.release_date_start or params.release_date_end:
|
||||
release_dict = {}
|
||||
if params.release_date_start:
|
||||
release_dict["start"] = params.release_date_start
|
||||
if params.release_date_end:
|
||||
release_dict["end"] = params.release_date_end
|
||||
variables["releaseDateConstraint"] = {"releaseDateRange": release_dict}
|
||||
if params.award_constraint:
|
||||
constraints = []
|
||||
for award in params.award_constraint:
|
||||
c = self._award_to_constraint(award)
|
||||
if c:
|
||||
constraints.append(c)
|
||||
variables["awardConstraint"] = {"allEventNominations": constraints}
|
||||
if params.ranked:
|
||||
constraints = []
|
||||
for r in params.ranked:
|
||||
c = OfficialApiClient._ranked_list_to_constraint(r)
|
||||
if c:
|
||||
constraints.append(c)
|
||||
variables["rankedTitleListConstraint"] = {"allRankedTitleLists": constraints,
|
||||
"excludeRankedTitleLists": []}
|
||||
if params.interests:
|
||||
constraints = []
|
||||
for interest in params.interests:
|
||||
in_id = self.flat_interest_id.get(interest)
|
||||
if in_id:
|
||||
constraints.append(in_id)
|
||||
variables["interestConstraint"] = {"allInterestIds": constraints, "excludeInterestIds": []}
|
||||
if last_cursor:
|
||||
variables["after"] = last_cursor
|
||||
|
||||
params = {"operationName": operation_name,
|
||||
"variables": variables}
|
||||
data = await self._async_request(params, sha256)
|
||||
if not data:
|
||||
return None
|
||||
if 'error' in data:
|
||||
error = data['error']
|
||||
if error:
|
||||
if error.get('message') == 'PersistedQueryNotFound':
|
||||
await self._async_request.cache_clear()
|
||||
raise PersistedQueryNotFound(error['message'])
|
||||
return None
|
||||
try:
|
||||
ret = AdvancedTitleSearchResponse.model_validate(data)
|
||||
except ValidationError as err:
|
||||
logger.error(f"{err}")
|
||||
return None
|
||||
return ret.advanced_title_search
|
||||
|
||||
async def advanced_title_search_generator(self, params: SearchParams, sha256: str) -> AsyncGenerator[
|
||||
TitleEdge, None]:
|
||||
last_cursor = None
|
||||
while True:
|
||||
response = await self.async_advanced_title_search(params, sha256, last_cursor=last_cursor)
|
||||
if not response:
|
||||
return
|
||||
|
||||
for edge in response.edges:
|
||||
yield edge
|
||||
|
||||
last_cursor = response.page_info.end_cursor
|
||||
if not last_cursor or not response.page_info.has_next_page:
|
||||
break
|
||||
|
||||
@staticmethod
|
||||
def _ranked_list_to_constraint(ranked: str) -> Optional[Dict]:
|
||||
"""
|
||||
"TOP_RATED_MOVIES-100": "IMDb Top 100",
|
||||
"TOP_RATED_MOVIES-250": "IMDb Top 250",
|
||||
"TOP_RATED_MOVIES-1000": "IMDb Top 1000",
|
||||
"LOWEST_RATED_MOVIES-100": "IMDb Bottom 100",
|
||||
"LOWEST_RATED_MOVIES-250": "IMDb Bottom 250",
|
||||
"LOWEST_RATED_MOVIES-1000": "IMDb Bottom 1000"
|
||||
"""
|
||||
pattern = r'^(TOP_RATED_MOVIES|LOWEST_RATED_MOVIES)-(\d+)$'
|
||||
match = re.match(pattern, ranked)
|
||||
if match:
|
||||
ranked_title_list_type = match.group(1)
|
||||
rank_range = int(match.group(2))
|
||||
constraint = {"rankRange": {"max": rank_range}, "rankedTitleListType": ranked_title_list_type}
|
||||
return constraint
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _award_to_constraint(award: str) -> Optional[Dict]:
|
||||
pattern = r'^(ev\d+)(?:-(best\w+))?-(Winning|Nominated)$'
|
||||
match = re.match(pattern, award)
|
||||
constraint = {}
|
||||
if match:
|
||||
# 第一部分:evXXXXXXXX
|
||||
ev_id = match.group(1)
|
||||
# 第二部分:bestXX(可选)
|
||||
best = match.group(2)
|
||||
# 第三部分:Winning/Nominated
|
||||
status = match.group(3)
|
||||
constraint["eventId"] = ev_id
|
||||
if status == "Winning":
|
||||
constraint["winnerFilter"] = "WINNER_ONLY"
|
||||
if best:
|
||||
constraint["searchAwardCategoryId"] = best
|
||||
return constraint
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def interests_id(self) -> Dict[str, str]:
|
||||
return self.flat_interest_id
|
||||
138
plugins.v2/imdbsource/schema/__init__.py
Normal file
138
plugins.v2/imdbsource/schema/__init__.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from enum import Enum
|
||||
from typing import Optional, List, Tuple, Union
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
from .imdbapi import ImdbApiTitle, ImdbApiEpisode, ImdbApiCredit, ImdbapiImage
|
||||
from .imdbtypes import ImdbTitle, ImdbName, ImdbImage, ImdbVideo, AkasNode, TitleEdge
|
||||
|
||||
|
||||
class ErrorType(Enum):
|
||||
PERSISTED_QUERY_NOT_FOUND = 'PERSISTED_QUERY_NOT_FOUND'
|
||||
|
||||
|
||||
class StaffPickEntry(BaseModel):
|
||||
name: str
|
||||
ttconst: str
|
||||
rmconst: str
|
||||
detail: Optional[str] = ""
|
||||
description: Optional[str] = ""
|
||||
relatedconst: List[str] = Field(default_factory=list)
|
||||
viconst: Optional[str] = None
|
||||
|
||||
|
||||
class VerticalList(BaseModel):
|
||||
titles: List[ImdbTitle] = Field(default_factory=list)
|
||||
names: List[ImdbName] = Field(default_factory=list)
|
||||
videos: List[ImdbVideo] = Field(default_factory=list)
|
||||
images: List[ImdbImage] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StaffPickApiResponse(BaseModel):
|
||||
updated_at: Optional[str]
|
||||
entries: List[StaffPickEntry] = Field(default_factory=list)
|
||||
imdb_items: VerticalList
|
||||
|
||||
|
||||
class ImdbMediaInfo(ImdbApiTitle):
|
||||
akas: List[AkasNode] = Field(default_factory=list)
|
||||
episodes: List[ImdbApiEpisode] = Field(default_factory=list)
|
||||
credits: List[ImdbApiCredit] = Field(default_factory=list)
|
||||
images: List[ImdbapiImage] = Field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_title(
|
||||
cls,
|
||||
title: ImdbApiTitle,
|
||||
akas: Optional[List[AkasNode]] = None,
|
||||
episodes: Optional[List[ImdbApiEpisode]] = None,
|
||||
api_credits: Optional[List[ImdbApiCredit]] = None,
|
||||
images: Optional[List[ImdbapiImage]] = None
|
||||
) -> "ImdbMediaInfo":
|
||||
fields = {
|
||||
**title.model_dump(exclude_none=True, by_alias=True),
|
||||
}
|
||||
if akas is not None:
|
||||
fields['akas'] = akas
|
||||
if episodes is not None:
|
||||
fields['episodes'] = episodes
|
||||
if api_credits is not None:
|
||||
fields['credits'] = api_credits
|
||||
if images is not None:
|
||||
fields['images'] = images
|
||||
return cls(**fields)
|
||||
|
||||
def backdrop_path(self) -> str | None:
|
||||
if self.images:
|
||||
for image in self.images:
|
||||
if image.url and image.type == 'still_frame':
|
||||
# replace('@._V1', '@._V1_QL75_UX327_')
|
||||
return image.url
|
||||
return None
|
||||
|
||||
class ImdbApiHash(BaseModel):
|
||||
advanced_title_search: str = Field(alias="AdvancedTitleSearch")
|
||||
|
||||
|
||||
class PageInfo(BaseModel):
|
||||
has_previous_page: Optional[bool] = Field(None, alias="hasPreviousPage")
|
||||
has_next_page: Optional[bool] = Field(None, alias="hasNextPage")
|
||||
start_cursor: Optional[str] = Field(None, alias="startCursor")
|
||||
end_cursor: Optional[str] = Field(None, alias="endCursor")
|
||||
|
||||
|
||||
class FilterInfo(BaseModel):
|
||||
filter_id: Optional[str] = Field(default=None, alias='filterId')
|
||||
text: Optional[str] = Field(default=None, alias='text')
|
||||
total: Optional[int] = Field(default=None, alias='total')
|
||||
|
||||
|
||||
class SearchState(BaseModel):
|
||||
total: int = 0
|
||||
page_info: PageInfo = Field(default_factory=PageInfo, alias="pageInfo")
|
||||
genres: List[FilterInfo] = Field(default_factory=list)
|
||||
keywords: List[FilterInfo] = Field(default_factory=list)
|
||||
title_types: List[FilterInfo] = Field(default_factory=list, alias='titleTypes')
|
||||
|
||||
|
||||
class AdvancedTitleSearch(SearchState):
|
||||
edges: List[TitleEdge] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdvancedTitleSearchResponse(BaseModel):
|
||||
advanced_title_search: AdvancedTitleSearch = Field(default_factory=AdvancedTitleSearch, alias="advancedTitleSearch")
|
||||
|
||||
|
||||
class SearchParams(BaseModel):
|
||||
title_types: Optional[Tuple[str, ...]] = None
|
||||
genres: Optional[Tuple[str, ...]] = None
|
||||
sort_by: str = 'POPULARITY'
|
||||
sort_order: str = 'ASC'
|
||||
rating_min: Optional[float] = None
|
||||
rating_max: Optional[float] = None
|
||||
countries: Optional[Tuple[str, ...]] = None
|
||||
languages: Optional[Tuple[str, ...]] = None
|
||||
release_date_end: Optional[str] = None
|
||||
release_date_start: Optional[str] = None
|
||||
award_constraint: Optional[Tuple[str, ...]] = None
|
||||
ranked: Optional[Tuple[str, ...]] = None
|
||||
interests: Optional[Tuple[str, ...]] = None
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True
|
||||
)
|
||||
|
||||
|
||||
class ErrorExtension(BaseModel):
|
||||
code: Union[ErrorType, str]
|
||||
error_type: str = Field('CLIENT', alias='errorType')
|
||||
is_retryable: bool = Field(False, alias='isRetryable')
|
||||
|
||||
|
||||
class ErrorValue(BaseModel):
|
||||
message: Optional[str] = Field(default=None, alias='message')
|
||||
extensions: Optional[ErrorExtension]
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
errors: Optional[List[ErrorValue]] = Field(default_factory=list)
|
||||
156
plugins.v2/imdbsource/schema/imdbapi.py
Normal file
156
plugins.v2/imdbsource/schema/imdbapi.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .imdbtypes import ImdbType, RatingsSummary, AkasNode, ImdbDate
|
||||
|
||||
|
||||
class ImdbapiImage(BaseModel):
|
||||
url: Optional[str] = None
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
type: Optional[str] = None
|
||||
|
||||
|
||||
class ImdbApiMetacritic(BaseModel):
|
||||
url: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
review_count: Optional[int] = Field(None, alias='reviewCount')
|
||||
|
||||
|
||||
class ImdbApiMeterRanking(BaseModel):
|
||||
current_rank: Optional[int] = Field(None, alias='currentRank')
|
||||
change_direction: Optional[str] = Field(None, alias='changeDirection')
|
||||
difference: Optional[int] = None
|
||||
|
||||
|
||||
class ImdbApiPerson(BaseModel):
|
||||
id: Optional[str] = None
|
||||
display_name: Optional[str] = Field(None, alias='displayName')
|
||||
alternative_names: Optional[List[str]] = Field(None, alias='alternativeNames')
|
||||
primary_image: Optional[ImdbapiImage] = Field(None, alias='primaryImage')
|
||||
primary_professions: Optional[List[str]] = Field(None, alias='primaryProfessions')
|
||||
biography: Optional[str] = None
|
||||
height_cm: Optional[float] = Field(None, alias='heightCm')
|
||||
birth_name: Optional[str] = Field(None, alias='birthName')
|
||||
birth_date: Optional[ImdbDate] = Field(None, alias='birthDate')
|
||||
birth_location: Optional[str] = Field(None, alias='birthLocation')
|
||||
death_date: Optional[ImdbDate] = Field(None, alias='deathDate')
|
||||
death_location: Optional[str] = Field(None, alias='deathLocation')
|
||||
death_reason: Optional[str] = Field(None, alias='deathReason')
|
||||
meter_ranking: Optional[ImdbApiMeterRanking] = Field(None, alias='meterRanking')
|
||||
|
||||
|
||||
class ImdbApiCountry(BaseModel):
|
||||
# The ISO 3166-1 alpha-2 country code for the title, (e.g. "US" for the United States, "JP" for Japan)
|
||||
code: Optional[str] = None
|
||||
# The name of the country in English.
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class ImdbApiLanguage(BaseModel):
|
||||
# The ISO 639-3 language code for the title, (e.g. "eng" for English, "jpn" for Japanese)
|
||||
code: Optional[str] = None
|
||||
# The name of the language in English.
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class ImdbapiPrecisionDate(BaseModel):
|
||||
year: Optional[int] = None
|
||||
month: Optional[int] = None
|
||||
day: Optional[int] = None
|
||||
|
||||
|
||||
class ImdbApiInterest(BaseModel):
|
||||
id: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
primary_image: Optional[ImdbapiImage] = Field(None, alias='primaryImage')
|
||||
description: Optional[str] = None
|
||||
is_subgenre: Optional[bool] = Field(None, alias='isSubgenre')
|
||||
similar_interests: Optional[List['ImdbApiInterest']] = Field(None, alias='similarInterests')
|
||||
|
||||
|
||||
class ImdbApiTitle(BaseModel):
|
||||
id: str
|
||||
type: ImdbType
|
||||
is_adult: Optional[bool] = Field(None, alias='isAdult')
|
||||
primary_title: Optional[str] = Field(None, alias='primaryTitle')
|
||||
original_title: Optional[str] = Field(None, alias='originalTitle')
|
||||
primary_image: Optional[ImdbapiImage] = Field(None, alias='primaryImage')
|
||||
start_year: Optional[int] = Field(None, alias='startYear')
|
||||
end_year: Optional[int] = Field(None, alias='endYear')
|
||||
runtime_seconds: Optional[int] = Field(None, alias='runtimeSeconds')
|
||||
genres: Optional[List[str]] = None
|
||||
rating: Optional[RatingsSummary] = None
|
||||
metacritic: Optional[ImdbApiMetacritic] = None
|
||||
plot: Optional[str] = None
|
||||
directors: Optional[List[ImdbApiPerson]] = Field(default_factory=list)
|
||||
writers: Optional[List[ImdbApiPerson]] = Field(default_factory=list)
|
||||
stars: Optional[List[ImdbApiPerson]] = Field(default_factory=list)
|
||||
origin_countries: Optional[List[ImdbApiCountry]] = Field(default_factory=list, alias='originCountries')
|
||||
spoken_languages: Optional[List[ImdbApiLanguage]] = Field(default_factory=list, alias='spokenLanguages')
|
||||
interests: Optional[List[ImdbApiInterest]] = None
|
||||
|
||||
|
||||
class ImdbApiSearchTitlesResponse(BaseModel):
|
||||
titles: List[ImdbApiTitle]
|
||||
|
||||
|
||||
class ImdbApiListTitlesResponse(BaseModel):
|
||||
titles: List[ImdbApiTitle] = Field(default_factory=list)
|
||||
total_count: int = Field(alias='totalCount')
|
||||
next_page_token: Optional[str] = Field(None, alias='nextPageToken')
|
||||
|
||||
|
||||
class ImdbApiEpisode(BaseModel):
|
||||
id: str
|
||||
title: Optional[str] = None
|
||||
primary_image: Optional[ImdbapiImage] = Field(None, alias='primaryImage')
|
||||
season: Optional[str] = Field(None, alias='season')
|
||||
episode_number: Optional[int] = Field(None, alias='episodeNumber')
|
||||
runtime_seconds: Optional[int] = Field(None, alias='runtimeSeconds')
|
||||
plot: Optional[str] = Field(None, alias='plot')
|
||||
rating: Optional[RatingsSummary] = Field(None, alias='rating')
|
||||
release_date: Optional[ImdbapiPrecisionDate] = Field(None, alias='releaseDate')
|
||||
|
||||
|
||||
class PagedResponse(BaseModel):
|
||||
total_count: int = Field(alias='totalCount')
|
||||
next_page_token: Optional[str] = Field(None, alias='nextPageToken')
|
||||
|
||||
|
||||
class ImdbApiListTitleEpisodesResponse(PagedResponse):
|
||||
episodes: List[ImdbApiEpisode] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ImdbApiSeason(BaseModel):
|
||||
season: Optional[str] = None
|
||||
episode_count: Optional[int] = Field(None, alias='episodeCount')
|
||||
|
||||
|
||||
class ImdbApiListTitleSeasonsResponse(BaseModel):
|
||||
seasons: List[ImdbApiSeason] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ImdbApiCredit(BaseModel):
|
||||
title: Optional[ImdbApiTitle] = None
|
||||
name: Optional[ImdbApiPerson] = None
|
||||
category: Optional[str] = None
|
||||
characters: Optional[List[str]] = None
|
||||
episode_count: Optional[int] = Field(None, alias='episodeCount')
|
||||
|
||||
|
||||
class ImdbApiListTitleCreditsResponse(PagedResponse):
|
||||
credits: List[ImdbApiCredit] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ImdbapiAka(AkasNode):
|
||||
attributes: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ImdbapiListTitleAKAsResponse(BaseModel):
|
||||
akas: List[ImdbapiAka]
|
||||
|
||||
|
||||
class ImdbApiTitleImagesResponse(PagedResponse):
|
||||
images: List[ImdbapiImage] = Field(default_factory=list)
|
||||
180
plugins.v2/imdbsource/schema/imdbtypes.py
Normal file
180
plugins.v2/imdbsource/schema/imdbtypes.py
Normal file
@@ -0,0 +1,180 @@
|
||||
from enum import Enum
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ImdbType(Enum):
|
||||
TV_SERIES = "tvSeries"
|
||||
TV_MINI_SERIES = "tvMiniSeries"
|
||||
MOVIE = "movie"
|
||||
TV_MOVIE = "tvMovie"
|
||||
MUSIC_VIDEO = "musicVideo"
|
||||
TV_SHORT = "tvShort"
|
||||
SHORT = "short"
|
||||
TV_EPISODE = "tvEpisode"
|
||||
TV_SPECIAL = "tvSpecial"
|
||||
VIDEO_GAME = "videoGame"
|
||||
VIDEO = "video"
|
||||
PODCAST_SERIES = "podcastSeries"
|
||||
PODCAST_EPISODE = "podcastEpisode"
|
||||
|
||||
|
||||
class TitleType(BaseModel):
|
||||
id: ImdbType
|
||||
|
||||
|
||||
class ReleaseYear(BaseModel):
|
||||
year: Optional[int] = None
|
||||
|
||||
|
||||
class Country(BaseModel):
|
||||
id: str
|
||||
text: str
|
||||
|
||||
|
||||
class TextField(BaseModel):
|
||||
text: Optional[str] = ''
|
||||
|
||||
|
||||
class ValueField(BaseModel):
|
||||
value: Optional[str] = None
|
||||
|
||||
|
||||
class SecondsField(BaseModel):
|
||||
seconds: Optional[int] = None
|
||||
|
||||
|
||||
class AkasNode(BaseModel):
|
||||
text: Optional[str] = ''
|
||||
country: Optional[Country] = None
|
||||
language: Optional[TextField] = None
|
||||
|
||||
|
||||
class AkasEdge(BaseModel):
|
||||
node: AkasNode
|
||||
|
||||
|
||||
class Akas(BaseModel):
|
||||
edges: List[AkasEdge] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PlotText(BaseModel):
|
||||
plain_text: Optional[str] = Field(default='', alias='plainText')
|
||||
|
||||
|
||||
class Plot(BaseModel):
|
||||
plot_text: Optional[PlotText] = Field(None, alias='plotText')
|
||||
|
||||
|
||||
class ImdbImage(BaseModel):
|
||||
id: str
|
||||
url: Optional[str] = None
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
|
||||
def poster_path(self):
|
||||
if self.url:
|
||||
return self.url.replace('@._V1', '@._V1_QL75_UY414_CR6,0,280,414_')
|
||||
return None
|
||||
|
||||
|
||||
class RankChange(BaseModel):
|
||||
change_direction: Optional[str] = Field(default=None, alias='changeDirection')
|
||||
difference: Optional[int] = None
|
||||
|
||||
|
||||
class MeterRanking(BaseModel):
|
||||
current_rank: Optional[int] = Field(default=None, alias='currentRank')
|
||||
meter_type: Optional[str] = Field(default=None, alias='meterType')
|
||||
rank_change: Optional[RankChange] = Field(default=None, alias='rankChange')
|
||||
|
||||
|
||||
class RatingsSummary(BaseModel):
|
||||
aggregate_rating: Optional[float] = Field(default=None, alias='aggregateRating')
|
||||
vote_count: Optional[int] = Field(None, alias='voteCount')
|
||||
|
||||
|
||||
class ImdbName(BaseModel):
|
||||
id: str
|
||||
name_text: TextField = Field(alias='nameText')
|
||||
primary_image: Optional[ImdbImage] = Field(default=None, alias='primaryImage')
|
||||
|
||||
|
||||
class ContentType(BaseModel):
|
||||
display_name: ValueField = Field(alias='displayName')
|
||||
id: str
|
||||
|
||||
|
||||
class VideoUrl(BaseModel):
|
||||
display_name: ValueField = Field(alias='displayName')
|
||||
url: str
|
||||
video_definition: str = Field(alias='videoDefinition')
|
||||
video_mime_type: str = Field(alias='videoMimeType')
|
||||
|
||||
|
||||
class ImdbDate(BaseModel):
|
||||
year: Optional[int] = None
|
||||
month: Optional[int] = None
|
||||
day: Optional[int] = None
|
||||
|
||||
|
||||
class Genre(BaseModel):
|
||||
genre: Optional[TextField] = None
|
||||
|
||||
|
||||
class TitleGenre(BaseModel):
|
||||
genres: List[Genre] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Certificate(BaseModel):
|
||||
rating: Optional[str] = None
|
||||
|
||||
|
||||
class ImdbTitle(BaseModel):
|
||||
id: str
|
||||
title_text: TextField = Field(alias='titleText')
|
||||
title_type: TitleType = Field(alias='titleType')
|
||||
release_year: Optional[ReleaseYear] = Field(None, alias='releaseYear')
|
||||
akas: Optional[Akas] = None
|
||||
plot: Optional[Plot] = None
|
||||
primary_image: Optional[ImdbImage] = Field(default=None, alias='primaryImage')
|
||||
meter_ranking: Optional[MeterRanking] = Field(default=None, alias='meterRanking')
|
||||
ratings_summary: Optional[RatingsSummary] = Field(default=None, alias='ratingsSummary')
|
||||
release_date: Optional[ImdbDate] = Field(None, alias='releaseDate')
|
||||
title_genres: Optional[TitleGenre] = Field(default=None, alias='titleGenres')
|
||||
certificate: Optional[Certificate] = None
|
||||
original_title_text: Optional[TextField] = Field(default=None, alias='originalTitleText')
|
||||
runtime: Optional[SecondsField] = Field(default=None, alias='runtime')
|
||||
|
||||
@property
|
||||
def plot_text(self) -> str:
|
||||
return self.plot.plot_text.plain_text if self.plot and self.plot.plot_text else ''
|
||||
|
||||
@property
|
||||
def rating_text(self) -> str:
|
||||
if self.ratings_summary and self.ratings_summary.aggregate_rating:
|
||||
return f"{self.ratings_summary.aggregate_rating:.1f}"
|
||||
return "-"
|
||||
|
||||
class Thumbnail(BaseModel):
|
||||
url: str
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
|
||||
|
||||
class ImdbVideo(BaseModel):
|
||||
id: str
|
||||
name: ValueField
|
||||
content_type: ContentType = Field(alias='contentType')
|
||||
preview_urls: List[VideoUrl] = Field(default_factory=list, alias='previewURLs')
|
||||
playback_urls: List[VideoUrl] = Field(default_factory=list, alias='playbackURLs')
|
||||
thumbnails: Optional[Thumbnail] = None
|
||||
|
||||
|
||||
class TitleNode(BaseModel):
|
||||
title: ImdbTitle
|
||||
|
||||
|
||||
class TitleEdge(BaseModel):
|
||||
node: TitleNode
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Event
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
@@ -33,7 +34,7 @@ class IYUUAutoSeed(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "IYUU.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.14"
|
||||
plugin_version = "2.15"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp,CKun"
|
||||
# 作者主页
|
||||
@@ -1224,6 +1225,12 @@ class IYUUAutoSeed(_PluginBase):
|
||||
"""
|
||||
return True if "monikadesign." in url else False
|
||||
|
||||
def __is_gpw(url: str):
|
||||
"""
|
||||
判断是否为gpw站点
|
||||
"""
|
||||
return True if "greatposterwall." in url else False
|
||||
|
||||
def __get_mteam_enclosure(tid: str, apikey: str):
|
||||
"""
|
||||
获取mteam种子下载链接
|
||||
@@ -1264,6 +1271,69 @@ class IYUUAutoSeed(_PluginBase):
|
||||
rsskey = rss_match.group(1)
|
||||
return f"{site.get('url')}torrents/download/{tid}.{rsskey}"
|
||||
|
||||
def __get_gpw_torrent_url_from_page(seed: dict, site: dict):
|
||||
"""
|
||||
从详情页面获取下载链接
|
||||
"""
|
||||
if not site.get('url'):
|
||||
logger.warn(f"站点 {site.get('name')} 未获取站点地址,无法获取种子下载链接")
|
||||
return None
|
||||
|
||||
try:
|
||||
page_url = f"{site.get('url')}torrents.php?torrentid={seed.get('torrent_id')}&hit=1"
|
||||
logger.info(f"正在获取种子下载链接:{page_url} ...")
|
||||
|
||||
res = RequestUtils(
|
||||
cookies=site.get("cookie"),
|
||||
ua=site.get("ua") or settings.USER_AGENT,
|
||||
proxies=settings.PROXY if site.get("proxy") else None
|
||||
).get_res(url=page_url)
|
||||
|
||||
|
||||
if res is None or res.status_code not in (200, 500):
|
||||
logger.error(f"获取种子下载链接失败,请求失败:{page_url},{res.status_code if res else ''}")
|
||||
return None
|
||||
# Fix encoding
|
||||
if "charset=utf-8" in res.text or "charset=UTF-8" in res.text:
|
||||
res.encoding = "UTF-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
|
||||
if not res.text:
|
||||
logger.warn(f"获取种子下载链接失败,页面内容为空:{page_url}")
|
||||
return None
|
||||
# 使用xpath从页面中获取下载链接
|
||||
html = etree.HTML(res.text)
|
||||
if html is None:
|
||||
logger.warning(f"解析页面失败:{page_url}")
|
||||
return None
|
||||
|
||||
xpath = "//a[contains(@href, 'torrents.php?action=download')]/@href"
|
||||
urls = html.xpath(xpath)
|
||||
|
||||
if not urls:
|
||||
logger.warning(f"获取种子下载链接失败,未找到下载链接:{page_url}")
|
||||
return None
|
||||
|
||||
torrent_id = str(seed.get("torrent_id"))
|
||||
matched_url = None
|
||||
# Strict match using regex id=xxxx
|
||||
for u in urls:
|
||||
if re.search(rf"id={torrent_id}(?:&|$)", u):
|
||||
matched_url = u
|
||||
break
|
||||
if not matched_url:
|
||||
logger.warning(f"未找到与 torrent_id={torrent_id} 对应的下载链接")
|
||||
return None
|
||||
|
||||
final_url = urljoin(site['url'], matched_url)
|
||||
|
||||
logger.info(f"获取种子下载链接成功:{final_url}")
|
||||
return final_url
|
||||
except Exception as e:
|
||||
logger.warn(f"获取种子下载链接失败:{str(e)}")
|
||||
return None
|
||||
|
||||
def __is_special_site(url: str):
|
||||
"""
|
||||
判断是否为特殊站点
|
||||
@@ -1288,6 +1358,10 @@ class IYUUAutoSeed(_PluginBase):
|
||||
if __is_monika(site.get('url')):
|
||||
# 返回种子id和站点配置中所Monika的rss链接
|
||||
return __get_monika_torrent(tid=seed.get("torrent_id"), rssurl=site.get("rss"))
|
||||
if __is_gpw(site.get('url')):
|
||||
# 从详情页面获取下载链接
|
||||
return __get_gpw_torrent_url_from_page(seed=seed, site=site)
|
||||
|
||||
elif __is_special_site(site.get('url')):
|
||||
# 从详情页面获取下载链接
|
||||
return self.__get_torrent_url_from_page(seed=seed, site=site)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user