Compare commits

...

60 Commits

Author SHA1 Message Date
jxxghp
e93bfc6667 Merge pull request #913 from KoWming/main 2025-11-01 19:51:06 +08:00
KoWming
b963398987 修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI
增强代码健壮性。
2025-10-23 11:35:18 +08:00
KoWming
ed395a26a9 Update package.json 2025-10-23 11:34:40 +08:00
KoWming
5a642e1e51 修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI。 2025-10-22 10:12:42 +08:00
KoWming
a8813b0272 Update package.json 2025-10-22 10:12:05 +08:00
jxxghp
66ce816a31 Merge pull request #908 from wumode/clashruleprovider 2025-10-08 10:30:35 +08:00
wumode
241e3200f8 fix: typo 2025-10-08 00:57:09 +08:00
wumode
19f52d6217 update: ImdbSource & ClashRuleProvider
ImdbSource
- 使用 Pydantic 重构 IMDb API

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

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

View File

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

185
README.md
View File

@@ -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

View File

@@ -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",

View File

@@ -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": "加强脱敏处理"
}
}
}

View File

@@ -7,10 +7,6 @@ from typing import Any, List, Dict, Tuple, Optional
from urllib.parse import urljoin
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from ruamel.yaml import CommentedMap
from app import schemas
from app.core.config import settings
from app.core.event import eventmanager, Event
@@ -26,6 +22,9 @@ from app.utils.http import RequestUtils
from app.utils.site import SiteUtils
from app.utils.string import StringUtils
from app.utils.timer import TimerUtils
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from ruamel.yaml import CommentedMap
class AutoSignIn(_PluginBase):
@@ -36,7 +35,7 @@ class AutoSignIn(_PluginBase):
# 插件图标
plugin_icon = "signin.png"
# 插件版本
plugin_version = "2.6"
plugin_version = "2.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]:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,8 @@
- 即时通知 Clash 刷新规则集合
- 基于 Meta 内核丰富的代理组配置,提供灵活的路由功能
- 支持按大洲分组节点
- 支持按大洲和国家分组节点
- 支持覆写出站代理
- GEO 规则输入提示
- 支持 [ACL4SSR](https://github.com/ACL4SSR/ACL4SSR) 规则集合

File diff suppressed because it is too large Load Diff

View 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())

View 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

View 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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@@ -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)},};

View 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)

View 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

View 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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

View File

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

View File

@@ -0,0 +1,3 @@
from .proxy import *
from .ruleproviders import *
from .proxygroups import *

View 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

View 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")

View 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')

View File

@@ -0,0 +1,7 @@
from typing import Literal
from .proxybase import ProxyBase
class DirectProxy(ProxyBase):
type: Literal['direct'] = 'direct'

View File

@@ -0,0 +1,7 @@
from typing import Literal
from .proxybase import ProxyBase
class DnsProxy(ProxyBase):
type: Literal['dns'] = 'dns'

View 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

View 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')

View 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

View 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

View 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')

View 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

View 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

View File

@@ -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

View 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')

View 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

View 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')

View 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')

View 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

View 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')

View 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

View 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')

View 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')

View 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)

View 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]

View 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]

View File

@@ -1,2 +1,3 @@
websockets
sse_starlette~=2.3.6
websockets
sse_starlette~=2.3.6
PyYAML~=6.0.2

File diff suppressed because it is too large Load Diff

View 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': []})

View 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

View 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

View 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

View 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)

View 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]

View 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

View File

@@ -7,6 +7,7 @@
![](https://images2.imgbox.com/d6/b6/kZu6EH2a_o.png)
![](https://images2.imgbox.com/c8/3a/rEJBWu5v_o.png)
![](https://images2.imgbox.com/97/b7/d6RXFtwD_o.png)
![](https://images2.imgbox.com/8a/d4/AtgOe265_o.jpg)
# 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