mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-06-13 07:26:50 +00:00
Compare commits
60 Commits
AutoSignIn
...
InvitesSig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e93bfc6667 | ||
|
|
b963398987 | ||
|
|
ed395a26a9 | ||
|
|
5a642e1e51 | ||
|
|
a8813b0272 | ||
|
|
66ce816a31 | ||
|
|
241e3200f8 | ||
|
|
19f52d6217 | ||
|
|
884efaebbf | ||
|
|
b51ba3d92a | ||
|
|
ec74481160 | ||
|
|
c60a4f01aa | ||
|
|
e34cafd641 | ||
|
|
5f8bb72641 | ||
|
|
df3e42987a | ||
|
|
8a738b7684 | ||
|
|
491f40663b | ||
|
|
fe8a7c6cd2 | ||
|
|
6245940466 | ||
|
|
c86cbc473f | ||
|
|
d93665a572 | ||
|
|
250ee4ada8 | ||
|
|
dfe2247b25 | ||
|
|
858261ddcc | ||
|
|
47bf56afe4 | ||
|
|
af3956d86f | ||
|
|
a69feb73ca | ||
|
|
88b29169fc | ||
|
|
2c9e108ac4 | ||
|
|
73b2d778a0 | ||
|
|
bf67d6e567 | ||
|
|
5e9da0802d | ||
|
|
2811021996 | ||
|
|
8c0a05b2de | ||
|
|
bb070bf83e | ||
|
|
21aec36ea5 | ||
|
|
6019cf92ac | ||
|
|
42d5dd1e89 | ||
|
|
0b3313e078 | ||
|
|
5684ba056a | ||
|
|
44af7dbb78 | ||
|
|
2102a03740 | ||
|
|
0a9cadf7ab | ||
|
|
279efe8000 | ||
|
|
fd92e58f81 | ||
|
|
fe93e46e02 | ||
|
|
cbf541992f | ||
|
|
8e1d336250 | ||
|
|
12e0e2b9f5 | ||
|
|
ac914f70f3 | ||
|
|
a07b8a4f4a | ||
|
|
6960b3f7aa | ||
|
|
fe83ff1be8 | ||
|
|
6357dc8e4a | ||
|
|
f1d94d0aa3 | ||
|
|
53dd3bc796 | ||
|
|
a9d528fc05 | ||
|
|
0388c437b1 | ||
|
|
ac4b53e745 | ||
|
|
53297fccaf |
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -90,15 +90,18 @@ jobs:
|
||||
rm -f "$asset"
|
||||
(cd "$(dirname "$plugin_dir")" && zip -r "$GITHUB_WORKSPACE/$asset" "$(basename "$plugin_dir")" -x "*/__pycache__/*" -x "*.pyc") >/dev/null
|
||||
|
||||
# If same tag exists, delete release and remote tag first
|
||||
# If same tag exists, delete release and both remote/local tag first
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
echo "Release $tag exists, deleting..."
|
||||
gh release delete "$tag" -y
|
||||
git push origin :refs/tags/"$tag" || true
|
||||
fi
|
||||
|
||||
# Ensure no stale local tag remains
|
||||
git tag -d "$tag" >/dev/null 2>&1 || true
|
||||
|
||||
echo "Creating release $tag"
|
||||
gh release create "$tag" "$asset" --title "$tag" --notes "Automated release of $plugin_id $plugin_version" --latest
|
||||
gh release create "$tag" "$asset" --title "$tag" --notes "Automated release of $plugin_id $plugin_version" --latest --target "$GITHUB_SHA"
|
||||
|
||||
echo "$tag" >> processed_tags.txt
|
||||
done
|
||||
|
||||
185
README.md
185
README.md
@@ -23,6 +23,7 @@ MoviePilot官方插件市场:https://github.com/jxxghp/MoviePilot-Plugins
|
||||
- [12. 如何通过插件扩展支持的存储类型?](#12-如何通过插件扩展支持的存储类型)
|
||||
- [13. 如何将插件功能集成到工作流?](#13-如何将插件功能集成到工作流)
|
||||
- [14. 如何在插件中通过消息持续与用户交互?](#14-如何在插件中通过消息持续与用户交互)
|
||||
- [15. 如何在插件中使用系统级统一缓存?](#15-如何在插件中使用系统级统一缓存)
|
||||
- [版本发布](#版本发布)
|
||||
- [1. 如何发布插件版本?](#1-如何发布插件版本)
|
||||
- [2. 如何开发V2版本的插件以及实现插件多版本兼容?](#2-如何开发v2版本的插件以及实现插件多版本兼容)
|
||||
@@ -1167,6 +1168,190 @@ def get_actions(self) -> List[Dict[str, Any]]:
|
||||
- 建议在交互中保存用户状态数据,以支持复杂的多步骤操作
|
||||
- 可以结合插件数据存储功能保存用户的交互历史和偏好设置
|
||||
|
||||
### 15. 如何在插件中使用系统级统一缓存?
|
||||
**(仅支持 `v2.7.4+` 版本)**
|
||||
- MoviePilot提供了统一的缓存系统,支持内存缓存、文件系统缓存和Redis缓存自动管理,当有Redis时优先使用Redis,否则使用内存或文件系统。插件可以通过系统提供的缓存接口实现高效的缓存管理,无需关心系统设置。
|
||||
|
||||
- 1. 使用缓存装饰器:
|
||||
```python
|
||||
from app.core.cache import cached
|
||||
|
||||
class MyPlugin(_PluginBase):
|
||||
@cached(region="my_plugin", ttl=3600)
|
||||
def get_data(self, key: str):
|
||||
"""
|
||||
使用缓存装饰器,缓存结果1小时
|
||||
"""
|
||||
# 复杂的计算或网络请求
|
||||
return expensive_operation(key)
|
||||
|
||||
@cached(region="my_plugin_async", ttl=1800, skip_none=True)
|
||||
async def get_async_data(self, key: str):
|
||||
"""
|
||||
异步函数缓存,跳过None值
|
||||
"""
|
||||
return await async_expensive_operation(key)
|
||||
```
|
||||
|
||||
- 2. 使用TTLCache类:
|
||||
```python
|
||||
from app.core.cache import TTLCache
|
||||
|
||||
class MyPlugin(_PluginBase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 创建缓存实例,最大128项,TTL 30分钟
|
||||
self.cache = TTLCache(region="my_plugin", maxsize=128, ttl=1800)
|
||||
|
||||
def process_data(self, key: str):
|
||||
# 检查缓存
|
||||
if key in self.cache:
|
||||
return self.cache[key]
|
||||
|
||||
# 计算并缓存结果
|
||||
result = expensive_operation(key)
|
||||
self.cache[key] = result
|
||||
return result
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
清理插件缓存
|
||||
"""
|
||||
self.cache.clear()
|
||||
```
|
||||
|
||||
- 3. 使用文件缓存后端(适用于大文件缓存):
|
||||
```python
|
||||
from app.core.cache import FileCache, AsyncFileCache
|
||||
from pathlib import Path
|
||||
|
||||
class MyPlugin(_PluginBase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 获取文件缓存后端,支持Redis和文件系统
|
||||
self.file_cache = FileCache(
|
||||
base=Path("/tmp/my_plugin_cache"),
|
||||
ttl=86400 # 24小时
|
||||
)
|
||||
|
||||
def cache_large_file(self, key: str, data: bytes):
|
||||
"""
|
||||
缓存大文件数据
|
||||
"""
|
||||
self.file_cache.set(key, data, region="large_files")
|
||||
|
||||
def get_cached_file(self, key: str) -> Optional[bytes]:
|
||||
"""
|
||||
获取缓存的文件数据
|
||||
"""
|
||||
return self.file_cache.get(key, region="large_files")
|
||||
|
||||
async def async_cache_operations(self):
|
||||
"""
|
||||
异步文件缓存操作
|
||||
"""
|
||||
async_cache = AsyncFileCache(
|
||||
base=Path("/tmp/my_plugin_async_cache"),
|
||||
ttl=3600
|
||||
)
|
||||
|
||||
# 异步设置缓存
|
||||
await async_cache.set("async_key", b"async_data", region="async_files")
|
||||
|
||||
# 异步获取缓存
|
||||
data = await async_cache.get("async_key", region="async_files")
|
||||
|
||||
await async_cache.close()
|
||||
```
|
||||
|
||||
- 4. 直接使用缓存后端(高级用法):
|
||||
```python
|
||||
from app.core.cache import Cache
|
||||
|
||||
class MyPlugin(_PluginBase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 直接获取缓存后端实例,系统自动选择Redis或内存缓存
|
||||
self.cache_backend = Cache(maxsize=256, ttl=3600)
|
||||
|
||||
def custom_cache_operation(self, key: str, value: Any):
|
||||
"""
|
||||
自定义缓存操作
|
||||
"""
|
||||
# 设置缓存
|
||||
self.cache_backend.set(key, value, region="custom_region")
|
||||
|
||||
# 检查缓存是否存在
|
||||
if self.cache_backend.exists(key, region="custom_region"):
|
||||
# 获取缓存
|
||||
cached_value = self.cache_backend.get(key, region="custom_region")
|
||||
return cached_value
|
||||
|
||||
return None
|
||||
|
||||
def iterate_cache_items(self):
|
||||
"""
|
||||
遍历缓存项
|
||||
"""
|
||||
for key, value in self.cache_backend.items(region="custom_region"):
|
||||
print(f"缓存键: {key}, 值: {value}")
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
清理缓存
|
||||
"""
|
||||
self.cache_backend.clear(region="custom_region")
|
||||
self.cache_backend.close()
|
||||
```
|
||||
|
||||
- 5. 缓存装饰器参数说明:
|
||||
```python
|
||||
@cached(
|
||||
region="my_plugin", # 缓存区域,用于隔离不同插件的缓存
|
||||
maxsize=512, # 最大缓存条目数(仅内存缓存有效)
|
||||
ttl=1800, # 缓存存活时间(秒)
|
||||
skip_none=True, # 是否跳过None值缓存
|
||||
skip_empty=False # 是否跳过空值缓存(空列表、空字典等)
|
||||
)
|
||||
def my_function(self, param):
|
||||
pass
|
||||
```
|
||||
|
||||
- 6. 缓存管理功能:
|
||||
```python
|
||||
class MyPlugin(_PluginBase):
|
||||
@cached(region="my_plugin")
|
||||
def cached_function(self, param):
|
||||
return expensive_operation(param)
|
||||
|
||||
def clear_my_cache(self):
|
||||
"""
|
||||
清理指定区域的缓存
|
||||
"""
|
||||
self.cached_function.cache_clear()
|
||||
|
||||
def get_cache_info(self):
|
||||
"""
|
||||
获取缓存信息
|
||||
"""
|
||||
cache_region = self.cached_function.cache_region
|
||||
return f"缓存区域: {cache_region}"
|
||||
```
|
||||
|
||||
- 7. 缓存后端自动选择:
|
||||
- 系统会根据配置自动选择缓存后端:
|
||||
- `CACHE_BACKEND_TYPE=redis`:使用Redis作为缓存后端
|
||||
- `CACHE_BACKEND_TYPE=memory`:使用内存缓存(cachetools)
|
||||
- 插件代码无需修改,系统会自动处理缓存后端的切换
|
||||
|
||||
- 8. 最佳实践:
|
||||
- 为每个插件使用独立的缓存区域(region),避免缓存键冲突
|
||||
- 合理设置TTL,避免缓存过期时间过长导致数据过期
|
||||
- 对于频繁访问的数据使用较长的TTL,对于实时性要求高的数据使用较短的TTL
|
||||
- 使用`skip_none=True`避免缓存无意义的None值
|
||||
- 大文件或二进制数据建议使用文件缓存后端
|
||||
- 在插件卸载时清理相关缓存,避免内存泄漏
|
||||
|
||||
|
||||
## 版本发布
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 35 KiB |
20
package.json
20
package.json
@@ -217,12 +217,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安全性"
|
||||
@@ -463,12 +464,14 @@
|
||||
"name": "药丸签到",
|
||||
"description": "药丸论坛签到。",
|
||||
"labels": "站点",
|
||||
"version": "1.4.1",
|
||||
"version": "2.0.0",
|
||||
"icon": "invites.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.0.0": "修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI",
|
||||
"v1.4.1": "更新签到域名前缀",
|
||||
"v1.4": "自定义保留消息天数"
|
||||
}
|
||||
@@ -477,11 +480,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版本后刮削报错问题"
|
||||
}
|
||||
@@ -560,12 +564,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版本无法读取媒体库的问题",
|
||||
@@ -943,11 +948,14 @@
|
||||
"name": "钉钉机器人",
|
||||
"description": "支持使用钉钉机器人发送消息通知。",
|
||||
"labels": "消息通知,钉钉机器人",
|
||||
"version": "1.12",
|
||||
"version": "1.13",
|
||||
"icon": "Dingding_A.png",
|
||||
"author": "nnlegenda",
|
||||
"level": 1,
|
||||
"v2": true
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.13": "优化钉钉消息换行"
|
||||
}
|
||||
},
|
||||
"DynamicWeChat": {
|
||||
"name": "动态企微可信IP",
|
||||
|
||||
@@ -42,12 +42,13 @@
|
||||
"name": "站点自动签到",
|
||||
"description": "自动模拟登录、签到站点。",
|
||||
"labels": "站点",
|
||||
"version": "2.6",
|
||||
"version": "2.7",
|
||||
"icon": "signin.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.7": "站点请求使用站点设置的超时时间",
|
||||
"v2.6": "感谢madrays佬提供的UI!",
|
||||
"v2.5.4": "增加保号风险提示",
|
||||
"v2.5.3": "优化执行周期输入,需要MoviePilot v2.2.1+",
|
||||
@@ -182,11 +183,13 @@
|
||||
"name": "演职人员刮削",
|
||||
"description": "刮削演职人员图片以及中文名称。",
|
||||
"labels": "媒体库,刮削",
|
||||
"version": "2.2",
|
||||
"version": "2.2.2",
|
||||
"icon": "actor.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.2.2": "修复异常日志问题",
|
||||
"v2.2.1": "优化错误数据兼容处理",
|
||||
"v2.2": "修改使用自定义图片域名时无法下载图片的问题",
|
||||
"v2.1": "优化执行周期输入,需要MoviePilot v2.2.1+",
|
||||
"v2.0": "兼容MoviePilot V2 版本",
|
||||
@@ -350,6 +353,18 @@
|
||||
"v2.0": "适配新的目录结构变化,短剧分类名称调整为配置目录路径,升级后需要重新调整设置后才能使用。"
|
||||
}
|
||||
},
|
||||
"MultiClass": {
|
||||
"name": "视频多级分类",
|
||||
"description": "支持视频多级分类",
|
||||
"labels": "文件整理",
|
||||
"version": "0.1",
|
||||
"icon": "Calibreweb_B.png",
|
||||
"author": "liuhangbin",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v0.1": "视频多级分类插件, 目前仅支持电影按评分,年代,系列分类。"
|
||||
}
|
||||
},
|
||||
"MoviePilotUpdateNotify": {
|
||||
"name": "MoviePilot更新推送",
|
||||
"description": "MoviePilot推送release更新通知、自动重启。",
|
||||
@@ -419,11 +434,12 @@
|
||||
"name": "绕过Trackers",
|
||||
"description": "提供tracker服务器IP地址列表,帮助IPv6连接绕过OpenClash。",
|
||||
"labels": "工具",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.3",
|
||||
"icon": "Clash_A.png",
|
||||
"author": "wumode",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.4.3": "修复 bug",
|
||||
"v1.4.2": "修复插件动作",
|
||||
"v1.4.1": "修复通知类型错误",
|
||||
"v1.4": "异步查询DNS",
|
||||
@@ -437,11 +453,14 @@
|
||||
"name": "IMDb源",
|
||||
"description": "让探索,推荐和媒体识别支持IMDb数据源。",
|
||||
"labels": "探索",
|
||||
"version": "1.5.6",
|
||||
"version": "1.6.1",
|
||||
"icon": "IMDb_IOS-OSX_App.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.6.1": "添加中文主屏幕组件; 修复 bug",
|
||||
"v1.5.8": "修改UA",
|
||||
"v1.5.7": "改进异常处理",
|
||||
"v1.5.6": "固定仪表盘组件海报比例; 修复 bug",
|
||||
"v1.5.5": "修复初始化错误",
|
||||
"v1.5.4": "改进媒体识别",
|
||||
@@ -467,12 +486,24 @@
|
||||
"name": "Clash Rule Provider",
|
||||
"description": "随时为Clash添加一些额外的规则。",
|
||||
"labels": "工具",
|
||||
"version": "1.3.2",
|
||||
"version": "2.0.8",
|
||||
"icon": "Mihomo_Meta_A.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.0.8": "修复已知问题",
|
||||
"v2.0.7": "修复子规则比较错误",
|
||||
"v2.0.6": "修复已知问题; 改进对代理组的配置和验证",
|
||||
"v2.0.5": "完善了对嵌套逻辑规则和子规则的配置和验证",
|
||||
"v2.0.4": "修复已知问题; 使用异步调度器; 显示规则更改日期",
|
||||
"v2.0.3": "修复已知问题",
|
||||
"v2.0.2": "修复分享链接转换问题",
|
||||
"v2.0.1": "支持独立的订阅链接配置, 覆写代理组和出站代理; 优化数据结构; 修复分享链接解析问题",
|
||||
"v1.4.2": "优化移动端 UI; 支持显示节点链接",
|
||||
"v1.4.1": "修复配置模板保存错误, 请重新配置Clash模板",
|
||||
"v1.4.0": "优化 UI; 支持连接多个 Clash Dashboards",
|
||||
"v1.3.3": "通过emoji识别国家; 按国家分组节点; mrs格式支持",
|
||||
"v1.3.2": "注册插件动作",
|
||||
"v1.3.1": "支持配置 Hosts",
|
||||
"v1.2.8": "改进导入界面",
|
||||
@@ -495,11 +526,14 @@
|
||||
"name": "美剧生词标注",
|
||||
"description": "根据CEFR等级,为英语影视剧标注高级词汇。",
|
||||
"labels": "英语",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.2",
|
||||
"icon": "LexiAnnot.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.1.2": "使用子进程避免 spaCy 模型常驻内存",
|
||||
"v1.1.1": "添加任务页面; 改进 spaCy 模型加载逻辑",
|
||||
"v1.1.0": "支持考试词汇标注; 优化分词处理; 修复错误",
|
||||
"v1.0.1": "合并连字符词; 避免ARM平台依赖问题",
|
||||
"v1.0": "新增LexiAnnot"
|
||||
}
|
||||
@@ -516,5 +550,19 @@
|
||||
"v1.0.0": "首个版本,新增MeoW消息通知",
|
||||
"v1.0.1": "优化代码,修复运行一次按钮没办法自动关闭的问题"
|
||||
}
|
||||
},
|
||||
"BugReporter": {
|
||||
"name": "Bug反馈",
|
||||
"description": "自动上报异常,协助开发者发现和解决问题。",
|
||||
"labels": "开发",
|
||||
"version": "1.3",
|
||||
"icon": "Alist_encrypt_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.3": "减少网络异常信息上送",
|
||||
"v1.2": "优化上报信息量",
|
||||
"v1.1": "加强脱敏处理"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@ from typing import Any, List, Dict, Tuple, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
@@ -26,6 +22,9 @@ from app.utils.http import RequestUtils
|
||||
from app.utils.site import SiteUtils
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.timer import TimerUtils
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
|
||||
class AutoSignIn(_PluginBase):
|
||||
@@ -36,7 +35,7 @@ class AutoSignIn(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "signin.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.6"
|
||||
plugin_version = "2.7"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
@@ -1545,6 +1544,7 @@ class AutoSignIn(_PluginBase):
|
||||
render = site_info.get("render")
|
||||
proxies = settings.PROXY if site_info.get("proxy") else None
|
||||
proxy_server = settings.PROXY_SERVER if site_info.get("proxy") else None
|
||||
timeout = site_info.get("timeout") or 60
|
||||
if not site_url or not site_cookie:
|
||||
logger.warn(f"未配置 {site} 的站点地址或Cookie,无法签到")
|
||||
return False, ""
|
||||
@@ -1560,7 +1560,8 @@ class AutoSignIn(_PluginBase):
|
||||
page_source = PlaywrightHelper().get_page_source(url=checkin_url,
|
||||
cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxy_server)
|
||||
proxies=proxy_server,
|
||||
timeout=timeout)
|
||||
if not SiteUtils.is_logged_in(page_source):
|
||||
if under_challenge(page_source):
|
||||
return False, f"无法通过Cloudflare!"
|
||||
@@ -1574,13 +1575,15 @@ class AutoSignIn(_PluginBase):
|
||||
else:
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).get_res(url=checkin_url)
|
||||
if not res and site_url != checkin_url:
|
||||
logger.info(f"开始站点模拟登录:{site},地址:{site_url}...")
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).get_res(url=site_url)
|
||||
# 判断登录状态
|
||||
if res and res.status_code in [200, 500, 403]:
|
||||
@@ -1647,6 +1650,7 @@ class AutoSignIn(_PluginBase):
|
||||
render = site_info.get("render")
|
||||
proxies = settings.PROXY if site_info.get("proxy") else None
|
||||
proxy_server = settings.PROXY_SERVER if site_info.get("proxy") else None
|
||||
timeout = site_info.get("timeout") or 60
|
||||
if not site_url or not site_cookie:
|
||||
logger.warn(f"未配置 {site} 的站点地址或Cookie,无法签到")
|
||||
return False, ""
|
||||
@@ -1659,7 +1663,8 @@ class AutoSignIn(_PluginBase):
|
||||
page_source = PlaywrightHelper().get_page_source(url=site_url,
|
||||
cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxy_server)
|
||||
proxies=proxy_server,
|
||||
timeout=timeout)
|
||||
if not SiteUtils.is_logged_in(page_source):
|
||||
if under_challenge(page_source):
|
||||
return False, f"无法通过Cloudflare!"
|
||||
@@ -1669,7 +1674,8 @@ class AutoSignIn(_PluginBase):
|
||||
else:
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).get_res(url=site_url)
|
||||
# 判断登录状态
|
||||
if res and res.status_code in [200, 500, 403]:
|
||||
|
||||
@@ -2,13 +2,12 @@ import random
|
||||
import re
|
||||
from typing import Tuple
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.plugins.autosignin.sites import _ISiteSigninHandler
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
from lxml import etree
|
||||
|
||||
|
||||
class Pt52(_ISiteSigninHandler):
|
||||
@@ -46,14 +45,16 @@ class Pt52(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
render = site_info.get("render")
|
||||
proxy = site_info.get("proxy")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 判断今日是否已签到
|
||||
html_text = self.get_page_source(url='https://52pt.site/bakatest.php',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
@@ -97,14 +98,16 @@ class Pt52(_ISiteSigninHandler):
|
||||
site_cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
site=site)
|
||||
site=site,
|
||||
timeout=timeout)
|
||||
|
||||
def __signin(self, questionid: str,
|
||||
choice: list,
|
||||
site: str,
|
||||
site_cookie: str,
|
||||
ua: str,
|
||||
proxy: bool) -> Tuple[bool, str]:
|
||||
proxy: bool,
|
||||
timeout: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
签到请求
|
||||
questionid: 450
|
||||
@@ -124,7 +127,8 @@ class Pt52(_ISiteSigninHandler):
|
||||
|
||||
sign_res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
timeout=timeout
|
||||
).post_res(url='https://52pt.site/bakatest.php', data=data)
|
||||
if not sign_res or sign_res.status_code != 200:
|
||||
logger.error(f"{site} 签到失败,签到接口请求失败")
|
||||
|
||||
@@ -42,7 +42,8 @@ class _ISiteSigninHandler(metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_page_source(url: str, cookie: str, ua: str, proxy: bool, render: bool, token: str = None) -> str:
|
||||
def get_page_source(url: str, cookie: str, ua: str, proxy: bool, render: bool,
|
||||
token: str = None, timeout: int = None) -> str:
|
||||
"""
|
||||
获取页面源码
|
||||
:param url: Url地址
|
||||
@@ -51,13 +52,15 @@ class _ISiteSigninHandler(metaclass=ABCMeta):
|
||||
:param proxy: 是否使用代理
|
||||
:param render: 是否渲染
|
||||
:param token: JWT Token
|
||||
:param timeout: 请求超时时间,单位秒
|
||||
:return: 页面源码,错误信息
|
||||
"""
|
||||
if render:
|
||||
return PlaywrightHelper().get_page_source(url=url,
|
||||
cookies=cookie,
|
||||
ua=ua,
|
||||
proxies=settings.PROXY_SERVER if proxy else None)
|
||||
proxies=settings.PROXY_SERVER if proxy else None,
|
||||
timeout=timeout or 60)
|
||||
else:
|
||||
if token:
|
||||
headers = {
|
||||
@@ -70,7 +73,8 @@ class _ISiteSigninHandler(metaclass=ABCMeta):
|
||||
"Cookie": cookie
|
||||
}
|
||||
res = RequestUtils(headers=headers,
|
||||
proxies=settings.PROXY if proxy else None).get_res(url=url)
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
timeout=timeout or 20).get_res(url=url)
|
||||
if res is not None:
|
||||
# 使用chardet检测字符编码
|
||||
raw_data = res.content
|
||||
|
||||
@@ -37,6 +37,7 @@ class BTSchool(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
render = site_info.get("render")
|
||||
proxy = site_info.get("proxy")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
logger.info(f"{site} 开始签到")
|
||||
# 判断今日是否已签到
|
||||
@@ -44,7 +45,8 @@ class BTSchool(_ISiteSigninHandler):
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
@@ -63,7 +65,8 @@ class BTSchool(_ISiteSigninHandler):
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,签到接口请求失败")
|
||||
|
||||
@@ -47,13 +47,15 @@ class CHDBits(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 判断今日是否已签到
|
||||
html_text = self.get_page_source(url='https://ptchdbits.co/bakatest.php',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
|
||||
@@ -37,21 +37,24 @@ class HaiDan(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 签到
|
||||
# 签到页会重定向到index.php,由于302重定向特性,导致index.php没有携带cookie
|
||||
self.get_page_source(url='https://www.haidan.video/signin.php',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
# 重新携带cookie获取index.php查看签到结果
|
||||
html_text = self.get_page_source(url='https://www.haidan.video/index.php',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -40,13 +40,15 @@ class Hares(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
html_text = self.get_page_source(url='https://club.hares.top',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
if not html_text:
|
||||
logger.error(f"{site} 模拟访问失败,请检查站点连通性")
|
||||
@@ -66,7 +68,8 @@ class Hares(_ISiteSigninHandler):
|
||||
}
|
||||
sign_res = RequestUtils(cookies=site_cookie,
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
timeout=timeout
|
||||
).get_res(url="https://club.hares.top/attendance.php?action=sign")
|
||||
if not sign_res or sign_res.status_code != 200:
|
||||
logger.error(f"{site} 签到失败,签到接口请求失败")
|
||||
|
||||
@@ -40,6 +40,7 @@ class HDArea(_ISiteSigninHandler):
|
||||
site_cookie = site_info.get("cookie")
|
||||
ua = site_info.get("ua")
|
||||
proxies = settings.PROXY if site_info.get("proxy") else None
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
data = {
|
||||
@@ -47,7 +48,8 @@ class HDArea(_ISiteSigninHandler):
|
||||
}
|
||||
html_res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).post_res(url="https://hdarea.club/sign_in.php", data=data)
|
||||
if not html_res or html_res.status_code != 200:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
|
||||
@@ -40,6 +40,7 @@ class HDChina(_ISiteSigninHandler):
|
||||
site_cookie = site_info.get("cookie")
|
||||
ua = site_info.get("ua")
|
||||
proxies = settings.PROXY if site_info.get("proxy") else None
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 尝试解决瓷器cookie每天签到后过期,只保留hdchina=部分
|
||||
cookie = ""
|
||||
@@ -59,7 +60,8 @@ class HDChina(_ISiteSigninHandler):
|
||||
# 获取页面html
|
||||
html_res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).get_res(url="https://hdchina.org/index.php")
|
||||
if not html_res or html_res.status_code != 200:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
@@ -99,7 +101,8 @@ class HDChina(_ISiteSigninHandler):
|
||||
}
|
||||
sign_res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).post_res(url="https://hdchina.org/plugin_sign-in.php?cmd=signin", data=data)
|
||||
if not sign_res or sign_res.status_code != 200:
|
||||
logger.error(f"{site} 签到失败,签到接口请求失败")
|
||||
|
||||
@@ -39,13 +39,15 @@ class HDCity(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
html_text = self.get_page_source(url='https://hdcity.city/sign',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -43,13 +43,15 @@ class HDSky(_ISiteSigninHandler):
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
referer = site_info.get("url")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 判断今日是否已签到
|
||||
html_text = self.get_page_source(url='https://hdsky.me',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
@@ -73,7 +75,8 @@ class HDSky(_ISiteSigninHandler):
|
||||
content_type='application/x-www-form-urlencoded; charset=UTF-8',
|
||||
referer="https://hdsky.me/index.php",
|
||||
accept_type="*/*",
|
||||
proxies=settings.PROXY if proxy else None
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
timeout=timeout
|
||||
).post_res(url='https://hdsky.me/image_code_ajax.php',
|
||||
data={'action': 'new'})
|
||||
if image_res and image_res.status_code == 200:
|
||||
|
||||
@@ -41,13 +41,15 @@ class HDUpt(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
html_text = self.get_page_source(url='https://pt.hdupt.com',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
@@ -67,7 +69,8 @@ class HDUpt(_ISiteSigninHandler):
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from typing import Tuple
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
@@ -38,10 +37,11 @@ class MTorrent(_ISiteSigninHandler):
|
||||
"Authorization": site_info.get("token")
|
||||
}
|
||||
url = site_info.get('url')
|
||||
timeout = site_info.get("timeout")
|
||||
domain = StringUtils.get_url_domain(url)
|
||||
# 更新最后访问时间
|
||||
res = RequestUtils(headers=headers,
|
||||
timeout=60,
|
||||
timeout=timeout,
|
||||
proxies=settings.PROXY if site_info.get("proxy") else None,
|
||||
referer=f"{url}index"
|
||||
).post_res(url=f"https://api.{domain}/api/member/updateLastBrowse")
|
||||
|
||||
@@ -40,6 +40,7 @@ class NexusHD(_ISiteSigninHandler):
|
||||
site_cookie = site_info.get("cookie")
|
||||
ua = site_info.get("ua")
|
||||
proxies = settings.PROXY if site_info.get("proxy") else None
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
data = {
|
||||
@@ -48,7 +49,8 @@ class NexusHD(_ISiteSigninHandler):
|
||||
}
|
||||
html_res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).post_res(url="https://v6.nexushd.org/signin.php", data=data)
|
||||
if not html_res or html_res.status_code != 200:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
|
||||
@@ -43,13 +43,15 @@ class Opencd(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 判断今日是否已签到
|
||||
html_text = self.get_page_source(url='https://www.open.cd',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -35,13 +35,15 @@ class PTerClub(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 签到
|
||||
html_text = self.get_page_source(url='https://pterclub.com/attendance-ajax.php',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -37,6 +37,7 @@ class PTTime(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 签到
|
||||
# 签到返回:<html><head></head><body>签到成功</body></html>
|
||||
@@ -44,7 +45,8 @@ class PTTime(_ISiteSigninHandler):
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
|
||||
@@ -57,6 +57,7 @@ class Tjupt(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 创建正确答案存储目录
|
||||
if not os.path.exists(os.path.dirname(self._answer_file)):
|
||||
@@ -67,7 +68,8 @@ class Tjupt(_ISiteSigninHandler):
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
# 获取签到后返回html,判断是否签到成功
|
||||
if not html_text:
|
||||
|
||||
@@ -44,13 +44,15 @@ class TTG(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
html_text = self.get_page_source(url="https://totheglory.im",
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -50,6 +50,7 @@ class U2(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
now = datetime.datetime.now()
|
||||
# 判断当前时间是否小于9点
|
||||
@@ -62,7 +63,8 @@ class U2(_ISiteSigninHandler):
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -37,7 +37,7 @@ class YemaPT(_ISiteSigninHandler):
|
||||
}
|
||||
# 获取用户信息,更新最后访问时间
|
||||
res = (RequestUtils(headers=headers,
|
||||
timeout=15,
|
||||
timeout=site_info.get("timeout"),
|
||||
cookies=site_info.get("cookie"),
|
||||
proxies=settings.PROXY if site_info.get("proxy") else None,
|
||||
referer=site_info.get('url')
|
||||
@@ -64,7 +64,7 @@ class YemaPT(_ISiteSigninHandler):
|
||||
}
|
||||
# 获取用户信息,更新最后访问时间
|
||||
res = (RequestUtils(headers=headers,
|
||||
timeout=15,
|
||||
timeout=site_info.get("timeout"),
|
||||
cookies=site_info.get("cookie"),
|
||||
proxies=settings.PROXY if site_info.get("proxy") else None,
|
||||
referer=site_info.get('url')
|
||||
|
||||
@@ -38,13 +38,15 @@ class ZhuQue(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
html_text = self.get_page_source(url="https://zhuque.in",
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 模拟登录失败,请检查站点连通性")
|
||||
return False, '模拟登录失败,请检查站点连通性'
|
||||
@@ -73,7 +75,8 @@ class ZhuQue(_ISiteSigninHandler):
|
||||
}
|
||||
skill_res = RequestUtils(cookies=site_cookie,
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
timeout=timeout
|
||||
).post_res(url="https://zhuque.in/api/gaming/fireGenshinCharacterMagic", json=data)
|
||||
if not skill_res or skill_res.status_code != 200:
|
||||
logger.error(f"模拟登录失败,释放技能失败")
|
||||
|
||||
263
plugins.v2/bugreporter/__init__.py
Normal file
263
plugins.v2/bugreporter/__init__.py
Normal file
@@ -0,0 +1,263 @@
|
||||
import re
|
||||
from typing import Any, Dict
|
||||
from typing import List, Tuple
|
||||
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
||||
|
||||
import sentry_sdk
|
||||
from app.plugins import _PluginBase
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
class SentrySanitizer:
|
||||
# 常见敏感字段名(可自行扩展)
|
||||
SENSITIVE_KEYS = {
|
||||
"password", "passwd", "pwd",
|
||||
"secret", "token", "access_token", "refresh_token",
|
||||
"authorization", "api_key", "apikey",
|
||||
"cookie", "set-cookie", "passkey",
|
||||
"key", "credential", "auth", "login", "user", "username",
|
||||
"email", "phone", "address", "ip", "host", "domain"
|
||||
}
|
||||
|
||||
# 匹配包含敏感关键词的正则
|
||||
SENSITIVE_PATTERN = re.compile(
|
||||
"|".join(re.escape(key) for key in SENSITIVE_KEYS), re.IGNORECASE
|
||||
)
|
||||
|
||||
# 网络连接错误类异常(不上报)
|
||||
NETWORK_ERRORS = {
|
||||
"ConnectionError", "ConnectionRefusedError", "ConnectionAbortedError",
|
||||
"ConnectionResetError", "TimeoutError", "socket.timeout", "socket.error",
|
||||
"ssl.SSLError", "ssl.SSLCertVerificationError", "ssl.SSLWantReadError",
|
||||
"ssl.SSLWantWriteError", "ssl.SSLZeroReturnError", "ssl.SSLSyscallError",
|
||||
"urllib.error.URLError", "urllib.error.HTTPError", "requests.exceptions.ConnectionError",
|
||||
"requests.exceptions.Timeout", "requests.exceptions.ConnectTimeout",
|
||||
"requests.exceptions.ReadTimeout", "requests.exceptions.SSLError",
|
||||
"aiohttp.ClientConnectionError", "aiohttp.ClientTimeout", "aiohttp.ServerTimeoutError",
|
||||
"aiohttp.ServerDisconnectedError", "aiohttp.ClientOSError"
|
||||
}
|
||||
|
||||
# 网络连接错误关键词
|
||||
NETWORK_ERROR_KEYWORDS = [
|
||||
"connection", "timeout", "network", "dns", "ssl", "certificate",
|
||||
"refused", "reset", "aborted", "unreachable", "no route to host",
|
||||
"name or service not known", "temporary failure", "network is unreachable",
|
||||
"SOCKSHTTPSConnectionPool", "ERR_HTTP_RESPONSE_CODE_FAILURE", "HTTPSConnectionPool",
|
||||
"网络连接", "无法连接", "请求失败", "下载失败", "请求返回空值", "图片失败", "未获取到返回数据",
|
||||
"请求返回空值", "返回空响应", "连接出错", "请求错误", "未获取到"
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def scrub_dict(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
递归清洗字典中的敏感信息
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
sanitized = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
sanitized[key] = cls.scrub_dict(value)
|
||||
elif isinstance(value, list):
|
||||
sanitized[key] = [cls.scrub_dict(v) if isinstance(v, dict) else v for v in value]
|
||||
else:
|
||||
if cls.SENSITIVE_PATTERN.search(str(key)):
|
||||
sanitized[key] = "[Filtered]"
|
||||
else:
|
||||
sanitized[key] = value
|
||||
return sanitized
|
||||
|
||||
@classmethod
|
||||
def scrub_url(cls, url: str) -> str:
|
||||
"""
|
||||
清理 URL 中的敏感 query 参数
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||
for key in query:
|
||||
if cls.SENSITIVE_PATTERN.search(key):
|
||||
query[key] = ["[Filtered]"]
|
||||
new_query = urlencode(query, doseq=True)
|
||||
return urlunparse(parsed._replace(query=new_query))
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return url
|
||||
|
||||
@classmethod
|
||||
def is_network_error(cls, event) -> bool:
|
||||
"""
|
||||
判断是否为网络连接错误类异常
|
||||
"""
|
||||
# 检查异常类型
|
||||
if "exception" in event:
|
||||
for exc in event["exception"].get("values", []):
|
||||
if "type" in exc:
|
||||
exc_type = exc["type"]
|
||||
if exc_type in cls.NETWORK_ERRORS:
|
||||
return True
|
||||
|
||||
# 检查异常消息是否包含网络错误关键词
|
||||
if "value" in exc:
|
||||
exc_value = exc["value"].lower()
|
||||
for keyword in cls.NETWORK_ERROR_KEYWORDS:
|
||||
if keyword in exc_value:
|
||||
return True
|
||||
|
||||
# 检查日志消息
|
||||
if "message" in event:
|
||||
message = event["message"].lower()
|
||||
for keyword in cls.NETWORK_ERROR_KEYWORDS:
|
||||
if keyword in message:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def before_send(cls, event, hint):
|
||||
"""
|
||||
在发送到 Sentry 之前脱敏和过滤
|
||||
"""
|
||||
# 如果是网络连接错误,直接返回 None 不上报
|
||||
if cls.is_network_error(event):
|
||||
return None
|
||||
|
||||
# 处理 request 数据
|
||||
request = event.get("request", {})
|
||||
if "url" in request:
|
||||
request["url"] = cls.scrub_url(request["url"])
|
||||
if "headers" in request:
|
||||
request["headers"] = cls.scrub_dict(request["headers"])
|
||||
if "data" in request:
|
||||
request["data"] = cls.scrub_dict(request["data"])
|
||||
if "cookies" in request:
|
||||
request["cookies"] = cls.scrub_dict(request["cookies"])
|
||||
|
||||
# 处理 user 数据
|
||||
if "user" in event:
|
||||
event["user"] = cls.scrub_dict(event["user"])
|
||||
|
||||
# 处理 extra 数据
|
||||
if "extra" in event:
|
||||
event["extra"] = cls.scrub_dict(event["extra"])
|
||||
|
||||
# 处理异常信息(避免敏感数据出现在 message 中)
|
||||
if "exception" in event:
|
||||
for exc in event["exception"].get("values", []):
|
||||
if "value" in exc and cls.SENSITIVE_PATTERN.search(exc["value"]):
|
||||
exc["value"] = "[Filtered Exception Message]"
|
||||
|
||||
# 清理异常堆栈中的敏感信息
|
||||
if "stacktrace" in exc and "frames" in exc["stacktrace"]:
|
||||
for frame in exc["stacktrace"]["frames"]:
|
||||
if "vars" in frame:
|
||||
frame["vars"] = cls.scrub_dict(frame["vars"])
|
||||
if "context_line" in frame and cls.SENSITIVE_PATTERN.search(frame["context_line"]):
|
||||
frame["context_line"] = "[Filtered]"
|
||||
|
||||
# 清理消息中的敏感信息
|
||||
if "message" in event and cls.SENSITIVE_PATTERN.search(event["message"]):
|
||||
event["message"] = "[Filtered Message]"
|
||||
|
||||
return event
|
||||
|
||||
|
||||
class BugReporter(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "Bug反馈"
|
||||
# 插件描述
|
||||
plugin_desc = "自动上报异常,协助开发者发现和解决问题。"
|
||||
# 插件图标
|
||||
plugin_icon = "Alist_encrypt_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.3"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jxxghp"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "bugreporter_"
|
||||
# 加载顺序
|
||||
plugin_order = 99
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
_enable: bool = False
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self._enable = config.get("enable")
|
||||
if self._enable:
|
||||
sentry_sdk.init("https://88da01ad33b4423cb0380620de53efa8@glitchtip.movie-pilot.org/1",
|
||||
before_send=SentrySanitizer.before_send,
|
||||
release=APP_VERSION,
|
||||
send_default_pii=False)
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enable',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'warning',
|
||||
'variant': 'tonal',
|
||||
'text': '注意:开启插件即代表你同意将部分异常信息自动发送给开发者,以帮助改进软件;如果你不希望自动发送任何数据,请关闭或卸载此插件;仅上报系统异常信息,不会包含任何个人隐私信息或敏感数据;网络连接错误类异常不会上报;异常信息采集为使用开源项目解决方案:GlitchTip。',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enable": self._enable,
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enable
|
||||
|
||||
def stop_service(self):
|
||||
pass
|
||||
1
plugins.v2/bugreporter/requirements.txt
Normal file
1
plugins.v2/bugreporter/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
sentry_sdk~=2.35.1
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
- 即时通知 Clash 刷新规则集合
|
||||
- 基于 Meta 内核丰富的代理组配置,提供灵活的路由功能
|
||||
- 支持按大洲分组节点
|
||||
- 支持按大洲和国家分组节点
|
||||
- 支持覆写出站代理
|
||||
- GEO 规则输入提示
|
||||
- 支持 [ACL4SSR](https://github.com/ACL4SSR/ACL4SSR) 规则集合
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
264
plugins.v2/clashruleprovider/api.py
Normal file
264
plugins.v2/clashruleprovider/api.py
Normal file
@@ -0,0 +1,264 @@
|
||||
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
|
||||
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
|
||||
from .models.api import RuleData, Connectivity, Subscription, RuleProviderData, SubscriptionInfo, HostData
|
||||
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] = ''):
|
||||
|
||||
def decorator(func: Callable):
|
||||
route_meta: Dict[str, Any] = {
|
||||
'path': path,
|
||||
'methods': methods,
|
||||
'summary': summary,
|
||||
'endpoint': func
|
||||
}
|
||||
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": 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: Literal['ruleset', 'top']) -> schemas.Response:
|
||||
data = self.services.get_rules(ruleset)
|
||||
return schemas.Response(success=True, data={'rules': data})
|
||||
|
||||
@apis.register(path="/reorder-rules/{ruleset}/{target_priority}", methods=["PUT"], auth="bear",
|
||||
summary="重新排序规则")
|
||||
def reorder_rules(self, ruleset: Literal['ruleset', 'top'], target_priority: int,
|
||||
rule_data: RuleData) -> schemas.Response:
|
||||
moved_priority = rule_data.priority
|
||||
success, message = self.services.reorder_rules(ruleset, moved_priority, target_priority)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}/{priority}", methods=["PATCH"], auth="bear", summary="更新规则")
|
||||
def update_rule(self, ruleset: Literal['ruleset', 'top'], 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: Literal['ruleset', 'top'], rule_data: RuleData) -> schemas.Response:
|
||||
success, message = self.services.add_rule(ruleset, rule_data)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}/{priority}", methods=["DELETE"], auth="bear", summary="删除规则")
|
||||
def delete_rule(self, ruleset: Literal['ruleset', 'top'], priority: int) -> schemas.Response:
|
||||
self.services.delete_rule(ruleset, priority)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@apis.register(path="/refresh", methods=["PUT"], auth="bear", summary="更新订阅")
|
||||
async def refresh_subscription(self, subscription: Subscription) -> schemas.Response:
|
||||
success, message = await self.services.refresh_subscription(subscription.url)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rule-providers", methods=["GET"], auth="bear", summary="获取规则集合")
|
||||
def get_rule_providers(self) -> schemas.Response:
|
||||
return schemas.Response(success=True, data=self.services.rule_providers())
|
||||
|
||||
@apis.register(path="/rule-providers/{name}", methods=["POST"], 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}", 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="获取出站代理")
|
||||
def get_proxies(self):
|
||||
proxies = self.services.get_all_proxies_with_details()
|
||||
return schemas.Response(success=True, data={'proxies': 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, params: Dict[str, Any]):
|
||||
success, message = self.services.import_proxies(params)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxies/{name}", methods=["PATCH"], auth="bear", summary="更新出站代理")
|
||||
def update_proxy(self, name: str, param: Dict[str, Any]) -> schemas.Response:
|
||||
success, message = self.services.update_proxy(name, param)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-groups", methods=["GET"], auth="bear", summary="获取代理组")
|
||||
def get_proxy_groups(self):
|
||||
proxy_groups = self.services.get_all_proxy_groups_with_source()
|
||||
return schemas.Response(success=True, data={'proxy_groups': 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", 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/{previous_name}", methods=["PATCH"], auth="bear", summary="更新代理组")
|
||||
def update_proxy_group(self, previous_name: str, item: ProxyGroup):
|
||||
success, message = self.services.update_proxy_group(previous_name, item)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-providers", methods=["GET"], auth="bear", summary="获取代理集合")
|
||||
def get_proxy_providers(self):
|
||||
proxy_providers = self.services.all_proxy_providers()
|
||||
return schemas.Response(success=True, data={'proxy_providers': proxy_providers})
|
||||
|
||||
@apis.register(path="/ruleset", methods=["GET"], allow_anonymous=bool(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, params: Dict[str, Any]):
|
||||
self.services.import_rules(params)
|
||||
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={'hosts': self.services.get_hosts()})
|
||||
|
||||
@apis.register(path="/hosts", methods=["POST"], auth="bear", summary="更新 Hosts")
|
||||
def update_hosts(self, host: HostData):
|
||||
success, message = self.services.update_hosts(host)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/hosts", methods=["DELETE"], auth="bear", summary="删除 Hosts")
|
||||
def delete_host(self, host: HostData):
|
||||
success, message = self.services.delete_host(host)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/subscription-info", methods=["POST"], auth="bear", summary="更新订阅信息")
|
||||
def update_subscription_info(self, sub_info: SubscriptionInfo):
|
||||
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):
|
||||
_apikey = self.config.apikey or settings.API_TOKEN
|
||||
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.clash_config()
|
||||
if not config:
|
||||
raise HTTPException(status_code=500, detail="配置不可用")
|
||||
|
||||
res = yaml.dump(config, allow_unicode=True, sort_keys=False)
|
||||
sub_info = self.services.get_subscription_user_info()
|
||||
headers = {'Subscription-Userinfo': f'upload={sub_info["upload"]}; download={sub_info["download"]}; '
|
||||
f'total={sub_info["total"]}; expire={sub_info["expire"]}'}
|
||||
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())
|
||||
38
plugins.v2/clashruleprovider/base.py
Normal file
38
plugins.v2/clashruleprovider/base.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from abc import ABC
|
||||
from typing import Final, Optional, Literal, Dict
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
from app.plugins import _PluginBase
|
||||
|
||||
from .config import PluginConfig
|
||||
from .state import PluginState
|
||||
from .store import PluginStore
|
||||
|
||||
|
||||
class _ClashRuleProviderBase(_PluginBase, ABC):
|
||||
# Constants
|
||||
DEFAULT_CLASH_CONF: Final[
|
||||
Dict[Literal['rules', 'rule-providers', 'proxies', 'proxy-groups', 'proxy-providers'], dict | list]] = {
|
||||
'rules': [], 'rule-providers': {},
|
||||
'proxies': [], 'proxy-groups': [], 'proxy-providers': {}
|
||||
}
|
||||
OVERWRITTEN_PROXIES_LIFETIME: 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
|
||||
KEY_TOP_RULES: Final[str] = "top_rules"
|
||||
KEY_RULESET_RULES: Final[str] = "ruleset_rules"
|
||||
KEY_PROXIES: Final[str] = "proxies"
|
||||
KEY_PROXY_GROUPS: Final[str] = "proxy-groups"
|
||||
KEY_NAME: Final[str] = "name"
|
||||
|
||||
def __init__(self):
|
||||
# Configuration attributes
|
||||
super().__init__()
|
||||
|
||||
# Runtime variables
|
||||
self.state: Optional[PluginState] = None
|
||||
self.config: Optional[PluginConfig] = None
|
||||
self.store: Optional[PluginStore] = None
|
||||
self.scheduler: Optional[AsyncIOScheduler] = None
|
||||
File diff suppressed because it is too large
Load Diff
102
plugins.v2/clashruleprovider/config.py
Normal file
102
plugins.v2/clashruleprovider/config.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from .models.api import ClashApi
|
||||
|
||||
|
||||
class SubscriptionConfig(BaseModel):
|
||||
url: str
|
||||
rules: Optional[bool] = True
|
||||
rule_providers: Optional[bool] = Field(True, alias='rule-providers')
|
||||
proxies: Optional[bool] = True
|
||||
proxy_groups: Optional[bool] = Field(True, alias='proxy-groups')
|
||||
proxy_providers: Optional[bool] = Field(True, alias='proxy-providers')
|
||||
|
||||
@validator('url', allow_reuse=True)
|
||||
def validate_url(cls, v: str):
|
||||
return v.strip()
|
||||
|
||||
|
||||
class PluginConfig(BaseModel):
|
||||
"""
|
||||
A dataclass to hold all the configuration of the ClashRuleProvider plugin.
|
||||
"""
|
||||
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
|
||||
|
||||
@validator('clash_dashboards', allow_reuse=True)
|
||||
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
|
||||
|
||||
@validator('movie_pilot_url', allow_reuse=True)
|
||||
def validate_movie_pilot_url(cls, v: str):
|
||||
return v.rstrip('/')
|
||||
|
||||
@validator('ruleset_prefix', allow_reuse=True)
|
||||
def validate_ruleset_prefix(cls, v: str):
|
||||
return v.strip()
|
||||
|
||||
@validator('acl4ssr_prefix', allow_reuse=True)
|
||||
def validate_acl4ssr_prefix(cls, v: str):
|
||||
return v.strip()
|
||||
|
||||
@staticmethod
|
||||
def upgrade_conf(conf: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if conf.get('sub_links'):
|
||||
subscriptions_config = conf.get('subscriptions_config') or []
|
||||
subscriptions_config.extend(
|
||||
[{'url': url, 'rules': True, 'rule-providers': True, 'proxies': True, 'proxy-groups': True,
|
||||
'proxy-providers': True}
|
||||
for url in conf['sub_links']]
|
||||
)
|
||||
conf['subscriptions_config'] = subscriptions_config
|
||||
if conf.get('clash_dashboard_url') and conf.get('clash_dashboard_secret'):
|
||||
clash_dashboards = conf.get('clash_dashboards') or []
|
||||
clash_dashboards.append({'url': conf.get('clash_dashboard_url'), 'secret': conf.get('clash_dashboard_secret')})
|
||||
conf['clash_dashboards'] = clash_dashboards
|
||||
return conf
|
||||
|
||||
@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
|
||||
5136
plugins.v2/clashruleprovider/countries.json
Executable file → Normal file
5136
plugins.v2/clashruleprovider/countries.json
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
|
||||
.plugin-config[data-v-929102b8] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
1513
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-C8YPPEsk.js
vendored
Normal file
1513
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-C8YPPEsk.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
4
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-D7x82s8Y.css
vendored
Normal file
4
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-D7x82s8Y.css
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
.plugin-config[data-v-5f383f33] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
|
||||
.plugin-page[data-v-d6db167c] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 使卡片等宽并适应移动端 */
|
||||
.d-flex.flex-wrap[data-v-d6db167c] {
|
||||
gap: 16px;
|
||||
}
|
||||
.url-display[data-v-d6db167c] {
|
||||
word-break: break-all;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 移动端堆叠布局 */
|
||||
@media (max-width: 768px) {
|
||||
.d-flex.flex-wrap[data-v-d6db167c] {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add visual distinction between sections */
|
||||
.ruleset-section[data-v-d6db167c] {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.top-section[data-v-d6db167c] {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
/* Optional: Add different border colors to further distinguish */
|
||||
.ruleset-section[data-v-d6db167c] {
|
||||
border-left: 4px solid #2196F3; /* Blue accent */
|
||||
}
|
||||
.top-section[data-v-d6db167c] {
|
||||
border-left: 4px solid #4CAF50; /* Green accent */
|
||||
}
|
||||
.drag-handle[data-v-d6db167c] {
|
||||
cursor: move;
|
||||
}
|
||||
.gap-2[data-v-d6db167c] {
|
||||
gap: 8px;
|
||||
}
|
||||
11795
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-CUYOswsP.js
vendored
Normal file
11795
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-CUYOswsP.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
70
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-Dx-0nC8K.css
vendored
Normal file
70
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-Dx-0nC8K.css
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
|
||||
|
||||
.plugin-page[data-v-67d1defe] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 使卡片等宽并适应移动端 */
|
||||
.d-flex.flex-wrap[data-v-67d1defe] {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 移动端堆叠布局 */
|
||||
@media (max-width: 768px) {
|
||||
.d-flex.flex-wrap[data-v-67d1defe] {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.drag-handle[data-v-67d1defe] {
|
||||
cursor: move;
|
||||
}
|
||||
.toggle-container[data-v-67d1defe] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem;
|
||||
margin-left: 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
.subscription-card[data-v-67d1defe] {
|
||||
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-67d1defe]:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.card-title[data-v-67d1defe] {
|
||||
color: whitesmoke;
|
||||
}
|
||||
.card-header[data-v-67d1defe] {
|
||||
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-67d1defe] {
|
||||
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-67d1defe] {
|
||||
max-width: 25rem;
|
||||
}
|
||||
.clash-data-table[data-v-67d1defe] {
|
||||
max-height: 40rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ const currentImports = {};
|
||||
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
|
||||
let moduleMap = {
|
||||
"./Page":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Page-BOym_1fV.css"], false, './Page');
|
||||
return __federation_import('./__federation_expose_Page-D5l2MyNA.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
dynamicLoadingCss(["__federation_expose_Page-Dx-0nC8K.css"], false, './Page');
|
||||
return __federation_import('./__federation_expose_Page-CUYOswsP.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Config":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Config-BrXQaadr.css"], false, './Config');
|
||||
return __federation_import('./__federation_expose_Config-NH09p1Am.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
dynamicLoadingCss(["__federation_expose_Config-D7x82s8Y.css"], false, './Config');
|
||||
return __federation_import('./__federation_expose_Config-C8YPPEsk.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)},};
|
||||
|
||||
131
plugins.v2/clashruleprovider/helper/clashrulemanager.py
Normal file
131
plugins.v2/clashruleprovider/helper/clashrulemanager.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import time
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Union, Iterator
|
||||
|
||||
from .clashruleparser import ClashRuleParser
|
||||
from ..models.rule import Action, RoutingRuleType, MatchRule, ClashRule, LogicRule
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuleItem:
|
||||
"""Clash rule item"""
|
||||
rule: Union[ClashRule, LogicRule, MatchRule]
|
||||
remark: str = field(default="")
|
||||
time_modified: float = field(default=0)
|
||||
|
||||
class ClashRuleManager:
|
||||
"""Clash rule manager"""
|
||||
def __init__(self):
|
||||
self.rules: List[RuleItem] = []
|
||||
|
||||
def import_rules(self, rules_list: List[Dict[str, str]]):
|
||||
self.rules = []
|
||||
for r in rules_list:
|
||||
rule = ClashRuleParser.parse_rule_line(r['rule'])
|
||||
if rule is None:
|
||||
continue
|
||||
remark = r.get('remark', '')
|
||||
time_modified = r.get('time_modified', time.time())
|
||||
self.rules.append(RuleItem(rule=rule, remark=remark, time_modified=time_modified))
|
||||
|
||||
def export_rules(self) -> List[Dict[str, str]]:
|
||||
rules_list = []
|
||||
for rule in self.rules:
|
||||
rules_list.append({'rule': str(rule.rule), 'remark': rule.remark, 'time_modified': rule.time_modified})
|
||||
return rules_list
|
||||
|
||||
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_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.remark == r.remark 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 to_list(self) -> List[Dict[str, Any]]:
|
||||
"""Convert parsed rules to a list"""
|
||||
result = []
|
||||
for priority, rule_item in enumerate(self.rules):
|
||||
rule_dict = {'remark': rule_item.remark, 'time_modified': rule_item.time_modified,'priority': priority,
|
||||
**rule_item.rule.to_dict()}
|
||||
result.append(rule_dict)
|
||||
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)
|
||||
332
plugins.v2/clashruleprovider/helper/clashruleparser.py
Normal file
332
plugins.v2/clashruleprovider/helper/clashruleparser.py
Normal file
@@ -0,0 +1,332 @@
|
||||
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
|
||||
|
||||
|
||||
class ClashRuleParser:
|
||||
"""Parser for Clash routing rules"""
|
||||
|
||||
@staticmethod
|
||||
def parse_rule_line(line: str) -> Optional[RuleType]:
|
||||
"""Parse a single rule line"""
|
||||
line = line.strip()
|
||||
try:
|
||||
# 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)
|
||||
|
||||
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)
|
||||
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 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.
|
||||
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 action_string(action: Union[Action, str]) -> str:
|
||||
return action.value if isinstance(action, Action) else action
|
||||
|
||||
@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 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 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
|
||||
298
plugins.v2/clashruleprovider/helper/configconverter.py
Normal file
298
plugins.v2/clashruleprovider/helper/configconverter.py
Normal file
@@ -0,0 +1,298 @@
|
||||
import base64
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
from typing import List, 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: Optional[Dict[str, int]] = None, skip_exception: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
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 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) -> List[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 = []
|
||||
names = {}
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
proxy = self.convert_line(line, names, skip_exception=skip_exception)
|
||||
if proxy:
|
||||
proxies.append(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
|
||||
58
plugins.v2/clashruleprovider/helper/converters/hysteria.py
Normal file
58
plugins.v2/clashruleprovider/helper/converters/hysteria.py
Normal file
@@ -0,0 +1,58 @@
|
||||
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,
|
||||
}
|
||||
|
||||
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
|
||||
44
plugins.v2/clashruleprovider/helper/converters/hysteria2.py
Normal file
44
plugins.v2/clashruleprovider/helper/converters/hysteria2.py
Normal file
@@ -0,0 +1,44 @@
|
||||
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"),
|
||||
}
|
||||
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
|
||||
105
plugins.v2/clashruleprovider/helper/proxiesmanager.py
Normal file
105
plugins.v2/clashruleprovider/helper/proxiesmanager.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Dict, List, Optional, Union, Any, Iterator
|
||||
|
||||
from ..models.proxy import Proxy, ProxyType
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProxyItem:
|
||||
proxy: ProxyType
|
||||
remark: str = ""
|
||||
raw: Optional[Union[str, Dict[str, Any]]] = None
|
||||
|
||||
class ProxyManager:
|
||||
"""Proxy Manager"""
|
||||
def __init__(self):
|
||||
self.proxies: Dict[str,ProxyItem] = {}
|
||||
|
||||
def add(self, proxy: ProxyType, remark: str = "", raw: Optional[str|Dict[str, Any]] = None):
|
||||
"""Add a proxy to the proxy manager. """
|
||||
if proxy.name not in self.proxies:
|
||||
self.proxies[proxy.name] = ProxyItem(proxy, remark, raw=copy.deepcopy(raw))
|
||||
else:
|
||||
raise ValueError(f"Proxy with name {proxy.name!r} already exists.")
|
||||
|
||||
def add_proxy_dict(self, proxy_dict: Dict[str, Any], remark: str = "", raw: Optional[str] = None):
|
||||
"""
|
||||
Add a proxy to the proxies list.
|
||||
:param proxy_dict: Proxy dict with proxy name as key
|
||||
:param remark: Proxy remark
|
||||
:param raw: Proxy raw
|
||||
:raises: ValueError if proxy name already exists
|
||||
"""
|
||||
proxy = Proxy.parse_obj(proxy_dict)
|
||||
raw = raw or proxy_dict
|
||||
self.add(proxy.__root__, remark=remark, raw=raw)
|
||||
|
||||
def add_from_list(self, proxies: List[Dict[str, Any]], remark: str = "", skip_existing: bool = False):
|
||||
"""Add proxies from the proxies list. """
|
||||
proxies_list = []
|
||||
for proxy in proxies:
|
||||
p = Proxy.parse_obj(proxy)
|
||||
proxies_list.append(ProxyItem(p.__root__, remark, raw=proxy))
|
||||
|
||||
for proxy_item in proxies_list:
|
||||
try:
|
||||
self.add(proxy_item.proxy, remark=remark, raw=proxy_item.raw)
|
||||
except ValueError:
|
||||
if skip_existing:
|
||||
continue
|
||||
raise
|
||||
|
||||
def get_all_proxies(self) -> List[Dict[str, Any]]:
|
||||
proxies = []
|
||||
for proxy_item in self.proxies.values():
|
||||
proxy_dict = proxy_item.proxy.dict(by_alias=True, exclude_none=True)
|
||||
proxies.append(proxy_dict)
|
||||
return proxies
|
||||
|
||||
def remove_proxy(self, name):
|
||||
if name in self.proxies:
|
||||
del self.proxies[name]
|
||||
|
||||
def remove_proxies_by_condition(self, condition: Callable[[ProxyItem], bool]) -> int:
|
||||
"""
|
||||
Removes proxies from the manager based on a given condition.
|
||||
:param condition: A callable that takes a ProxyItem and returns True if the proxy should be removed.
|
||||
:return: The number of proxies removed.
|
||||
"""
|
||||
initial_count = len(self.proxies)
|
||||
self.proxies = {
|
||||
name: item
|
||||
for name, item in self.proxies.items()
|
||||
if not condition(item)
|
||||
}
|
||||
return initial_count - len(self.proxies)
|
||||
|
||||
def filter_proxies_by_condition(self, condition: Callable[[ProxyItem], bool]) -> List[ProxyItem]:
|
||||
return [proxy for proxy in self.proxies.values() if condition(proxy)]
|
||||
|
||||
def clear(self):
|
||||
self.proxies.clear()
|
||||
|
||||
def export_raw(self, condition: Optional[Callable[[ProxyItem], bool]] = None) -> List[str|Dict[str, Any]]:
|
||||
proxies = []
|
||||
for proxy in self.proxies.values():
|
||||
if condition and not condition(proxy):
|
||||
continue
|
||||
if proxy.raw:
|
||||
proxies.append(copy.deepcopy(proxy.raw))
|
||||
else:
|
||||
proxies.append(proxy.proxy.dict(by_alias=True, exclude_none=True))
|
||||
return proxies
|
||||
|
||||
def proxy_names(self) -> Iterator[str]:
|
||||
return iter(self.proxies)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.proxies)
|
||||
|
||||
def __iter__(self) -> Iterator[ProxyItem]:
|
||||
return iter(self.proxies.values())
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
return name in self.proxies
|
||||
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
|
||||
3
plugins.v2/clashruleprovider/models/__init__.py
Normal file
3
plugins.v2/clashruleprovider/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .proxy import *
|
||||
from .ruleproviders import *
|
||||
from .proxygroups import *
|
||||
47
plugins.v2/clashruleprovider/models/api.py
Normal file
47
plugins.v2/clashruleprovider/models/api.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from typing import List, Optional, Union, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .rule import RoutingRuleType, Action, AdditionalParam
|
||||
from .ruleproviders import RuleProvider
|
||||
|
||||
class RuleData(BaseModel):
|
||||
priority: int
|
||||
type: RoutingRuleType
|
||||
payload: Optional[str] = None
|
||||
action: Union[Action, str]
|
||||
additional_params: Optional[AdditionalParam] = None
|
||||
conditions: Optional[List[str]] = None
|
||||
condition: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
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 Subscription(BaseModel):
|
||||
url: str
|
||||
|
||||
class RuleProviderData(BaseModel):
|
||||
name: str
|
||||
rule_provider: RuleProvider
|
||||
|
||||
class SubscriptionInfo(BaseModel):
|
||||
url: str
|
||||
field: Literal['name', 'enabled']
|
||||
value: Union[bool, str]
|
||||
|
||||
class Host(BaseModel):
|
||||
domain: str
|
||||
value: List[str]
|
||||
using_cloudflare: bool
|
||||
|
||||
class HostData(BaseModel):
|
||||
domain: str
|
||||
value: Optional[Host] = None
|
||||
47
plugins.v2/clashruleprovider/models/proxy/__init__.py
Normal file
47
plugins.v2/clashruleprovider/models/proxy/__init__.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from typing import Union
|
||||
|
||||
from pydantic import Field, BaseModel
|
||||
|
||||
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
|
||||
|
||||
ProxyType = Union[
|
||||
AnyTLSProxy,
|
||||
DirectProxy,
|
||||
DnsProxy,
|
||||
HttpProxy,
|
||||
HysteriaProxy,
|
||||
Hysteria2Proxy,
|
||||
MieruProxy,
|
||||
ShadowsocksProxy,
|
||||
ShadowsocksRProxy,
|
||||
SnellProxy,
|
||||
Socks5Proxy,
|
||||
SshProxy,
|
||||
TrojanProxy,
|
||||
TuicProxy,
|
||||
VlessProxy,
|
||||
VmessProxy,
|
||||
WireGuardProxy,
|
||||
]
|
||||
|
||||
class Proxy(BaseModel):
|
||||
__root__: ProxyType = Field(..., discriminator="type")
|
||||
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[str] = None
|
||||
down: Optional[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')
|
||||
26
plugins.v2/clashruleprovider/models/proxy/hysteriaproxy.py
Normal file
26
plugins.v2/clashruleprovider/models/proxy/hysteriaproxy.py
Normal file
@@ -0,0 +1,26 @@
|
||||
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[str] = None
|
||||
down: Optional[str] = None
|
||||
up_speed: Optional[int] = Field(None, alias='up-speed')
|
||||
down_speed: Optional[int] = Field(None, alias='down-speed')
|
||||
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
|
||||
26
plugins.v2/clashruleprovider/models/proxy/mieruproxy.py
Normal file
26
plugins.v2/clashruleprovider/models/proxy/mieruproxy.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import Field, 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'
|
||||
|
||||
@validator('port', 'port_range', allow_reuse=True)
|
||||
def validate_port_config(cls, v, values):
|
||||
port = values.get('port')
|
||||
port_range = values.get('port_range')
|
||||
if not port and not port_range:
|
||||
raise ValueError("either port or port-range must be set")
|
||||
if port and port_range:
|
||||
raise ValueError("port and port-range cannot be set at the same time")
|
||||
return v
|
||||
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, validator
|
||||
|
||||
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')
|
||||
|
||||
class Config:
|
||||
extra = 'allow'
|
||||
allow_population_by_field_name = True
|
||||
|
||||
@validator('plugin_opts', allow_reuse=True)
|
||||
def validate_plugin_opts(cls, v, values):
|
||||
plugin = values.get('plugin')
|
||||
if plugin and v:
|
||||
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 not isinstance(v, expected_model):
|
||||
raise ValueError(f"{plugin} plugin requires {expected_model.__name__}")
|
||||
|
||||
return v
|
||||
@@ -0,0 +1,18 @@
|
||||
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')
|
||||
|
||||
class Config:
|
||||
extra = 'allow'
|
||||
allow_population_by_field_name = True
|
||||
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')
|
||||
28
plugins.v2/clashruleprovider/models/proxy/tlsmixin.py
Normal file
28
plugins.v2/clashruleprovider/models/proxy/tlsmixin.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from typing import List, Optional, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
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[Literal['chrome', 'firefox', 'safari', 'ios', 'android', 'edge', '360', 'qq', 'random']] = 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')
|
||||
121
plugins.v2/clashruleprovider/models/proxygroups.py
Normal file
121
plugins.v2/clashruleprovider/models/proxygroups.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import re
|
||||
from typing import List, Optional, Union, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
class ProxyGroupBase(BaseModel):
|
||||
"""
|
||||
包含所有代理组类型共有的通用字段。
|
||||
"""
|
||||
# Required field
|
||||
name: str = Field(..., description="The name of the proxy group.")
|
||||
|
||||
# Proxy and provider references
|
||||
proxies: Optional[List[str]] = Field(None, description="References to outbound proxies or other proxy groups.")
|
||||
use: Optional[List[str]] = Field(None, description="References to proxy provider sets.")
|
||||
|
||||
# Health check fields
|
||||
url: Optional[str] = Field(None, description="Health check test address.")
|
||||
interval: Optional[int] = Field(None, description="Health check interval in seconds.")
|
||||
lazy: Optional[bool] = Field(True, description="If not selected, no health checks are performed.")
|
||||
timeout: Optional[int] = Field(None, description="Health check timeout in milliseconds.")
|
||||
max_failed_times: Optional[int] = Field(5, description="Maximum number of failures before a forced health check.",
|
||||
alias="max-failed-times")
|
||||
expected_status: Optional[str] = Field('*',
|
||||
description="Expected HTTP response status code for health checks.",
|
||||
alias="expected-status")
|
||||
|
||||
# Network and routing fields
|
||||
disable_udp: Optional[bool] = Field(False, description="Disables UDP for this proxy group.", alias="disable-udp")
|
||||
interface_name: Optional[str] = Field(None, description="DEPRECATED. Specifies the outbound interface.",
|
||||
alias="interface-name")
|
||||
routing_mark: Optional[int] = Field(None, description="DEPRECATED. The routing mark for outbound connections.",
|
||||
alias="routing-mark")
|
||||
|
||||
# Dynamic proxy inclusion
|
||||
include_all: Optional[bool] = Field(False, description="Includes all outbound proxies and proxy sets.",
|
||||
alias="include-all")
|
||||
include_all_proxies: Optional[bool] = Field(False, description="Includes all outbound proxies.",
|
||||
alias="include-all-proxies")
|
||||
include_all_providers: Optional[bool] = Field(False, description="Includes all proxy provider sets.",
|
||||
alias="include-all-providers")
|
||||
|
||||
# Filtering
|
||||
filter: Optional[str] = Field(None, description="Regex to filter nodes from providers.")
|
||||
exclude_filter: Optional[str] = Field(None, description="Regex to exclude nodes.", alias="exclude-filter")
|
||||
exclude_type: Optional[str] = Field(None, description="Exclude nodes by adapter type, separated by '|'.",
|
||||
alias="exclude-type")
|
||||
|
||||
# UI fields
|
||||
hidden: Optional[bool] = Field(False, description="Hides the proxy group in the API.")
|
||||
icon: Optional[str] = Field(None, description="Icon string for the proxy group, for UI use.")
|
||||
|
||||
@validator('expected_status', allow_reuse=True)
|
||||
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']
|
||||
|
||||
|
||||
class RelayGroup(ProxyGroupBase):
|
||||
type: Literal['relay']
|
||||
|
||||
|
||||
class FallbackGroup(ProxyGroupBase):
|
||||
type: Literal['fallback']
|
||||
|
||||
|
||||
class UrlTestGroup(ProxyGroupBase):
|
||||
type: Literal['url-test']
|
||||
tolerance: Optional[int] = Field(None, description="proxies switch tolerance, measured in milliseconds (ms).")
|
||||
|
||||
|
||||
class LoadBalanceGroup(ProxyGroupBase):
|
||||
type: Literal['load-balance']
|
||||
strategy: Optional[Literal['round-robin', 'consistent-hashing', 'sticky-sessions']] = Field(
|
||||
'round-robin',
|
||||
description="Load balancing strategy."
|
||||
)
|
||||
|
||||
|
||||
class SmartGroup(ProxyGroupBase):
|
||||
type: Literal['smart']
|
||||
uselightgbm: bool = Field(..., description="Use LightGBM model predict weight.")
|
||||
collectdata: bool = Field(..., description="Collect datas for model training.")
|
||||
policy_priority: Optional[str] = Field("1",
|
||||
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(
|
||||
'sticky-sessions',
|
||||
description="Load balancing strategy."
|
||||
)
|
||||
sample_rate: Optional[int] = Field(1, description="Data acquisition rate.", alias="sample-rate")
|
||||
|
||||
|
||||
# Discriminated Union
|
||||
ProxyGroupType = Union[SelectGroup, RelayGroup, FallbackGroup, UrlTestGroup, LoadBalanceGroup, SmartGroup]
|
||||
|
||||
|
||||
class ProxyGroup(BaseModel):
|
||||
__root__: ProxyGroupType = Field(..., discriminator='type')
|
||||
|
||||
def dict(self, **kwargs):
|
||||
return self.__root__.dict(**kwargs)
|
||||
190
plugins.v2/clashruleprovider/models/rule/__init__.py
Normal file
190
plugins.v2/clashruleprovider/models/rule/__init__.py
Normal file
@@ -0,0 +1,190 @@
|
||||
from enum import Enum
|
||||
from typing import Any, List, Optional, Union, Dict, Literal
|
||||
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
|
||||
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(Enum):
|
||||
"""Enumeration of rule actions"""
|
||||
DIRECT = "DIRECT"
|
||||
REJECT = "REJECT"
|
||||
REJECT_DROP = "REJECT-DROP"
|
||||
PASS = "PASS"
|
||||
COMPATIBLE = "COMPATIBLE"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
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, str]:
|
||||
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,
|
||||
'raw': self.raw_rule
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@validator('payload', allow_reuse=True)
|
||||
def validate_payload(cls, v: str, values: Dict[str, Any]) -> Optional[str]:
|
||||
if values.get('rule_type') == RoutingRuleType.NETWORK 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 = []
|
||||
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,
|
||||
'raw': self.raw_rule
|
||||
}
|
||||
|
||||
@validator('conditions', allow_reuse=True)
|
||||
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,
|
||||
'raw': self.raw_rule
|
||||
}
|
||||
|
||||
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,
|
||||
'raw': self.raw_rule
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.condition_string()},{self.action}"
|
||||
|
||||
|
||||
RuleType = Union[ClashRule, LogicRule, SubRule, MatchRule]
|
||||
59
plugins.v2/clashruleprovider/models/ruleproviders.py
Normal file
59
plugins.v2/clashruleprovider/models/ruleproviders.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from typing import List, Optional, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, validator, HttpUrl
|
||||
|
||||
|
||||
class RuleProvider(BaseModel):
|
||||
type: Literal["http", "file", "inline"] = Field(..., description="Provider type")
|
||||
url: Optional[HttpUrl] = Field(None, description="Must be configured if the type is http")
|
||||
path: Optional[str] = Field(None, description="Optional, file path, must be unique.")
|
||||
interval: Optional[int] = Field(None, ge=0, description="The update interval for the provider, in seconds.")
|
||||
proxy: Optional[str] = Field(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: int = Field(0, ge=0, description="The maximum size of downloadable files in bytes (0 for no limit)",
|
||||
alias="size-limit")
|
||||
payload: Optional[List[str]] = Field(None, description="Content, only effective when type is inline")
|
||||
|
||||
@validator("url", pre=True, always=True, allow_reuse=True)
|
||||
def check_url_for_http_type(cls, v, values):
|
||||
if values.get("type") == "http" and v is None:
|
||||
raise ValueError("url must be configured if the type is 'http'")
|
||||
elif values.get("type") != "http":
|
||||
return None
|
||||
return v
|
||||
|
||||
@validator("path", pre=True, always=True, allow_reuse=True)
|
||||
def check_path_for_file_type(cls, v, values):
|
||||
if values.get("type") == "file" and v is None:
|
||||
raise ValueError("path must be configured if the type is 'file'")
|
||||
elif values.get("type") != "file":
|
||||
return None
|
||||
return v
|
||||
|
||||
@validator("payload", pre=True, always=True, allow_reuse=True)
|
||||
def handle_payload_for_non_inline_type(cls, v, values):
|
||||
# If type is not inline, payload should be ignored (set to None)
|
||||
if values.get("type") != "inline" and v is not None:
|
||||
return None
|
||||
return v
|
||||
|
||||
@validator("payload", allow_reuse=True)
|
||||
def check_payload_type_for_inline(cls, v, values):
|
||||
if values.get("type") == "inline" and v is not None and not isinstance(v, list):
|
||||
raise ValueError("payload must be a list of strings when type is 'inline'")
|
||||
if values.get("type") == "inline" and v is None:
|
||||
raise ValueError("payload must be configured if the type is 'inline'")
|
||||
return v
|
||||
|
||||
@validator("format", allow_reuse=True)
|
||||
def check_format_with_behavior(cls, v, values):
|
||||
behavior = values.get("behavior")
|
||||
if v == "mrs" and behavior not in ["domain", "ipcidr"]:
|
||||
raise ValueError("mrs format only supports 'domain' or 'ipcidr' behavior")
|
||||
return v
|
||||
|
||||
|
||||
class RuleProviders(BaseModel):
|
||||
__root__: dict[str, RuleProvider]
|
||||
@@ -1,2 +1,3 @@
|
||||
websockets
|
||||
sse_starlette~=2.3.6
|
||||
websockets
|
||||
sse_starlette~=2.3.6
|
||||
PyYAML~=6.0.2
|
||||
1082
plugins.v2/clashruleprovider/services.py
Normal file
1082
plugins.v2/clashruleprovider/services.py
Normal file
File diff suppressed because it is too large
Load Diff
33
plugins.v2/clashruleprovider/state.py
Normal file
33
plugins.v2/clashruleprovider/state.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .helper.clashrulemanager import ClashRuleManager
|
||||
from .helper.proxiesmanager import ProxyManager
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginState:
|
||||
"""
|
||||
A dataclass to hold all the runtime state of the ClashRuleProvider plugin.
|
||||
"""
|
||||
# Rule and Proxy Managers
|
||||
top_rules_manager: ClashRuleManager = field(default_factory=ClashRuleManager)
|
||||
ruleset_rules_manager: ClashRuleManager = field(default_factory=ClashRuleManager)
|
||||
proxies_manager: ProxyManager = field(default_factory=ProxyManager)
|
||||
|
||||
# Loaded from saved data
|
||||
proxy_groups: List[Dict[str, Any]] = field(default_factory=list)
|
||||
extra_proxies: List[Dict[str, Any]] = field(default_factory=list)
|
||||
subscription_info: Dict[str, Any] = field(default_factory=dict)
|
||||
rule_provider: Dict[str, Any] = field(default_factory=dict)
|
||||
rule_providers: Dict[str, Any] = field(default_factory=dict)
|
||||
ruleset_names: Dict[str, str] = field(default_factory=dict)
|
||||
acl4ssr_providers: Dict[str, Any] = field(default_factory=dict)
|
||||
clash_configs: Dict[str, Any] = field(default_factory=dict)
|
||||
hosts: List[Dict[str, Any]] = field(default_factory=list)
|
||||
overwritten_region_groups: Dict[str, Any] = field(default_factory=dict)
|
||||
overwritten_proxies: Dict[str, Any] = field(default_factory=dict)
|
||||
clash_template_dict: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Volatile state (generated at runtime)
|
||||
geo_rules: Dict[str, List[str]] = field(default_factory=lambda: {'geoip': [], 'geosite': []})
|
||||
19
plugins.v2/clashruleprovider/store.py
Executable file
19
plugins.v2/clashruleprovider/store.py
Executable file
@@ -0,0 +1,19 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db.plugindata_oper import PluginDataOper
|
||||
|
||||
|
||||
class PluginStore:
|
||||
"""数据持久化"""
|
||||
def __init__(self, plugin_id: str):
|
||||
self.plugin_id = plugin_id
|
||||
self.plugin_data = PluginDataOper()
|
||||
|
||||
def get_data(self, key: Optional[str] = None) -> 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 del_data(self, key: str) -> Any:
|
||||
self.plugin_data.del_data(self.plugin_id, key)
|
||||
File diff suppressed because it is too large
Load Diff
673
plugins.v2/imdbsource/imdbapi.py
Normal file
673
plugins.v2/imdbsource/imdbapi.py
Normal file
@@ -0,0 +1,673 @@
|
||||
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
|
||||
from .schema.imdbapi import ImdbApiSearchTitlesResponse, ImdbApiListTitlesResponse, ImdbApiListTitleEpisodesResponse, \
|
||||
ImdbApiListTitleSeasonsResponse, ImdbApiListTitleCreditsResponse, ImdbapiListTitleAKAsResponse
|
||||
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)
|
||||
@cached(maxsize=1024, 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.warn(f"{r.json().get('message')}")
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
return r.json()
|
||||
|
||||
@retry(Exception, logger=logger)
|
||||
@cached(maxsize=1024, 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.warn(f"{path}: {r.json().get('message')}")
|
||||
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. Maximum is 50.
|
||||
:return: Search results.
|
||||
See `curl -X 'GET' 'https://api.imdbapi.dev/search/titles?query=Kite' -H 'accept: application/json'`
|
||||
"""
|
||||
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.parse_obj(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.parse_obj(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.
|
||||
See `curl -X 'GET' 'https://api.imdbapi.dev/search/titles?query=Kite' -H 'accept: application/json'`
|
||||
"""
|
||||
|
||||
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.
|
||||
See `curl -X 'GET' 'https://api.imdbapi.dev/search/titles?query=Kite' -H 'accept: application/json'`
|
||||
"""
|
||||
|
||||
data = await self.async_search_titles(query=query, limit=limit)
|
||||
if data is None:
|
||||
return None
|
||||
if year:
|
||||
data = [title for title in data.titles if title.start_year == year]
|
||||
if media_types:
|
||||
data = [title for title in data.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.parse_obj(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.parse_obj(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.
|
||||
See `curl -X 'GET' 'https://api.imdbapi.dev/titles/tt0944947' -H 'accept: application/json'`
|
||||
"""
|
||||
path = '/titles/%s'
|
||||
try:
|
||||
r = self._free_imdb_api(path=path % title_id)
|
||||
ret = ImdbApiTitle.parse_obj(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.parse_obj(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. Default is 20.
|
||||
:param page_token: Optional. Token for pagination, if applicable.
|
||||
:return: Episodes.
|
||||
See `curl -X 'GET' 'https://api.imdbapi.dev/titles/tt0944947/episodes?season=1&pageSize=5' \
|
||||
-H 'accept: application/json'`
|
||||
"""
|
||||
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.parse_obj(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.parse_obj(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.parse_obj(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.parse_obj(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. Default is 20.
|
||||
:param page_token: Optional. Token for pagination, if applicable.
|
||||
:return: Credits.
|
||||
See `curl -X 'GET' 'https://api.imdbapi.dev/titles/tt0944947/credits?categories=CAST' \
|
||||
-H 'accept: application/json'`
|
||||
"""
|
||||
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.parse_obj(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.parse_obj(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.parse_obj(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.parse_obj(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving alternative titles: {e}")
|
||||
return None
|
||||
return ret
|
||||
File diff suppressed because one or more lines are too long
584
plugins.v2/imdbsource/officialapi.py
Normal file
584
plugins.v2/imdbsource/officialapi.py
Normal file
@@ -0,0 +1,584 @@
|
||||
import re
|
||||
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] = \
|
||||
"""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=None,
|
||||
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)
|
||||
@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)
|
||||
@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.parse_obj(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.parse_obj(data)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while querying VerticalListPageItems: {e}")
|
||||
return None
|
||||
|
||||
return ret
|
||||
|
||||
@retry(Exception, logger=logger)
|
||||
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": params.title_types}
|
||||
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}
|
||||
try:
|
||||
data = await self._async_request(params, sha256)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while querying {operation_name}: {e}")
|
||||
return None
|
||||
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.parse_obj(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
|
||||
112
plugins.v2/imdbsource/schema/__init__.py
Normal file
112
plugins.v2/imdbsource/schema/__init__.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from enum import Enum
|
||||
from typing import Optional, List, Tuple, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .imdbapi import ImdbApiTitle, ImdbApiEpisode, ImdbApiCredit
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
def from_title(cls, parent: ImdbApiTitle, **kwargs):
|
||||
return cls(**parent.dict(by_alias=True), **kwargs)
|
||||
|
||||
|
||||
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
|
||||
|
||||
class Config:
|
||||
frozen = True
|
||||
allow_mutation = False
|
||||
|
||||
|
||||
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)
|
||||
151
plugins.v2/imdbsource/schema/imdbapi.py
Normal file
151
plugins.v2/imdbsource/schema/imdbapi.py
Normal file
@@ -0,0 +1,151 @@
|
||||
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 ImdbApiListTitleEpisodesResponse(BaseModel):
|
||||
episodes: List[ImdbApiEpisode] = Field(default_factory=list)
|
||||
total_count: int = Field(alias='totalCount')
|
||||
next_page_token: Optional[str] = Field(None, alias='nextPageToken')
|
||||
|
||||
|
||||
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(BaseModel):
|
||||
credits: List[ImdbApiCredit] = Field(default_factory=list)
|
||||
total_count: int = Field(alias='totalCount')
|
||||
next_page_token: Optional[str] = Field(None, alias='nextPageToken')
|
||||
|
||||
|
||||
class ImdbapiAka(AkasNode):
|
||||
attributes: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ImdbapiListTitleAKAsResponse(BaseModel):
|
||||
akas: List[ImdbapiAka]
|
||||
166
plugins.v2/imdbsource/schema/imdbtypes.py
Normal file
166
plugins.v2/imdbsource/schema/imdbtypes.py
Normal file
@@ -0,0 +1,166 @@
|
||||
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: str
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = 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]
|
||||
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')
|
||||
|
||||
|
||||
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
|
||||
@@ -7,6 +7,7 @@
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
# Gemini
|
||||
|
||||
@@ -38,7 +39,7 @@ CEFR全称是Common European Framework of Reference for Languages。
|
||||
# 计划
|
||||
|
||||
- 双语字幕支持
|
||||
- 考试词汇标注
|
||||
- ~~考试词汇标注~~
|
||||
|
||||
# FAQ
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user