mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-06-13 23:16:49 +00:00
Compare commits
198 Commits
AutoSignIn
...
ClashRuleP
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
145e9747a9 | ||
|
|
87e4dcd211 | ||
|
|
633c8bad97 | ||
|
|
0927d0388a | ||
|
|
323289aa74 | ||
|
|
1f80e3b078 | ||
|
|
0ac725383e | ||
|
|
659f4f2b0d | ||
|
|
d65979323e | ||
|
|
d2503648a9 | ||
|
|
fffad33cc5 | ||
|
|
ae99671190 | ||
|
|
528b938f0f | ||
|
|
722f8da96d | ||
|
|
c53a3dc152 | ||
|
|
e29f59c28c | ||
|
|
c2c1320b18 | ||
|
|
e15733b7de | ||
|
|
02a2518fce | ||
|
|
861f416aad | ||
|
|
17cf85c1c1 | ||
|
|
6dbf539d88 | ||
|
|
24b9c2ec29 | ||
|
|
9a8e939414 | ||
|
|
a6b5286bf9 | ||
|
|
490c740c54 | ||
|
|
39d64a1cf4 | ||
|
|
a0272dfcaf | ||
|
|
44d3db72b4 | ||
|
|
48b5d1018e | ||
|
|
738e224ba3 | ||
|
|
6f2a0b2213 | ||
|
|
c2ccdf2b8e | ||
|
|
adb6230eea | ||
|
|
aa89750d1f | ||
|
|
4ca2d14076 | ||
|
|
8bd590e1ea | ||
|
|
d7effcd625 | ||
|
|
a7b830e4fd | ||
|
|
5b8f5b406f | ||
|
|
69b430bdc3 | ||
|
|
00d3346dfc | ||
|
|
7452540a93 | ||
|
|
d98902e536 | ||
|
|
5ecefb4a41 | ||
|
|
814149e0f3 | ||
|
|
d306145a14 | ||
|
|
da72e1b252 | ||
|
|
b6fc76cdb7 | ||
|
|
7842375d11 | ||
|
|
f6d83a5d31 | ||
|
|
97b8e7028a | ||
|
|
cc6cc55ad0 | ||
|
|
52063367f8 | ||
|
|
0003e4382b | ||
|
|
e2cbe22e8d | ||
|
|
436983e49e | ||
|
|
8829414a47 | ||
|
|
9f46c829db | ||
|
|
0de6531aed | ||
|
|
a5a96b74e3 | ||
|
|
f7b1a027f5 | ||
|
|
bde04fd7e1 | ||
|
|
af38909f58 | ||
|
|
5ccd80c4f1 | ||
|
|
ebf407b8b2 | ||
|
|
d0be1feec5 | ||
|
|
02fbbc87b4 | ||
|
|
ce1804cd0f | ||
|
|
53da73f11e | ||
|
|
fb3d8e9c0d | ||
|
|
5039a94bbf | ||
|
|
3ae993050b | ||
|
|
0dddb4675f | ||
|
|
56abaaf31c | ||
|
|
900f4fec95 | ||
|
|
88688672db | ||
|
|
cc6b95e5a1 | ||
|
|
377808f3da | ||
|
|
1d5e44e02c | ||
|
|
ff9c35041e | ||
|
|
d9afb64d00 | ||
|
|
6d60123272 | ||
|
|
84fcc3762f | ||
|
|
77b34dba5c | ||
|
|
4d8f36f674 | ||
|
|
5ccbb412eb | ||
|
|
4a0c700e6b | ||
|
|
00c65a0983 | ||
|
|
b961a52440 | ||
|
|
707feedda2 | ||
|
|
07c6ee1341 | ||
|
|
fd360cf21d | ||
|
|
a267df9e5d | ||
|
|
8feecbcb42 | ||
|
|
4224939f30 | ||
|
|
234ceba60c | ||
|
|
5c8a6647e2 | ||
|
|
5b763dff42 | ||
|
|
ee453841df | ||
|
|
6768d2c244 | ||
|
|
cb14efcc68 | ||
|
|
7871dfd0b8 | ||
|
|
99d1bfe37e | ||
|
|
b65c1b8bf7 | ||
|
|
517a16f0a3 | ||
|
|
89bfb9750d | ||
|
|
01eac66a6a | ||
|
|
cd53b8d454 | ||
|
|
d986f45634 | ||
|
|
0ceb633d96 | ||
|
|
2965743cfe | ||
|
|
9fa02d62e2 | ||
|
|
b2bd0f3701 | ||
|
|
de0e83f830 | ||
|
|
94b6df246e | ||
|
|
6b895919a0 | ||
|
|
a9830202e8 | ||
|
|
e96eece117 | ||
|
|
107b8e408f | ||
|
|
6629aeadef | ||
|
|
b0e5680260 | ||
|
|
a322274d77 | ||
|
|
be2289739a | ||
|
|
7536a8782e | ||
|
|
4d71a24fbc | ||
|
|
85ac9dd393 | ||
|
|
75c65b96d4 | ||
|
|
7d8433b768 | ||
|
|
d66413dd7a | ||
|
|
a0c9afc3ed | ||
|
|
e0c39170e6 | ||
|
|
8e199afe24 | ||
|
|
e68d915f36 | ||
|
|
b3e78c3e5e | ||
|
|
f02b90552b | ||
|
|
e93bfc6667 | ||
|
|
131463cfbe | ||
|
|
b963398987 | ||
|
|
ed395a26a9 | ||
|
|
03a2b35930 | ||
|
|
5a642e1e51 | ||
|
|
a8813b0272 | ||
|
|
66ce816a31 | ||
|
|
241e3200f8 | ||
|
|
19f52d6217 | ||
|
|
884efaebbf | ||
|
|
b51ba3d92a | ||
|
|
ec74481160 | ||
|
|
c60a4f01aa | ||
|
|
e34cafd641 | ||
|
|
5f8bb72641 | ||
|
|
df3e42987a | ||
|
|
8a738b7684 | ||
|
|
491f40663b | ||
|
|
fe8a7c6cd2 | ||
|
|
6245940466 | ||
|
|
c86cbc473f | ||
|
|
d93665a572 | ||
|
|
250ee4ada8 | ||
|
|
dfe2247b25 | ||
|
|
858261ddcc | ||
|
|
47bf56afe4 | ||
|
|
af3956d86f | ||
|
|
a69feb73ca | ||
|
|
88b29169fc | ||
|
|
2c9e108ac4 | ||
|
|
73b2d778a0 | ||
|
|
bf67d6e567 | ||
|
|
5e9da0802d | ||
|
|
2811021996 | ||
|
|
8c0a05b2de | ||
|
|
bb070bf83e | ||
|
|
21aec36ea5 | ||
|
|
6019cf92ac | ||
|
|
42d5dd1e89 | ||
|
|
0b3313e078 | ||
|
|
5684ba056a | ||
|
|
44af7dbb78 | ||
|
|
2102a03740 | ||
|
|
0a9cadf7ab | ||
|
|
279efe8000 | ||
|
|
fd92e58f81 | ||
|
|
fe93e46e02 | ||
|
|
cbf541992f | ||
|
|
8e1d336250 | ||
|
|
12e0e2b9f5 | ||
|
|
ac914f70f3 | ||
|
|
a07b8a4f4a | ||
|
|
6960b3f7aa | ||
|
|
fe83ff1be8 | ||
|
|
6357dc8e4a | ||
|
|
f1d94d0aa3 | ||
|
|
53dd3bc796 | ||
|
|
a9d528fc05 | ||
|
|
0388c437b1 | ||
|
|
ac4b53e745 | ||
|
|
53297fccaf |
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -90,15 +90,18 @@ jobs:
|
||||
rm -f "$asset"
|
||||
(cd "$(dirname "$plugin_dir")" && zip -r "$GITHUB_WORKSPACE/$asset" "$(basename "$plugin_dir")" -x "*/__pycache__/*" -x "*.pyc") >/dev/null
|
||||
|
||||
# If same tag exists, delete release and remote tag first
|
||||
# If same tag exists, delete release and both remote/local tag first
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
echo "Release $tag exists, deleting..."
|
||||
gh release delete "$tag" -y
|
||||
git push origin :refs/tags/"$tag" || true
|
||||
fi
|
||||
|
||||
# Ensure no stale local tag remains
|
||||
git tag -d "$tag" >/dev/null 2>&1 || true
|
||||
|
||||
echo "Creating release $tag"
|
||||
gh release create "$tag" "$asset" --title "$tag" --notes "Automated release of $plugin_id $plugin_version" --latest
|
||||
gh release create "$tag" "$asset" --title "$tag" --notes "Automated release of $plugin_id $plugin_version" --latest --target "$GITHUB_SHA"
|
||||
|
||||
echo "$tag" >> processed_tags.txt
|
||||
done
|
||||
|
||||
287
README.md
287
README.md
@@ -23,6 +23,8 @@ MoviePilot官方插件市场:https://github.com/jxxghp/MoviePilot-Plugins
|
||||
- [12. 如何通过插件扩展支持的存储类型?](#12-如何通过插件扩展支持的存储类型)
|
||||
- [13. 如何将插件功能集成到工作流?](#13-如何将插件功能集成到工作流)
|
||||
- [14. 如何在插件中通过消息持续与用户交互?](#14-如何在插件中通过消息持续与用户交互)
|
||||
- [15. 如何在插件中使用系统级统一缓存?](#15-如何在插件中使用系统级统一缓存)
|
||||
- [16. 如何在插件中注册智能体工具?](#16-如何在插件中注册智能体工具)
|
||||
- [版本发布](#版本发布)
|
||||
- [1. 如何发布插件版本?](#1-如何发布插件版本)
|
||||
- [2. 如何开发V2版本的插件以及实现插件多版本兼容?](#2-如何开发v2版本的插件以及实现插件多版本兼容)
|
||||
@@ -1167,6 +1169,291 @@ 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值
|
||||
- 大文件或二进制数据建议使用文件缓存后端
|
||||
- 在插件卸载时清理相关缓存,避免内存泄漏
|
||||
|
||||
### 16. 如何在插件中注册智能体工具?
|
||||
**(仅支持 `v2.8.0+` 版本)**
|
||||
- MoviePilot的AI智能体功能支持通过插件扩展工具能力,插件可以注册自定义工具供智能体调用,实现更丰富的功能扩展。
|
||||
- 1. 实现 `get_agent_tools()` 方法,返回工具类列表:
|
||||
```python
|
||||
def get_agent_tools(self) -> List[Type]:
|
||||
"""
|
||||
获取插件智能体工具
|
||||
返回工具类列表,每个工具类必须继承自 MoviePilotTool
|
||||
"""
|
||||
return [MyCustomTool, AnotherTool]
|
||||
```
|
||||
|
||||
- 2. 创建工具类,必须继承自 `MoviePilotTool` 并实现相关要求:
|
||||
```python
|
||||
from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.chain import ToolChain
|
||||
|
||||
class MyToolInput(BaseModel):
|
||||
"""工具输入参数模型"""
|
||||
explanation: str = Field(..., description="工具使用说明")
|
||||
query: str = Field(..., description="查询内容")
|
||||
limit: Optional[int] = Field(10, description="返回结果数量限制")
|
||||
|
||||
class MyCustomTool(MoviePilotTool):
|
||||
"""自定义工具示例"""
|
||||
# 工具名称,用于智能体识别和调用
|
||||
name: str = "my_custom_tool"
|
||||
|
||||
# 工具描述,用于智能体理解工具功能,建议详细描述工具用途和使用场景
|
||||
description: str = "This tool is used to perform custom operations. Use it when you need to query or process specific data."
|
||||
|
||||
# 输入参数模型,定义工具接收的参数及其类型和说明
|
||||
args_schema: Type[BaseModel] = MyToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据订阅参数生成友好的提示消息"""
|
||||
pass
|
||||
|
||||
async def run(self, query: str, limit: Optional[int] = None, **kwargs) -> str:
|
||||
"""
|
||||
实现工具的核心逻辑(异步方法)
|
||||
:param query: 查询内容
|
||||
:param limit: 结果数量限制
|
||||
:param kwargs: 其他参数,包含 explanation(工具使用说明)
|
||||
:return: 工具执行结果,返回字符串格式
|
||||
"""
|
||||
try:
|
||||
# 获取上下文信息(系统自动注入)
|
||||
session_id = self._session_id
|
||||
user_id = self._user_id
|
||||
channel = self._channel
|
||||
source = self._source
|
||||
username = self._username
|
||||
|
||||
# 执行工具逻辑
|
||||
result = await self._perform_operation(query, limit)
|
||||
|
||||
# 可以通过 send_tool_message 发送消息给用户
|
||||
await self.send_tool_message(f"操作完成: {result}", title="工具执行")
|
||||
|
||||
# 返回执行结果
|
||||
return f"成功处理查询 '{query}',返回 {len(result)} 条结果"
|
||||
except Exception as e:
|
||||
return f"执行失败: {str(e)}"
|
||||
|
||||
async def _perform_operation(self, query: str, limit: int):
|
||||
"""内部方法,执行具体操作"""
|
||||
# 实现具体业务逻辑
|
||||
pass
|
||||
```
|
||||
|
||||
- 3. 工具类可用的上下文属性和方法:
|
||||
- `self._session_id`: 当前会话ID
|
||||
- `self._user_id`: 用户ID
|
||||
- `self._channel`: 消息渠道(如 Telegram、Slack 等)
|
||||
- `self._source`: 消息来源
|
||||
- `self._username`: 用户名
|
||||
- `self.send_tool_message(message: str, title: str = "")`: 发送消息给用户
|
||||
- `ToolChain()`: 访问处理链功能,可调用系统其他功能
|
||||
|
||||
- 4. 工具类实现要求:
|
||||
- **必须继承自 `app.agent.tools.base.MoviePilotTool`**
|
||||
- **必须实现 `run` 方法**(异步方法),接收参数并返回字符串结果
|
||||
- **必须实现 `get_tool_message` 方法**,以显示友好的工具执行提示给用户
|
||||
- **必须定义 `name` 属性**(字符串),工具的唯一标识
|
||||
- **必须定义 `description` 属性**(字符串),详细描述工具功能,帮助智能体理解何时使用该工具
|
||||
- **可选定义 `args_schema` 属性**(Pydantic模型类),用于定义输入参数的结构和验证
|
||||
|
||||
- 5. 注意事项:
|
||||
- 工具的描述(`description`)应该清晰明确,帮助智能体理解工具的功能和使用场景
|
||||
- 工具的参数模型(`args_schema`)应该包含详细的字段描述,帮助智能体正确构造参数
|
||||
- 工具执行结果应该返回有意义的字符串,便于智能体理解和向用户展示
|
||||
- 工具可以通过 `send_tool_message` 方法向用户发送实时消息,提升交互体验
|
||||
- 工具类在初始化时会自动注入会话和用户信息,可以通过私有属性访问
|
||||
- 如果工具需要访问插件实例,需要自行通过 `PluginManager` 获取
|
||||
- 工具执行时间应该尽量短,避免阻塞智能体的响应
|
||||
- 建议在工具执行过程中添加适当的错误处理和日志记录
|
||||
|
||||
|
||||
## 版本发布
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 35 KiB |
43
package.json
43
package.json
@@ -26,7 +26,7 @@
|
||||
"name": "AI字幕自动生成(v2)",
|
||||
"description": "使用whisper自动生成视频文件字幕,使用大模型翻译字幕成中文。",
|
||||
"labels": "字幕",
|
||||
"version": "2.3",
|
||||
"version": "2.4",
|
||||
"icon": "autosubtitles.jpeg",
|
||||
"author": "TimoYoung",
|
||||
"level": 1,
|
||||
@@ -38,7 +38,8 @@
|
||||
"v2.0": "1.引入任务队列 2.支持监听媒体入库自动生成字幕 3.增加任务状态展示界面",
|
||||
"v2.1": "支持清除历史记录",
|
||||
"v2.2": "fix",
|
||||
"v2.3": "支持独立的大模型调用配置"
|
||||
"v2.3": "支持独立的大模型调用配置",
|
||||
"v2.4": "适配openai api v1"
|
||||
}
|
||||
},
|
||||
"CustomSites": {
|
||||
@@ -174,11 +175,12 @@
|
||||
"name": "媒体文件同步删除",
|
||||
"description": "同步删除历史记录、源文件和下载任务。",
|
||||
"labels": "文件整理",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.2",
|
||||
"icon": "mediasyncdel.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.7.2": "兼容windows路径",
|
||||
"v1.7.1": "修复删除剧集辅种失败报错问题",
|
||||
"v1.7": "修复重新整理被一并删除问题",
|
||||
"v1.6": "修复删除辅种",
|
||||
@@ -217,12 +219,13 @@
|
||||
"name": "Cloudflare IP优选",
|
||||
"description": "🌩 测试 Cloudflare CDN 延迟和速度,自动优选IP。",
|
||||
"labels": "网络,站点",
|
||||
"version": "1.4",
|
||||
"version": "1.5",
|
||||
"icon": "cloudflare.jpg",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.5": "适配CloudflareSpeedTest新版名称",
|
||||
"v1.4": "修复立即运行一次",
|
||||
"v1.3": "调整插件开启状态判断条件",
|
||||
"v1.2": "增强API安全性"
|
||||
@@ -319,11 +322,12 @@
|
||||
"name": "IYUU自动辅种",
|
||||
"description": "基于IYUU官方Api实现自动辅种。",
|
||||
"labels": "做种,IYUU",
|
||||
"version": "1.9.11",
|
||||
"version": "1.9.12",
|
||||
"icon": "IYUU.png",
|
||||
"author": "jxxghp",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.9.12": "修复海豹不能辅种的问题",
|
||||
"v1.9.11": "修复馒头不能辅种的问题",
|
||||
"v1.9.10": "Revert 辅种结束后,一起开始所有辅种后暂停的种子(排除了出错的种子)",
|
||||
"v1.9.9": "修复qb辅种结束后自动开始暂停的种子",
|
||||
@@ -463,12 +467,16 @@
|
||||
"name": "药丸签到",
|
||||
"description": "药丸论坛签到。",
|
||||
"labels": "站点",
|
||||
"version": "1.4.1",
|
||||
"version": "2.0.2",
|
||||
"icon": "invites.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
|
||||
"v2.0.1": "尝试修复签到失败问题,新增使用代理、Cookie自动更新功能",
|
||||
"v2.0.0": "修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI",
|
||||
"v1.4.1": "更新签到域名前缀",
|
||||
"v1.4": "自定义保留消息天数"
|
||||
}
|
||||
@@ -477,11 +485,12 @@
|
||||
"name": "演职人员刮削",
|
||||
"description": "刮削演职人员图片以及中文名称。",
|
||||
"labels": "媒体库,刮削",
|
||||
"version": "1.4",
|
||||
"version": "1.4.1",
|
||||
"icon": "actor.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.4.1": "修复异常报错问题",
|
||||
"v1.4": "人物图片调整为优先从TMDB获取,避免douban图片CDN加载过慢的问题",
|
||||
"v1.3": "修复v1.8.5版本后刮削报错问题"
|
||||
}
|
||||
@@ -490,11 +499,12 @@
|
||||
"name": "MoviePilot更新推送",
|
||||
"description": "MoviePilot推送release更新通知、自动重启。",
|
||||
"labels": "消息通知,自动更新",
|
||||
"version": "1.4",
|
||||
"version": "1.5",
|
||||
"icon": "Moviepilot_A.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.5": "修复版本描述为空时的报错",
|
||||
"v1.4": "兼容更新内容带版本号的情况",
|
||||
"v1.3": "增加前端版本更新检查,需要主程序升级至v1.8.4+版本"
|
||||
}
|
||||
@@ -560,12 +570,13 @@
|
||||
"name": "TMDB剧集组刮削",
|
||||
"description": "从TMDB剧集组刮削季集的实际顺序。",
|
||||
"labels": "刮削",
|
||||
"version": "2.6",
|
||||
"version": "2.6.1",
|
||||
"icon": "Element_A.png",
|
||||
"author": "叮叮当",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v2.6.1": "修复异常报错日志",
|
||||
"v2.6": "修复无法获取媒体库中季0的问题",
|
||||
"v2.5": "修复当媒体服务器中剧集的季不完整时会中断的问题",
|
||||
"v2.3": "修复v2版本无法读取媒体库的问题",
|
||||
@@ -801,13 +812,15 @@
|
||||
"name": "ntfy消息推送",
|
||||
"description": "支持使用ntfy发送消息通知。",
|
||||
"labels": "消息通知",
|
||||
"version": "1.1",
|
||||
"version": "1.3",
|
||||
"icon": "Ntfy_A.png",
|
||||
"author": "lethargicScribe",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.1": "添加Token认证和用户动作"
|
||||
"v1.1": "添加Token认证和用户动作",
|
||||
"v1.2": "修复 ntfy 通知图标链接失效的问题",
|
||||
"v1.3": "修复标题或文本为空时,通知发送失败的问题"
|
||||
}
|
||||
},
|
||||
"GotifyMsg": {
|
||||
@@ -838,7 +851,6 @@
|
||||
"icon": "Macos_Sierra.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.4.1": "修复Bing壁纸命名问题",
|
||||
"v1.3": "适配MoviePilot v2.5.3+版本",
|
||||
@@ -943,11 +955,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",
|
||||
|
||||
124
package.v2.json
124
package.v2.json
@@ -24,11 +24,13 @@
|
||||
"name": "站点刷流",
|
||||
"description": "自动托管刷流,将会提高对应站点的访问频率。",
|
||||
"labels": "刷流,仪表板",
|
||||
"version": "4.3.3",
|
||||
"version": "4.3.5",
|
||||
"icon": "brush.jpg",
|
||||
"author": "jxxghp,InfinityPacer",
|
||||
"author": "jxxghp,InfinityPacer,Seed680",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v4.3.5": "提升匹配规则时的健壮性",
|
||||
"v4.3.4": "添加RSS支持配置选项",
|
||||
"v4.3.2": "增加'删除促销结束的未完成下载'功能",
|
||||
"v4.3.1": "修复了一些细节问题",
|
||||
"v4.3": "支持带宽采样并计算平均值,以优化刷流效率",
|
||||
@@ -42,12 +44,14 @@
|
||||
"name": "站点自动签到",
|
||||
"description": "自动模拟登录、签到站点。",
|
||||
"labels": "站点",
|
||||
"version": "2.6",
|
||||
"version": "2.8",
|
||||
"icon": "signin.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.8": "适配站点 Rousi Pro",
|
||||
"v2.7": "站点请求使用站点设置的超时时间",
|
||||
"v2.6": "感谢madrays佬提供的UI!",
|
||||
"v2.5.4": "增加保号风险提示",
|
||||
"v2.5.3": "优化执行周期输入,需要MoviePilot v2.2.1+",
|
||||
@@ -60,11 +64,15 @@
|
||||
"name": "下载任务分类与标签",
|
||||
"description": "自动给下载任务分类与打站点标签、剧集名称标签",
|
||||
"labels": "下载管理",
|
||||
"version": "2.2",
|
||||
"version": "2.6",
|
||||
"icon": "Youtube-dl_B.png",
|
||||
"author": "叮叮当",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.6": "增加站点/剧名前缀功能",
|
||||
"v2.5": "优化采用公共服务自动清理未使用标签",
|
||||
"v2.4": "增加自动清理未使用标签",
|
||||
"v2.3": "增加tracker映射配置",
|
||||
"v2.2": "MoviePilot V2 版本下载任务分类与标签插件"
|
||||
}
|
||||
},
|
||||
@@ -87,11 +95,17 @@
|
||||
"name": "媒体库服务器通知",
|
||||
"description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。",
|
||||
"labels": "消息通知,媒体库",
|
||||
"version": "1.6",
|
||||
"version": "1.8.2.2",
|
||||
"icon": "mediaplay.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.8.2.2": "修复emby多条相同新入库消息推送多次的问题",
|
||||
"v1.8.2.1": "修复多集时有概率图片获取失败的问题;修复emby测试通知类型接收失败的问题",
|
||||
"v1.8.1": "修复单集剧情信息有概率获取失败的问题",
|
||||
"v1.8": "当整理路径中没有tmdbid时,会尝试从媒体服务器中获取",
|
||||
"v1.7.1": "未获取到tmdb信息则按原有逻辑发送;电影显示海报",
|
||||
"v1.7": "对TV剧集入库事件进行聚合,避免消息轰炸。更新后如果打不开插件,请重置插件",
|
||||
"v1.6": "查询剧集图片兼容没有季集信息的情况",
|
||||
"v1.5": "支持独立控制媒体服务器通知",
|
||||
"v1.4": "MoviePilot V2 版本媒体库服务器通知插件"
|
||||
@@ -101,11 +115,12 @@
|
||||
"name": "ChatGPT",
|
||||
"description": "消息交互支持与ChatGPT对话。",
|
||||
"labels": "消息通知,识别",
|
||||
"version": "2.1.7",
|
||||
"version": "2.1.8",
|
||||
"icon": "Chatgpt_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.1.8": "修复 OpenAI API >=1.0.0 兼容性问题",
|
||||
"v2.1.7":"独立安装OpenAi SDK依赖",
|
||||
"v2.1.6": "支持自定义辅助识别提示词",
|
||||
"v2.1.5": "兼容一些模型返回json数据信息用markdown语法包裹的情况",
|
||||
@@ -182,11 +197,13 @@
|
||||
"name": "演职人员刮削",
|
||||
"description": "刮削演职人员图片以及中文名称。",
|
||||
"labels": "媒体库,刮削",
|
||||
"version": "2.2",
|
||||
"version": "2.2.2",
|
||||
"icon": "actor.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.2.2": "修复异常日志问题",
|
||||
"v2.2.1": "优化错误数据兼容处理",
|
||||
"v2.2": "修改使用自定义图片域名时无法下载图片的问题",
|
||||
"v2.1": "优化执行周期输入,需要MoviePilot v2.2.1+",
|
||||
"v2.0": "兼容MoviePilot V2 版本",
|
||||
@@ -240,11 +257,12 @@
|
||||
"name": "IYUU自动辅种",
|
||||
"description": "基于IYUU官方Api实现自动辅种。",
|
||||
"labels": "做种,IYUU",
|
||||
"version": "2.14",
|
||||
"version": "2.15",
|
||||
"icon": "IYUU.png",
|
||||
"author": "jxxghp,CKun",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v2.15": "修复海豹不能辅种的问题",
|
||||
"v2.14": "修复馒头不能辅种的问题",
|
||||
"v2.13": "开启跳过校验后需手动开启自动开始",
|
||||
"v2.12": "增加qb下载器分类复用配置",
|
||||
@@ -350,15 +368,28 @@
|
||||
"v2.0": "适配新的目录结构变化,短剧分类名称调整为配置目录路径,升级后需要重新调整设置后才能使用。"
|
||||
}
|
||||
},
|
||||
"MultiClass": {
|
||||
"name": "视频多级分类",
|
||||
"description": "支持视频多级分类",
|
||||
"labels": "文件整理",
|
||||
"version": "0.1",
|
||||
"icon": "Calibreweb_B.png",
|
||||
"author": "liuhangbin",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v0.1": "视频多级分类插件, 目前仅支持电影按评分,年代,系列分类。"
|
||||
}
|
||||
},
|
||||
"MoviePilotUpdateNotify": {
|
||||
"name": "MoviePilot更新推送",
|
||||
"description": "MoviePilot推送release更新通知、自动重启。",
|
||||
"labels": "消息通知,自动更新",
|
||||
"version": "2.2",
|
||||
"version": "2.3",
|
||||
"icon": "Moviepilot_A.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.3": "修复版本描述为空时的报错",
|
||||
"v2.2": "支持 MoviePilot v2.5.0+",
|
||||
"v2.1": "优化执行周期输入,需要MoviePilot v2.2.1+",
|
||||
"v2.0": "兼容MoviePilot V2"
|
||||
@@ -419,11 +450,15 @@
|
||||
"name": "绕过Trackers",
|
||||
"description": "提供tracker服务器IP地址列表,帮助IPv6连接绕过OpenClash。",
|
||||
"labels": "工具",
|
||||
"version": "1.4.2",
|
||||
"version": "1.5.2",
|
||||
"icon": "Clash_A.png",
|
||||
"author": "wumode",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.5.2": "支持从站点首页获取最新 Trackers",
|
||||
"v1.5.1": "新增 Tracker",
|
||||
"v1.5.0": "新增 Page 界面; 支持通过`/check_ip` 命令检查IP; 改进 UI",
|
||||
"v1.4.3": "修复 bug",
|
||||
"v1.4.2": "修复插件动作",
|
||||
"v1.4.1": "修复通知类型错误",
|
||||
"v1.4": "异步查询DNS",
|
||||
@@ -437,11 +472,20 @@
|
||||
"name": "IMDb源",
|
||||
"description": "让探索,推荐和媒体识别支持IMDb数据源。",
|
||||
"labels": "探索",
|
||||
"version": "1.5.6",
|
||||
"version": "1.6.7",
|
||||
"icon": "IMDb_IOS-OSX_App.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.6.7": "优化界面显示; 增加榜单排名显示; 添加制作公司过滤项",
|
||||
"v1.6.6": "优化主页组件链接跳转",
|
||||
"v1.6.5": "仪表盘组件支持图片缓存",
|
||||
"v1.6.4": "为元数据增加背景图",
|
||||
"v1.6.3": "优化媒体识别速度; 适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
|
||||
"v1.6.2": "修复 API 查询错误重试问题",
|
||||
"v1.6.1": "添加中文主屏幕组件; 修复 bug",
|
||||
"v1.5.8": "修改UA",
|
||||
"v1.5.7": "改进异常处理",
|
||||
"v1.5.6": "固定仪表盘组件海报比例; 修复 bug",
|
||||
"v1.5.5": "修复初始化错误",
|
||||
"v1.5.4": "改进媒体识别",
|
||||
@@ -453,7 +497,7 @@
|
||||
"v1.4.3": "为仪表盘组件添加缓存",
|
||||
"v1.4.2": "优化小屏幕组件显示",
|
||||
"v1.4.1": "优化亮色主题显示",
|
||||
"v1.4.0":"添加仪表盘组件: IMDb 编辑精选",
|
||||
"v1.4.0": "添加仪表盘组件: IMDb 编辑精选",
|
||||
"v1.3.3": "修复依赖问题",
|
||||
"v1.3.2": "更新 API query hash",
|
||||
"v1.3.1": "修复按日期排序错误",
|
||||
@@ -467,12 +511,28 @@
|
||||
"name": "Clash Rule Provider",
|
||||
"description": "随时为Clash添加一些额外的规则。",
|
||||
"labels": "工具",
|
||||
"version": "1.3.2",
|
||||
"version": "2.1.2",
|
||||
"icon": "Mihomo_Meta_A.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.1.2": "修复规则集序列化错误",
|
||||
"v2.1.1": "增强数据管理功能",
|
||||
"v2.0.10": "适配 MoviePilot 2.8.4",
|
||||
"v2.0.9": "适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
|
||||
"v2.0.8": "修复已知问题",
|
||||
"v2.0.7": "修复子规则比较错误",
|
||||
"v2.0.6": "修复已知问题; 改进对代理组的配置和验证",
|
||||
"v2.0.5": "完善了对嵌套逻辑规则和子规则的配置和验证",
|
||||
"v2.0.4": "修复已知问题; 使用异步调度器; 显示规则更改日期",
|
||||
"v2.0.3": "修复已知问题",
|
||||
"v2.0.2": "修复分享链接转换问题",
|
||||
"v2.0.1": "支持独立的订阅链接配置, 覆写代理组和出站代理; 优化数据结构; 修复分享链接解析问题",
|
||||
"v1.4.2": "优化移动端 UI; 支持显示节点链接",
|
||||
"v1.4.1": "修复配置模板保存错误, 请重新配置Clash模板",
|
||||
"v1.4.0": "优化 UI; 支持连接多个 Clash Dashboards",
|
||||
"v1.3.3": "通过emoji识别国家; 按国家分组节点; mrs格式支持",
|
||||
"v1.3.2": "注册插件动作",
|
||||
"v1.3.1": "支持配置 Hosts",
|
||||
"v1.2.8": "改进导入界面",
|
||||
@@ -495,11 +555,20 @@
|
||||
"name": "美剧生词标注",
|
||||
"description": "根据CEFR等级,为英语影视剧标注高级词汇。",
|
||||
"labels": "英语",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.4",
|
||||
"icon": "LexiAnnot.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.2.4": "增强数据校验",
|
||||
"v1.2.3": "优化提示词",
|
||||
"v1.2.1": "改进字幕样式获取方法",
|
||||
"v1.2.0": "引入大模型候选词决策和词义丰富处理链; 支持读取系统智能体配置; 添加智能体工具; 优化通知样式; 改进 UI",
|
||||
"v1.1.4": "优化字幕选择决策",
|
||||
"v1.1.3": "适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
|
||||
"v1.1.2": "使用子进程避免 spaCy 模型常驻内存",
|
||||
"v1.1.1": "添加任务页面; 改进 spaCy 模型加载逻辑",
|
||||
"v1.1.0": "支持考试词汇标注; 优化分词处理; 修复错误",
|
||||
"v1.0.1": "合并连字符词; 避免ARM平台依赖问题",
|
||||
"v1.0": "新增LexiAnnot"
|
||||
}
|
||||
@@ -516,5 +585,32 @@
|
||||
"v1.0.0": "首个版本,新增MeoW消息通知",
|
||||
"v1.0.1": "优化代码,修复运行一次按钮没办法自动关闭的问题"
|
||||
}
|
||||
},
|
||||
"BugReporter": {
|
||||
"name": "Bug反馈",
|
||||
"description": "自动上报异常,协助开发者发现和解决问题。",
|
||||
"labels": "开发",
|
||||
"version": "1.3",
|
||||
"icon": "Alist_encrypt_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.3": "减少网络异常信息上送",
|
||||
"v1.2": "优化上报信息量",
|
||||
"v1.1": "加强脱敏处理"
|
||||
}
|
||||
},
|
||||
"TmdbWallpaper": {
|
||||
"name": "登录壁纸本地化",
|
||||
"description": "将MoviePilot的登录壁纸下载到本地。",
|
||||
"labels": "壁纸,本地化",
|
||||
"version": "1.4.2",
|
||||
"icon": "Macos_Sierra.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.4.2": "适配MoviePilot v2.8.8+",
|
||||
"v1.4.1": "MoviePilot V2 版本登录壁纸本地化插件"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@ from typing import Any, List, Dict, Tuple, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
@@ -26,6 +22,9 @@ from app.utils.http import RequestUtils
|
||||
from app.utils.site import SiteUtils
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.timer import TimerUtils
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
|
||||
class AutoSignIn(_PluginBase):
|
||||
@@ -36,7 +35,7 @@ class AutoSignIn(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "signin.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.6"
|
||||
plugin_version = "2.8"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
@@ -1545,6 +1544,7 @@ class AutoSignIn(_PluginBase):
|
||||
render = site_info.get("render")
|
||||
proxies = settings.PROXY if site_info.get("proxy") else None
|
||||
proxy_server = settings.PROXY_SERVER if site_info.get("proxy") else None
|
||||
timeout = site_info.get("timeout") or 60
|
||||
if not site_url or not site_cookie:
|
||||
logger.warn(f"未配置 {site} 的站点地址或Cookie,无法签到")
|
||||
return False, ""
|
||||
@@ -1560,7 +1560,8 @@ class AutoSignIn(_PluginBase):
|
||||
page_source = PlaywrightHelper().get_page_source(url=checkin_url,
|
||||
cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxy_server)
|
||||
proxies=proxy_server,
|
||||
timeout=timeout)
|
||||
if not SiteUtils.is_logged_in(page_source):
|
||||
if under_challenge(page_source):
|
||||
return False, f"无法通过Cloudflare!"
|
||||
@@ -1574,13 +1575,15 @@ class AutoSignIn(_PluginBase):
|
||||
else:
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).get_res(url=checkin_url)
|
||||
if not res and site_url != checkin_url:
|
||||
logger.info(f"开始站点模拟登录:{site},地址:{site_url}...")
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).get_res(url=site_url)
|
||||
# 判断登录状态
|
||||
if res and res.status_code in [200, 500, 403]:
|
||||
@@ -1647,6 +1650,7 @@ class AutoSignIn(_PluginBase):
|
||||
render = site_info.get("render")
|
||||
proxies = settings.PROXY if site_info.get("proxy") else None
|
||||
proxy_server = settings.PROXY_SERVER if site_info.get("proxy") else None
|
||||
timeout = site_info.get("timeout") or 60
|
||||
if not site_url or not site_cookie:
|
||||
logger.warn(f"未配置 {site} 的站点地址或Cookie,无法签到")
|
||||
return False, ""
|
||||
@@ -1659,7 +1663,8 @@ class AutoSignIn(_PluginBase):
|
||||
page_source = PlaywrightHelper().get_page_source(url=site_url,
|
||||
cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxy_server)
|
||||
proxies=proxy_server,
|
||||
timeout=timeout)
|
||||
if not SiteUtils.is_logged_in(page_source):
|
||||
if under_challenge(page_source):
|
||||
return False, f"无法通过Cloudflare!"
|
||||
@@ -1669,7 +1674,8 @@ class AutoSignIn(_PluginBase):
|
||||
else:
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).get_res(url=site_url)
|
||||
# 判断登录状态
|
||||
if res and res.status_code in [200, 500, 403]:
|
||||
|
||||
@@ -2,13 +2,12 @@ import random
|
||||
import re
|
||||
from typing import Tuple
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.plugins.autosignin.sites import _ISiteSigninHandler
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
from lxml import etree
|
||||
|
||||
|
||||
class Pt52(_ISiteSigninHandler):
|
||||
@@ -46,14 +45,16 @@ class Pt52(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
render = site_info.get("render")
|
||||
proxy = site_info.get("proxy")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 判断今日是否已签到
|
||||
html_text = self.get_page_source(url='https://52pt.site/bakatest.php',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
@@ -97,14 +98,16 @@ class Pt52(_ISiteSigninHandler):
|
||||
site_cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
site=site)
|
||||
site=site,
|
||||
timeout=timeout)
|
||||
|
||||
def __signin(self, questionid: str,
|
||||
choice: list,
|
||||
site: str,
|
||||
site_cookie: str,
|
||||
ua: str,
|
||||
proxy: bool) -> Tuple[bool, str]:
|
||||
proxy: bool,
|
||||
timeout: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
签到请求
|
||||
questionid: 450
|
||||
@@ -124,7 +127,8 @@ class Pt52(_ISiteSigninHandler):
|
||||
|
||||
sign_res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
timeout=timeout
|
||||
).post_res(url='https://52pt.site/bakatest.php', data=data)
|
||||
if not sign_res or sign_res.status_code != 200:
|
||||
logger.error(f"{site} 签到失败,签到接口请求失败")
|
||||
|
||||
@@ -42,7 +42,8 @@ class _ISiteSigninHandler(metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_page_source(url: str, cookie: str, ua: str, proxy: bool, render: bool, token: str = None) -> str:
|
||||
def get_page_source(url: str, cookie: str, ua: str, proxy: bool, render: bool,
|
||||
token: str = None, timeout: int = None) -> str:
|
||||
"""
|
||||
获取页面源码
|
||||
:param url: Url地址
|
||||
@@ -51,13 +52,15 @@ class _ISiteSigninHandler(metaclass=ABCMeta):
|
||||
:param proxy: 是否使用代理
|
||||
:param render: 是否渲染
|
||||
:param token: JWT Token
|
||||
:param timeout: 请求超时时间,单位秒
|
||||
:return: 页面源码,错误信息
|
||||
"""
|
||||
if render:
|
||||
return PlaywrightHelper().get_page_source(url=url,
|
||||
cookies=cookie,
|
||||
ua=ua,
|
||||
proxies=settings.PROXY_SERVER if proxy else None)
|
||||
proxies=settings.PROXY_SERVER if proxy else None,
|
||||
timeout=timeout or 60)
|
||||
else:
|
||||
if token:
|
||||
headers = {
|
||||
@@ -70,7 +73,8 @@ class _ISiteSigninHandler(metaclass=ABCMeta):
|
||||
"Cookie": cookie
|
||||
}
|
||||
res = RequestUtils(headers=headers,
|
||||
proxies=settings.PROXY if proxy else None).get_res(url=url)
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
timeout=timeout or 20).get_res(url=url)
|
||||
if res is not None:
|
||||
# 使用chardet检测字符编码
|
||||
raw_data = res.content
|
||||
|
||||
@@ -37,6 +37,7 @@ class BTSchool(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
render = site_info.get("render")
|
||||
proxy = site_info.get("proxy")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
logger.info(f"{site} 开始签到")
|
||||
# 判断今日是否已签到
|
||||
@@ -44,7 +45,8 @@ class BTSchool(_ISiteSigninHandler):
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
@@ -63,7 +65,8 @@ class BTSchool(_ISiteSigninHandler):
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,签到接口请求失败")
|
||||
|
||||
@@ -47,13 +47,15 @@ class CHDBits(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 判断今日是否已签到
|
||||
html_text = self.get_page_source(url='https://ptchdbits.co/bakatest.php',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
|
||||
@@ -37,21 +37,24 @@ class HaiDan(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 签到
|
||||
# 签到页会重定向到index.php,由于302重定向特性,导致index.php没有携带cookie
|
||||
self.get_page_source(url='https://www.haidan.video/signin.php',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
# 重新携带cookie获取index.php查看签到结果
|
||||
html_text = self.get_page_source(url='https://www.haidan.video/index.php',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -40,13 +40,15 @@ class Hares(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
html_text = self.get_page_source(url='https://club.hares.top',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
if not html_text:
|
||||
logger.error(f"{site} 模拟访问失败,请检查站点连通性")
|
||||
@@ -66,7 +68,8 @@ class Hares(_ISiteSigninHandler):
|
||||
}
|
||||
sign_res = RequestUtils(cookies=site_cookie,
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
timeout=timeout
|
||||
).get_res(url="https://club.hares.top/attendance.php?action=sign")
|
||||
if not sign_res or sign_res.status_code != 200:
|
||||
logger.error(f"{site} 签到失败,签到接口请求失败")
|
||||
|
||||
@@ -40,6 +40,7 @@ class HDArea(_ISiteSigninHandler):
|
||||
site_cookie = site_info.get("cookie")
|
||||
ua = site_info.get("ua")
|
||||
proxies = settings.PROXY if site_info.get("proxy") else None
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
data = {
|
||||
@@ -47,7 +48,8 @@ class HDArea(_ISiteSigninHandler):
|
||||
}
|
||||
html_res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).post_res(url="https://hdarea.club/sign_in.php", data=data)
|
||||
if not html_res or html_res.status_code != 200:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
|
||||
@@ -40,6 +40,7 @@ class HDChina(_ISiteSigninHandler):
|
||||
site_cookie = site_info.get("cookie")
|
||||
ua = site_info.get("ua")
|
||||
proxies = settings.PROXY if site_info.get("proxy") else None
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 尝试解决瓷器cookie每天签到后过期,只保留hdchina=部分
|
||||
cookie = ""
|
||||
@@ -59,7 +60,8 @@ class HDChina(_ISiteSigninHandler):
|
||||
# 获取页面html
|
||||
html_res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).get_res(url="https://hdchina.org/index.php")
|
||||
if not html_res or html_res.status_code != 200:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
@@ -99,7 +101,8 @@ class HDChina(_ISiteSigninHandler):
|
||||
}
|
||||
sign_res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).post_res(url="https://hdchina.org/plugin_sign-in.php?cmd=signin", data=data)
|
||||
if not sign_res or sign_res.status_code != 200:
|
||||
logger.error(f"{site} 签到失败,签到接口请求失败")
|
||||
|
||||
@@ -39,13 +39,15 @@ class HDCity(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
html_text = self.get_page_source(url='https://hdcity.city/sign',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -43,13 +43,15 @@ class HDSky(_ISiteSigninHandler):
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
referer = site_info.get("url")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 判断今日是否已签到
|
||||
html_text = self.get_page_source(url='https://hdsky.me',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
@@ -73,7 +75,8 @@ class HDSky(_ISiteSigninHandler):
|
||||
content_type='application/x-www-form-urlencoded; charset=UTF-8',
|
||||
referer="https://hdsky.me/index.php",
|
||||
accept_type="*/*",
|
||||
proxies=settings.PROXY if proxy else None
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
timeout=timeout
|
||||
).post_res(url='https://hdsky.me/image_code_ajax.php',
|
||||
data={'action': 'new'})
|
||||
if image_res and image_res.status_code == 200:
|
||||
|
||||
@@ -41,13 +41,15 @@ class HDUpt(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
html_text = self.get_page_source(url='https://pt.hdupt.com',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
@@ -67,7 +69,8 @@ class HDUpt(_ISiteSigninHandler):
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from typing import Tuple
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
@@ -38,10 +37,11 @@ class MTorrent(_ISiteSigninHandler):
|
||||
"Authorization": site_info.get("token")
|
||||
}
|
||||
url = site_info.get('url')
|
||||
timeout = site_info.get("timeout")
|
||||
domain = StringUtils.get_url_domain(url)
|
||||
# 更新最后访问时间
|
||||
res = RequestUtils(headers=headers,
|
||||
timeout=60,
|
||||
timeout=timeout,
|
||||
proxies=settings.PROXY if site_info.get("proxy") else None,
|
||||
referer=f"{url}index"
|
||||
).post_res(url=f"https://api.{domain}/api/member/updateLastBrowse")
|
||||
|
||||
@@ -40,6 +40,7 @@ class NexusHD(_ISiteSigninHandler):
|
||||
site_cookie = site_info.get("cookie")
|
||||
ua = site_info.get("ua")
|
||||
proxies = settings.PROXY if site_info.get("proxy") else None
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
data = {
|
||||
@@ -48,7 +49,8 @@ class NexusHD(_ISiteSigninHandler):
|
||||
}
|
||||
html_res = RequestUtils(cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
proxies=proxies,
|
||||
timeout=timeout
|
||||
).post_res(url="https://v6.nexushd.org/signin.php", data=data)
|
||||
if not html_res or html_res.status_code != 200:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
|
||||
@@ -43,13 +43,15 @@ class Opencd(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 判断今日是否已签到
|
||||
html_text = self.get_page_source(url='https://www.open.cd',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -35,13 +35,15 @@ class PTerClub(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 签到
|
||||
html_text = self.get_page_source(url='https://pterclub.com/attendance-ajax.php',
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -37,6 +37,7 @@ class PTTime(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 签到
|
||||
# 签到返回:<html><head></head><body>签到成功</body></html>
|
||||
@@ -44,7 +45,8 @@ class PTTime(_ISiteSigninHandler):
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
|
||||
114
plugins.v2/autosignin/sites/rousipro.py
Normal file
114
plugins.v2/autosignin/sites/rousipro.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from typing import Tuple
|
||||
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
from app.log import logger
|
||||
from app.core.config import settings
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
from app.plugins.autosignin.sites import _ISiteSigninHandler
|
||||
|
||||
|
||||
class RousiPro(_ISiteSigninHandler):
|
||||
"""
|
||||
rousi pro 签到
|
||||
"""
|
||||
# 匹配的站点Url,每一个实现类都需要设置为自己的站点Url
|
||||
site_url = "rousi.pro"
|
||||
|
||||
@classmethod
|
||||
def match(cls, url: str) -> bool:
|
||||
"""
|
||||
根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可
|
||||
:param url: 站点Url
|
||||
:return: 是否匹配,如匹配则会调用该类的signin方法
|
||||
"""
|
||||
return True if StringUtils.url_equal(url, cls.site_url) else False
|
||||
|
||||
def signin(self, site_info: CommentedMap) -> Tuple[bool, str]:
|
||||
"""
|
||||
执行签到操作,固定签到
|
||||
:param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息
|
||||
:return: 签到结果信息
|
||||
"""
|
||||
site = site_info.get("name")
|
||||
ua = site_info.get("ua")
|
||||
token = site_info.get("token")
|
||||
timeout = site_info.get("timeout")
|
||||
if not token or token.strip() == "":
|
||||
logger.error(f"{site} 签到失败,缺少 Authorization 信息")
|
||||
return False, "签到失败,缺少 Authorization 信息"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": ua,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Authorization": token if token.startswith("Bearer ") else f"Bearer {token}"
|
||||
}
|
||||
body = {
|
||||
"mode": "fixed"
|
||||
}
|
||||
res = RequestUtils(
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
proxies=settings.PROXY if site_info.get("proxy") else None,
|
||||
).post_res(
|
||||
url="https://rousi.pro/api/points/attendance",
|
||||
json=body
|
||||
)
|
||||
|
||||
if res is not None and res.status_code == 200 and "签到成功" in res.json().get("message", ""):
|
||||
logger.info(f"{site} 签到成功")
|
||||
return True, "签到成功"
|
||||
elif res is not None and res.status_code == 400 and res.json().get("error", "") == "今日已签到":
|
||||
logger.info(f"{site} 今日已签到")
|
||||
return True, "今日已签到"
|
||||
elif res is not None and res.status_code == 401:
|
||||
logger.error(f"{site} 签到失败,登录状态无效")
|
||||
return False, "签到失败,登录状态无效"
|
||||
elif res is not None:
|
||||
logger.error(f"{site} 签到失败,状态码:{res.status_code}")
|
||||
return False, f"签到失败,状态码:{res.status_code}"
|
||||
else:
|
||||
logger.error(f"{site} 签到失败,无法访问网站")
|
||||
return False, "签到失败,无法访问网站"
|
||||
|
||||
def login(self, site_info: CommentedMap) -> Tuple[bool, str]:
|
||||
"""
|
||||
执行登录操作,访问签到统计接口更新站点最后活跃时间
|
||||
:param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息
|
||||
:return: 登录结果信息
|
||||
"""
|
||||
site = site_info.get("name")
|
||||
ua = site_info.get("ua")
|
||||
token = site_info.get("token")
|
||||
timeout = site_info.get("timeout")
|
||||
if not token or token.strip() == "":
|
||||
logger.error(f"{site} 模拟登录失败,缺少 Authorization 信息")
|
||||
return False, "模拟登录失败,缺少 Authorization 信息"
|
||||
|
||||
headers = {
|
||||
"User-Agent": ua,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Authorization": token if token.startswith("Bearer ") else f"Bearer {token}"
|
||||
}
|
||||
res = RequestUtils(
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
proxies=settings.PROXY if site_info.get("proxy") else None,
|
||||
).get_res(
|
||||
url="https://rousi.pro/api/points/attendance/stats"
|
||||
)
|
||||
|
||||
if res is not None and res.status_code == 200 and "attended_dates" in res.json():
|
||||
logger.info(f"{site} 模拟登录成功")
|
||||
return True, "模拟登录成功"
|
||||
elif res is not None and res.status_code == 401:
|
||||
logger.error(f"{site} 模拟登录失败,登录状态无效")
|
||||
return False, "模拟登录失败,登录状态无效"
|
||||
elif res is not None:
|
||||
logger.error(f"{site} 模拟登录失败,状态码:{res.status_code}")
|
||||
return False, f"模拟登录失败,状态码:{res.status_code}"
|
||||
else:
|
||||
logger.error(f"{site} 模拟登录失败,无法访问网站")
|
||||
return False, "模拟登录失败,无法访问网站"
|
||||
@@ -57,6 +57,7 @@ class Tjupt(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 创建正确答案存储目录
|
||||
if not os.path.exists(os.path.dirname(self._answer_file)):
|
||||
@@ -67,7 +68,8 @@ class Tjupt(_ISiteSigninHandler):
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
|
||||
# 获取签到后返回html,判断是否签到成功
|
||||
if not html_text:
|
||||
|
||||
@@ -44,13 +44,15 @@ class TTG(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
html_text = self.get_page_source(url="https://totheglory.im",
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -50,6 +50,7 @@ class U2(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
now = datetime.datetime.now()
|
||||
# 判断当前时间是否小于9点
|
||||
@@ -62,7 +63,8 @@ class U2(_ISiteSigninHandler):
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 签到失败,请检查站点连通性")
|
||||
return False, '签到失败,请检查站点连通性'
|
||||
|
||||
@@ -37,7 +37,7 @@ class YemaPT(_ISiteSigninHandler):
|
||||
}
|
||||
# 获取用户信息,更新最后访问时间
|
||||
res = (RequestUtils(headers=headers,
|
||||
timeout=15,
|
||||
timeout=site_info.get("timeout"),
|
||||
cookies=site_info.get("cookie"),
|
||||
proxies=settings.PROXY if site_info.get("proxy") else None,
|
||||
referer=site_info.get('url')
|
||||
@@ -64,7 +64,7 @@ class YemaPT(_ISiteSigninHandler):
|
||||
}
|
||||
# 获取用户信息,更新最后访问时间
|
||||
res = (RequestUtils(headers=headers,
|
||||
timeout=15,
|
||||
timeout=site_info.get("timeout"),
|
||||
cookies=site_info.get("cookie"),
|
||||
proxies=settings.PROXY if site_info.get("proxy") else None,
|
||||
referer=site_info.get('url')
|
||||
|
||||
@@ -38,13 +38,15 @@ class ZhuQue(_ISiteSigninHandler):
|
||||
ua = site_info.get("ua")
|
||||
proxy = site_info.get("proxy")
|
||||
render = site_info.get("render")
|
||||
timeout = site_info.get("timeout")
|
||||
|
||||
# 获取页面html
|
||||
html_text = self.get_page_source(url="https://zhuque.in",
|
||||
cookie=site_cookie,
|
||||
ua=ua,
|
||||
proxy=proxy,
|
||||
render=render)
|
||||
render=render,
|
||||
timeout=timeout)
|
||||
if not html_text:
|
||||
logger.error(f"{site} 模拟登录失败,请检查站点连通性")
|
||||
return False, '模拟登录失败,请检查站点连通性'
|
||||
@@ -73,7 +75,8 @@ class ZhuQue(_ISiteSigninHandler):
|
||||
}
|
||||
skill_res = RequestUtils(cookies=site_cookie,
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
timeout=timeout
|
||||
).post_res(url="https://zhuque.in/api/gaming/fireGenshinCharacterMagic", json=data)
|
||||
if not skill_res or skill_res.status_code != 200:
|
||||
logger.error(f"模拟登录失败,释放技能失败")
|
||||
|
||||
@@ -79,6 +79,7 @@ class BrushConfig:
|
||||
self.qb_category = config.get("qb_category")
|
||||
self.site_hr_active = config.get("site_hr_active", False)
|
||||
self.site_skip_tips = config.get("site_skip_tips", False)
|
||||
self.rss_support = config.get("rss_support", False)
|
||||
|
||||
self.brush_tag = "刷流"
|
||||
# 站点独立配置
|
||||
@@ -123,7 +124,8 @@ class BrushConfig:
|
||||
"qb_category",
|
||||
"site_hr_active",
|
||||
"site_skip_tips",
|
||||
"del_no_free"
|
||||
"del_no_free",
|
||||
"rss_support"
|
||||
# 当新增支持字段时,仅在此处添加字段名
|
||||
}
|
||||
try:
|
||||
@@ -193,7 +195,8 @@ class BrushConfig:
|
||||
"del_no_free": false,
|
||||
"qb_category": "刷流",
|
||||
"site_hr_active": true,
|
||||
"site_skip_tips": true
|
||||
"site_skip_tips": true,
|
||||
"rss_support": true
|
||||
}]"""
|
||||
return desc + config
|
||||
|
||||
@@ -259,9 +262,9 @@ class BrushFlow(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "brush.jpg"
|
||||
# 插件版本
|
||||
plugin_version = "4.3.3"
|
||||
plugin_version = "4.3.5"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp,InfinityPacer"
|
||||
plugin_author = "jxxghp,InfinityPacer,Seed680"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/InfinityPacer"
|
||||
# 插件配置项ID前缀
|
||||
@@ -1638,6 +1641,22 @@ class BrushFlow(_PluginBase):
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'rss_support',
|
||||
'label': '启用RSS支持',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1817,7 +1836,8 @@ class BrushFlow(_PluginBase):
|
||||
"freeleech": "free",
|
||||
"hr": "yes",
|
||||
"enable_site_config": False,
|
||||
"site_config": BrushConfig.get_demo_site_config()
|
||||
"site_config": BrushConfig.get_demo_site_config(),
|
||||
"rss_support": False
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
@@ -2002,7 +2022,14 @@ class BrushFlow(_PluginBase):
|
||||
return True
|
||||
|
||||
logger.info(f"开始获取站点 {siteinfo.name} 的新种子 ...")
|
||||
torrents = TorrentsChain().browse(domain=siteinfo.domain)
|
||||
|
||||
# 根据rss_support配置决定使用browse还是rss方法获取种子
|
||||
brush_config = self.__get_brush_config(sitename=siteinfo.name)
|
||||
if brush_config.rss_support:
|
||||
torrents = TorrentsChain().rss(domain=siteinfo.domain)
|
||||
else:
|
||||
torrents = TorrentsChain().browse(domain=siteinfo.domain)
|
||||
|
||||
if not torrents:
|
||||
logger.info(f"站点 {siteinfo.name} 没有获取到种子")
|
||||
return True
|
||||
@@ -2219,16 +2246,34 @@ class BrushFlow(_PluginBase):
|
||||
return False, "存在H&R"
|
||||
|
||||
# 包含规则
|
||||
if brush_config.include and not (
|
||||
re.search(brush_config.include, torrent.title, re.I) or re.search(brush_config.include,
|
||||
torrent.description, re.I)):
|
||||
return False, "不符合包含规则"
|
||||
if brush_config.include:
|
||||
try:
|
||||
include_match = False
|
||||
if torrent.title and re.search(brush_config.include, torrent.title, re.I):
|
||||
include_match = True
|
||||
elif torrent.description and re.search(brush_config.include, torrent.description, re.I):
|
||||
include_match = True
|
||||
|
||||
if not include_match:
|
||||
return False, "不符合包含规则"
|
||||
except re.error:
|
||||
logger.warning(f"包含规则正则表达式错误: {brush_config.include}")
|
||||
return False, "包含规则正则表达式错误"
|
||||
|
||||
# 排除规则
|
||||
if brush_config.exclude and (
|
||||
re.search(brush_config.exclude, torrent.title, re.I) or re.search(brush_config.exclude,
|
||||
torrent.description, re.I)):
|
||||
return False, "符合排除规则"
|
||||
if brush_config.exclude:
|
||||
try:
|
||||
exclude_match = False
|
||||
if torrent.title and re.search(brush_config.exclude, torrent.title, re.I):
|
||||
exclude_match = True
|
||||
elif torrent.description and re.search(brush_config.exclude, torrent.description, re.I):
|
||||
exclude_match = True
|
||||
|
||||
if exclude_match:
|
||||
return False, "符合排除规则"
|
||||
except re.error:
|
||||
logger.warning(f"排除规则正则表达式错误: {brush_config.exclude}")
|
||||
return False, "排除规则正则表达式错误"
|
||||
|
||||
# 种子大小(GB)
|
||||
if brush_config.size:
|
||||
@@ -3048,6 +3093,7 @@ class BrushFlow(_PluginBase):
|
||||
"enable_site_config": brush_config.enable_site_config,
|
||||
"site_config": brush_config.site_config,
|
||||
"del_no_free": brush_config.del_no_free,
|
||||
"rss_support": brush_config.rss_support,
|
||||
"_tabs": self._tabs
|
||||
}
|
||||
|
||||
|
||||
263
plugins.v2/bugreporter/__init__.py
Normal file
263
plugins.v2/bugreporter/__init__.py
Normal file
@@ -0,0 +1,263 @@
|
||||
import re
|
||||
from typing import Any, Dict
|
||||
from typing import List, Tuple
|
||||
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
||||
|
||||
import sentry_sdk
|
||||
from app.plugins import _PluginBase
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
class SentrySanitizer:
|
||||
# 常见敏感字段名(可自行扩展)
|
||||
SENSITIVE_KEYS = {
|
||||
"password", "passwd", "pwd",
|
||||
"secret", "token", "access_token", "refresh_token",
|
||||
"authorization", "api_key", "apikey",
|
||||
"cookie", "set-cookie", "passkey",
|
||||
"key", "credential", "auth", "login", "user", "username",
|
||||
"email", "phone", "address", "ip", "host", "domain"
|
||||
}
|
||||
|
||||
# 匹配包含敏感关键词的正则
|
||||
SENSITIVE_PATTERN = re.compile(
|
||||
"|".join(re.escape(key) for key in SENSITIVE_KEYS), re.IGNORECASE
|
||||
)
|
||||
|
||||
# 网络连接错误类异常(不上报)
|
||||
NETWORK_ERRORS = {
|
||||
"ConnectionError", "ConnectionRefusedError", "ConnectionAbortedError",
|
||||
"ConnectionResetError", "TimeoutError", "socket.timeout", "socket.error",
|
||||
"ssl.SSLError", "ssl.SSLCertVerificationError", "ssl.SSLWantReadError",
|
||||
"ssl.SSLWantWriteError", "ssl.SSLZeroReturnError", "ssl.SSLSyscallError",
|
||||
"urllib.error.URLError", "urllib.error.HTTPError", "requests.exceptions.ConnectionError",
|
||||
"requests.exceptions.Timeout", "requests.exceptions.ConnectTimeout",
|
||||
"requests.exceptions.ReadTimeout", "requests.exceptions.SSLError",
|
||||
"aiohttp.ClientConnectionError", "aiohttp.ClientTimeout", "aiohttp.ServerTimeoutError",
|
||||
"aiohttp.ServerDisconnectedError", "aiohttp.ClientOSError"
|
||||
}
|
||||
|
||||
# 网络连接错误关键词
|
||||
NETWORK_ERROR_KEYWORDS = [
|
||||
"connection", "timeout", "network", "dns", "ssl", "certificate",
|
||||
"refused", "reset", "aborted", "unreachable", "no route to host",
|
||||
"name or service not known", "temporary failure", "network is unreachable",
|
||||
"SOCKSHTTPSConnectionPool", "ERR_HTTP_RESPONSE_CODE_FAILURE", "HTTPSConnectionPool",
|
||||
"网络连接", "无法连接", "请求失败", "下载失败", "请求返回空值", "图片失败", "未获取到返回数据",
|
||||
"请求返回空值", "返回空响应", "连接出错", "请求错误", "未获取到"
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def scrub_dict(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
递归清洗字典中的敏感信息
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
sanitized = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
sanitized[key] = cls.scrub_dict(value)
|
||||
elif isinstance(value, list):
|
||||
sanitized[key] = [cls.scrub_dict(v) if isinstance(v, dict) else v for v in value]
|
||||
else:
|
||||
if cls.SENSITIVE_PATTERN.search(str(key)):
|
||||
sanitized[key] = "[Filtered]"
|
||||
else:
|
||||
sanitized[key] = value
|
||||
return sanitized
|
||||
|
||||
@classmethod
|
||||
def scrub_url(cls, url: str) -> str:
|
||||
"""
|
||||
清理 URL 中的敏感 query 参数
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||
for key in query:
|
||||
if cls.SENSITIVE_PATTERN.search(key):
|
||||
query[key] = ["[Filtered]"]
|
||||
new_query = urlencode(query, doseq=True)
|
||||
return urlunparse(parsed._replace(query=new_query))
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return url
|
||||
|
||||
@classmethod
|
||||
def is_network_error(cls, event) -> bool:
|
||||
"""
|
||||
判断是否为网络连接错误类异常
|
||||
"""
|
||||
# 检查异常类型
|
||||
if "exception" in event:
|
||||
for exc in event["exception"].get("values", []):
|
||||
if "type" in exc:
|
||||
exc_type = exc["type"]
|
||||
if exc_type in cls.NETWORK_ERRORS:
|
||||
return True
|
||||
|
||||
# 检查异常消息是否包含网络错误关键词
|
||||
if "value" in exc:
|
||||
exc_value = exc["value"].lower()
|
||||
for keyword in cls.NETWORK_ERROR_KEYWORDS:
|
||||
if keyword in exc_value:
|
||||
return True
|
||||
|
||||
# 检查日志消息
|
||||
if "message" in event:
|
||||
message = event["message"].lower()
|
||||
for keyword in cls.NETWORK_ERROR_KEYWORDS:
|
||||
if keyword in message:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def before_send(cls, event, hint):
|
||||
"""
|
||||
在发送到 Sentry 之前脱敏和过滤
|
||||
"""
|
||||
# 如果是网络连接错误,直接返回 None 不上报
|
||||
if cls.is_network_error(event):
|
||||
return None
|
||||
|
||||
# 处理 request 数据
|
||||
request = event.get("request", {})
|
||||
if "url" in request:
|
||||
request["url"] = cls.scrub_url(request["url"])
|
||||
if "headers" in request:
|
||||
request["headers"] = cls.scrub_dict(request["headers"])
|
||||
if "data" in request:
|
||||
request["data"] = cls.scrub_dict(request["data"])
|
||||
if "cookies" in request:
|
||||
request["cookies"] = cls.scrub_dict(request["cookies"])
|
||||
|
||||
# 处理 user 数据
|
||||
if "user" in event:
|
||||
event["user"] = cls.scrub_dict(event["user"])
|
||||
|
||||
# 处理 extra 数据
|
||||
if "extra" in event:
|
||||
event["extra"] = cls.scrub_dict(event["extra"])
|
||||
|
||||
# 处理异常信息(避免敏感数据出现在 message 中)
|
||||
if "exception" in event:
|
||||
for exc in event["exception"].get("values", []):
|
||||
if "value" in exc and cls.SENSITIVE_PATTERN.search(exc["value"]):
|
||||
exc["value"] = "[Filtered Exception Message]"
|
||||
|
||||
# 清理异常堆栈中的敏感信息
|
||||
if "stacktrace" in exc and "frames" in exc["stacktrace"]:
|
||||
for frame in exc["stacktrace"]["frames"]:
|
||||
if "vars" in frame:
|
||||
frame["vars"] = cls.scrub_dict(frame["vars"])
|
||||
if "context_line" in frame and cls.SENSITIVE_PATTERN.search(frame["context_line"]):
|
||||
frame["context_line"] = "[Filtered]"
|
||||
|
||||
# 清理消息中的敏感信息
|
||||
if "message" in event and cls.SENSITIVE_PATTERN.search(event["message"]):
|
||||
event["message"] = "[Filtered Message]"
|
||||
|
||||
return event
|
||||
|
||||
|
||||
class BugReporter(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "Bug反馈"
|
||||
# 插件描述
|
||||
plugin_desc = "自动上报异常,协助开发者发现和解决问题。"
|
||||
# 插件图标
|
||||
plugin_icon = "Alist_encrypt_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.3"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jxxghp"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "bugreporter_"
|
||||
# 加载顺序
|
||||
plugin_order = 99
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
_enable: bool = False
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self._enable = config.get("enable")
|
||||
if self._enable:
|
||||
sentry_sdk.init("https://88da01ad33b4423cb0380620de53efa8@glitchtip.movie-pilot.org/1",
|
||||
before_send=SentrySanitizer.before_send,
|
||||
release=APP_VERSION,
|
||||
send_default_pii=False)
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enable',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'warning',
|
||||
'variant': 'tonal',
|
||||
'text': '注意:开启插件即代表你同意将部分异常信息自动发送给开发者,以帮助改进软件;如果你不希望自动发送任何数据,请关闭或卸载此插件;仅上报系统异常信息,不会包含任何个人隐私信息或敏感数据;网络连接错误类异常不会上报;异常信息采集为使用开源项目解决方案:GlitchTip。',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enable": self._enable,
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enable
|
||||
|
||||
def stop_service(self):
|
||||
pass
|
||||
1
plugins.v2/bugreporter/requirements.txt
Normal file
1
plugins.v2/bugreporter/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
sentry_sdk~=2.44.0
|
||||
@@ -17,7 +17,7 @@ class ChatGPT(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Chatgpt_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.1.7"
|
||||
plugin_version = "2.1.8"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
|
||||
@@ -14,22 +14,33 @@ class OpenAi:
|
||||
_api_url: str = None
|
||||
_model: str = "gpt-3.5-turbo"
|
||||
_prompt: str = '接下来我会给你一个电影或电视剧的文件名,你需要识别文件名中的名称、版本、分段、年份、分瓣率、季集等信息,并按以下JSON格式返回:{"name":string,"version":string,"part":string,"year":string,"resolution":string,"season":number|null,"episode":number|null},特别注意返回结果需要严格附合JSON格式,不需要有任何其它的字符。如果中文电影或电视剧的文件名中存在谐音字或字母替代的情况,请还原最有可能的结果。'
|
||||
_client: openai.OpenAI = None
|
||||
|
||||
def __init__(self, api_key: str = None, api_url: str = None, proxy: dict = None, model: str = None, compatible:
|
||||
bool = False, customize_prompt: str = None):
|
||||
def __init__(self, api_key: str = None, api_url: str = None,
|
||||
proxy: dict = None, model: str = None,
|
||||
compatible: bool = False, customize_prompt: str = None):
|
||||
self._api_key = api_key
|
||||
self._api_url = api_url
|
||||
if compatible:
|
||||
openai.api_base = self._api_url
|
||||
else:
|
||||
openai.api_base = self._api_url + "/v1"
|
||||
openai.api_key = self._api_key
|
||||
if proxy and proxy.get("https"):
|
||||
openai.proxy = proxy.get("https")
|
||||
if model:
|
||||
self._model = model
|
||||
if customize_prompt:
|
||||
self._prompt = customize_prompt
|
||||
|
||||
# 初始化 OpenAI 客户端
|
||||
if self._api_key and self._api_url:
|
||||
base_url = self._api_url if compatible else self._api_url + "/v1"
|
||||
http_client = None
|
||||
if proxy and proxy.get("https"):
|
||||
import httpx
|
||||
proxy_url = proxy.get("https")
|
||||
# httpx 支持字符串格式的代理 URL
|
||||
http_client = httpx.Client(proxies=proxy_url, timeout=60.0)
|
||||
self._client = openai.OpenAI(
|
||||
api_key=self._api_key,
|
||||
base_url=base_url,
|
||||
http_client=http_client
|
||||
)
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return True if self._api_key else False
|
||||
|
||||
@@ -82,6 +93,8 @@ class OpenAi:
|
||||
"""
|
||||
获取模型
|
||||
"""
|
||||
if not self._client:
|
||||
raise ValueError("OpenAI client not initialized. Please check API key and API URL.")
|
||||
if not isinstance(message, list):
|
||||
if prompt:
|
||||
message = [
|
||||
@@ -101,9 +114,10 @@ class OpenAi:
|
||||
"content": message
|
||||
}
|
||||
]
|
||||
return openai.ChatCompletion.create(
|
||||
# 新版本 API 不支持 user 参数,需要从 kwargs 中移除
|
||||
kwargs.pop('user', None)
|
||||
return self._client.chat.completions.create(
|
||||
model=self._model,
|
||||
user=user,
|
||||
messages=message,
|
||||
**kwargs
|
||||
)
|
||||
@@ -170,11 +184,11 @@ class OpenAi:
|
||||
if result:
|
||||
self.__save_session(userid, text)
|
||||
return result
|
||||
except openai.error.RateLimitError as e:
|
||||
except openai.RateLimitError as e:
|
||||
return f"请求被ChatGPT拒绝了,{str(e)}"
|
||||
except openai.error.APIConnectionError as e:
|
||||
except openai.APIConnectionError as e:
|
||||
return f"ChatGPT网络连接失败:{str(e)}"
|
||||
except openai.error.Timeout as e:
|
||||
except openai.APITimeoutError as e:
|
||||
return f"没有接收到ChatGPT的返回消息:{str(e)}"
|
||||
except Exception as e:
|
||||
return f"请求ChatGPT出现错误:{str(e)}"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
openai~=0.27.2
|
||||
@@ -1,10 +1,10 @@
|
||||
# Clash Rule Provider
|
||||
|
||||
**Clash Rule Provider** 生成适用于 [Meta Kernel](https://github.com/MetaCubeX/mihomo/tree/Meta) 定制配置,便于增加、修改和删除规则。
|
||||
**Clash Rule Provider** 是一个[MoviePilot](https://github.com/jxxghp/MoviePilot)插件,用于生成适用于 [Meta Kernel](https://github.com/MetaCubeX/mihomo/tree/Meta) 定制配置,便于增加、修改和删除规则,基于 Meta 内核丰富的代理组配置,提供灵活的路由功能。
|
||||
|
||||
- 即时通知 Clash 刷新规则集合
|
||||
- 基于 Meta 内核丰富的代理组配置,提供灵活的路由功能
|
||||
- 支持按大洲分组节点
|
||||
- 支持按大洲和国家分组节点
|
||||
- 支持覆写出站代理
|
||||
- GEO 规则输入提示
|
||||
- 支持 [ACL4SSR](https://github.com/ACL4SSR/ACL4SSR) 规则集合
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
### 规则集规则
|
||||
|
||||
用于添加能够在 Clash 中即时生效的规则,Clash Rule Provider 会根据每条规则的**出站**生成相应的**规则集合** `📂<-` + `出站`。
|
||||
用于添加能够在 Clash 中即时生效的规则,Clash Rule Provider 会根据每条规则的**出站**生成相应的**规则集合**。
|
||||
|
||||
### 置顶规则
|
||||
|
||||
@@ -40,4 +40,28 @@
|
||||
|
||||
### Hosts
|
||||
|
||||
如果需要自动更新此处使用的 Cloudflare IP, 可以通过其它[插件](https://github.com/wumode/MoviePilot-Addons)实现。
|
||||
如果需要自动更新此处使用的 Cloudflare IP, 可以通过其它[插件](https://github.com/wumode/MoviePilot-Addons)实现。
|
||||
|
||||
### 配置隐藏
|
||||
|
||||
如果希望某些代理组、规则或是代理节点仅在特定条件下可见,可以使用可见性限制功能。例如,可以设置某些规则集仅在特定网络环境下可见。
|
||||
自定义表达式是个返回`bool`值的Python表达式,可以使用以下变量:
|
||||
|
||||
```python
|
||||
# 请求 URL
|
||||
url: str
|
||||
# 客户端的IP地址
|
||||
client_host: str
|
||||
# 请求的标识符
|
||||
identifier: str | None = None
|
||||
# User-Agent
|
||||
user_agent : str | None = None
|
||||
```
|
||||
|
||||
表达式示例:
|
||||
- `client_host == '192.168.1.1'`
|
||||
- `identifier == 'office-laptop' and 'Mobile' in user_agent`
|
||||
|
||||
## 远程组件
|
||||
|
||||
[ClashRuleProvider-Remote](https://github.com/wumode/ClashRuleProvider-Remote)
|
||||
File diff suppressed because it is too large
Load Diff
320
plugins.v2/clashruleprovider/api.py
Normal file
320
plugins.v2/clashruleprovider/api.py
Normal file
@@ -0,0 +1,320 @@
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
from typing import Any, Dict, List, Callable, Optional, Literal
|
||||
|
||||
import websockets
|
||||
import yaml
|
||||
from fastapi import HTTPException, Request, status, Response, Body
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
|
||||
from .config import PluginConfig
|
||||
from .models import ProxyGroup, Proxy, HostData, RuleData, RuleProvider, RuleProviderData
|
||||
from .models.api import Connectivity, SubscriptionSetting, ConfigRequest
|
||||
from .models.metadata import Metadata
|
||||
from .models.types import RuleSet, DataSource
|
||||
from .services import ClashRuleProviderService
|
||||
|
||||
|
||||
class ApiCollection:
|
||||
def __init__(self):
|
||||
self.route_definitions = []
|
||||
|
||||
def register(self, path: str,
|
||||
methods: List[Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD', 'TRACE']],
|
||||
allow_anonymous: Optional[bool] = None,
|
||||
auth: Optional[str] = None,
|
||||
summary: Optional[str] = '',
|
||||
**kwargs):
|
||||
|
||||
def decorator(func: Callable):
|
||||
route_meta: Dict[str, Any] = {
|
||||
'path': path,
|
||||
'methods': methods,
|
||||
'summary': summary,
|
||||
'endpoint': func,
|
||||
**kwargs
|
||||
}
|
||||
if allow_anonymous is not None:
|
||||
route_meta['allow_anonymous'] = allow_anonymous
|
||||
if auth is not None:
|
||||
route_meta['auth'] = auth
|
||||
self.route_definitions.append(route_meta)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def get_routes(self, instance: Any) -> List[Dict[str, Any]]:
|
||||
bound_routes = []
|
||||
for route in self.route_definitions:
|
||||
func_name = route['endpoint'].__name__
|
||||
bound_method = getattr(instance, func_name)
|
||||
bound_routes.append({**route, 'endpoint': bound_method})
|
||||
return bound_routes
|
||||
|
||||
|
||||
apis = ApiCollection()
|
||||
|
||||
|
||||
class ClashRuleProviderApi:
|
||||
|
||||
def __init__(self, services: ClashRuleProviderService, config: PluginConfig):
|
||||
self.services: ClashRuleProviderService = services
|
||||
self.config = config
|
||||
|
||||
@apis.register(path="/connectivity", methods=["POST"], auth="bear", summary="测试连接")
|
||||
async def test_connectivity(self, item: Connectivity) -> schemas.Response:
|
||||
success, message = await self.services.test_connectivity(item.clash_apis, item.sub_links)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/clash-outbound", methods=["GET"], auth="bear", summary="获取所有出站")
|
||||
def get_clash_outbound(self) -> schemas.Response:
|
||||
outbound = self.services.clash_outbound()
|
||||
return schemas.Response(success=True, data=outbound)
|
||||
|
||||
@apis.register(path="/status", methods=["GET"], auth="bear", summary="插件状态")
|
||||
def get_status(self) -> schemas.Response:
|
||||
data = self.services.get_status()
|
||||
return schemas.Response(success=True, data=data)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}", methods=["GET"], auth="bear", summary="获取指定集合中的规则")
|
||||
def get_rules(self, ruleset: RuleSet) -> schemas.Response:
|
||||
data = self.services.get_rules(ruleset)
|
||||
return schemas.Response(success=True, data=data)
|
||||
|
||||
@apis.register(path="/reorder-rules/{ruleset}/{target}", methods=["PUT"], auth="bear", summary="重新排序规则")
|
||||
def reorder_rules(self, ruleset: RuleSet, target: int,
|
||||
moved_priority: int = Body(..., embed=True)) -> schemas.Response:
|
||||
success, message = self.services.reorder_rules(ruleset, moved_priority, target)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}/{priority}", methods=["PATCH"], auth="bear", summary="更新规则")
|
||||
def update_rule(self, ruleset: RuleSet, priority: int, rule_data: RuleData) -> schemas.Response:
|
||||
success, message = self.services.update_rule(ruleset, priority, rule_data)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}", methods=["POST"], auth="bear", summary="添加规则")
|
||||
def add_rule(self, ruleset: RuleSet, rule_data: RuleData = Body(...)) -> schemas.Response:
|
||||
success, message = self.services.add_rule(ruleset, rule_data)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}/{priority}/meta", methods=["PATCH"], auth="bear", summary="更新规则元数据")
|
||||
def update_rule_meta(self, ruleset: RuleSet, priority: int, meta: Metadata = Body(...)) -> schemas.Response:
|
||||
success, message = self.services.update_rule_meta(ruleset, priority, meta)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}/metadata/disabled", methods=["POST"], auth="bear", summary="设置规则状态")
|
||||
def set_rules_status(self, ruleset: RuleSet, priorities: dict[int, bool] = Body(...)):
|
||||
self.services.set_rules_status(ruleset, priorities)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}/{priority}", methods=["DELETE"], auth="bear", summary="删除规则")
|
||||
def delete_rule(self, ruleset: RuleSet, priority: int) -> schemas.Response:
|
||||
self.services.delete_rule(ruleset, priority)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}", methods=["DELETE"], auth="bear", summary="批量删除规则")
|
||||
def delete_rules(self, ruleset: RuleSet, priority: list[int] = Body(...)) -> schemas.Response:
|
||||
self.services.delete_rules(ruleset, priority)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@apis.register(path="/refresh", methods=["PUT"], auth="bear", summary="更新订阅")
|
||||
async def refresh_subscription(self, url: str = Body(..., embed=True)) -> schemas.Response:
|
||||
success, message = await self.services.refresh_subscription(url)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rule-providers", methods=["GET"], auth="bear", summary="获取规则集合",
|
||||
response_model=schemas.Response, response_model_exclude_none=True)
|
||||
def get_rule_providers(self) -> schemas.Response:
|
||||
return schemas.Response(success=True, data=self.services.state.all_rule_providers)
|
||||
|
||||
@apis.register(path="/rule-providers/{name}", methods=["POST"], auth="bear", summary="添加规则集合")
|
||||
def add_rule_provider(self, name: str, item: RuleProvider):
|
||||
success, message = self.services.add_rule_provider(name, item)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rule-providers/{name}", methods=["PATCH"], auth="bear", summary="更新规则集合")
|
||||
def update_rule_provider(self, name: str, item: RuleProviderData):
|
||||
success, message = self.services.update_rule_provider(name, item)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rule-providers/{name}/meta", methods=["PATCH"], auth="bear", summary="更新规则集元数据")
|
||||
def update_rule_providers_meta(self, name: str, meta: Metadata):
|
||||
success, message = self.services.update_rule_providers_meta(name, meta)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rule-providers/{name}", methods=["DELETE"], auth="bear", summary="删除规则集合")
|
||||
def delete_rule_provider(self, name: str):
|
||||
self.services.delete_rule_provider(name)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@apis.register(path="/proxies", methods=["GET"], auth="bear", summary="获取代理",
|
||||
response_model=schemas.Response, response_model_exclude_none=True)
|
||||
def get_proxies(self):
|
||||
proxies = self.services.get_proxies()
|
||||
return schemas.Response(success=True, data=proxies)
|
||||
|
||||
@apis.register(path="/proxies/{name}", methods=["DELETE"], auth="bear", summary="删除出站代理")
|
||||
def delete_proxy(self, name: str):
|
||||
self.services.delete_proxy(name)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@apis.register(path="/proxies", methods=["PUT"], auth="bear", summary="添加出站代理")
|
||||
def import_proxies(self, vehicle: Literal["YAML", "LINK"] = Body(...), payload: str = Body(...)):
|
||||
success, message = self.services.import_proxies(vehicle, payload)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxies/{name}", methods=["PATCH"], auth="bear", summary="更新出站代理")
|
||||
def update_proxy(self, name: str, source: DataSource = Body(...), proxy: Proxy = Body(...)) -> schemas.Response:
|
||||
success, message = self.services.update_proxy(name, source, proxy)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxies/{name}/meta", methods=["PATCH"], auth="bear", summary="更新代理组元数据")
|
||||
def update_proxy_meta(self, name: str, meta: Metadata):
|
||||
success, message = self.services.update_proxy_meta(name, meta)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxies/{name}/patch", methods=["DELETE"], auth="bear", summary="删除代理补丁")
|
||||
def delete_proxy_patch(self, name: str):
|
||||
success, message = self.services.delete_proxy_patch(name)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-groups", methods=["GET"], auth="bear", summary="获取代理组",
|
||||
response_model=schemas.Response, response_model_exclude_none=True)
|
||||
def get_proxy_groups(self):
|
||||
proxy_groups = self.services.get_proxy_groups()
|
||||
return schemas.Response(success=True, data=proxy_groups)
|
||||
|
||||
@apis.register(path="/proxy-groups/{name}", methods=["DELETE"], auth="bear", summary="删除代理组")
|
||||
def delete_proxy_group(self, name: str):
|
||||
success, message = self.services.delete_proxy_group(name)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-groups/{name}/meta", methods=["PATCH"], auth="bear", summary="更新代理组元数据")
|
||||
def update_proxy_group_meta(self, name: str, meta: Metadata):
|
||||
success, message = self.services.update_proxy_group_meta(name, meta)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-groups/{name}/patch", methods=["DELETE"], auth="bear", summary="删除代理组补丁")
|
||||
def delete_proxy_group_patch(self, name: str):
|
||||
success, message = self.services.delete_proxy_group_patch(name)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-groups", methods=["POST"], auth="bear", summary="添加代理组")
|
||||
def add_proxy_group(self, item: ProxyGroup):
|
||||
success, message = self.services.add_proxy_group(item)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-groups/{name}", methods=["PATCH"], auth="bear", summary="更新代理组")
|
||||
def update_proxy_group(self, name: str, source: DataSource = Body(...), proxy_group: ProxyGroup = Body(...)):
|
||||
success, message = self.services.update_proxy_group(name, source, proxy_group)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-providers", methods=["GET"], auth="bear", summary="获取代理集合",
|
||||
response_model=schemas.Response, response_model_exclude_none=True)
|
||||
def get_proxy_providers(self):
|
||||
proxy_providers = self.services.state.all_proxy_providers
|
||||
return schemas.Response(success=True, data=proxy_providers)
|
||||
|
||||
@apis.register(path="/ruleset", methods=["GET"], allow_anonymous=True, summary="获取规则集规则")
|
||||
def get_ruleset(self, name: str, apikey: str) -> PlainTextResponse:
|
||||
_apikey = self.config.apikey or settings.API_TOKEN
|
||||
if not secrets.compare_digest(_apikey, apikey):
|
||||
raise HTTPException(status_code=403, detail="Invalid API Key")
|
||||
res = self.services.get_ruleset(name)
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail=f"Ruleset {name!r} not found")
|
||||
return PlainTextResponse(content=res, media_type="application/x-yaml")
|
||||
|
||||
@apis.register(path="/import", methods=["POST"], auth="bear", summary="导入规则")
|
||||
def import_rules(self, vehicle: Literal["YAML"] = Body(...), payload: str = Body(...)):
|
||||
self.services.import_rules(vehicle, payload)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@apis.register(path="/hosts", methods=["GET"], auth="bear", summary="获取 Hosts")
|
||||
def get_hosts(self):
|
||||
return schemas.Response(success=True, data=self.services.state.hosts.model_dump(mode='json'))
|
||||
|
||||
@apis.register(path="/hosts", methods=["POST"], auth="bear", summary="更新 Hosts")
|
||||
def update_hosts(self, domain: str = Body(..., embed=True), host: HostData = Body(...)):
|
||||
success, message = self.services.update_hosts(domain, host)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/hosts/{domain}", methods=["DELETE"], auth="bear", summary="删除 Hosts")
|
||||
def delete_host(self, domain: str):
|
||||
success, message = self.services.delete_host(domain)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/subscription-info", methods=["POST"], auth="bear", summary="更新订阅信息")
|
||||
def update_subscription_info(self, sub_info: SubscriptionSetting):
|
||||
self.services.update_subscription_info(sub_info)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@apis.register(path="/config", methods=["GET"], allow_anonymous=bool(True), summary="获取 Clash 配置")
|
||||
def get_clash_config(self, apikey: str, request: Request, identifier: str | None = None):
|
||||
_apikey = self.config.apikey or settings.API_TOKEN
|
||||
param = ConfigRequest(
|
||||
url=str(request.url),
|
||||
client_host=request.client.host,
|
||||
identifier=identifier,
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
if not secrets.compare_digest(apikey, _apikey):
|
||||
raise HTTPException(status_code=403, detail="Invalid API Key")
|
||||
logger.info(f"{request.client.host} 正在获取配置")
|
||||
config = self.services.build_clash_config(param=param)
|
||||
if not config:
|
||||
raise HTTPException(status_code=500, detail="配置不可用")
|
||||
|
||||
config_dict = config.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
res = yaml.dump(config_dict, allow_unicode=True, sort_keys=False)
|
||||
sub_info = self.services.get_subscription_user_info()
|
||||
headers = {'Subscription-Userinfo': sub_info.header}
|
||||
return Response(headers=headers, content=res, media_type="text/yaml")
|
||||
|
||||
@apis.register(path="/clash/proxy/{path:path}", methods=["GET"], auth="bear", summary="转发 Clash API 请求")
|
||||
async def clash_proxy(self, path: str):
|
||||
return await self.services.fetch_clash_data(path)
|
||||
|
||||
@apis.register(path="/clash/ws/{endpoint}", methods=["GET"], allow_anonymous=True,
|
||||
summary="转发 Clash API Websocket 请求")
|
||||
async def clash_websocket(self, request: Request, endpoint: str, secret: str):
|
||||
if not secrets.compare_digest(secret, self.config.dashboard_secret):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Secret 校验不通过")
|
||||
if endpoint not in ['traffic', 'connections', 'memory']:
|
||||
raise HTTPException(status_code=400, detail="Invalid endpoint")
|
||||
|
||||
# This logic is highly coupled with the web framework, so it stays here.
|
||||
queue = asyncio.Queue()
|
||||
ws_base = self.config.dashboard_url.replace(
|
||||
'http://', 'ws://').replace('https://', 'wss://')
|
||||
url = f"{ws_base}/{endpoint}?token={self.config.dashboard_secret}"
|
||||
|
||||
async def clash_ws_listener():
|
||||
try:
|
||||
async with websockets.connect(url, ping_interval=None) as ws:
|
||||
async for message in ws:
|
||||
await queue.put(json.loads(message))
|
||||
except Exception as e:
|
||||
await queue.put({"error": str(e)})
|
||||
|
||||
listener_task = asyncio.create_task(clash_ws_listener())
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
data = await queue.get()
|
||||
yield {'event': endpoint, 'data': json.dumps(data)}
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
finally:
|
||||
listener_task.cancel()
|
||||
|
||||
return EventSourceResponse(event_generator())
|
||||
8
plugins.v2/clashruleprovider/base.py
Normal file
8
plugins.v2/clashruleprovider/base.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from typing import Final
|
||||
|
||||
|
||||
class Constant:
|
||||
PATCH_LIFESPAN: Final[int] = 10
|
||||
ACL4SSR_API: Final[str] = "https://api.github.com/repos/ACL4SSR/ACL4SSR"
|
||||
METACUBEX_RULE_DAT_API: Final[str] = "https://api.github.com/repos/MetaCubeX/meta-rules-dat"
|
||||
MISFIRE_GRACE_TIME: Final[int] = 120
|
||||
File diff suppressed because it is too large
Load Diff
90
plugins.v2/clashruleprovider/config.py
Normal file
90
plugins.v2/clashruleprovider/config.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from .models.api import ClashApi
|
||||
|
||||
|
||||
class SubscriptionConfig(BaseModel):
|
||||
url: str
|
||||
rules: Optional[bool] = True
|
||||
rule_providers: Optional[bool] = Field(default=True, alias='rule-providers')
|
||||
proxies: Optional[bool] = True
|
||||
proxy_groups: Optional[bool] = Field(default=True, alias='proxy-groups')
|
||||
proxy_providers: Optional[bool] = Field(default=True, alias='proxy-providers')
|
||||
|
||||
@field_validator('url')
|
||||
@classmethod
|
||||
def validate_url(cls, v: str) -> str:
|
||||
return v.strip()
|
||||
|
||||
|
||||
class PluginConfig(BaseModel):
|
||||
"""
|
||||
A dataclass to hold all the configuration of the ClashRuleProvider plugin.
|
||||
"""
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
)
|
||||
|
||||
enabled: bool = False
|
||||
proxy: bool = False
|
||||
notify: bool = False
|
||||
subscriptions_config: list[SubscriptionConfig] = Field(default_factory=list)
|
||||
movie_pilot_url: str = ''
|
||||
cron_string: str = '30 12 * * *'
|
||||
timeout: int = 10
|
||||
retry_times: int = 3
|
||||
filter_keywords: List[str] = Field(default_factory=list)
|
||||
auto_update_subscriptions: bool = True
|
||||
ruleset_prefix: str = '📂<='
|
||||
acl4ssr_prefix: str = '🗂️=>'
|
||||
group_by_region: bool = False
|
||||
group_by_country: bool = False
|
||||
refresh_delay: int = 5
|
||||
enable_acl4ssr: bool = False
|
||||
dashboard_components: List[str] = Field(default_factory=list)
|
||||
clash_template: str = ''
|
||||
hint_geo_dat: bool = False
|
||||
best_cf_ip: List[str] = Field(default_factory=list)
|
||||
apikey: Optional[str] = None
|
||||
clash_dashboards: List[ClashApi] = Field(default_factory=list)
|
||||
active_dashboard: Optional[int] = None
|
||||
identifiers: list[str] = Field(default_factory=list)
|
||||
cache_ttl: int = 3600
|
||||
|
||||
@field_validator('clash_dashboards')
|
||||
@classmethod
|
||||
def validate_clash_dashboards(cls, v: List[ClashApi]):
|
||||
for item in v:
|
||||
url = item.url.rstrip('/')
|
||||
if not (url.startswith('http://') or url.startswith('https://')):
|
||||
url = 'http://' + url
|
||||
item.url = url
|
||||
return v
|
||||
|
||||
@field_validator('movie_pilot_url')
|
||||
@classmethod
|
||||
def validate_movie_pilot_url(cls, v: str):
|
||||
return v.rstrip('/')
|
||||
|
||||
@property
|
||||
def sub_links(self) -> List[str]:
|
||||
return [sub.url for sub in self.subscriptions_config]
|
||||
|
||||
@property
|
||||
def dashboard_url(self) -> str:
|
||||
dashboard_url = ''
|
||||
if self.active_dashboard is not None and self.active_dashboard in range(len(self.clash_dashboards)):
|
||||
dashboard_url = self.clash_dashboards[self.active_dashboard].url
|
||||
return dashboard_url
|
||||
|
||||
@property
|
||||
def dashboard_secret(self) -> str:
|
||||
dashboard_secret = ''
|
||||
if self.active_dashboard is not None and self.active_dashboard in range(len(self.clash_dashboards)):
|
||||
dashboard_secret = self.clash_dashboards[self.active_dashboard].secret
|
||||
return dashboard_secret
|
||||
|
||||
def get_sub_conf(self, url: str) -> SubscriptionConfig:
|
||||
return next((conf for conf in self.subscriptions_config if conf.url == url), SubscriptionConfig(url=url))
|
||||
5136
plugins.v2/clashruleprovider/countries.json
Executable file → Normal file
5136
plugins.v2/clashruleprovider/countries.json
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
3
plugins.v2/clashruleprovider/dist/assets/Meta-1zu2nKV2.js
vendored
Normal file
3
plugins.v2/clashruleprovider/dist/assets/Meta-1zu2nKV2.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
const MetaLogo = "/assets/Meta-uqWbsmWL.png";
|
||||
|
||||
export { MetaLogo as M };
|
||||
BIN
plugins.v2/clashruleprovider/dist/assets/Meta-uqWbsmWL.png
vendored
Normal file
BIN
plugins.v2/clashruleprovider/dist/assets/Meta-uqWbsmWL.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -1,4 +0,0 @@
|
||||
|
||||
.plugin-config[data-v-929102b8] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
1479
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CY46uj5g.js
vendored
Normal file
1479
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CY46uj5g.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
4
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CwbjkOP2.css
vendored
Normal file
4
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CwbjkOP2.css
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
.plugin-config[data-v-3fef8398] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
|
||||
.dashboard-widget[data-v-de7a088e] {
|
||||
.dashboard-widget[data-v-318a5020] {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
|
||||
.plugin-page[data-v-d6db167c] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 使卡片等宽并适应移动端 */
|
||||
.d-flex.flex-wrap[data-v-d6db167c] {
|
||||
gap: 16px;
|
||||
}
|
||||
.url-display[data-v-d6db167c] {
|
||||
word-break: break-all;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 移动端堆叠布局 */
|
||||
@media (max-width: 768px) {
|
||||
.d-flex.flex-wrap[data-v-d6db167c] {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add visual distinction between sections */
|
||||
.ruleset-section[data-v-d6db167c] {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.top-section[data-v-d6db167c] {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
/* Optional: Add different border colors to further distinguish */
|
||||
.ruleset-section[data-v-d6db167c] {
|
||||
border-left: 4px solid #2196F3; /* Blue accent */
|
||||
}
|
||||
.top-section[data-v-d6db167c] {
|
||||
border-left: 4px solid #4CAF50; /* Green accent */
|
||||
}
|
||||
.drag-handle[data-v-d6db167c] {
|
||||
cursor: move;
|
||||
}
|
||||
.gap-2[data-v-d6db167c] {
|
||||
gap: 8px;
|
||||
}
|
||||
84
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-CJILOVp4.css
vendored
Normal file
84
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-CJILOVp4.css
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
|
||||
.rule-card[data-v-5bf9d562]:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.proxy-group-card[data-v-88bfc397]:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.proxy-card[data-v-e80a10d3]:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.subscription-card[data-v-97c0f367] {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.subscription-card[data-v-97c0f367]:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.1);
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
.card-header[data-v-97c0f367] {
|
||||
background: rgba(var(--v-theme-surface-variant), 0.05);
|
||||
}
|
||||
.bg-surface-variant-lighten[data-v-97c0f367] {
|
||||
background: rgba(var(--v-theme-surface-variant), 0.02);
|
||||
}
|
||||
.stats-grid[data-v-97c0f367] {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bounce[data-v-6a1d5a83] {
|
||||
animation: bounce-6a1d5a83 2s infinite;
|
||||
}
|
||||
@keyframes bounce-6a1d5a83 {
|
||||
0%,
|
||||
20%,
|
||||
50%,
|
||||
80%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.rule-provider-card[data-v-01e2e8ef]:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.host-card[data-v-a5d6e0e6]:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
.search-field {
|
||||
max-width: 25rem;
|
||||
}
|
||||
|
||||
.clash-data-table {
|
||||
max-height: 40rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.dragging-item {
|
||||
opacity: 0.5;
|
||||
background-color: rgb(var(--v-theme-grey-200));
|
||||
}
|
||||
|
||||
.drop-over {
|
||||
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
||||
}
|
||||
|
||||
.plugin-page[data-v-ab912b83] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
14246
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-DhQfGEOD.js
vendored
Normal file
14246
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-DhQfGEOD.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
208
plugins.v2/clashruleprovider/dist/assets/_plugin-vue_export-helper-D32QZFxh.js
vendored
Normal file
208
plugins.v2/clashruleprovider/dist/assets/_plugin-vue_export-helper-D32QZFxh.js
vendored
Normal file
@@ -0,0 +1,208 @@
|
||||
const isValidUrl = (urlString) => {
|
||||
if (!urlString) return false;
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
function isValidIP(ip) {
|
||||
const ipv4Regex = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(([0-9a-fA-F]{1,4}:){1,7}|:):([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})$/;
|
||||
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
|
||||
}
|
||||
function validateIPs(ips) {
|
||||
if (ips.length === 0) {
|
||||
return `至少需要一个 IP 地址`;
|
||||
}
|
||||
for (const ip of ips) {
|
||||
if (!isValidIP(ip)) {
|
||||
return `无效的 IP 地址: ${ip}`;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function getUsageColor(percentage) {
|
||||
return percentage > 90 ? "error" : percentage > 70 ? "warning" : "success";
|
||||
}
|
||||
function getBehaviorColor(action) {
|
||||
const colors = {
|
||||
classical: "success",
|
||||
domain: "error",
|
||||
ipcidr: "error"
|
||||
};
|
||||
return colors[action] || "primary";
|
||||
}
|
||||
function getFormatColor(action) {
|
||||
const colors = {
|
||||
yaml: "success",
|
||||
text: "warning",
|
||||
mrs: "info"
|
||||
};
|
||||
return colors[action] || "secondary";
|
||||
}
|
||||
function getRuleTypeColor(type) {
|
||||
const colors = {
|
||||
DOMAIN: "primary",
|
||||
"DOMAIN-SUFFIX": "primary",
|
||||
"DOMAIN-KEYWORD": "primary",
|
||||
"DOMAIN-REGEX": "primary",
|
||||
"DOMAIN-WILDCARD": "primary",
|
||||
GEOSITE: "info",
|
||||
GEOIP: "info",
|
||||
"IP-CIDR": "warning",
|
||||
"IP-CIDR6": "warning",
|
||||
"IP-SUFFIX": "warning",
|
||||
"IP-ASN": "warning",
|
||||
"SRC-GEOIP": "info",
|
||||
"SRC-IP-ASN": "warning",
|
||||
"SRC-IP-CIDR": "warning",
|
||||
"SRC-IP-SUFFIX": "warning",
|
||||
"DST-PORT": "success",
|
||||
"SRC-PORT": "success",
|
||||
"IN-PORT": "success",
|
||||
"IN-TYPE": "success",
|
||||
"IN-USER": "success",
|
||||
"IN-NAME": "success",
|
||||
"PROCESS-PATH": "error",
|
||||
"PROCESS-PATH-REGEX": "error",
|
||||
"PROCESS-NAME": "error",
|
||||
"PROCESS-NAME-REGEX": "error",
|
||||
UID: "secondary",
|
||||
NETWORK: "secondary",
|
||||
DSCP: "secondary",
|
||||
"RULE-SET": "deep-purple",
|
||||
AND: "deep-orange",
|
||||
OR: "deep-orange",
|
||||
NOT: "deep-orange",
|
||||
"SUB-RULE": "deep-orange",
|
||||
MATCH: "teal"
|
||||
};
|
||||
return colors[type] || "grey";
|
||||
}
|
||||
function getSourceColor(source) {
|
||||
const colors = {
|
||||
Auto: "success",
|
||||
Manual: "info"
|
||||
};
|
||||
return colors[source] || "primary";
|
||||
}
|
||||
function getActionColor(action) {
|
||||
const colors = {
|
||||
DIRECT: "success",
|
||||
REJECT: "error",
|
||||
"REJECT-DROP": "error",
|
||||
PASS: "warning",
|
||||
COMPATIBLE: "info"
|
||||
};
|
||||
return colors[action] || "primary";
|
||||
}
|
||||
function getProxyGroupTypeColor(action) {
|
||||
const colors = {
|
||||
"url-test": "success",
|
||||
fallback: "error",
|
||||
"load-balance": "primary",
|
||||
select: "info"
|
||||
};
|
||||
return colors[action] || "warning";
|
||||
}
|
||||
function getProxyColor(action) {
|
||||
const colors = {
|
||||
ss: "success",
|
||||
ssr: "success",
|
||||
trojan: "error",
|
||||
vmess: "primary",
|
||||
vless: "primary",
|
||||
hysteria: "info",
|
||||
hysteria2: "info",
|
||||
anytls: "warning"
|
||||
};
|
||||
return colors[action] || "secondary";
|
||||
}
|
||||
function getBoolColor(value) {
|
||||
if (value) {
|
||||
return "primary";
|
||||
}
|
||||
return "success";
|
||||
}
|
||||
function isSystemRule(rule) {
|
||||
return rule.meta.source?.startsWith("Auto");
|
||||
}
|
||||
function isManual(source) {
|
||||
return source === "Manual";
|
||||
}
|
||||
function isInvalid(source) {
|
||||
return source === "Invalid";
|
||||
}
|
||||
function isRegion(source) {
|
||||
return source === "Auto";
|
||||
}
|
||||
function pageTitle(itemPerPageValue) {
|
||||
if (itemPerPageValue < 0) {
|
||||
return "♾️";
|
||||
}
|
||||
return `${itemPerPageValue}`;
|
||||
}
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
function formatTimestamp(timestamp) {
|
||||
if (!timestamp) return "N/A";
|
||||
const date = new Date(timestamp * 1e3);
|
||||
return date.toLocaleDateString("zh-CN");
|
||||
}
|
||||
function timestampToDate(timestamp) {
|
||||
if (!timestamp) return "N/A";
|
||||
const date = new Date(timestamp * 1e3);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
// 'en-GB' 表示使用英国格式(YYYY-MM-DD HH:mm:ss)
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false
|
||||
// 24小时制
|
||||
});
|
||||
}
|
||||
function getExpireColor(timestamp) {
|
||||
if (!timestamp) return "grey";
|
||||
const secondsLeft = timestamp - Math.floor(Date.now() / 1e3);
|
||||
const daysLeft = secondsLeft / 86400;
|
||||
return daysLeft < 7 ? "error" : daysLeft < 30 ? "warning" : "success";
|
||||
}
|
||||
function extractDomain(url) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
const parts = hostname.split(".");
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(":")) {
|
||||
return hostname;
|
||||
}
|
||||
if (parts.length <= 2) {
|
||||
return hostname;
|
||||
}
|
||||
return parts.slice(-2).join(".");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
function getUsedPercentageFloor(data) {
|
||||
const used = data.upload + data.download;
|
||||
return data.total > 0 ? Math.floor(used / data.total * 100) : 0;
|
||||
}
|
||||
|
||||
const _export_sfc = (sfc, props) => {
|
||||
const target = sfc.__vccOpts || sfc;
|
||||
for (const [key, val] of props) {
|
||||
target[key] = val;
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
export { _export_sfc as _, getActionColor as a, isManual as b, isRegion as c, getSourceColor as d, getProxyGroupTypeColor as e, isValidUrl as f, getRuleTypeColor as g, isInvalid as h, isSystemRule as i, getProxyColor as j, extractDomain as k, formatTimestamp as l, getExpireColor as m, formatBytes as n, getUsageColor as o, pageTitle as p, getUsedPercentageFloor as q, getFormatColor as r, getBehaviorColor as s, timestampToDate as t, getBoolColor as u, validateIPs as v };
|
||||
@@ -1,9 +0,0 @@
|
||||
const _export_sfc = (sfc, props) => {
|
||||
const target = sfc.__vccOpts || sfc;
|
||||
for (const [key, val] of props) {
|
||||
target[key] = val;
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
export { _export_sfc as _ };
|
||||
@@ -2,14 +2,14 @@ const currentImports = {};
|
||||
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
|
||||
let moduleMap = {
|
||||
"./Page":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Page-BOym_1fV.css"], false, './Page');
|
||||
return __federation_import('./__federation_expose_Page-D5l2MyNA.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
dynamicLoadingCss(["__federation_expose_Page-CJILOVp4.css"], false, './Page');
|
||||
return __federation_import('./__federation_expose_Page-DhQfGEOD.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Config":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Config-BrXQaadr.css"], false, './Config');
|
||||
return __federation_import('./__federation_expose_Config-NH09p1Am.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
dynamicLoadingCss(["__federation_expose_Config-CwbjkOP2.css"], false, './Config');
|
||||
return __federation_import('./__federation_expose_Config-CY46uj5g.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Dashboard":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Dashboard-vS9Qm2ZB.css"], false, './Dashboard');
|
||||
return __federation_import('./__federation_expose_Dashboard-BDSt5WaH.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
|
||||
dynamicLoadingCss(["__federation_expose_Dashboard-CFBdUa27.css"], false, './Dashboard');
|
||||
return __federation_import('./__federation_expose_Dashboard-CybypqLB.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
|
||||
const seen = {};
|
||||
const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => {
|
||||
const metaUrl = import.meta.url;
|
||||
|
||||
@@ -713,6 +713,37 @@ var ace$2 = {exports: {}};
|
||||
exports.importCssStylsheet = function (uri, doc) {
|
||||
exports.buildDom(["link", { rel: "stylesheet", href: uri }], exports.getDocumentHead(doc));
|
||||
};
|
||||
exports.$fixPositionBug = function (el) {
|
||||
var rect = el.getBoundingClientRect();
|
||||
if (el.style.left) {
|
||||
var target = parseFloat(el.style.left);
|
||||
var result = +rect.left;
|
||||
if (Math.abs(target - result) > 1) {
|
||||
el.style.left = 2 * target - result + "px";
|
||||
}
|
||||
}
|
||||
if (el.style.right) {
|
||||
var target = parseFloat(el.style.right);
|
||||
var result = window.innerWidth - rect.right;
|
||||
if (Math.abs(target - result) > 1) {
|
||||
el.style.right = 2 * target - result + "px";
|
||||
}
|
||||
}
|
||||
if (el.style.top) {
|
||||
var target = parseFloat(el.style.top);
|
||||
var result = +rect.top;
|
||||
if (Math.abs(target - result) > 1) {
|
||||
el.style.top = 2 * target - result + "px";
|
||||
}
|
||||
}
|
||||
if (el.style.bottom) {
|
||||
var target = parseFloat(el.style.bottom);
|
||||
var result = window.innerHeight - rect.bottom;
|
||||
if (Math.abs(target - result) > 1) {
|
||||
el.style.bottom = 2 * target - result + "px";
|
||||
}
|
||||
}
|
||||
};
|
||||
exports.scrollbarWidth = function (doc) {
|
||||
var inner = exports.createElement("ace_inner");
|
||||
inner.style.width = "100%";
|
||||
@@ -1319,7 +1350,7 @@ var ace$2 = {exports: {}};
|
||||
reportErrorIfPathIsNotConfigured = function () { };
|
||||
}
|
||||
};
|
||||
exports.version = "1.43.2";
|
||||
exports.version = "1.43.5";
|
||||
|
||||
});
|
||||
|
||||
@@ -2072,6 +2103,7 @@ var ace$2 = {exports: {}};
|
||||
this.text = dom.createElement("textarea");
|
||||
this.text.className = "ace_text-input";
|
||||
this.text.setAttribute("wrap", "off");
|
||||
this.text.setAttribute("autocomplete", "off");
|
||||
this.text.setAttribute("autocorrect", "off");
|
||||
this.text.setAttribute("autocapitalize", "off");
|
||||
this.text.setAttribute("spellcheck", "false");
|
||||
@@ -2858,7 +2890,7 @@ var ace$2 = {exports: {}};
|
||||
anchor = this.$clickSelection.start;
|
||||
}
|
||||
else {
|
||||
var orientedRange = calcRangeOrientation(this.$clickSelection, cursor);
|
||||
var orientedRange = calcRangeOrientation(this.$clickSelection, cursor, editor.session);
|
||||
cursor = orientedRange.cursor;
|
||||
anchor = orientedRange.anchor;
|
||||
}
|
||||
@@ -2889,7 +2921,7 @@ var ace$2 = {exports: {}};
|
||||
anchor = range.start;
|
||||
}
|
||||
else {
|
||||
var orientedRange = calcRangeOrientation(this.$clickSelection, cursor);
|
||||
var orientedRange = calcRangeOrientation(this.$clickSelection, cursor, editor.session);
|
||||
cursor = orientedRange.cursor;
|
||||
anchor = orientedRange.anchor;
|
||||
}
|
||||
@@ -3003,11 +3035,11 @@ var ace$2 = {exports: {}};
|
||||
function calcDistance(ax, ay, bx, by) {
|
||||
return Math.sqrt(Math.pow(bx - ax, 2) + Math.pow(by - ay, 2));
|
||||
}
|
||||
function calcRangeOrientation(range, cursor) {
|
||||
function calcRangeOrientation(range, cursor, session) {
|
||||
if (range.start.row == range.end.row)
|
||||
var cmp = 2 * cursor.column - range.start.column - range.end.column;
|
||||
else if (range.start.row == range.end.row - 1 && !range.start.column && !range.end.column)
|
||||
var cmp = cursor.column - 4;
|
||||
var cmp = 3 * cursor.column - 2 * session.getLine(range.start.row).length;
|
||||
else
|
||||
var cmp = 2 * cursor.row - range.start.row - range.end.row;
|
||||
if (cmp < 0)
|
||||
@@ -3018,6 +3050,71 @@ var ace$2 = {exports: {}};
|
||||
|
||||
});
|
||||
|
||||
ace.define("ace/mouse/mouse_event",["require","exports","module","ace/lib/event","ace/lib/useragent"], function(require, exports, module){ var event = require("../lib/event");
|
||||
var useragent = require("../lib/useragent");
|
||||
var MouseEvent = /** @class */ (function () {
|
||||
function MouseEvent(domEvent, editor) { this.speed; this.wheelX; this.wheelY;
|
||||
this.domEvent = domEvent;
|
||||
this.editor = editor;
|
||||
this.x = this.clientX = domEvent.clientX;
|
||||
this.y = this.clientY = domEvent.clientY;
|
||||
this.$pos = null;
|
||||
this.$inSelection = null;
|
||||
this.propagationStopped = false;
|
||||
this.defaultPrevented = false;
|
||||
}
|
||||
MouseEvent.prototype.stopPropagation = function () {
|
||||
event.stopPropagation(this.domEvent);
|
||||
this.propagationStopped = true;
|
||||
};
|
||||
MouseEvent.prototype.preventDefault = function () {
|
||||
event.preventDefault(this.domEvent);
|
||||
this.defaultPrevented = true;
|
||||
};
|
||||
MouseEvent.prototype.stop = function () {
|
||||
this.stopPropagation();
|
||||
this.preventDefault();
|
||||
};
|
||||
MouseEvent.prototype.getDocumentPosition = function () {
|
||||
if (this.$pos)
|
||||
return this.$pos;
|
||||
this.$pos = this.editor.renderer.screenToTextCoordinates(this.clientX, this.clientY);
|
||||
return this.$pos;
|
||||
};
|
||||
MouseEvent.prototype.getGutterRow = function () {
|
||||
var documentRow = this.getDocumentPosition().row;
|
||||
var screenRow = this.editor.session.documentToScreenRow(documentRow, 0);
|
||||
var screenTopRow = this.editor.session.documentToScreenRow(this.editor.renderer.$gutterLayer.$lines.get(0).row, 0);
|
||||
return screenRow - screenTopRow;
|
||||
};
|
||||
MouseEvent.prototype.inSelection = function () {
|
||||
if (this.$inSelection !== null)
|
||||
return this.$inSelection;
|
||||
var editor = this.editor;
|
||||
var selectionRange = editor.getSelectionRange();
|
||||
if (selectionRange.isEmpty())
|
||||
this.$inSelection = false;
|
||||
else {
|
||||
var pos = this.getDocumentPosition();
|
||||
this.$inSelection = selectionRange.contains(pos.row, pos.column);
|
||||
}
|
||||
return this.$inSelection;
|
||||
};
|
||||
MouseEvent.prototype.getButton = function () {
|
||||
return event.getButton(this.domEvent);
|
||||
};
|
||||
MouseEvent.prototype.getShiftKey = function () {
|
||||
return this.domEvent.shiftKey;
|
||||
};
|
||||
MouseEvent.prototype.getAccelKey = function () {
|
||||
return useragent.isMac ? this.domEvent.metaKey : this.domEvent.ctrlKey;
|
||||
};
|
||||
return MouseEvent;
|
||||
}());
|
||||
exports.MouseEvent = MouseEvent;
|
||||
|
||||
});
|
||||
|
||||
ace.define("ace/lib/scroll",["require","exports","module"], function(require, exports, module){exports.preventParentScroll = function preventParentScroll(event) {
|
||||
event.stopPropagation();
|
||||
var target = event.currentTarget;
|
||||
@@ -3090,8 +3187,20 @@ var ace$2 = {exports: {}};
|
||||
dom.addCssClass(this.getElement(), className);
|
||||
};
|
||||
Tooltip.prototype.setTheme = function (theme) {
|
||||
this.$element.className = CLASSNAME + " " +
|
||||
(theme.isDark ? "ace_dark " : "") + (theme.cssClass || "");
|
||||
if (this.theme) {
|
||||
this.theme.isDark && dom.removeCssClass(this.getElement(), "ace_dark");
|
||||
this.theme.cssClass && dom.removeCssClass(this.getElement(), this.theme.cssClass);
|
||||
}
|
||||
if (theme.isDark) {
|
||||
dom.addCssClass(this.getElement(), "ace_dark");
|
||||
}
|
||||
if (theme.cssClass) {
|
||||
dom.addCssClass(this.getElement(), theme.cssClass);
|
||||
}
|
||||
this.theme = {
|
||||
isDark: theme.isDark,
|
||||
cssClass: theme.cssClass
|
||||
};
|
||||
};
|
||||
Tooltip.prototype.show = function (text, x, y) {
|
||||
if (text != null)
|
||||
@@ -3218,12 +3327,18 @@ var ace$2 = {exports: {}};
|
||||
HoverTooltip.prototype.addToEditor = function (editor) {
|
||||
editor.on("mousemove", this.onMouseMove);
|
||||
editor.on("mousedown", this.hide);
|
||||
editor.renderer.getMouseEventTarget().addEventListener("mouseout", this.onMouseOut, true);
|
||||
var target = editor.renderer.getMouseEventTarget();
|
||||
if (target && typeof target.removeEventListener === "function") {
|
||||
target.addEventListener("mouseout", this.onMouseOut, true);
|
||||
}
|
||||
};
|
||||
HoverTooltip.prototype.removeFromEditor = function (editor) {
|
||||
editor.off("mousemove", this.onMouseMove);
|
||||
editor.off("mousedown", this.hide);
|
||||
editor.renderer.getMouseEventTarget().removeEventListener("mouseout", this.onMouseOut, true);
|
||||
var target = editor.renderer.getMouseEventTarget();
|
||||
if (target && typeof target.removeEventListener === "function") {
|
||||
target.removeEventListener("mouseout", this.onMouseOut, true);
|
||||
}
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = null;
|
||||
@@ -3278,7 +3393,6 @@ var ace$2 = {exports: {}};
|
||||
this.$gatherData = value;
|
||||
};
|
||||
HoverTooltip.prototype.showForRange = function (editor, range, domNode, startingEvent) {
|
||||
var MARGIN = 10;
|
||||
if (startingEvent && startingEvent != this.lastEvent)
|
||||
return;
|
||||
if (this.isOpen && document.activeElement == this.getElement())
|
||||
@@ -3290,7 +3404,6 @@ var ace$2 = {exports: {}};
|
||||
this.setTheme(renderer.theme);
|
||||
}
|
||||
this.isOpen = true;
|
||||
this.addMarker(range, editor.session);
|
||||
this.range = Range.fromPoints(range.start, range.end);
|
||||
var position = renderer.textToScreenCoordinates(range.start.row, range.start.column);
|
||||
var rect = renderer.scroller.getBoundingClientRect();
|
||||
@@ -3301,17 +3414,27 @@ var ace$2 = {exports: {}};
|
||||
element.appendChild(domNode);
|
||||
element.style.maxHeight = "";
|
||||
element.style.display = "block";
|
||||
var labelHeight = element.clientHeight;
|
||||
var labelWidth = element.clientWidth;
|
||||
var spaceBelow = window.innerHeight - position.pageY - renderer.lineHeight;
|
||||
var isAbove = true;
|
||||
if (position.pageY - labelHeight < 0 && position.pageY < spaceBelow) {
|
||||
isAbove = false;
|
||||
}
|
||||
element.style.maxHeight = (isAbove ? position.pageY : spaceBelow) - MARGIN + "px";
|
||||
element.style.top = isAbove ? "" : position.pageY + renderer.lineHeight + "px";
|
||||
element.style.bottom = isAbove ? window.innerHeight - position.pageY + "px" : "";
|
||||
element.style.left = Math.min(position.pageX, window.innerWidth - labelWidth - MARGIN) + "px";
|
||||
this.$setPosition(editor, position, true, range);
|
||||
dom.$fixPositionBug(element);
|
||||
};
|
||||
HoverTooltip.prototype.$setPosition = function (editor, position, withMarker, range) {
|
||||
var MARGIN = 10;
|
||||
withMarker && this.addMarker(range, editor.session);
|
||||
var renderer = editor.renderer;
|
||||
var element = this.getElement();
|
||||
var labelHeight = element.offsetHeight;
|
||||
var labelWidth = element.offsetWidth;
|
||||
var anchorTop = position.pageY;
|
||||
var anchorLeft = position.pageX;
|
||||
var spaceBelow = window.innerHeight - anchorTop - renderer.lineHeight;
|
||||
var isAbove = this.$shouldPlaceAbove(labelHeight, anchorTop, spaceBelow - MARGIN);
|
||||
element.style.maxHeight = (isAbove ? anchorTop : spaceBelow) - MARGIN + "px";
|
||||
element.style.top = isAbove ? "" : anchorTop + renderer.lineHeight + "px";
|
||||
element.style.bottom = isAbove ? window.innerHeight - anchorTop + "px" : "";
|
||||
element.style.left = Math.min(anchorLeft, window.innerWidth - labelWidth - MARGIN) + "px";
|
||||
};
|
||||
HoverTooltip.prototype.$shouldPlaceAbove = function (labelHeight, anchorTop, spaceBelow) {
|
||||
return !(anchorTop - labelHeight < 0 && anchorTop < spaceBelow);
|
||||
};
|
||||
HoverTooltip.prototype.addMarker = function (range, session) {
|
||||
if (this.marker) {
|
||||
@@ -3321,6 +3444,11 @@ var ace$2 = {exports: {}};
|
||||
this.marker = session && session.addMarker(range, "ace_highlight-marker", "text");
|
||||
};
|
||||
HoverTooltip.prototype.hide = function (e) {
|
||||
if (e && this.$fromKeyboard && e.type == "keydown") {
|
||||
if (e.code == "Escape") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!e && document.activeElement == this.getElement())
|
||||
return;
|
||||
if (e && e.target && (e.type != "keydown" || e.ctrlKey || e.metaKey) && this.$element.contains(e.target))
|
||||
@@ -3331,6 +3459,7 @@ var ace$2 = {exports: {}};
|
||||
this.timeout = null;
|
||||
this.addMarker(null);
|
||||
if (this.isOpen) {
|
||||
this.$fromKeyboard = false;
|
||||
this.$removeCloseEvents();
|
||||
this.getElement().style.display = "none";
|
||||
this.isOpen = false;
|
||||
@@ -3368,7 +3497,7 @@ var ace$2 = {exports: {}};
|
||||
|
||||
});
|
||||
|
||||
ace.define("ace/mouse/default_gutter_handler",["require","exports","module","ace/lib/dom","ace/lib/event","ace/tooltip","ace/config"], function(require, exports, module){ var __extends = (this && this.__extends) || (function () {
|
||||
ace.define("ace/mouse/default_gutter_handler",["require","exports","module","ace/lib/dom","ace/mouse/mouse_event","ace/tooltip","ace/config","ace/range"], function(require, exports, module){ var __extends = (this && this.__extends) || (function () {
|
||||
var extendStatics = function (d, b) {
|
||||
extendStatics = Object.setPrototypeOf ||
|
||||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
|
||||
@@ -3395,17 +3524,19 @@ var ace$2 = {exports: {}};
|
||||
throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
|
||||
};
|
||||
var dom = require("../lib/dom");
|
||||
var event = require("../lib/event");
|
||||
var Tooltip = require("../tooltip").Tooltip;
|
||||
var MouseEvent = require("./mouse_event").MouseEvent;
|
||||
var HoverTooltip = require("../tooltip").HoverTooltip;
|
||||
var nls = require("../config").nls;
|
||||
var GUTTER_TOOLTIP_LEFT_OFFSET = 5;
|
||||
var GUTTER_TOOLTIP_TOP_OFFSET = 3;
|
||||
exports.GUTTER_TOOLTIP_LEFT_OFFSET = GUTTER_TOOLTIP_LEFT_OFFSET;
|
||||
exports.GUTTER_TOOLTIP_TOP_OFFSET = GUTTER_TOOLTIP_TOP_OFFSET;
|
||||
var Range = require("../range").Range;
|
||||
function GutterHandler(mouseHandler) {
|
||||
var editor = mouseHandler.editor;
|
||||
var gutter = editor.renderer.$gutterLayer;
|
||||
var tooltip = new GutterTooltip(editor, true);
|
||||
mouseHandler.$tooltip = new GutterTooltip(editor);
|
||||
mouseHandler.$tooltip.addToEditor(editor);
|
||||
mouseHandler.$tooltip.setDataProvider(function (e, editor) {
|
||||
var row = e.getDocumentPosition().row;
|
||||
mouseHandler.$tooltip.showTooltip(row);
|
||||
});
|
||||
mouseHandler.editor.setDefaultHandler("guttermousedown", function (e) {
|
||||
if (!editor.isFocused() || e.getButton() != 0)
|
||||
return;
|
||||
@@ -3427,87 +3558,11 @@ var ace$2 = {exports: {}};
|
||||
mouseHandler.captureMouse(e);
|
||||
return e.preventDefault();
|
||||
});
|
||||
var tooltipTimeout, mouseEvent;
|
||||
function showTooltip() {
|
||||
var row = mouseEvent.getDocumentPosition().row;
|
||||
var maxRow = editor.session.getLength();
|
||||
if (row == maxRow) {
|
||||
var screenRow = editor.renderer.pixelToScreenCoordinates(0, mouseEvent.y).row;
|
||||
var pos = mouseEvent.$pos;
|
||||
if (screenRow > editor.session.documentToScreenRow(pos.row, pos.column))
|
||||
return hideTooltip();
|
||||
}
|
||||
tooltip.showTooltip(row);
|
||||
if (!tooltip.isOpen)
|
||||
return;
|
||||
editor.on("mousewheel", hideTooltip);
|
||||
editor.on("changeSession", hideTooltip);
|
||||
window.addEventListener("keydown", hideTooltip, true);
|
||||
if (mouseHandler.$tooltipFollowsMouse) {
|
||||
moveTooltip(mouseEvent);
|
||||
}
|
||||
else {
|
||||
var gutterRow = mouseEvent.getGutterRow();
|
||||
var gutterCell = gutter.$lines.get(gutterRow);
|
||||
if (gutterCell) {
|
||||
var gutterElement = gutterCell.element.querySelector(".ace_gutter_annotation");
|
||||
var rect = gutterElement.getBoundingClientRect();
|
||||
var style = tooltip.getElement().style;
|
||||
style.left = (rect.right - GUTTER_TOOLTIP_LEFT_OFFSET) + "px";
|
||||
style.top = (rect.bottom - GUTTER_TOOLTIP_TOP_OFFSET) + "px";
|
||||
}
|
||||
else {
|
||||
moveTooltip(mouseEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
function hideTooltip(e) {
|
||||
if (e && e.type === "keydown" && (e.ctrlKey || e.metaKey))
|
||||
return;
|
||||
if (e && e.type === "mouseout" && (!e.relatedTarget || tooltip.getElement().contains(e.relatedTarget)))
|
||||
return;
|
||||
if (tooltipTimeout)
|
||||
tooltipTimeout = clearTimeout(tooltipTimeout);
|
||||
if (tooltip.isOpen) {
|
||||
tooltip.hideTooltip();
|
||||
editor.off("mousewheel", hideTooltip);
|
||||
editor.off("changeSession", hideTooltip);
|
||||
window.removeEventListener("keydown", hideTooltip, true);
|
||||
}
|
||||
}
|
||||
function moveTooltip(e) {
|
||||
tooltip.setPosition(e.x, e.y);
|
||||
}
|
||||
mouseHandler.editor.setDefaultHandler("guttermousemove", function (e) {
|
||||
var target = e.domEvent.target || e.domEvent.srcElement;
|
||||
if (dom.hasCssClass(target, "ace_fold-widget") || dom.hasCssClass(target, "ace_custom-widget"))
|
||||
return hideTooltip();
|
||||
if (tooltip.isOpen && mouseHandler.$tooltipFollowsMouse)
|
||||
moveTooltip(e);
|
||||
mouseEvent = e;
|
||||
if (tooltipTimeout)
|
||||
return;
|
||||
tooltipTimeout = setTimeout(function () {
|
||||
tooltipTimeout = null;
|
||||
if (mouseEvent && !mouseHandler.isMousePressed)
|
||||
showTooltip();
|
||||
}, 50);
|
||||
});
|
||||
event.addListener(editor.renderer.$gutter, "mouseout", function (e) {
|
||||
mouseEvent = null;
|
||||
if (!tooltip.isOpen)
|
||||
return;
|
||||
tooltipTimeout = setTimeout(function () {
|
||||
tooltipTimeout = null;
|
||||
hideTooltip(e);
|
||||
}, 50);
|
||||
}, editor);
|
||||
}
|
||||
exports.GutterHandler = GutterHandler;
|
||||
var GutterTooltip = /** @class */ (function (_super) {
|
||||
__extends(GutterTooltip, _super);
|
||||
function GutterTooltip(editor, isHover) {
|
||||
if (isHover === void 0) { isHover = false; }
|
||||
function GutterTooltip(editor) {
|
||||
var _this = _super.call(this, editor.container) || this;
|
||||
_this.id = "gt" + (++GutterTooltip.$uid);
|
||||
_this.editor = editor;
|
||||
@@ -3516,35 +3571,37 @@ var ace$2 = {exports: {}};
|
||||
el.setAttribute("role", "tooltip");
|
||||
el.setAttribute("id", _this.id);
|
||||
el.style.pointerEvents = "auto";
|
||||
if (isHover) {
|
||||
_this.onMouseOut = _this.onMouseOut.bind(_this);
|
||||
el.addEventListener("mouseout", _this.onMouseOut);
|
||||
}
|
||||
_this.idleTime = 50;
|
||||
_this.onDomMouseMove = _this.onDomMouseMove.bind(_this);
|
||||
_this.onDomMouseOut = _this.onDomMouseOut.bind(_this);
|
||||
_this.setClassName("ace_gutter-tooltip");
|
||||
return _this;
|
||||
}
|
||||
GutterTooltip.prototype.onMouseOut = function (e) {
|
||||
if (!this.isOpen)
|
||||
return;
|
||||
if (!e.relatedTarget || this.getElement().contains(e.relatedTarget))
|
||||
return;
|
||||
if (e && e.currentTarget.contains(e.relatedTarget))
|
||||
return;
|
||||
this.hideTooltip();
|
||||
GutterTooltip.prototype.onDomMouseMove = function (domEvent) {
|
||||
var aceEvent = new MouseEvent(domEvent, this.editor);
|
||||
this.onMouseMove(aceEvent, this.editor);
|
||||
};
|
||||
GutterTooltip.prototype.setPosition = function (x, y) {
|
||||
var windowWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||
var windowHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
var width = this.getWidth();
|
||||
var height = this.getHeight();
|
||||
x += 15;
|
||||
y += 15;
|
||||
if (x + width > windowWidth) {
|
||||
x -= (x + width) - windowWidth;
|
||||
GutterTooltip.prototype.onDomMouseOut = function (domEvent) {
|
||||
var aceEvent = new MouseEvent(domEvent, this.editor);
|
||||
this.onMouseOut(aceEvent);
|
||||
};
|
||||
GutterTooltip.prototype.addToEditor = function (editor) {
|
||||
var gutter = editor.renderer.$gutter;
|
||||
gutter.addEventListener("mousemove", this.onDomMouseMove);
|
||||
gutter.addEventListener("mouseout", this.onDomMouseOut);
|
||||
_super.prototype.addToEditor.call(this, editor);
|
||||
};
|
||||
GutterTooltip.prototype.removeFromEditor = function (editor) {
|
||||
var gutter = editor.renderer.$gutter;
|
||||
gutter.removeEventListener("mousemove", this.onDomMouseMove);
|
||||
gutter.removeEventListener("mouseout", this.onDomMouseOut);
|
||||
_super.prototype.removeFromEditor.call(this, editor);
|
||||
};
|
||||
GutterTooltip.prototype.destroy = function () {
|
||||
if (this.editor) {
|
||||
this.removeFromEditor(this.editor);
|
||||
}
|
||||
if (y + height > windowHeight) {
|
||||
y -= 20 + height;
|
||||
}
|
||||
Tooltip.prototype.setPosition.call(this, x, y);
|
||||
_super.prototype.destroy.call(this);
|
||||
};
|
||||
Object.defineProperty(GutterTooltip, "annotationLabels", {
|
||||
get: function () {
|
||||
@@ -3610,7 +3667,7 @@ var ace$2 = {exports: {}};
|
||||
}
|
||||
}
|
||||
if (annotation.displayText.length === 0)
|
||||
return this.hideTooltip();
|
||||
return this.hide();
|
||||
var annotationMessages = { error: [], security: [], warning: [], info: [], hint: [] };
|
||||
var iconClassName = gutter.$useSvgGutterIcons ? "ace_icon_svg" : "ace_icon";
|
||||
for (var i = 0; i < annotation.displayText.length; i++) {
|
||||
@@ -3625,26 +3682,42 @@ var ace$2 = {exports: {}};
|
||||
lineElement.appendChild(dom.createElement("br"));
|
||||
annotationMessages[annotation.type[i].replace("_fold", "")].push(lineElement);
|
||||
}
|
||||
var tooltipElement = this.getElement();
|
||||
dom.removeChildren(tooltipElement);
|
||||
var tooltipElement = dom.createElement("span");
|
||||
annotationMessages.error.forEach(function (el) { return tooltipElement.appendChild(el); });
|
||||
annotationMessages.security.forEach(function (el) { return tooltipElement.appendChild(el); });
|
||||
annotationMessages.warning.forEach(function (el) { return tooltipElement.appendChild(el); });
|
||||
annotationMessages.info.forEach(function (el) { return tooltipElement.appendChild(el); });
|
||||
annotationMessages.hint.forEach(function (el) { return tooltipElement.appendChild(el); });
|
||||
tooltipElement.setAttribute("aria-live", "polite");
|
||||
if (!this.isOpen) {
|
||||
this.setTheme(this.editor.renderer.theme);
|
||||
this.setClassName("ace_gutter-tooltip");
|
||||
}
|
||||
var annotationNode = this.$findLinkedAnnotationNode(row);
|
||||
if (annotationNode) {
|
||||
annotationNode.setAttribute("aria-describedby", this.id);
|
||||
}
|
||||
this.show();
|
||||
var range = Range.fromPoints({ row: row, column: 0 }, { row: row, column: 0 });
|
||||
this.showForRange(this.editor, range, tooltipElement);
|
||||
this.visibleTooltipRow = row;
|
||||
this.editor._signal("showGutterTooltip", this);
|
||||
};
|
||||
GutterTooltip.prototype.$setPosition = function (editor, _ignoredPosition, _withMarker, range) {
|
||||
var gutterCell = this.$findCellByRow(range.start.row);
|
||||
if (!gutterCell)
|
||||
return;
|
||||
var el = gutterCell && gutterCell.element;
|
||||
var anchorEl = el && (el.querySelector(".ace_gutter_annotation"));
|
||||
if (!anchorEl)
|
||||
return;
|
||||
var r = anchorEl.getBoundingClientRect();
|
||||
if (!r)
|
||||
return;
|
||||
var position = {
|
||||
pageX: r.right,
|
||||
pageY: r.top
|
||||
};
|
||||
return _super.prototype.$setPosition.call(this, editor, position, false, range);
|
||||
};
|
||||
GutterTooltip.prototype.$shouldPlaceAbove = function (labelHeight, anchorTop, spaceBelow) {
|
||||
return spaceBelow < labelHeight;
|
||||
};
|
||||
GutterTooltip.prototype.$findLinkedAnnotationNode = function (row) {
|
||||
var cell = this.$findCellByRow(row);
|
||||
if (cell) {
|
||||
@@ -3657,12 +3730,11 @@ var ace$2 = {exports: {}};
|
||||
GutterTooltip.prototype.$findCellByRow = function (row) {
|
||||
return this.editor.renderer.$gutterLayer.$lines.cells.find(function (el) { return el.row === row; });
|
||||
};
|
||||
GutterTooltip.prototype.hideTooltip = function () {
|
||||
GutterTooltip.prototype.hide = function (e) {
|
||||
if (!this.isOpen) {
|
||||
return;
|
||||
}
|
||||
this.$element.removeAttribute("aria-live");
|
||||
this.hide();
|
||||
if (this.visibleTooltipRow != undefined) {
|
||||
var annotationNode = this.$findLinkedAnnotationNode(this.visibleTooltipRow);
|
||||
if (annotationNode) {
|
||||
@@ -3671,6 +3743,7 @@ var ace$2 = {exports: {}};
|
||||
}
|
||||
this.visibleTooltipRow = undefined;
|
||||
this.editor._signal("hideGutterTooltip", this);
|
||||
_super.prototype.hide.call(this, e);
|
||||
};
|
||||
GutterTooltip.annotationsToSummaryString = function (annotations) {
|
||||
var e_1, _a;
|
||||
@@ -3694,78 +3767,19 @@ var ace$2 = {exports: {}};
|
||||
}
|
||||
return summary.join(", ");
|
||||
};
|
||||
GutterTooltip.prototype.isOutsideOfText = function (e) {
|
||||
var editor = e.editor;
|
||||
var rect = editor.renderer.$gutter.getBoundingClientRect();
|
||||
return !(e.clientX >= rect.left && e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top && e.clientY <= rect.bottom);
|
||||
};
|
||||
return GutterTooltip;
|
||||
}(Tooltip));
|
||||
}(HoverTooltip));
|
||||
GutterTooltip.$uid = 0;
|
||||
exports.GutterTooltip = GutterTooltip;
|
||||
|
||||
});
|
||||
|
||||
ace.define("ace/mouse/mouse_event",["require","exports","module","ace/lib/event","ace/lib/useragent"], function(require, exports, module){ var event = require("../lib/event");
|
||||
var useragent = require("../lib/useragent");
|
||||
var MouseEvent = /** @class */ (function () {
|
||||
function MouseEvent(domEvent, editor) { this.speed; this.wheelX; this.wheelY;
|
||||
this.domEvent = domEvent;
|
||||
this.editor = editor;
|
||||
this.x = this.clientX = domEvent.clientX;
|
||||
this.y = this.clientY = domEvent.clientY;
|
||||
this.$pos = null;
|
||||
this.$inSelection = null;
|
||||
this.propagationStopped = false;
|
||||
this.defaultPrevented = false;
|
||||
}
|
||||
MouseEvent.prototype.stopPropagation = function () {
|
||||
event.stopPropagation(this.domEvent);
|
||||
this.propagationStopped = true;
|
||||
};
|
||||
MouseEvent.prototype.preventDefault = function () {
|
||||
event.preventDefault(this.domEvent);
|
||||
this.defaultPrevented = true;
|
||||
};
|
||||
MouseEvent.prototype.stop = function () {
|
||||
this.stopPropagation();
|
||||
this.preventDefault();
|
||||
};
|
||||
MouseEvent.prototype.getDocumentPosition = function () {
|
||||
if (this.$pos)
|
||||
return this.$pos;
|
||||
this.$pos = this.editor.renderer.screenToTextCoordinates(this.clientX, this.clientY);
|
||||
return this.$pos;
|
||||
};
|
||||
MouseEvent.prototype.getGutterRow = function () {
|
||||
var documentRow = this.getDocumentPosition().row;
|
||||
var screenRow = this.editor.session.documentToScreenRow(documentRow, 0);
|
||||
var screenTopRow = this.editor.session.documentToScreenRow(this.editor.renderer.$gutterLayer.$lines.get(0).row, 0);
|
||||
return screenRow - screenTopRow;
|
||||
};
|
||||
MouseEvent.prototype.inSelection = function () {
|
||||
if (this.$inSelection !== null)
|
||||
return this.$inSelection;
|
||||
var editor = this.editor;
|
||||
var selectionRange = editor.getSelectionRange();
|
||||
if (selectionRange.isEmpty())
|
||||
this.$inSelection = false;
|
||||
else {
|
||||
var pos = this.getDocumentPosition();
|
||||
this.$inSelection = selectionRange.contains(pos.row, pos.column);
|
||||
}
|
||||
return this.$inSelection;
|
||||
};
|
||||
MouseEvent.prototype.getButton = function () {
|
||||
return event.getButton(this.domEvent);
|
||||
};
|
||||
MouseEvent.prototype.getShiftKey = function () {
|
||||
return this.domEvent.shiftKey;
|
||||
};
|
||||
MouseEvent.prototype.getAccelKey = function () {
|
||||
return useragent.isMac ? this.domEvent.metaKey : this.domEvent.ctrlKey;
|
||||
};
|
||||
return MouseEvent;
|
||||
}());
|
||||
exports.MouseEvent = MouseEvent;
|
||||
|
||||
});
|
||||
|
||||
ace.define("ace/mouse/dragdrop_handler",["require","exports","module","ace/lib/dom","ace/lib/event","ace/lib/useragent"], function(require, exports, module){ var dom = require("../lib/dom");
|
||||
var event = require("../lib/event");
|
||||
var useragent = require("../lib/useragent");
|
||||
@@ -4574,6 +4588,8 @@ var ace$2 = {exports: {}};
|
||||
MouseHandler.prototype.destroy = function () {
|
||||
if (this.releaseMouse)
|
||||
this.releaseMouse();
|
||||
if (this.$tooltip)
|
||||
this.$tooltip.destroy();
|
||||
};
|
||||
return MouseHandler;
|
||||
}());
|
||||
@@ -4583,7 +4599,6 @@ var ace$2 = {exports: {}};
|
||||
dragDelay: { initialValue: (useragent.isMac ? 150 : 0) },
|
||||
dragEnabled: { initialValue: true },
|
||||
focusTimeout: { initialValue: 0 },
|
||||
tooltipFollowsMouse: { initialValue: true }
|
||||
});
|
||||
exports.MouseHandler = MouseHandler;
|
||||
|
||||
@@ -13724,8 +13739,7 @@ var ace$2 = {exports: {}};
|
||||
|
||||
});
|
||||
|
||||
ace.define("ace/keyboard/gutter_handler",["require","exports","module","ace/lib/keys","ace/mouse/default_gutter_handler"], function(require, exports, module){ var keys = require('../lib/keys');
|
||||
var GutterTooltip = require("../mouse/default_gutter_handler").GutterTooltip;
|
||||
ace.define("ace/keyboard/gutter_handler",["require","exports","module","ace/lib/keys"], function(require, exports, module){ var keys = require('../lib/keys');
|
||||
var GutterKeyboardHandler = /** @class */ (function () {
|
||||
function GutterKeyboardHandler(editor) {
|
||||
this.editor = editor;
|
||||
@@ -13734,7 +13748,7 @@ var ace$2 = {exports: {}};
|
||||
this.lines = editor.renderer.$gutterLayer.$lines;
|
||||
this.activeRowIndex = null;
|
||||
this.activeLane = null;
|
||||
this.annotationTooltip = new GutterTooltip(this.editor);
|
||||
this.annotationTooltip = this.editor.$mouseHandler.$tooltip;
|
||||
}
|
||||
GutterKeyboardHandler.prototype.addListener = function () {
|
||||
this.element.addEventListener("keydown", this.$onGutterKeyDown.bind(this));
|
||||
@@ -13750,7 +13764,7 @@ var ace$2 = {exports: {}};
|
||||
if (this.annotationTooltip.isOpen) {
|
||||
e.preventDefault();
|
||||
if (e.keyCode === keys["escape"])
|
||||
this.annotationTooltip.hideTooltip();
|
||||
this.annotationTooltip.hide();
|
||||
return;
|
||||
}
|
||||
if (e.target === this.element) {
|
||||
@@ -13869,12 +13883,8 @@ var ace$2 = {exports: {}};
|
||||
}
|
||||
return;
|
||||
case "annotation":
|
||||
var gutterElement = this.lines.cells[this.activeRowIndex].element.childNodes[2];
|
||||
var rect = gutterElement.getBoundingClientRect();
|
||||
var style = this.annotationTooltip.getElement().style;
|
||||
style.left = rect.right + "px";
|
||||
style.top = rect.bottom + "px";
|
||||
this.annotationTooltip.showTooltip(this.$rowIndexToRow(this.activeRowIndex));
|
||||
this.annotationTooltip.$fromKeyboard = true;
|
||||
break;
|
||||
}
|
||||
return;
|
||||
@@ -13893,7 +13903,7 @@ var ace$2 = {exports: {}};
|
||||
}
|
||||
}
|
||||
if (this.annotationTooltip.isOpen)
|
||||
this.annotationTooltip.hideTooltip();
|
||||
this.annotationTooltip.hide();
|
||||
return;
|
||||
};
|
||||
GutterKeyboardHandler.prototype.$isFoldWidgetVisible = function (index) {
|
||||
@@ -16178,7 +16188,6 @@ var ace$2 = {exports: {}};
|
||||
dragDelay: "$mouseHandler",
|
||||
dragEnabled: "$mouseHandler",
|
||||
focusTimeout: "$mouseHandler",
|
||||
tooltipFollowsMouse: "$mouseHandler",
|
||||
firstLineNumber: "session",
|
||||
overwrite: "session",
|
||||
newLineMode: "session",
|
||||
@@ -16328,6 +16337,7 @@ var ace$2 = {exports: {}};
|
||||
var nls = require("../config").nls;
|
||||
var Gutter = /** @class */ (function () {
|
||||
function Gutter(parentEl) {
|
||||
this.$showCursorMarker = null;
|
||||
this.element = dom.createElement("div");
|
||||
this.element.className = "ace_layer ace_gutter-layer";
|
||||
parentEl.appendChild(this.element);
|
||||
@@ -16448,6 +16458,8 @@ var ace$2 = {exports: {}};
|
||||
}
|
||||
this._signal("afterRender");
|
||||
this.$updateGutterWidth(config);
|
||||
if (this.$showCursorMarker && this.$highlightGutterLine)
|
||||
this.$updateCursorMarker();
|
||||
};
|
||||
Gutter.prototype.$updateGutterWidth = function (config) {
|
||||
var session = this.session;
|
||||
@@ -16476,6 +16488,8 @@ var ace$2 = {exports: {}};
|
||||
this.$cursorRow = position.row;
|
||||
};
|
||||
Gutter.prototype.updateLineHighlight = function () {
|
||||
if (this.$showCursorMarker)
|
||||
this.$updateCursorMarker();
|
||||
if (!this.$highlightGutterLine)
|
||||
return;
|
||||
var row = this.session.selection.cursor.row;
|
||||
@@ -16502,6 +16516,26 @@ var ace$2 = {exports: {}};
|
||||
}
|
||||
}
|
||||
};
|
||||
Gutter.prototype.$updateCursorMarker = function () {
|
||||
if (!this.session)
|
||||
return;
|
||||
var session = this.session;
|
||||
if (!this.$highlightElement) {
|
||||
this.$highlightElement = dom.createElement("div");
|
||||
this.$highlightElement.className = "ace_gutter-cursor";
|
||||
this.$highlightElement.style.pointerEvents = "none";
|
||||
this.element.appendChild(this.$highlightElement);
|
||||
}
|
||||
var pos = session.selection.cursor;
|
||||
var config = this.config;
|
||||
var lines = this.$lines;
|
||||
var screenTop = config.firstRowScreen * config.lineHeight;
|
||||
var screenPage = Math.floor(screenTop / lines.canvasHeight);
|
||||
var lineTop = session.documentToScreenRow(pos) * config.lineHeight;
|
||||
var top = lineTop - (screenPage * lines.canvasHeight);
|
||||
dom.setStyle(this.$highlightElement.style, "height", config.lineHeight + "px");
|
||||
dom.setStyle(this.$highlightElement.style, "top", top + "px");
|
||||
};
|
||||
Gutter.prototype.scrollLines = function (config) {
|
||||
var oldConfig = this.config;
|
||||
this.config = config;
|
||||
@@ -16745,6 +16779,10 @@ var ace$2 = {exports: {}};
|
||||
};
|
||||
Gutter.prototype.setHighlightGutterLine = function (highlightGutterLine) {
|
||||
this.$highlightGutterLine = highlightGutterLine;
|
||||
if (!highlightGutterLine && this.$highlightElement) {
|
||||
this.$highlightElement.remove();
|
||||
this.$highlightElement = null;
|
||||
}
|
||||
};
|
||||
Gutter.prototype.setShowLineNumbers = function (show) {
|
||||
this.$renderer = !show && {
|
||||
@@ -16786,8 +16824,24 @@ var ace$2 = {exports: {}};
|
||||
};
|
||||
Gutter.prototype.$getGutterCell = function (row) {
|
||||
var cells = this.$lines.cells;
|
||||
var visibileRow = this.session.documentToScreenRow(row, 0);
|
||||
return cells[row - this.config.firstRowScreen - (row - visibileRow)];
|
||||
var min = 0;
|
||||
var max = cells.length - 1;
|
||||
if (row < cells[0].row || row > cells[max].row)
|
||||
return;
|
||||
while (min <= max) {
|
||||
var mid = Math.floor((min + max) / 2);
|
||||
var cell = cells[mid];
|
||||
if (cell.row > row) {
|
||||
max = mid - 1;
|
||||
}
|
||||
else if (cell.row < row) {
|
||||
min = mid + 1;
|
||||
}
|
||||
else {
|
||||
return cell;
|
||||
}
|
||||
}
|
||||
return cell;
|
||||
};
|
||||
Gutter.prototype.$addCustomWidget = function (row, _a, cell) {
|
||||
var className = _a.className, label = _a.label, title = _a.title, callbacks = _a.callbacks;
|
||||
@@ -16850,7 +16904,7 @@ var ace$2 = {exports: {}};
|
||||
}());
|
||||
Gutter.prototype.$fixedWidth = false;
|
||||
Gutter.prototype.$highlightGutterLine = true;
|
||||
Gutter.prototype.$renderer = "";
|
||||
Gutter.prototype.$renderer = undefined;
|
||||
Gutter.prototype.$showLineNumbers = true;
|
||||
Gutter.prototype.$showFoldWidgets = true;
|
||||
oop.implement(Gutter.prototype, EventEmitter);
|
||||
@@ -19856,6 +19910,15 @@ var ace$2 = {exports: {}};
|
||||
: "padding" in (_self.theme || {}) ? 4 : _self.$padding;
|
||||
if (_self.$padding && padding != _self.$padding)
|
||||
_self.setPadding(padding);
|
||||
if (_self.$gutterLayer) {
|
||||
var showGutterCursor = module["$showGutterCursorMarker"];
|
||||
if (showGutterCursor && !_self.$gutterLayer.$showCursorMarker) {
|
||||
_self.$gutterLayer.$showCursorMarker = "theme";
|
||||
}
|
||||
else if (!showGutterCursor && _self.$gutterLayer.$showCursorMarker == "theme") {
|
||||
_self.$gutterLayer.$showCursorMarker = null;
|
||||
}
|
||||
}
|
||||
_self.$theme = module.cssClass;
|
||||
_self.theme = module;
|
||||
dom.addCssClass(_self.container, module.cssClass);
|
||||
144
plugins.v2/clashruleprovider/helper/clashrulemanager.py
Normal file
144
plugins.v2/clashruleprovider/helper/clashrulemanager.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from typing import Any, Callable, Dict, List, Optional, Union, Iterator
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from ..models.metadata import Metadata
|
||||
from ..models.rule import Action, RoutingRuleType, MatchRule, ClashRule, LogicRule
|
||||
from ..models.ruleitem import RuleItem, RuleData
|
||||
|
||||
|
||||
class ClashRuleManager:
|
||||
"""Clash rule manager"""
|
||||
def __init__(self):
|
||||
self.rules: List[RuleItem] = []
|
||||
|
||||
def import_rules(self, rules_list: List[Dict[str, Any]]):
|
||||
self.rules.clear()
|
||||
for r in rules_list:
|
||||
try:
|
||||
rule = RuleItem.model_validate(r)
|
||||
except ValidationError:
|
||||
continue
|
||||
self.rules.append(rule)
|
||||
|
||||
def export_rules(self) -> List[Dict[str, str]]:
|
||||
adapter = TypeAdapter(list[RuleItem])
|
||||
return adapter.dump_python(self.rules, mode='json')
|
||||
|
||||
def append_rules(self, clash_rules: List[RuleItem]):
|
||||
self.rules.extend(clash_rules)
|
||||
|
||||
def insert_rule_at_priority(self, clash_rule: RuleItem, priority: int):
|
||||
self.rules.insert(priority, clash_rule)
|
||||
|
||||
def update_rule_at_priority(self, clash_rule: RuleItem, src_priority: int, dst_priority) -> bool:
|
||||
if len(self.rules) > src_priority >= 0:
|
||||
if src_priority == dst_priority:
|
||||
self.rules[src_priority] = clash_rule
|
||||
else:
|
||||
self.remove_rule_at_priority(src_priority)
|
||||
self.insert_rule_at_priority(clash_rule, dst_priority)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_rule_at_priority(self, priority: int) -> Optional[RuleItem]:
|
||||
"""Get rule item by priority"""
|
||||
if len(self.rules) > priority >= 0:
|
||||
return self.rules[priority]
|
||||
return None
|
||||
|
||||
def remove_rule_at_priority(self, priority: int) -> Optional[RuleItem]:
|
||||
"""Remove rule at specific priority"""
|
||||
if 0 <= priority < len(self.rules):
|
||||
return self.rules.pop(priority)
|
||||
return None
|
||||
|
||||
def remove_rules_at_priorities(self, priorities: list[int]) -> list[RuleItem]:
|
||||
"""Remove rules at specific priorities"""
|
||||
removed = []
|
||||
# Sort priorities in descending order to avoid index shift issues during removal
|
||||
for priority in sorted(priorities, reverse=True):
|
||||
if 0 <= priority < len(self.rules):
|
||||
removed.append(self.rules.pop(priority))
|
||||
return removed
|
||||
|
||||
def remove_rules_by_lambda(self, condition: Callable[[RuleItem], bool]):
|
||||
"""Remove rules by lambda"""
|
||||
initial_count = len(self.rules)
|
||||
i = 0
|
||||
while i < len(self.rules):
|
||||
if condition(self.rules[i]):
|
||||
del self.rules[i]
|
||||
else:
|
||||
i += 1
|
||||
return initial_count - len(self.rules)
|
||||
|
||||
def move_rule_priority(self, from_priority: int, to_priority: int) -> bool:
|
||||
"""Move rule priority to priority"""
|
||||
clash_rule = self.remove_rule_at_priority(from_priority)
|
||||
if not clash_rule:
|
||||
return False
|
||||
self.insert_rule_at_priority(clash_rule, to_priority)
|
||||
return True
|
||||
|
||||
def filter_rules_by_condition(self, condition: Callable[[RuleItem], bool]):
|
||||
"""Filter rules by condition"""
|
||||
return [clash_rule for clash_rule in self.rules if condition(clash_rule)]
|
||||
|
||||
def filter_rules_by_type(self, rule_type: RoutingRuleType) -> List[RuleItem]:
|
||||
"""Filter rules by type"""
|
||||
return [clash_rule for clash_rule in self.rules
|
||||
if isinstance(clash_rule.rule, ClashRule) and clash_rule.rule.rule_type == rule_type]
|
||||
|
||||
def filter_rules_by_action(self, action: Union[Action, str]) -> List[RuleItem]:
|
||||
"""Filter rules by action"""
|
||||
return [clash_rule for clash_rule in self.rules if clash_rule.rule.action == action]
|
||||
|
||||
def has_rule(self, clash_rule: Union[ClashRule, LogicRule, MatchRule]) -> bool:
|
||||
"""Check if there is an identical rule"""
|
||||
return any(r.rule == clash_rule for r in self.rules)
|
||||
|
||||
def has_rule_item(self, clash_rule: RuleItem) -> bool:
|
||||
return any(clash_rule.meta.source == r.meta.source and r.rule == clash_rule.rule for r in self.rules)
|
||||
|
||||
def reorder_rules(self, moved_priority: int, target_priority: int) -> RuleItem:
|
||||
"""Reorder the rules"""
|
||||
if not (0 <= moved_priority < len(self.rules)):
|
||||
raise IndexError("moved_priority out of range")
|
||||
if not (0 <= target_priority < len(self.rules)):
|
||||
raise IndexError("target_priority out of range")
|
||||
rule = self.rules.pop(moved_priority)
|
||||
self.rules.insert(target_priority, rule)
|
||||
return rule
|
||||
|
||||
def update_rules_at_priorities(self, priorities: dict[int, bool]) -> list[RuleItem]:
|
||||
"""Disable rules"""
|
||||
updated = []
|
||||
for priority, disabled in priorities.items():
|
||||
if 0 <= priority < len(self.rules):
|
||||
self.rules[priority].meta.disabled = disabled
|
||||
updated.append(self.rules[priority])
|
||||
return updated
|
||||
|
||||
def update_rule_meta_at_priority(self, priority: int, meta: Metadata) -> bool:
|
||||
"""Update rule metadata at priority"""
|
||||
if 0 <= priority < len(self.rules):
|
||||
self.rules[priority].meta = meta
|
||||
return True
|
||||
return False
|
||||
|
||||
def to_list(self) -> list[RuleData]:
|
||||
"""Convert parsed rules to a list"""
|
||||
result: list[RuleData] = []
|
||||
for priority, rule_item in enumerate(self.rules):
|
||||
result.append(RuleData.from_rule_item(rule_item, priority))
|
||||
return result
|
||||
|
||||
def clear(self):
|
||||
self.rules.clear()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.rules)
|
||||
|
||||
def __iter__(self) -> Iterator[RuleItem]:
|
||||
return iter(self.rules)
|
||||
333
plugins.v2/clashruleprovider/helper/clashruleparser.py
Normal file
333
plugins.v2/clashruleprovider/helper/clashruleparser.py
Normal file
@@ -0,0 +1,333 @@
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ..models.rule import RuleType, Action, RoutingRuleType, MatchRule, ClashRule, LogicRule, SubRule, AdditionalParam
|
||||
|
||||
|
||||
class ClashRuleParser:
|
||||
"""Parser for Clash routing rules"""
|
||||
|
||||
@staticmethod
|
||||
def parse(line: str) -> RuleType:
|
||||
"""Parse a single rule line"""
|
||||
# Handle logic rules (AND, OR, NOT)
|
||||
if line.startswith(('AND,', 'OR,', 'NOT,')):
|
||||
return ClashRuleParser._parse_logic_rule(line)
|
||||
elif line.startswith('MATCH'):
|
||||
return ClashRuleParser._parse_match_rule(line)
|
||||
elif line.startswith('SUB-RULE'):
|
||||
return ClashRuleParser._parse_sub_rule(line)
|
||||
# Handle regular rules
|
||||
return ClashRuleParser._parse_regular_rule(line)
|
||||
|
||||
@staticmethod
|
||||
def parse_rule_line(line: str) -> Optional[RuleType]:
|
||||
"""Parse a single rule line"""
|
||||
line = line.strip()
|
||||
try:
|
||||
return ClashRuleParser.parse(line)
|
||||
except (ValidationError, TypeError, ValueError, RecursionError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def parse_rule_dict(clash_rule: Dict[str, Any]) -> Optional[RuleType]:
|
||||
if not clash_rule:
|
||||
return None
|
||||
try:
|
||||
if clash_rule.get("type") in ('AND', 'OR', 'NOT'):
|
||||
conditions = clash_rule.get("conditions", [])
|
||||
if not conditions:
|
||||
return None
|
||||
conditions = [ClashRuleParser._remove_parenthesis(f"({c})") for c in conditions]
|
||||
conditions_str = ','.join(conditions)
|
||||
conditions_str = f"({conditions_str})"
|
||||
raw_rule = f"{clash_rule.get('type')},{conditions_str},{clash_rule.get('action')}"
|
||||
rule = ClashRuleParser._parse_logic_rule(raw_rule)
|
||||
elif clash_rule.get("type") == 'MATCH':
|
||||
raw_rule = f"{clash_rule.get('type')},{clash_rule.get('action')}"
|
||||
rule = ClashRuleParser._parse_match_rule(raw_rule)
|
||||
elif clash_rule.get("type") == 'SUB-RULE':
|
||||
condition = clash_rule.get("condition")
|
||||
if not condition:
|
||||
return None
|
||||
condition_str = f"({condition})"
|
||||
condition_str = ClashRuleParser._remove_parenthesis(condition_str)
|
||||
raw_rule = f"{clash_rule.get('type')},{condition_str},{clash_rule.get('action')}"
|
||||
rule = ClashRuleParser._parse_sub_rule(raw_rule)
|
||||
else:
|
||||
raw_rule = f"{clash_rule.get('type')},{clash_rule.get('payload')},{clash_rule.get('action')}"
|
||||
if clash_rule.get('additional_params'):
|
||||
raw_rule += f',{clash_rule.get('additional_params')}'
|
||||
rule = ClashRuleParser._parse_regular_rule(raw_rule)
|
||||
|
||||
except (ValidationError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return rule
|
||||
|
||||
@staticmethod
|
||||
def _parse_match_rule(line: str) -> MatchRule:
|
||||
parts = line.split(',')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f"Invalid rule format: {line}")
|
||||
action = parts[1].strip()
|
||||
# Validate rule type
|
||||
try:
|
||||
action_enum = Action(action.upper())
|
||||
final_action = action_enum
|
||||
except ValueError:
|
||||
final_action = action
|
||||
|
||||
return MatchRule(
|
||||
action=final_action,
|
||||
raw_rule=line
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_regular_rule(line: str) -> ClashRule:
|
||||
"""Parse a regular (non-logic) rule"""
|
||||
parts = line.split(',')
|
||||
|
||||
if len(parts) < 3 or len(parts) > 4:
|
||||
raise ValueError(f"Invalid rule format: {line}")
|
||||
|
||||
rule_type_str = parts[0].upper().strip()
|
||||
payload = parts[1].strip()
|
||||
action = parts[2].strip()
|
||||
|
||||
if not payload or not rule_type_str:
|
||||
raise ValueError(f"Invalid rule format: {line}")
|
||||
|
||||
additional_params = parts[3].strip() if len(parts) > 3 else None
|
||||
|
||||
# Validate rule type
|
||||
try:
|
||||
rule_type = RoutingRuleType(rule_type_str)
|
||||
except ValueError:
|
||||
raise ValueError(f"Unknown rule type: {rule_type_str}")
|
||||
|
||||
# Try to convert action to enum, otherwise keep as string (custom proxy group)
|
||||
if additional_params is not None:
|
||||
additional_params = AdditionalParam(additional_params)
|
||||
try:
|
||||
action_enum = Action(action.upper())
|
||||
final_action = action_enum
|
||||
except ValueError:
|
||||
final_action = action
|
||||
|
||||
return ClashRule(
|
||||
rule_type=rule_type,
|
||||
payload=payload,
|
||||
action=final_action,
|
||||
additional_params=additional_params,
|
||||
raw_rule=line
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parenthesis_balance(s: str) -> Optional[int]:
|
||||
"""Calculate the balance of parenthesis"""
|
||||
balance = 0
|
||||
for i, char in enumerate(s):
|
||||
if char == '(':
|
||||
balance += 1
|
||||
elif char == ')':
|
||||
balance -= 1
|
||||
if balance < 0:
|
||||
return None
|
||||
return balance
|
||||
|
||||
@staticmethod
|
||||
def _parse_logic_rule(line: str) -> LogicRule:
|
||||
"""Parse a logic rule (AND, OR, NOT)"""
|
||||
# Extract logic type
|
||||
logic_type_str, rest = line.split(',', 1)
|
||||
logic_type = RoutingRuleType(logic_type_str.upper().strip())
|
||||
last_comma_index = rest.rfind(',')
|
||||
if last_comma_index == -1:
|
||||
raise ValueError(f"Invalid logic rule format: {line}")
|
||||
action_str = rest[last_comma_index + 1:]
|
||||
conditions_str = rest[:last_comma_index]
|
||||
|
||||
# Find the matching parenthesis for the conditions block to separate conditions from action
|
||||
balance = ClashRuleParser._parenthesis_balance(conditions_str)
|
||||
if balance != 0:
|
||||
raise ValueError(f"Mismatched parentheses in logic rule: {line}")
|
||||
|
||||
action = action_str.strip()
|
||||
# Try to convert action to enum
|
||||
try:
|
||||
action_enum = Action(action.upper())
|
||||
final_action = action_enum
|
||||
except ValueError:
|
||||
final_action = action
|
||||
|
||||
conditions = ClashRuleParser._parse_logic_conditions(conditions_str)
|
||||
|
||||
return LogicRule(
|
||||
rule_type=logic_type,
|
||||
conditions=conditions,
|
||||
action=final_action,
|
||||
raw_rule=line
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_sub_rule(line: str) -> SubRule:
|
||||
"""Parse a sub-rule"""
|
||||
rule_type_str, rest = line.split(',', 1)
|
||||
rule_type = RoutingRuleType(rule_type_str.upper().strip())
|
||||
if rule_type != RoutingRuleType.SUB_RULE:
|
||||
raise ValueError(f"{rule_type.value} is not a sub-rule")
|
||||
last_comma_index = rest.rfind(',')
|
||||
if last_comma_index == -1:
|
||||
raise ValueError(f"Invalid sub-rule format: {line}")
|
||||
condition_str = rest[:last_comma_index]
|
||||
action_str = rest[last_comma_index + 1:]
|
||||
|
||||
balance = ClashRuleParser._parenthesis_balance(condition_str)
|
||||
if balance != 0:
|
||||
raise ValueError(f"Mismatched parentheses in sub-rule: {line}")
|
||||
|
||||
conditions = ClashRuleParser._parse_logic_conditions(condition_str)
|
||||
if len(conditions) != 1:
|
||||
raise ValueError(f"Invalid sub-rule condition: {condition_str}")
|
||||
|
||||
return SubRule(
|
||||
condition=conditions[0],
|
||||
action=action_str,
|
||||
raw_rule=line
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _remove_parenthesis(_con_str: str):
|
||||
balance = 0
|
||||
filed_list = []
|
||||
field = ''
|
||||
for i, char in enumerate(_con_str):
|
||||
if char == '(':
|
||||
balance += 1
|
||||
elif char == ')':
|
||||
balance -= 1
|
||||
elif char == ',':
|
||||
if balance == 1:
|
||||
filed_list.append(field)
|
||||
else:
|
||||
if balance == 1 and char:
|
||||
field = field + char
|
||||
if not any(filed_list):
|
||||
return ClashRuleParser._remove_parenthesis(_con_str[1:-1])
|
||||
else:
|
||||
return _con_str
|
||||
|
||||
@staticmethod
|
||||
def _parse_logic_conditions(conditions_str: str) -> List[Union[ClashRule, LogicRule]]:
|
||||
"""
|
||||
Parse conditions within logic rules, supporting nested logic.
|
||||
The examples of conditions_str:
|
||||
- (DOMAIN,baidu.com)
|
||||
- (AND,(DOMAIN,baidu.com),(NETWORK,TCP))
|
||||
"""
|
||||
|
||||
def __extract_condition_strings(_con_str: str) -> List[str]:
|
||||
# Split conditions string by top-level commas
|
||||
_con_str = _con_str.replace(' ', '')
|
||||
_con_str = ClashRuleParser._remove_parenthesis(_con_str)
|
||||
_condition_strings = []
|
||||
balance = 0
|
||||
start = 0
|
||||
|
||||
for i, char in enumerate(_con_str):
|
||||
if char == '(':
|
||||
if balance == 0:
|
||||
start = i
|
||||
balance += 1
|
||||
elif char == ')':
|
||||
balance -= 1
|
||||
if balance == 0:
|
||||
_condition_strings.append(_con_str[start:i + 1])
|
||||
return _condition_strings
|
||||
|
||||
conditions = []
|
||||
|
||||
if not conditions_str:
|
||||
return conditions
|
||||
condition_strings = __extract_condition_strings(conditions_str)
|
||||
for cond_str in condition_strings:
|
||||
cond_str = cond_str.strip()
|
||||
if not cond_str.startswith('(') or not cond_str.endswith(')'):
|
||||
raise ValueError(f"Invalid nested logic rule format: {cond_str}")
|
||||
content = cond_str[1:-1] # remove parentheses
|
||||
if content.upper().startswith(('AND,', 'OR,', 'NOT,')):
|
||||
# This is a nested logic rule.
|
||||
parts = content.split(',', 1)
|
||||
logic_type_str = parts[0].strip().upper()
|
||||
logic_type = RoutingRuleType(logic_type_str)
|
||||
|
||||
nested_conditions_str = parts[1]
|
||||
nested_conditions = ClashRuleParser._parse_logic_conditions(f'({nested_conditions_str})')
|
||||
|
||||
condition = LogicRule(
|
||||
rule_type=logic_type,
|
||||
conditions=nested_conditions,
|
||||
action=Action.COMPATIBLE, # No action for conditions
|
||||
raw_rule=content
|
||||
)
|
||||
conditions.append(condition)
|
||||
else:
|
||||
# Simple rule
|
||||
parts = content.split(',', 1)
|
||||
if len(parts) == 2:
|
||||
rule_type_str, payload = parts
|
||||
try:
|
||||
rule_type = RoutingRuleType(rule_type_str.upper().strip())
|
||||
condition = ClashRule(
|
||||
rule_type=rule_type,
|
||||
payload=payload.strip(),
|
||||
action=Action.COMPATIBLE, # Logic conditions don't have actions
|
||||
raw_rule=content
|
||||
)
|
||||
conditions.append(condition)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid rule format: {content}")
|
||||
return conditions
|
||||
|
||||
@staticmethod
|
||||
def parse_rules(rules_text: str) -> List[Union[ClashRule, LogicRule, MatchRule]]:
|
||||
"""Parse multiple rules from text, preserving order and priority"""
|
||||
rules = []
|
||||
lines = rules_text.strip().split('\n')
|
||||
|
||||
for line in lines:
|
||||
rule = ClashRuleParser.parse_rule_line(line)
|
||||
if rule:
|
||||
rules.append(rule)
|
||||
|
||||
return rules
|
||||
|
||||
@staticmethod
|
||||
def validate_rule(rule: ClashRule) -> bool:
|
||||
"""Validate a parsed rule"""
|
||||
try:
|
||||
# Basic validation based on the rule type
|
||||
if rule.rule_type in [RoutingRuleType.IP_CIDR, RoutingRuleType.IP_CIDR6]:
|
||||
# Validate CIDR format
|
||||
return '/' in rule.payload
|
||||
|
||||
elif rule.rule_type == RoutingRuleType.DST_PORT or rule.rule_type == RoutingRuleType.SRC_PORT:
|
||||
# Validate port number/range
|
||||
return rule.payload.isdigit() or '-' in rule.payload
|
||||
|
||||
elif rule.rule_type == RoutingRuleType.NETWORK:
|
||||
# Validate the network type
|
||||
return rule.payload.lower() in ['tcp', 'udp']
|
||||
|
||||
elif rule.rule_type == RoutingRuleType.DOMAIN_REGEX or rule.rule_type == RoutingRuleType.PROCESS_PATH_REGEX:
|
||||
# Try to compile regex
|
||||
re.compile(rule.payload)
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
301
plugins.v2/clashruleprovider/helper/configconverter.py
Normal file
301
plugins.v2/clashruleprovider/helper/configconverter.py
Normal file
@@ -0,0 +1,301 @@
|
||||
import base64
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, Optional, Union
|
||||
from urllib.parse import quote
|
||||
|
||||
from .converters import BaseConverter
|
||||
|
||||
|
||||
class Converter:
|
||||
"""
|
||||
A refactored converter for V2Ray subscriptions that uses a strategy pattern.
|
||||
It dynamically loads protocol-specific converters from the 'converters' directory.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._converters: Dict[str, BaseConverter] = self._load_converters()
|
||||
|
||||
def _load_converters(self) -> Dict[str, BaseConverter]:
|
||||
"""
|
||||
Dynamically discovers and loads all converter classes from the .py files
|
||||
in the 'converters' directory.
|
||||
"""
|
||||
converters: Dict[str, BaseConverter] = {}
|
||||
converter_dir = os.path.dirname(__file__)
|
||||
module_names = [f.replace('.py', '') for f in os.listdir(os.path.join(converter_dir, 'converters'))
|
||||
if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
for module_name in module_names:
|
||||
try:
|
||||
module = importlib.import_module(f".converters.{module_name}", package=__package__)
|
||||
class_name = f"{module_name.capitalize()}Converter"
|
||||
converter_class = getattr(module, class_name, None)
|
||||
|
||||
if converter_class and issubclass(converter_class, BaseConverter):
|
||||
instance = converter_class()
|
||||
# Determine the protocol scheme based on the module name
|
||||
scheme = module_name
|
||||
if scheme == 'http':
|
||||
converters['http'] = instance
|
||||
converters['https'] = instance
|
||||
elif scheme == 'socks':
|
||||
converters['socks'] = instance
|
||||
converters['socks5'] = instance
|
||||
converters['socks5h'] = instance
|
||||
elif scheme == 'hysteria2':
|
||||
converters['hysteria2'] = instance
|
||||
converters['hy2'] = instance
|
||||
else:
|
||||
converters[scheme] = instance
|
||||
except (ImportError, AttributeError) as e:
|
||||
# Log this error appropriately in a real application
|
||||
print(f"Could not load converter for {module_name}: {e}")
|
||||
return converters
|
||||
|
||||
def convert_line(self, line: str, names: dict[str, int] | None = None, skip_exception: bool = True,
|
||||
logger: Any = None) -> dict[str, Any] | None:
|
||||
"""
|
||||
Parses a single subscription link and converts it to a proxy dictionary.
|
||||
"""
|
||||
if names is None:
|
||||
names = {}
|
||||
|
||||
if "://" not in line:
|
||||
return None
|
||||
|
||||
scheme, _ = line.split("://", 1)
|
||||
scheme = scheme.lower()
|
||||
|
||||
converter = self._converters.get(scheme)
|
||||
if converter:
|
||||
try:
|
||||
return converter.convert(line, names)
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.error(f"Error converting line {line}: {e}")
|
||||
if not skip_exception:
|
||||
raise ValueError(f"{scheme.upper()} parse error: {e}") from e
|
||||
return None
|
||||
return None
|
||||
|
||||
def convert_v2ray(self, v2ray_link: Union[list, bytes], skip_exception: bool = True,
|
||||
logger: Any = None) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Converts a base64 encoded V2Ray subscription content or a list of links
|
||||
into a list of proxy dictionaries.
|
||||
"""
|
||||
if isinstance(v2ray_link, bytes):
|
||||
decoded = BaseConverter.decode_base64(v2ray_link).decode("utf-8")
|
||||
lines = decoded.strip().splitlines()
|
||||
else:
|
||||
lines = v2ray_link
|
||||
|
||||
proxies: dict[str, dict[str, Any]] = {}
|
||||
names = {}
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
proxy = self.convert_line(line, names, skip_exception=skip_exception, logger=logger)
|
||||
if proxy:
|
||||
proxies[line] = proxy
|
||||
elif not skip_exception:
|
||||
raise ValueError("Failed to convert one of the links in the subscription.")
|
||||
return proxies
|
||||
|
||||
@staticmethod
|
||||
def convert_to_share_link(proxy_config: Dict[str, Any]) -> Optional[str]:
|
||||
proxy_type = proxy_config.get("type")
|
||||
name = proxy_config.get("name", "proxy")
|
||||
|
||||
if proxy_type == "vmess":
|
||||
vmess_config = {
|
||||
"v": "2",
|
||||
"ps": name,
|
||||
"add": proxy_config.get("server", ""),
|
||||
"port": str(proxy_config.get("port", "")),
|
||||
"id": proxy_config.get("uuid", ""),
|
||||
"aid": str(proxy_config.get("alterId", 0)),
|
||||
"scy": proxy_config.get("cipher", "auto"),
|
||||
"net": proxy_config.get("network", "tcp"),
|
||||
"type": "none",
|
||||
"tls": "tls" if proxy_config.get("tls") else "",
|
||||
"host": "",
|
||||
"path": "/",
|
||||
}
|
||||
|
||||
if proxy_config.get("network") == "http":
|
||||
vmess_config["type"] = "http"
|
||||
|
||||
network = proxy_config.get("network")
|
||||
if network == "ws":
|
||||
ws_opts = proxy_config.get("ws-opts", {})
|
||||
vmess_config["host"] = ws_opts.get("headers", {}).get("Host", "")
|
||||
vmess_config["path"] = ws_opts.get("path", "/")
|
||||
elif network == "http":
|
||||
http_opts = proxy_config.get("http-opts", {})
|
||||
vmess_config["host"] = http_opts.get("headers", {}).get("Host", "")
|
||||
vmess_config["path"] = http_opts.get("path", "/")
|
||||
elif network == "h2":
|
||||
h2_opts = proxy_config.get("h2-opts", {})
|
||||
vmess_config["host"] = h2_opts.get("host")[0] if h2_opts.get("host") else ""
|
||||
vmess_config["path"] = h2_opts.get("path", "/")
|
||||
# Remove empty values to keep the JSON clean
|
||||
vmess_config = {k: v for k, v in vmess_config.items() if v not in ["", None]}
|
||||
encoded_str = base64.b64encode(json.dumps(vmess_config).encode("utf-8")).decode("utf-8")
|
||||
return f"vmess://{encoded_str}"
|
||||
|
||||
elif proxy_type == "ss":
|
||||
method = proxy_config.get("cipher")
|
||||
password = proxy_config.get("password")
|
||||
server = proxy_config.get("server")
|
||||
port = proxy_config.get("port")
|
||||
if not all([method, password, server, port]):
|
||||
return None
|
||||
credentials = f"{method}:{password}@{server}:{port}"
|
||||
encoded_credentials = base64.b64encode(credentials.encode("utf-8")).decode("utf-8")
|
||||
return f"ss://{encoded_credentials}#{quote(name)}"
|
||||
|
||||
elif proxy_type == "trojan":
|
||||
password = proxy_config.get("password")
|
||||
server = proxy_config.get("server")
|
||||
port = proxy_config.get("port")
|
||||
if not all([password, server, port]):
|
||||
return None
|
||||
|
||||
query_params = {}
|
||||
if proxy_config.get("sni"):
|
||||
query_params["sni"] = proxy_config["sni"]
|
||||
if proxy_config.get("alpn"):
|
||||
query_params["alpn"] = ",".join(proxy_config["alpn"])
|
||||
if proxy_config.get("skip-cert-verify"):
|
||||
query_params["allowInsecure"] = "1"
|
||||
|
||||
network = proxy_config.get("network")
|
||||
if network:
|
||||
query_params["type"] = network
|
||||
if network == "ws":
|
||||
ws_opts = proxy_config.get("ws-opts", {})
|
||||
path = ws_opts.get("path", "/")
|
||||
host = ws_opts.get("headers", {}).get("Host", "")
|
||||
# Always add path and host for ws if they exist, even if defaulted, for round-trip consistency
|
||||
if path:
|
||||
query_params["path"] = path
|
||||
if host:
|
||||
query_params["host"] = host
|
||||
elif network == "grpc":
|
||||
grpc_opts = proxy_config.get("grpc-opts", {})
|
||||
service_name = grpc_opts.get("grpc-service-name", "")
|
||||
if service_name:
|
||||
query_params["serviceName"] = service_name
|
||||
|
||||
client_fingerprint = proxy_config.get("client-fingerprint")
|
||||
# Always add fp if it exists, to ensure round-trip consistency, as convert_v2ray defaults to "chrome"
|
||||
if client_fingerprint:
|
||||
query_params["fp"] = client_fingerprint
|
||||
|
||||
query_string = "&".join([f"{k}={quote(str(v))}" for k, v in query_params.items()])
|
||||
|
||||
base_link = f"trojan://{password}@{server}:{port}"
|
||||
if query_string:
|
||||
return f"{base_link}?{query_string}#{quote(name)}"
|
||||
else:
|
||||
return f"{base_link}#{quote(name)}"
|
||||
elif proxy_type == "vless":
|
||||
uuid = proxy_config.get("uuid")
|
||||
server = proxy_config.get("server")
|
||||
port = proxy_config.get("port")
|
||||
if not all([uuid, server, port]):
|
||||
return None
|
||||
|
||||
query_params = {}
|
||||
name = proxy_config.get("name", f"{server}:{port}")
|
||||
|
||||
tls = proxy_config.get("tls", False)
|
||||
if tls:
|
||||
if "reality-opts" in proxy_config:
|
||||
query_params["security"] = "reality"
|
||||
reality_opts = proxy_config["reality-opts"]
|
||||
if reality_opts.get("public-key"):
|
||||
query_params["pbk"] = reality_opts["public-key"]
|
||||
if reality_opts.get("short-id"):
|
||||
query_params["sid"] = reality_opts["short-id"]
|
||||
else:
|
||||
query_params["security"] = "tls"
|
||||
|
||||
if proxy_config.get("client-fingerprint"):
|
||||
query_params["fp"] = proxy_config["client-fingerprint"]
|
||||
if proxy_config.get("alpn"):
|
||||
query_params["alpn"] = ",".join(proxy_config["alpn"])
|
||||
if proxy_config.get("skip-cert-verify"):
|
||||
query_params["allowInsecure"] = "1"
|
||||
|
||||
if proxy_config.get("servername"):
|
||||
query_params["sni"] = proxy_config["servername"]
|
||||
|
||||
# Network settings
|
||||
network = proxy_config.get("network", "tcp")
|
||||
query_params["type"] = network
|
||||
|
||||
if network == "ws":
|
||||
ws_opts = proxy_config.get("ws-opts", {})
|
||||
path = ws_opts.get("path", "")
|
||||
host = ws_opts.get("headers", {}).get("Host", "")
|
||||
if path:
|
||||
query_params["path"] = path
|
||||
if host:
|
||||
query_params["host"] = host
|
||||
elif network == "grpc":
|
||||
grpc_opts = proxy_config.get("grpc-opts", {})
|
||||
service_name = grpc_opts.get("grpc-service-name", "")
|
||||
if service_name:
|
||||
query_params["serviceName"] = service_name
|
||||
|
||||
if proxy_config.get("flow"):
|
||||
query_params["flow"] = proxy_config["flow"]
|
||||
|
||||
query_string = "&".join([f"{k}={quote(str(v))}" for k, v in query_params.items()])
|
||||
|
||||
base_link = f"vless://{uuid}@{server}:{port}"
|
||||
if query_string:
|
||||
return f"{base_link}?{query_string}#{quote(name)}"
|
||||
else:
|
||||
return f"{base_link}#{quote(name)}"
|
||||
|
||||
elif proxy_type == "ssr":
|
||||
server = proxy_config.get("server")
|
||||
port = proxy_config.get("port")
|
||||
protocol = proxy_config.get("protocol", "origin")
|
||||
cipher = proxy_config.get("cipher")
|
||||
obfs = proxy_config.get("obfs", "plain")
|
||||
password = proxy_config.get("password")
|
||||
name = proxy_config.get("name", f"{server}:{port}")
|
||||
|
||||
if not all([server, port, protocol, cipher, obfs, password]):
|
||||
return None
|
||||
|
||||
password_enc = base64.urlsafe_b64encode(password.encode("utf-8")).decode("utf-8").rstrip('=')
|
||||
ssr_main_part = f"{server}:{port}:{protocol}:{cipher}:{obfs}:{password_enc}"
|
||||
|
||||
query_params = {}
|
||||
if proxy_config.get("obfs-param"):
|
||||
query_params["obfsparam"] = base64.urlsafe_b64encode(
|
||||
proxy_config["obfs-param"].encode("utf-8")).decode("utf-8").rstrip('=')
|
||||
if proxy_config.get("protocol-param"):
|
||||
query_params["protoparam"] = base64.urlsafe_b64encode(
|
||||
proxy_config["protocol-param"].encode("utf-8")).decode("utf-8").rstrip('=')
|
||||
|
||||
query_params["remarks"] = base64.urlsafe_b64encode(name.encode("utf-8")).decode("utf-8").rstrip('=')
|
||||
query_params["group"] = base64.urlsafe_b64encode("MoviePilot".encode("utf-8")).decode("utf-8").rstrip('=')
|
||||
|
||||
query_string = "&".join([f"{k}={v}" for k, v in query_params.items()])
|
||||
|
||||
full_ssr_link_body = f"{ssr_main_part}/?{query_string}"
|
||||
encoded_full_ssr_link_body = base64.urlsafe_b64encode(
|
||||
full_ssr_link_body.encode("utf-8")).decode("utf-8").rstrip('=')
|
||||
|
||||
return f"ssr://{encoded_full_ssr_link_body}"
|
||||
|
||||
return None
|
||||
163
plugins.v2/clashruleprovider/helper/converters/__init__.py
Normal file
163
plugins.v2/clashruleprovider/helper/converters/__init__.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import unquote, urlparse, parse_qsl
|
||||
|
||||
|
||||
class BaseConverter(ABC):
|
||||
"""
|
||||
Abstract base class for all protocol converters.
|
||||
It defines a common interface and provides shared utility methods.
|
||||
"""
|
||||
user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome'
|
||||
|
||||
@abstractmethod
|
||||
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Converts a subscription link to a proxy configuration dictionary.
|
||||
|
||||
:param link: The subscription link string (e.g., "vmess://...").
|
||||
:param names: A dictionary to track and ensure unique proxy names.
|
||||
:return: A dictionary representing the proxy configuration, or None if conversion fails.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def decode_base64(data):
|
||||
# Add fault tolerance for different padding
|
||||
data = data.strip()
|
||||
missing_padding = len(data) % 4
|
||||
if missing_padding:
|
||||
data += '=' * (4 - missing_padding)
|
||||
return base64.b64decode(data)
|
||||
|
||||
@staticmethod
|
||||
def decode_base64_urlsafe(data):
|
||||
data = data.strip()
|
||||
missing_padding = len(data) % 4
|
||||
if missing_padding:
|
||||
data += '=' * (4 - missing_padding)
|
||||
return base64.urlsafe_b64decode(data)
|
||||
|
||||
@staticmethod
|
||||
def try_decode_base64_json(data):
|
||||
try:
|
||||
return json.loads(BaseConverter.decode_base64(data).decode('utf-8'))
|
||||
except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def unique_name(name_map: Dict[str, int], name: str) -> str:
|
||||
index = name_map.get(name, 0)
|
||||
name_map[name] = index + 1
|
||||
if index > 0:
|
||||
return f"{name}-{index:02d}"
|
||||
return name
|
||||
|
||||
@staticmethod
|
||||
def lower_string(string: Optional[str]) -> Optional[str]:
|
||||
if isinstance(string, str):
|
||||
return string.lower()
|
||||
return string
|
||||
|
||||
@staticmethod
|
||||
def handle_vshare_link(link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
url_info = urlparse(link)
|
||||
query = dict(parse_qsl(url_info.query))
|
||||
scheme = url_info.scheme.lower()
|
||||
|
||||
if not url_info.hostname or not url_info.port:
|
||||
return None
|
||||
|
||||
proxy: Dict[str, Any] = {
|
||||
'name': BaseConverter.unique_name(names,
|
||||
unquote(url_info.fragment or f"{url_info.hostname}:{url_info.port}")),
|
||||
'type': scheme,
|
||||
'server': url_info.hostname,
|
||||
'port': url_info.port,
|
||||
'uuid': url_info.username,
|
||||
'udp': True
|
||||
}
|
||||
|
||||
# TLS and Reality settings
|
||||
tls_mode = BaseConverter.lower_string(query.get('security'))
|
||||
if tls_mode in ['tls', 'reality']:
|
||||
proxy['tls'] = True
|
||||
proxy['client-fingerprint'] = query.get('fp', 'chrome')
|
||||
if 'alpn' in query:
|
||||
proxy['alpn'] = query['alpn'].split(',')
|
||||
if 'sni' in query:
|
||||
proxy['servername'] = query['sni']
|
||||
|
||||
if tls_mode == 'reality':
|
||||
proxy['reality-opts'] = {
|
||||
'public-key': query.get('pbk'),
|
||||
'short-id': query.get('sid')
|
||||
}
|
||||
|
||||
# Network settings
|
||||
network = BaseConverter.lower_string(query.get('type', 'tcp'))
|
||||
header_type = BaseConverter.lower_string(query.get('headerType'))
|
||||
|
||||
if header_type == 'http':
|
||||
network = 'http'
|
||||
elif network == 'http':
|
||||
network = 'h2'
|
||||
|
||||
proxy['network'] = network
|
||||
|
||||
if network == 'tcp' and header_type == 'http':
|
||||
proxy['http-opts'] = {
|
||||
'method': query.get('method', 'GET'),
|
||||
'path': [query.get('path', '/')],
|
||||
'headers': {'Host': [query.get('host', url_info.hostname)]}
|
||||
}
|
||||
elif network == 'h2':
|
||||
proxy["h2-opts"] = {
|
||||
"path": query.get("path", "/"),
|
||||
"host": [query.get("host", url_info.hostname)]
|
||||
}
|
||||
elif network in ['ws', 'httpupgrade']:
|
||||
ws_opts: Dict[str, Any] = {
|
||||
'path': query.get('path', '/'),
|
||||
'headers': {
|
||||
'Host': query.get('host', url_info.hostname),
|
||||
'User-Agent': BaseConverter.user_agent
|
||||
}
|
||||
}
|
||||
if 'ed' in query:
|
||||
try:
|
||||
med = int(query['ed'])
|
||||
if network == 'ws':
|
||||
ws_opts['max-early-data'] = med
|
||||
ws_opts['early-data-header-name'] = query.get('eh', 'Sec-WebSocket-Protocol')
|
||||
elif network == 'httpupgrade':
|
||||
ws_opts['v2ray-http-upgrade-fast-open'] = True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
proxy['ws-opts'] = ws_opts
|
||||
elif network == 'grpc':
|
||||
proxy['grpc-opts'] = {
|
||||
'grpc-service-name': query.get('serviceName', '')
|
||||
}
|
||||
|
||||
# Packet Encoding
|
||||
packet_encoding = BaseConverter.lower_string(query.get('packetEncoding'))
|
||||
if packet_encoding == 'packet':
|
||||
proxy['packet-addr'] = True
|
||||
elif packet_encoding != 'none':
|
||||
proxy['xudp'] = True
|
||||
|
||||
# Encryption
|
||||
if 'encryption' in query and query['encryption']:
|
||||
proxy['encryption'] = query['encryption']
|
||||
|
||||
if 'flow' in query:
|
||||
proxy['flow'] = query['flow']
|
||||
|
||||
return proxy
|
||||
except Exception:
|
||||
return None
|
||||
36
plugins.v2/clashruleprovider/helper/converters/anytls.py
Normal file
36
plugins.v2/clashruleprovider/helper/converters/anytls.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import urlparse, parse_qsl, unquote
|
||||
|
||||
from . import BaseConverter
|
||||
|
||||
|
||||
class AnytlsConverter(BaseConverter):
|
||||
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
parsed = urlparse(link)
|
||||
query = dict(parse_qsl(parsed.query))
|
||||
|
||||
username = parsed.username
|
||||
password = parsed.password or username
|
||||
server = parsed.hostname
|
||||
port = parsed.port
|
||||
insecure = query.get("insecure", "0") == "1"
|
||||
sni = query.get("sni")
|
||||
fingerprint = query.get("hpkp")
|
||||
|
||||
name = self.unique_name(names, unquote(parsed.fragment or f"{server}:{port}"))
|
||||
proxy = {
|
||||
"name": name,
|
||||
"type": "anytls",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"sni": sni,
|
||||
"fingerprint": fingerprint,
|
||||
"skip-cert-verify": insecure,
|
||||
"udp": True
|
||||
}
|
||||
return proxy
|
||||
except Exception:
|
||||
return None
|
||||
46
plugins.v2/clashruleprovider/helper/converters/http.py
Normal file
46
plugins.v2/clashruleprovider/helper/converters/http.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import binascii
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import urlparse, unquote
|
||||
|
||||
from . import BaseConverter
|
||||
|
||||
|
||||
class HttpConverter(BaseConverter):
|
||||
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
parsed = urlparse(link)
|
||||
server = parsed.hostname
|
||||
port = parsed.port
|
||||
name = self.unique_name(names, unquote(parsed.fragment or f"{server}:{port}"))
|
||||
|
||||
username = None
|
||||
password = None
|
||||
if parsed.username:
|
||||
try:
|
||||
# The userinfo part might be base64 encoded
|
||||
decoded_userinfo = self.decode_base64(parsed.username.encode('utf-8')).decode('utf-8')
|
||||
if ":" in decoded_userinfo:
|
||||
username, password = decoded_userinfo.split(":", 1)
|
||||
else:
|
||||
username = decoded_userinfo
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
# If not base64 encoded, use directly
|
||||
username = parsed.username
|
||||
password = parsed.password if parsed.password else ""
|
||||
|
||||
proxy = {
|
||||
"name": name,
|
||||
"type": "http",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"skip-cert-verify": True
|
||||
}
|
||||
|
||||
if parsed.scheme == "https":
|
||||
proxy["tls"] = True
|
||||
|
||||
return proxy
|
||||
except Exception:
|
||||
return None
|
||||
59
plugins.v2/clashruleprovider/helper/converters/hysteria.py
Normal file
59
plugins.v2/clashruleprovider/helper/converters/hysteria.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import urlparse, parse_qsl, unquote
|
||||
|
||||
from app.utils.string import StringUtils
|
||||
from . import BaseConverter
|
||||
|
||||
|
||||
class HysteriaConverter(BaseConverter):
|
||||
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
parsed = urlparse(link)
|
||||
query = dict(parse_qsl(parsed.query))
|
||||
|
||||
name = self.unique_name(names, unquote(parsed.fragment or f"{parsed.hostname}:{parsed.port}"))
|
||||
hysteria: Dict[str, Any] = {
|
||||
"name": name,
|
||||
"type": "hysteria",
|
||||
"server": parsed.hostname,
|
||||
"port": parsed.port,
|
||||
"udp": True
|
||||
}
|
||||
|
||||
auth_str = query.get("auth")
|
||||
if auth_str:
|
||||
hysteria["auth_str"] = auth_str
|
||||
obfs = query.get("obfs")
|
||||
if obfs:
|
||||
hysteria["obfs"] = obfs
|
||||
sni = query.get("peer")
|
||||
if sni:
|
||||
hysteria["sni"] = sni
|
||||
protocol = query.get("protocol")
|
||||
if protocol:
|
||||
hysteria["protocol"] = protocol
|
||||
up = query.get("up")
|
||||
if not up:
|
||||
up = query.get("upmbps")
|
||||
if up:
|
||||
hysteria["up"] = up
|
||||
down = query.get("down")
|
||||
if not down:
|
||||
down = query.get("downmbps")
|
||||
if down:
|
||||
hysteria["down"] = down
|
||||
alpn = query.get("alpn", "")
|
||||
if alpn:
|
||||
hysteria["alpn"] = alpn.split(",")
|
||||
|
||||
# skip-cert-verify
|
||||
insecure_str = query.get("insecure", "false")
|
||||
try:
|
||||
skip_cert_verify = StringUtils.to_bool(insecure_str)
|
||||
if skip_cert_verify:
|
||||
hysteria["skip-cert-verify"] = skip_cert_verify
|
||||
except ValueError:
|
||||
pass
|
||||
return hysteria
|
||||
except Exception:
|
||||
return None
|
||||
45
plugins.v2/clashruleprovider/helper/converters/hysteria2.py
Normal file
45
plugins.v2/clashruleprovider/helper/converters/hysteria2.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import urlparse, parse_qsl, unquote
|
||||
|
||||
from app.utils.string import StringUtils
|
||||
from . import BaseConverter
|
||||
|
||||
|
||||
class Hysteria2Converter(BaseConverter):
|
||||
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
parsed = urlparse(link)
|
||||
query = dict(parse_qsl(parsed.query))
|
||||
|
||||
user_info = ""
|
||||
if parsed.username:
|
||||
if parsed.password:
|
||||
user_info = f"{parsed.username}:{parsed.password}"
|
||||
else:
|
||||
user_info = parsed.username
|
||||
password = user_info
|
||||
|
||||
server = parsed.hostname
|
||||
port = parsed.port or 443
|
||||
name = self.unique_name(names, unquote(parsed.fragment or f"{server}:{port}"))
|
||||
proxy = {
|
||||
"name": name,
|
||||
"type": "hysteria2",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"password": password,
|
||||
"obfs": query.get("obfs"),
|
||||
"obfs-password": query.get("obfs-password"),
|
||||
"sni": query.get("sni"),
|
||||
"skip-cert-verify": StringUtils.to_bool(query.get("insecure", "false")),
|
||||
"down": query.get("down"),
|
||||
"up": query.get("up"),
|
||||
"udp": True
|
||||
}
|
||||
if "pinSHA256" in query:
|
||||
proxy["fingerprint"] = query.get("pinSHA256")
|
||||
if "alpn" in query:
|
||||
proxy["alpn"] = query["alpn"].split(",")
|
||||
return proxy
|
||||
except Exception:
|
||||
return None
|
||||
42
plugins.v2/clashruleprovider/helper/converters/socks.py
Normal file
42
plugins.v2/clashruleprovider/helper/converters/socks.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import binascii
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import urlparse, unquote
|
||||
|
||||
from . import BaseConverter
|
||||
|
||||
|
||||
class SocksConverter(BaseConverter):
|
||||
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
parsed = urlparse(link)
|
||||
server = parsed.hostname
|
||||
port = parsed.port
|
||||
name = self.unique_name(names, unquote(parsed.fragment or f"{server}:{port}"))
|
||||
|
||||
username = None
|
||||
password = None
|
||||
if parsed.username:
|
||||
try:
|
||||
# The userinfo part might be base64 encoded
|
||||
decoded_userinfo = self.decode_base64(parsed.username.encode('utf-8')).decode('utf-8')
|
||||
if ":" in decoded_userinfo:
|
||||
username, password = decoded_userinfo.split(":", 1)
|
||||
else:
|
||||
username = decoded_userinfo
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
# If not base64 encoded, use directly
|
||||
username = parsed.username
|
||||
password = parsed.password if parsed.password else ""
|
||||
|
||||
proxy = {
|
||||
"name": name,
|
||||
"type": "socks5",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"skip-cert-verify": True
|
||||
}
|
||||
return proxy
|
||||
except Exception:
|
||||
return None
|
||||
75
plugins.v2/clashruleprovider/helper/converters/ss.py
Normal file
75
plugins.v2/clashruleprovider/helper/converters/ss.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import binascii
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import urlparse, parse_qsl, unquote
|
||||
|
||||
from . import BaseConverter
|
||||
|
||||
|
||||
class SsConverter(BaseConverter):
|
||||
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
parsed = urlparse(link)
|
||||
|
||||
if parsed.port is None and parsed.netloc:
|
||||
base64_body = parsed.netloc
|
||||
decoded_body = self.decode_base64_urlsafe(base64_body).decode('utf-8')
|
||||
|
||||
new_line = f"ss://{decoded_body}"
|
||||
if parsed.fragment:
|
||||
new_line += f"#{parsed.fragment}"
|
||||
parsed = urlparse(new_line)
|
||||
|
||||
name = self.unique_name(names, unquote(parsed.fragment or f"{parsed.hostname}:{parsed.port}"))
|
||||
|
||||
cipher_raw = parsed.username
|
||||
password = parsed.password
|
||||
cipher = cipher_raw
|
||||
|
||||
if not password and cipher_raw:
|
||||
try:
|
||||
decoded_user = self.decode_base64_urlsafe(cipher_raw).decode('utf-8')
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
decoded_user = self.decode_base64(cipher_raw).decode('utf-8')
|
||||
|
||||
if ":" in decoded_user:
|
||||
cipher, password = decoded_user.split(":", 1)
|
||||
else:
|
||||
cipher = decoded_user
|
||||
|
||||
server = parsed.hostname
|
||||
port = parsed.port
|
||||
query = dict(parse_qsl(parsed.query))
|
||||
proxy = {
|
||||
"name": name,
|
||||
"type": "ss",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"cipher": cipher,
|
||||
"password": password,
|
||||
"udp": True
|
||||
}
|
||||
if query.get("udp-over-tcp") == "true" or query.get("uot") == "1":
|
||||
proxy["udp-over-tcp"] = True
|
||||
plugin = query.get("plugin")
|
||||
if plugin and ";" in plugin:
|
||||
query_string = "pluginName=" + plugin.replace(";", "&")
|
||||
plugin_info = dict(parse_qsl(query_string))
|
||||
plugin_name = plugin_info.get("pluginName", "")
|
||||
|
||||
if "obfs" in plugin_name:
|
||||
proxy["plugin"] = "obfs"
|
||||
proxy["plugin-opts"] = {
|
||||
"mode": plugin_info.get("obfs"),
|
||||
"host": plugin_info.get("obfs-host"),
|
||||
}
|
||||
elif "v2ray-plugin" in plugin_name:
|
||||
proxy["plugin"] = "v2ray-plugin"
|
||||
proxy["plugin-opts"] = {
|
||||
"mode": plugin_info.get("mode"),
|
||||
"host": plugin_info.get("host"),
|
||||
"path": plugin_info.get("path"),
|
||||
"tls": "tls" in plugin,
|
||||
}
|
||||
return proxy
|
||||
except Exception:
|
||||
return None
|
||||
64
plugins.v2/clashruleprovider/helper/converters/ssr.py
Normal file
64
plugins.v2/clashruleprovider/helper/converters/ssr.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import binascii
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from . import BaseConverter
|
||||
|
||||
|
||||
class SsrConverter(BaseConverter):
|
||||
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
_, body = link.split("://", 1)
|
||||
try:
|
||||
decoded_body = self.decode_base64_urlsafe(body).decode('utf-8')
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
decoded_body = self.decode_base64(body).decode('utf-8')
|
||||
|
||||
parts, _, params_str = decoded_body.partition("/?")
|
||||
|
||||
part_list = parts.split(":", 5)
|
||||
if len(part_list) != 6:
|
||||
raise ValueError("Invalid SSR link format: incorrect number of parts")
|
||||
|
||||
host, port_str, protocol, method, obfs, password_enc = part_list
|
||||
|
||||
try:
|
||||
port = int(port_str)
|
||||
except ValueError:
|
||||
raise ValueError("Invalid port in SSR link")
|
||||
|
||||
password = self.decode_base64_urlsafe(password_enc).decode('utf-8')
|
||||
params = dict(parse_qsl(params_str))
|
||||
remarks_b64 = params.get("remarks", "")
|
||||
remarks = self.decode_base64_urlsafe(remarks_b64).decode('utf-8') if remarks_b64 else ""
|
||||
|
||||
obfsparam_b64 = params.get("obfsparam", "")
|
||||
obfsparam = self.decode_base64_urlsafe(obfsparam_b64).decode(
|
||||
'utf-8') if obfsparam_b64 else ""
|
||||
|
||||
protoparam_b64 = params.get("protoparam", "")
|
||||
protoparam = self.decode_base64_urlsafe(protoparam_b64).decode(
|
||||
'utf-8') if protoparam_b64 else ""
|
||||
|
||||
name = self.unique_name(names, remarks or f"{host}:{port}")
|
||||
|
||||
proxy = {
|
||||
"name": name,
|
||||
"type": "ssr",
|
||||
"server": host,
|
||||
"port": port,
|
||||
"cipher": method,
|
||||
"password": password,
|
||||
"obfs": obfs,
|
||||
"protocol": protocol,
|
||||
"udp": True
|
||||
}
|
||||
|
||||
if obfsparam:
|
||||
proxy["obfs-param"] = obfsparam
|
||||
if protoparam:
|
||||
proxy["protocol-param"] = protoparam
|
||||
|
||||
return proxy
|
||||
except Exception:
|
||||
return None
|
||||
60
plugins.v2/clashruleprovider/helper/converters/trojan.py
Normal file
60
plugins.v2/clashruleprovider/helper/converters/trojan.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import urlparse, parse_qsl, unquote
|
||||
|
||||
from app.utils.string import StringUtils
|
||||
from . import BaseConverter
|
||||
|
||||
|
||||
class TrojanConverter(BaseConverter):
|
||||
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
parsed = urlparse(link)
|
||||
query = dict(parse_qsl(parsed.query))
|
||||
|
||||
name = self.unique_name(names, unquote(parsed.fragment or f"{parsed.hostname}:{parsed.port}"))
|
||||
|
||||
trojan: Dict[str, Any] = {
|
||||
"name": name,
|
||||
"type": "trojan",
|
||||
"server": parsed.hostname,
|
||||
"port": parsed.port or 443,
|
||||
"password": parsed.username or "",
|
||||
"udp": True,
|
||||
"tls": True
|
||||
}
|
||||
|
||||
# skip-cert-verify
|
||||
try:
|
||||
trojan["skip-cert-verify"] = StringUtils.to_bool(query.get("allowInsecure", "0"))
|
||||
except ValueError:
|
||||
trojan["skip-cert-verify"] = False
|
||||
|
||||
# optional fields
|
||||
if "sni" in query:
|
||||
trojan["sni"] = query["sni"]
|
||||
|
||||
alpn = query.get("alpn")
|
||||
if alpn:
|
||||
trojan["alpn"] = alpn.split(",")
|
||||
|
||||
network = query.get("type", "").lower()
|
||||
if network:
|
||||
trojan["network"] = network
|
||||
|
||||
if network == "ws":
|
||||
headers = {"User-Agent": self.user_agent}
|
||||
trojan["ws-opts"] = {
|
||||
"path": query.get("path", "/"),
|
||||
"headers": headers
|
||||
}
|
||||
|
||||
elif network == "grpc":
|
||||
trojan["grpc-opts"] = {
|
||||
"grpc-service-name": query.get("serviceName")
|
||||
}
|
||||
|
||||
fp = query.get("fp")
|
||||
trojan["client-fingerprint"] = fp if fp else "chrome"
|
||||
return trojan
|
||||
except Exception:
|
||||
return None
|
||||
46
plugins.v2/clashruleprovider/helper/converters/tuic.py
Normal file
46
plugins.v2/clashruleprovider/helper/converters/tuic.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import urlparse, parse_qsl, unquote
|
||||
|
||||
from . import BaseConverter
|
||||
|
||||
|
||||
class TuicConverter(BaseConverter):
|
||||
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
parsed = urlparse(link)
|
||||
query = dict(parse_qsl(parsed.query))
|
||||
|
||||
user = parsed.username
|
||||
password = parsed.password
|
||||
server = parsed.hostname
|
||||
port = parsed.port
|
||||
|
||||
name = self.unique_name(names, unquote(parsed.fragment or f"{server}:{port}"))
|
||||
proxy = {
|
||||
"name": name,
|
||||
"type": "tuic",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"udp": True
|
||||
}
|
||||
|
||||
if password:
|
||||
proxy["uuid"] = user
|
||||
proxy["password"] = password
|
||||
else:
|
||||
proxy["token"] = user
|
||||
|
||||
if "congestion_control" in query:
|
||||
proxy["congestion-controller"] = query["congestion_control"]
|
||||
if "alpn" in query:
|
||||
proxy["alpn"] = query["alpn"].split(",")
|
||||
if "sni" in query:
|
||||
proxy["sni"] = query["sni"]
|
||||
if query.get("disable_sni", "0") == "1":
|
||||
proxy["disable-sni"] = True
|
||||
if "udp_relay_mode" in query:
|
||||
proxy["udp-relay-mode"] = query["udp_relay_mode"]
|
||||
|
||||
return proxy
|
||||
except Exception:
|
||||
return None
|
||||
11
plugins.v2/clashruleprovider/helper/converters/vless.py
Normal file
11
plugins.v2/clashruleprovider/helper/converters/vless.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from . import BaseConverter
|
||||
|
||||
|
||||
class VlessConverter(BaseConverter):
|
||||
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
return self.handle_vshare_link(link, names)
|
||||
except Exception:
|
||||
return None
|
||||
106
plugins.v2/clashruleprovider/helper/converters/vmess.py
Normal file
106
plugins.v2/clashruleprovider/helper/converters/vmess.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from . import BaseConverter
|
||||
|
||||
|
||||
class VmessConverter(BaseConverter):
|
||||
def convert(self, link: str, names: Dict[str, int]) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
_, body = link.split("://", 1)
|
||||
vmess_data = self.try_decode_base64_json(body)
|
||||
# Xray VMessAEAD share link
|
||||
if vmess_data is None:
|
||||
return self.handle_vshare_link(link, names)
|
||||
|
||||
name = self.unique_name(names, vmess_data.get("ps", "vmess"))
|
||||
net = self.lower_string(vmess_data.get("net"))
|
||||
fake_type = self.lower_string(vmess_data.get("type"))
|
||||
tls_mode = self.lower_string(vmess_data.get("tls"))
|
||||
cipher = vmess_data.get("scy", "auto") or "auto"
|
||||
alter_id = vmess_data.get("aid", 0)
|
||||
|
||||
# Adjust network type
|
||||
if fake_type == "http":
|
||||
net = "http"
|
||||
elif net == "http":
|
||||
net = "h2"
|
||||
|
||||
proxy = {
|
||||
"name": name,
|
||||
"type": "vmess",
|
||||
"server": vmess_data.get("add"),
|
||||
"port": vmess_data.get("port"),
|
||||
"uuid": vmess_data.get("id"),
|
||||
"alterId": alter_id,
|
||||
"cipher": cipher,
|
||||
"tls": tls_mode.endswith("tls") or tls_mode == "reality",
|
||||
"udp": True,
|
||||
"xudp": True,
|
||||
"skip-cert-verify": False,
|
||||
"network": net
|
||||
}
|
||||
|
||||
# TLS Reality extension
|
||||
if proxy["tls"]:
|
||||
proxy["client-fingerprint"] = vmess_data.get("fp", "chrome") or "chrome"
|
||||
alpn = vmess_data.get("alpn")
|
||||
if alpn:
|
||||
proxy["alpn"] = alpn.split(",") if isinstance(alpn, str) else alpn
|
||||
sni = vmess_data.get("sni")
|
||||
if sni:
|
||||
proxy["servername"] = sni
|
||||
|
||||
if tls_mode == "reality":
|
||||
proxy["reality-opts"] = {
|
||||
"public-key": vmess_data.get("pbk"),
|
||||
"short-id": vmess_data.get("sid")
|
||||
}
|
||||
|
||||
path = vmess_data.get("path", "/")
|
||||
host = vmess_data.get("host")
|
||||
|
||||
# Extension fields for different networks
|
||||
if net == "tcp":
|
||||
if fake_type == "http":
|
||||
proxy["http-opts"] = {
|
||||
"path": path,
|
||||
"headers": {"Host": host} if host else {}
|
||||
}
|
||||
elif net == "http":
|
||||
headers = {}
|
||||
if host:
|
||||
headers["Host"] = [host]
|
||||
proxy["http-opts"] = {"path": [path], "headers": headers}
|
||||
|
||||
elif net == "h2":
|
||||
proxy["h2-opts"] = {
|
||||
"path": path,
|
||||
"host": [host] if host else []
|
||||
}
|
||||
|
||||
elif net == "ws":
|
||||
ws_headers = {"Host": host} if host else {}
|
||||
ws_headers["User-Agent"] = self.user_agent
|
||||
ws_opts = {
|
||||
"path": path,
|
||||
"headers": ws_headers
|
||||
}
|
||||
# Add early-data config
|
||||
early_data = vmess_data.get("ed")
|
||||
if early_data:
|
||||
try:
|
||||
ws_opts["max-early-data"] = int(early_data)
|
||||
except ValueError:
|
||||
pass
|
||||
early_data_header = vmess_data.get("edh")
|
||||
if early_data_header:
|
||||
ws_opts["early-data-header-name"] = early_data_header
|
||||
proxy["ws-opts"] = ws_opts
|
||||
|
||||
elif net == "grpc":
|
||||
proxy["grpc-opts"] = {
|
||||
"grpc-service-name": path
|
||||
}
|
||||
return proxy
|
||||
except Exception:
|
||||
return None
|
||||
118
plugins.v2/clashruleprovider/helper/dataupgrader/v_2_1_0.py
Normal file
118
plugins.v2/clashruleprovider/helper/dataupgrader/v_2_1_0.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import copy
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import jsonpatch
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.db.plugindata_oper import PluginDataOper
|
||||
from app.log import logger
|
||||
|
||||
from ..configconverter import Converter
|
||||
from ..utilsprovider import UtilsProvider
|
||||
from ...models.proxygroups import ProxyGroupData
|
||||
from ...models.proxy import Proxy, ProxyData
|
||||
from ...models.ruleproviders import RuleProviderData
|
||||
from ...models.types import DataSource, DataKey
|
||||
from ...models.datapatch import PatchItem
|
||||
from ...models.metadata import Metadata
|
||||
|
||||
|
||||
def _overwrite_proxy(proxy: dict[str, Any], overwritten_proxies: dict[str, Any]) -> dict[str, Any]:
|
||||
if proxy["name"] in overwritten_proxies:
|
||||
for key in ['base', 'tls', 'network']:
|
||||
if overlay := overwritten_proxies[proxy["name"]].get(key):
|
||||
proxy.update(copy.deepcopy(overlay))
|
||||
return proxy
|
||||
|
||||
|
||||
def upgrade(plugin_id: str):
|
||||
data_oper = PluginDataOper()
|
||||
|
||||
# Upgrade proxy groups
|
||||
proxy_groups = data_oper.get_data(plugin_id, "proxy_groups") or []
|
||||
new_pg, invalid_pg, names = [], [], set()
|
||||
|
||||
for pg in proxy_groups:
|
||||
try:
|
||||
obj = ProxyGroupData(meta=Metadata(source=DataSource.MANUAL), data=pg, name=pg["name"])
|
||||
if obj.name not in names:
|
||||
new_pg.append(obj.model_dump(by_alias=True, exclude_none=True))
|
||||
names.add(obj.name)
|
||||
except ValidationError:
|
||||
logger.error(f"升级代理组失败: {pg}")
|
||||
invalid_pg.append(pg)
|
||||
|
||||
data_oper.save(plugin_id, DataKey.PROXY_GROUPS, new_pg)
|
||||
data_oper.save(plugin_id, "proxy_groups", invalid_pg)
|
||||
|
||||
# Upgrade rule providers
|
||||
rule_providers = data_oper.get_data(plugin_id, "extra_rule_providers") or {}
|
||||
new_rp, invalid_rp = [], []
|
||||
|
||||
for name, rp in rule_providers.items():
|
||||
try:
|
||||
obj = RuleProviderData(meta=Metadata(source=DataSource.MANUAL), name=name, data=rp)
|
||||
new_rp.append(obj.model_dump(by_alias=True, exclude_none=True))
|
||||
except ValidationError:
|
||||
logger.error(f"升级规则集失败: {rp}")
|
||||
invalid_rp.append(rp)
|
||||
|
||||
data_oper.save(plugin_id, DataKey.RULE_PROVIDERS, new_rp)
|
||||
data_oper.save(plugin_id, "extra_rule_providers", invalid_rp)
|
||||
|
||||
# Upgrade proxies
|
||||
proxies = data_oper.get_data(plugin_id, DataKey.PROXIES) or []
|
||||
new_proxies, invalid_proxies = [], []
|
||||
all_proxies = []
|
||||
names = set()
|
||||
converter = Converter()
|
||||
|
||||
for proxy in proxies:
|
||||
try:
|
||||
raw = None
|
||||
if isinstance(proxy, str):
|
||||
proxy_dict, raw = converter.convert_line(proxy), proxy
|
||||
elif isinstance(proxy, dict):
|
||||
proxy_dict = UtilsProvider.filter_empty(proxy, empty=['', None])
|
||||
else:
|
||||
continue
|
||||
|
||||
obj = Proxy.model_validate(proxy_dict)
|
||||
if obj.name in names: continue
|
||||
|
||||
p_data = ProxyData(data=obj, name=obj.name, meta=Metadata(source=DataSource.MANUAL), raw=raw)
|
||||
new_proxies.append(p_data.model_dump(by_alias=True, exclude_none=True))
|
||||
all_proxies.append(p_data.data)
|
||||
names.add(p_data.name)
|
||||
except Exception:
|
||||
logger.error(f"升级代理失败: {proxy}")
|
||||
invalid_proxies.append(proxy)
|
||||
|
||||
data_oper.save(plugin_id, DataKey.PROXIES, new_proxies)
|
||||
data_oper.save(plugin_id, "extra_proxies", invalid_proxies)
|
||||
|
||||
# Create proxy patches
|
||||
data_patch = {}
|
||||
overwritten = data_oper.get_data(plugin_id, "overwritten_proxies") or {}
|
||||
for name in overwritten:
|
||||
if proxy := next((p for p in all_proxies if p.name == name), None):
|
||||
src = proxy.model_dump(by_alias=True)
|
||||
# Create a deep copy for dst to avoid modifying src in place if _overwrite_proxy mutates
|
||||
dst = _overwrite_proxy(copy.deepcopy(src), overwritten)
|
||||
if patch := jsonpatch.make_patch(src, dst).to_string():
|
||||
data_patch[name] = PatchItem(patch=patch).model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
data_oper.save(plugin_id, DataKey.PROXY_PATCH, data_patch)
|
||||
data_oper.save(plugin_id, DataKey.ACL4SSR, [])
|
||||
|
||||
# Upgrade rules
|
||||
for key in [DataKey.TOP_RULES, DataKey.RULESET_RULES]:
|
||||
if rules := data_oper.get_data(plugin_id, key):
|
||||
for rule in rules:
|
||||
rule["meta"] = Metadata(
|
||||
source=rule.get("remark") or DataSource.MANUAL,
|
||||
time_modified=rule.get("time_modified") or time.time()
|
||||
).model_dump()
|
||||
data_oper.save(plugin_id, key, rules)
|
||||
data_oper.save(plugin_id, DataKey.DATA_VERSION, "2.1.0")
|
||||
73
plugins.v2/clashruleprovider/helper/utilsprovider.py
Normal file
73
plugins.v2/clashruleprovider/helper/utilsprovider.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import math
|
||||
import time
|
||||
from typing import Any, Optional, List, Dict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
class UtilsProvider:
|
||||
@staticmethod
|
||||
def filter_empty(original_dict: dict, empty: Optional[List[Any]] = None) -> dict:
|
||||
"""过滤字典中的空值"""
|
||||
return {k: v for k, v in original_dict.items() if v not in (empty or [None, '', [], {}])}
|
||||
|
||||
@staticmethod
|
||||
def get_url_domain(url: str) -> str:
|
||||
"""从 url 中提取域名"""
|
||||
if not url:
|
||||
return ""
|
||||
parsed = urlparse(url)
|
||||
if not parsed.netloc:
|
||||
parsed = urlparse("https://" + url)
|
||||
return parsed.netloc
|
||||
|
||||
@staticmethod
|
||||
def find_cycles(graph: Dict[Any, Any]) -> List[List[Any]]:
|
||||
"""DFS 检测环,并记录路径"""
|
||||
visited = set()
|
||||
stack = []
|
||||
cycles = []
|
||||
|
||||
def dfs(node):
|
||||
if node in stack:
|
||||
cycle_index = stack.index(node)
|
||||
cycles.append(stack[cycle_index:] + [node])
|
||||
return
|
||||
if node in visited:
|
||||
return
|
||||
|
||||
visited.add(node)
|
||||
stack.append(node)
|
||||
for nei in graph.get(node, []):
|
||||
dfs(nei)
|
||||
stack.pop()
|
||||
|
||||
for n in graph:
|
||||
if n not in visited:
|
||||
dfs(n)
|
||||
return cycles
|
||||
|
||||
@staticmethod
|
||||
def format_bytes(value_bytes):
|
||||
if value_bytes == 0:
|
||||
return '0 B'
|
||||
k = 1024
|
||||
sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
i = math.floor(math.log(value_bytes) / math.log(k)) if value_bytes > 0 else 0
|
||||
return f"{value_bytes / math.pow(k, i):.2f} {sizes[i]}"
|
||||
|
||||
@staticmethod
|
||||
def format_expire_time(timestamp):
|
||||
seconds_left = timestamp - int(time.time())
|
||||
days = seconds_left // 86400
|
||||
return f"{days}天后过期" if days > 0 else "已过期"
|
||||
|
||||
@staticmethod
|
||||
def update_with_checking(src_dict: Dict[str, Any], dst_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
跳过存在的键合并字典
|
||||
"""
|
||||
for key, value in src_dict.items():
|
||||
if key in dst_dict:
|
||||
continue
|
||||
dst_dict[key] = value
|
||||
return dst_dict
|
||||
6
plugins.v2/clashruleprovider/models/__init__.py
Normal file
6
plugins.v2/clashruleprovider/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .proxy import *
|
||||
from .hosts import *
|
||||
from .ruleitem import *
|
||||
from .ruleproviders import *
|
||||
from .proxygroups import *
|
||||
from .proxyproviders import *
|
||||
71
plugins.v2/clashruleprovider/models/api.py
Normal file
71
plugins.v2/clashruleprovider/models/api.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from simpleeval import simple_eval
|
||||
|
||||
|
||||
class ClashApi(BaseModel):
|
||||
url: str
|
||||
secret: str
|
||||
|
||||
|
||||
class Connectivity(BaseModel):
|
||||
clash_apis: List[ClashApi] = Field(default_factory=list)
|
||||
sub_links: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SubscriptionSetting(BaseModel):
|
||||
url: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DataUsage(BaseModel):
|
||||
upload: int = 0
|
||||
download: int = 0
|
||||
total: int = 0
|
||||
expire: int = 0
|
||||
|
||||
@property
|
||||
def header(self) -> str:
|
||||
return f'upload={self.upload}; download={self.download}; total={self.total}; expire={self.expire};'
|
||||
|
||||
|
||||
class SubscriptionInfo(DataUsage):
|
||||
last_update: int = Field(default=0)
|
||||
proxy_num: int = Field(default=0)
|
||||
enabled: bool = True
|
||||
|
||||
def update(self, setting: SubscriptionSetting):
|
||||
self.enabled = setting.enabled
|
||||
|
||||
|
||||
class SubscriptionsInfo(RootModel[dict[str, SubscriptionInfo]]):
|
||||
root: dict[str, SubscriptionInfo] = Field(default_factory=dict)
|
||||
|
||||
def update(self, urls: list[str]):
|
||||
if not urls:
|
||||
return
|
||||
|
||||
self.root.clear()
|
||||
for url in urls:
|
||||
self.root[url] = self.root.get(url, SubscriptionInfo())
|
||||
|
||||
def get(self, url: str) -> SubscriptionInfo:
|
||||
return self.root.get(url, SubscriptionInfo())
|
||||
|
||||
def __setitem__(self, key: str, value: SubscriptionInfo):
|
||||
self.root[key] = value
|
||||
|
||||
def set(self, setting: SubscriptionSetting):
|
||||
if setting.url in self.root:
|
||||
self.root[setting.url].update(setting)
|
||||
|
||||
|
||||
class ConfigRequest(BaseModel):
|
||||
url: str
|
||||
client_host: str
|
||||
identifier: str | None = None
|
||||
user_agent : str | None = None
|
||||
|
||||
def resolve(self, expr) -> bool:
|
||||
return bool(simple_eval(expr=expr, names=self.model_dump()))
|
||||
233
plugins.v2/clashruleprovider/models/configuration.py
Normal file
233
plugins.v2/clashruleprovider/models/configuration.py
Normal file
@@ -0,0 +1,233 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator, field_validator, field_serializer, PrivateAttr
|
||||
|
||||
from app.log import logger
|
||||
|
||||
from .proxy import Proxy
|
||||
from .proxygroups import ProxyGroup
|
||||
from .proxyproviders import ProxyProvider
|
||||
from .proxy.tlsmixin import ClientFingerprint
|
||||
from .ruleproviders import RuleProvider
|
||||
from .rule import RuleType, Action, RoutingRuleType
|
||||
from ..helper.clashruleparser import ClashRuleParser
|
||||
|
||||
|
||||
class ExternalControllerCors(BaseModel):
|
||||
allow_origins: list[str] = Field(default_factory=lambda: ["*"], alias="allow-origins")
|
||||
allow_credentials: bool = Field(default=True, alias="allow-credentials")
|
||||
|
||||
|
||||
class Profile(BaseModel):
|
||||
store_selected: bool = Field(default=False, alias="store-selected")
|
||||
store_fake_ip: bool = Field(default=False, alias="store-fake-ip")
|
||||
|
||||
|
||||
class NTP(BaseModel):
|
||||
enable: bool = Field(default=False)
|
||||
Server: str = Field(default="time.apple.com")
|
||||
port: int = Field(default=123)
|
||||
write_to_system: bool = Field(default=False, alias="write-to-system")
|
||||
|
||||
|
||||
class Experimental(BaseModel):
|
||||
quic_go_disable_gso: bool = Field(default=False, alias="quic-go-disable-gso")
|
||||
quic_go_disable_ecn: bool = Field(default=True, alias="quic-go-disable-ecn")
|
||||
dialer_ip4p_convert: bool = Field(default=False, alias="dialer-ip4p-convert")
|
||||
|
||||
|
||||
class ClashConfig(BaseModel):
|
||||
_raw_proxies: dict[str, str] = PrivateAttr(default_factory=dict)
|
||||
|
||||
dns: dict[str, Any] | None = Field(default=None)
|
||||
hosts: dict[str, list[str] | str] | None = Field(default=None)
|
||||
allow_lan: bool | None = Field(default=None, alias="allow-lan")
|
||||
bind_address: str = Field(default="*", alias="bind-address")
|
||||
lan_allowed_ips: list[str] = Field(default_factory=lambda: ["0.0.0.0/0", "::/0"], alias="lan-allowed-ips")
|
||||
lan_disallowed_ips: list[str] = Field(default_factory=list, alias="lan-disallowed-ips")
|
||||
authentication: list[str] = Field(default_factory=list)
|
||||
skip_auth_prefixes: list[str] = Field(default_factory=list, alias="skip-auth-prefixes")
|
||||
mode: Literal["rule", "global", "direct"] = Field(default="rule")
|
||||
log_level: Literal["silent", "error", "warning", "info", "debug"] = Field(default="info", alias="log-level")
|
||||
ipv6: bool = Field(default=True)
|
||||
keep_alive_interval: int = Field(default=0, alias="keep-alive-interval")
|
||||
keep_alive_idle: int = Field(default=0, alias="keep-alive-idle")
|
||||
disable_keep_alive: bool = Field(default=False, alias="disable-keep-alive")
|
||||
find_process_mode: Literal["strict", "always", "off"] = Field(default="strict", alias="find-process-mode")
|
||||
external_controller: str | None = Field(default=None, alias="external-controller")
|
||||
external_controller_cors: ExternalControllerCors = Field(default_factory=ExternalControllerCors,
|
||||
alias="external-controller-cors")
|
||||
external_controller_unix: str | None = Field(default=None, alias="external-controller-unix")
|
||||
external_controller_pipe: str | None = Field(default=None, alias="external-controller-pipe")
|
||||
external_controller_tls: str | None = Field(default=None, alias="external-controller-tls")
|
||||
secret: str | None = Field(default=None)
|
||||
external_ui: str | None = Field(default=None, alias="external-ui")
|
||||
external_ui_name: str | None = Field(default=None, alias="external-ui-name")
|
||||
external_ui_url: str | None = Field(default=None, alias="external-ui-url")
|
||||
profile: Profile = Field(default_factory=Profile)
|
||||
unified_delay: bool = Field(default=True, alias="unified-delay")
|
||||
tcp_concurrent: bool = Field(default=True, alias="tcp-concurrent")
|
||||
interface_name: str | None = Field(default=None, alias="interface-name")
|
||||
routing_mark: int | None = Field(default=None, alias="routing-mark")
|
||||
tls: dict[str, Any] | None = Field(default=None, alias="tls")
|
||||
global_client_fingerprint: ClientFingerprint | None = Field(default=ClientFingerprint.chrome,
|
||||
alias="global-client-fingerprint")
|
||||
geodata_mode: bool | None = Field(default=None, alias="geodata-mode")
|
||||
geodata_loader: Literal["memconservative", "standard"] = Field(default="memconservative", alias="geodata-loader")
|
||||
geo_auto_update: bool = Field(default=False, alias="geo-auto-update")
|
||||
geo_update_interval: int = Field(default=24, alias="geo-update-interval")
|
||||
global_ua: str = Field(default="clash.meta", alias="global-ua")
|
||||
etag_support: bool = Field(default=True, alias="etag-support")
|
||||
sniffer: dict[str, Any] | None = None
|
||||
listeners: list[dict[str, Any]] | None = Field(default=None)
|
||||
port: int = Field(default=0, description="HTTP(S) proxy port")
|
||||
socks_port: int = Field(default=0, alias="socks-port")
|
||||
mixed_port: int = Field(default=0, alias="mixed-port")
|
||||
redir_port: int = Field(default=0, alias="redir-port")
|
||||
tproxy_port: int = Field(default=0, alias="tproxy-port")
|
||||
tun: dict[str, Any] | None = Field(default=None)
|
||||
sub_rules: dict[str, Any] | None = Field(default=None, alias="sub-rules")
|
||||
tunnels: list[dict[str, Any] | str] | None = Field(default=None)
|
||||
ntp: NTP | None = Field(default=None)
|
||||
experimental: Experimental | None = Field(default=None)
|
||||
proxies: list[Proxy] = Field(default_factory=list)
|
||||
proxy_providers: dict[str, ProxyProvider] = Field(default_factory=dict, alias="proxy-providers")
|
||||
proxy_groups: list[ProxyGroup] = Field(default_factory=list, alias="proxy-groups")
|
||||
rules: list[RuleType] = Field(default_factory=list)
|
||||
rule_providers: dict[str, RuleProvider] = Field(default_factory=dict, alias="rule-providers")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def fill_none_with_default(cls, values: dict):
|
||||
fill_none_fields = {"proxies", "proxy_providers", "proxy_groups", "rules", "rule_providers"}
|
||||
for field_name in fill_none_fields:
|
||||
field = cls.model_fields[field_name]
|
||||
factory = field.default_factory
|
||||
if not factory:
|
||||
continue
|
||||
keys = {field_name}
|
||||
if field.alias:
|
||||
keys.add(field.alias)
|
||||
|
||||
for key in keys:
|
||||
if key in values and values[key] is None:
|
||||
values[key] = factory()
|
||||
return values
|
||||
|
||||
@field_serializer("proxies")
|
||||
def serialize_proxies(self, v: list[Proxy], _info):
|
||||
serialized_proxies = []
|
||||
seen_names = set()
|
||||
for proxy in v:
|
||||
if proxy.name in seen_names:
|
||||
logger.warning(f"Skipping duplicate proxy: {proxy.name}")
|
||||
continue
|
||||
seen_names.add(proxy.name)
|
||||
serialized_proxies.append(proxy.model_dump(by_alias=True, exclude_none=True, mode="json"))
|
||||
return serialized_proxies
|
||||
|
||||
@field_serializer("proxy_groups")
|
||||
def serialize_proxy_groups(self, v: list[ProxyGroup], _info):
|
||||
valid_outbounds = {a.value for a in Action}
|
||||
valid_outbounds.add("GLOBAL")
|
||||
if self.proxies:
|
||||
valid_outbounds.update(p.name for p in self.proxies)
|
||||
if v:
|
||||
valid_outbounds.update(pg.name for pg in v)
|
||||
|
||||
serialized_groups = []
|
||||
seen_names = set()
|
||||
for group in v:
|
||||
if group.name in seen_names:
|
||||
logger.warning(f"Skipping duplicate proxy group: {group.name}")
|
||||
continue
|
||||
seen_names.add(group.name)
|
||||
|
||||
group_data = group.model_dump(by_alias=True, exclude_none=True, mode="json")
|
||||
if "proxies" in group_data and group_data["proxies"]:
|
||||
original_proxies = group_data["proxies"]
|
||||
group_data["proxies"] = [
|
||||
p for p in original_proxies if p in valid_outbounds
|
||||
]
|
||||
removed = set(original_proxies) - set(group_data["proxies"])
|
||||
if removed:
|
||||
logger.warning(f"Proxy group {group.name} removed missing proxies: {removed}")
|
||||
serialized_groups.append(group_data)
|
||||
|
||||
return serialized_groups
|
||||
|
||||
@field_validator("mode", mode="before")
|
||||
@classmethod
|
||||
def validate_mode(cls, v):
|
||||
if isinstance(v, str):
|
||||
return v.lower()
|
||||
return v
|
||||
|
||||
@field_validator("rules", mode="before")
|
||||
@classmethod
|
||||
def validate_rules(cls, v):
|
||||
if isinstance(v, list):
|
||||
rules = []
|
||||
for item in v:
|
||||
if isinstance(item, str):
|
||||
rules.append(ClashRuleParser.parse(item))
|
||||
else:
|
||||
rules.append(item)
|
||||
return rules
|
||||
return v
|
||||
|
||||
@field_serializer("rules")
|
||||
def serialize_rules(self, v: list[RuleType], _info):
|
||||
valid_rules = []
|
||||
valid_outbounds = set(self.outbounds)
|
||||
valid_actions = {a.value for a in Action}
|
||||
|
||||
for rule in v:
|
||||
if rule.rule_type == RoutingRuleType.SUB_RULE:
|
||||
if self.sub_rules and rule.action in self.sub_rules:
|
||||
valid_rules.append(rule)
|
||||
else:
|
||||
logger.warning(f"Skipping rule with missing sub-rule action: {rule}")
|
||||
continue
|
||||
|
||||
if rule.rule_type == RoutingRuleType.RULE_SET:
|
||||
if rule.payload not in self.rule_providers:
|
||||
logger.warning(f"Skipping rule with missing rule-provider: {rule}")
|
||||
continue
|
||||
|
||||
action_str = str(rule.action)
|
||||
if action_str in valid_actions or action_str in valid_outbounds:
|
||||
valid_rules.append(rule)
|
||||
else:
|
||||
logger.warning(f"Skipping rule with invalid outbound: {rule}")
|
||||
|
||||
return [str(rule) for rule in valid_rules]
|
||||
|
||||
@property
|
||||
def outbounds(self) -> list[str]:
|
||||
outbounds = []
|
||||
if self.proxies:
|
||||
outbounds.extend(p.name for p in self.proxies)
|
||||
if self.proxy_groups:
|
||||
outbounds.extend(pg.name for pg in self.proxy_groups)
|
||||
return outbounds
|
||||
|
||||
@property
|
||||
def node_num(self) -> int:
|
||||
return len(self.proxies)
|
||||
|
||||
@property
|
||||
def raw_proxies(self) -> dict[str, str]:
|
||||
return self._raw_proxies
|
||||
|
||||
@raw_proxies.setter
|
||||
def raw_proxies(self, value: dict[str, str]):
|
||||
self._raw_proxies = value
|
||||
|
||||
def merge(self, other: 'ClashConfig') -> 'ClashConfig':
|
||||
self.proxies += other.proxies
|
||||
self.proxy_groups += other.proxy_groups
|
||||
self.rules += other.rules
|
||||
self.rule_providers |= other.rule_providers
|
||||
self.proxy_providers |= other.proxy_providers
|
||||
return self
|
||||
31
plugins.v2/clashruleprovider/models/datamodel.py
Normal file
31
plugins.v2/clashruleprovider/models/datamodel.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .api import SubscriptionsInfo
|
||||
from .configuration import ClashConfig
|
||||
from .datapatch import DataPatch
|
||||
from .hosts import Hosts
|
||||
from .proxy import Proxies
|
||||
from .proxygroups import ProxyGroups
|
||||
from .ruleproviders import RuleProviders, RuleProvider
|
||||
from .types import DataKey
|
||||
|
||||
|
||||
class GeoRules(BaseModel):
|
||||
geoip: list[str] = Field(default_factory=list)
|
||||
geosite: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PersistState(BaseModel):
|
||||
proxies: Proxies = Field(alias=DataKey.PROXIES, default_factory=Proxies)
|
||||
proxy_groups: ProxyGroups = Field(alias=DataKey.PROXY_GROUPS, default_factory=ProxyGroups)
|
||||
subscription_info: SubscriptionsInfo = Field(alias=DataKey.SUB_INFO, default_factory=SubscriptionsInfo)
|
||||
rule_provider: dict[str, RuleProvider] = Field(alias=DataKey.AUTO_RULE_PROVIDERS, default_factory=dict)
|
||||
rule_providers: RuleProviders = Field(alias=DataKey.RULE_PROVIDERS, default_factory=RuleProviders)
|
||||
ruleset_names: dict[str, str] = Field(alias=DataKey.RULESET_NAMES, default_factory=dict)
|
||||
acl4ssr_providers: RuleProviders = Field(alias=DataKey.ACL4SSR, default_factory=RuleProviders)
|
||||
sub_configs: dict[str, ClashConfig] = Field(alias=DataKey.SUB_CONFIGS, default_factory=dict)
|
||||
hosts: Hosts = Field(alias=DataKey.HOSTS, default_factory=Hosts)
|
||||
proxy_group_patch: DataPatch = Field(alias=DataKey.PROXY_GROUP_PATCH, default_factory=DataPatch)
|
||||
proxy_patch: DataPatch = Field(alias=DataKey.PROXY_PATCH, default_factory=DataPatch)
|
||||
geo_rules: GeoRules = Field(alias=DataKey.GEO_RULES, default_factory=GeoRules)
|
||||
rule_provider_patch: DataPatch = Field(alias=DataKey.RULE_PROVIDER_PATCH, default_factory=DataPatch)
|
||||
32
plugins.v2/clashruleprovider/models/datapatch.py
Normal file
32
plugins.v2/clashruleprovider/models/datapatch.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
|
||||
|
||||
class PatchItem(BaseModel):
|
||||
lifecycle: int = Field(default=3)
|
||||
patch: str
|
||||
|
||||
|
||||
class DataPatch(RootModel[dict[str, PatchItem]]):
|
||||
"""DataPatch model for storing patch items."""
|
||||
root: dict[str, PatchItem] = Field(default_factory=dict, description="Dictionary of patch items.")
|
||||
|
||||
def update_patch(self, alive_keys: list[str] | set[str], lifespan: int = 3):
|
||||
outdated_keys = []
|
||||
for key in list(self.root.keys()):
|
||||
if key not in alive_keys:
|
||||
self.root[key].lifecycle -= 1
|
||||
if self.root[key].lifecycle == 0:
|
||||
outdated_keys.append(key)
|
||||
else:
|
||||
self.root[key].lifecycle = lifespan
|
||||
for key in outdated_keys:
|
||||
del self.root[key]
|
||||
|
||||
def __setitem__(self, key: str, value: PatchItem):
|
||||
self.root[key] = value
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
return key in self.root
|
||||
|
||||
def __getitem__(self, key: str) -> PatchItem:
|
||||
return self.root[key]
|
||||
93
plugins.v2/clashruleprovider/models/generics.py
Normal file
93
plugins.v2/clashruleprovider/models/generics.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from typing import TypeVar, Generic, Iterator, Any
|
||||
from pydantic import BaseModel, RootModel, Field, model_validator
|
||||
from .metadata import Metadata
|
||||
|
||||
|
||||
# Specific data payload model
|
||||
T = TypeVar("T")
|
||||
|
||||
class ResourceItem(BaseModel, Generic[T]):
|
||||
"""Generic resource item model"""
|
||||
name: str = Field(..., description="Resource name")
|
||||
data: T = Field(..., description="Resource data payload")
|
||||
meta: Metadata = Field(default_factory=Metadata, description="Resource metadata")
|
||||
|
||||
|
||||
# Subclasses of ResourceItem
|
||||
R = TypeVar("R", bound=ResourceItem)
|
||||
|
||||
class ResourceList(RootModel[list[R]], Generic[R]):
|
||||
"""
|
||||
Generic configuration list base class
|
||||
"""
|
||||
root: list[R] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_unique_names(self) -> 'ResourceList[R]':
|
||||
names = [item.name for item in self.root]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("names must be unique")
|
||||
return self
|
||||
|
||||
def __iter__(self) -> Iterator[R]:
|
||||
return iter(self.root)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.root)
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
"""Check if a configuration with the specified name exists"""
|
||||
return any(item.name == name for item in self.root)
|
||||
|
||||
def get(self, name: str) -> R | None:
|
||||
"""Get the configuration item by name"""
|
||||
for item in self.root:
|
||||
if item.name == name:
|
||||
return item
|
||||
return None
|
||||
|
||||
def add(self, item: R):
|
||||
"""Add a configuration item, raise an exception if the name is duplicated"""
|
||||
if item.name in self:
|
||||
raise ValueError(f"name {item.name!r} already exists")
|
||||
self.root.insert(0, item)
|
||||
|
||||
def remove(self, name: str):
|
||||
"""Remove the configuration item by name"""
|
||||
self.root = [item for item in self.root if item.name != name]
|
||||
|
||||
def pop(self, name: str) -> R | None:
|
||||
"""Remove and return the configuration item with the specified name"""
|
||||
for i, item in enumerate(self.root) :
|
||||
if item.name == name:
|
||||
return self.root.pop(i)
|
||||
return None
|
||||
|
||||
def update(self, name: str, item: R):
|
||||
"""Update the configuration item with the specified name"""
|
||||
for i, existing_item in enumerate(self.root):
|
||||
if existing_item.name == name:
|
||||
item.meta = self.root[i].meta
|
||||
self.root[i] = item
|
||||
return
|
||||
|
||||
def update_data(self, name: str, data: Any) -> bool:
|
||||
"""Update only the data payload of the configuration item with the specified name"""
|
||||
item = self.get(name)
|
||||
if item:
|
||||
item.data = data
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_meta(self, name: str, meta: Metadata) -> bool:
|
||||
"""Set metadata for the specified configuration item"""
|
||||
item = self.get(name)
|
||||
if item:
|
||||
item.meta = meta
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def names(self) -> list[str]:
|
||||
"""Return a list of names for all configuration items"""
|
||||
return [item.name for item in self.root]
|
||||
33
plugins.v2/clashruleprovider/models/hosts.py
Normal file
33
plugins.v2/clashruleprovider/models/hosts.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from pydantic import Field, RootModel, BaseModel
|
||||
|
||||
from .metadata import Metadata
|
||||
|
||||
|
||||
class HostData(BaseModel):
|
||||
domain: str
|
||||
value: list[str]
|
||||
using_cloudflare: bool
|
||||
meta: Metadata = Field(default_factory=Metadata)
|
||||
|
||||
|
||||
class Hosts(RootModel[list[HostData]]):
|
||||
root: list[HostData] = Field(default_factory=list)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.root)
|
||||
|
||||
def update(self, domain: str, data: HostData):
|
||||
self.root = [host for host in self.root if host.domain != domain]
|
||||
self.root.append(data)
|
||||
|
||||
def delete(self, domain: str):
|
||||
self.root = [host for host in self.root if host.domain != domain]
|
||||
|
||||
def to_dict(self, cloudflare: list[str]) -> dict[str, list[str]]:
|
||||
hosts = {}
|
||||
for host in self.root:
|
||||
if host.using_cloudflare:
|
||||
hosts[host.domain] = cloudflare
|
||||
else:
|
||||
hosts[host.domain] = host.value
|
||||
return hosts
|
||||
25
plugins.v2/clashruleprovider/models/metadata.py
Normal file
25
plugins.v2/clashruleprovider/models/metadata.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import time
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .api import ConfigRequest
|
||||
from .types import DataSource
|
||||
|
||||
|
||||
class Metadata(BaseModel):
|
||||
"""Metadata model for Clash items"""
|
||||
# source of the item
|
||||
source: DataSource = Field(default=DataSource.MANUAL)
|
||||
# whether the item is disabled
|
||||
disabled: bool = Field(default=False)
|
||||
# roles that cannot see the item
|
||||
invisible_to: list[str] = Field(default_factory=list)
|
||||
# additional remarks
|
||||
remark: str = Field(default="")
|
||||
# last modified time
|
||||
time_modified: float = Field(default_factory=lambda: time.time())
|
||||
# whether the item has been patched
|
||||
patched: bool = Field(default=False)
|
||||
|
||||
def available(self, param: ConfigRequest | None = None) -> bool:
|
||||
return not self.disabled and (param is None or not any(param.resolve(expr) for expr in self.invisible_to))
|
||||
78
plugins.v2/clashruleprovider/models/proxy/__init__.py
Normal file
78
plugins.v2/clashruleprovider/models/proxy/__init__.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import jsonpatch
|
||||
from typing import Union, Any
|
||||
|
||||
from pydantic import Field, RootModel, model_validator
|
||||
|
||||
from .anytlsproxy import AnyTLSProxy
|
||||
from .directproxy import DirectProxy
|
||||
from .dnsproxy import DnsProxy
|
||||
from .httpproxy import HttpProxy
|
||||
from .hysteriaproxy import HysteriaProxy
|
||||
from .hysteria2proxy import Hysteria2Proxy
|
||||
from .mieruproxy import MieruProxy
|
||||
from .networkmixin import NetworkMixin
|
||||
from .proxybase import ProxyBase
|
||||
from .shadowsocksproxy import ShadowsocksProxy
|
||||
from .shadowsocksrproxy import ShadowsocksRProxy
|
||||
from .snellproxy import SnellProxy
|
||||
from .socks5proxy import Socks5Proxy
|
||||
from .sshproxy import SshProxy
|
||||
from .tlsmixin import TLSMixin
|
||||
from .trojanproxy import TrojanProxy
|
||||
from .tuicproxy import TuicProxy
|
||||
from .vlessproxy import VlessProxy
|
||||
from .vmessproxy import VmessProxy
|
||||
from .wireguardproxy import WireGuardProxy
|
||||
from ..generics import ResourceItem, ResourceList
|
||||
|
||||
ProxyType = Union[
|
||||
AnyTLSProxy,
|
||||
DirectProxy,
|
||||
DnsProxy,
|
||||
HttpProxy,
|
||||
HysteriaProxy,
|
||||
Hysteria2Proxy,
|
||||
MieruProxy,
|
||||
ShadowsocksProxy,
|
||||
ShadowsocksRProxy,
|
||||
SnellProxy,
|
||||
Socks5Proxy,
|
||||
SshProxy,
|
||||
TrojanProxy,
|
||||
TuicProxy,
|
||||
VlessProxy,
|
||||
VmessProxy,
|
||||
WireGuardProxy,
|
||||
]
|
||||
|
||||
|
||||
class Proxy(RootModel[ProxyType]):
|
||||
root: ProxyType = Field(..., discriminator="type")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.root.name
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self.root, item)
|
||||
|
||||
def patch(self, patch: str) -> 'Proxy':
|
||||
src = self.model_dump(mode='json', by_alias=True)
|
||||
patched = jsonpatch.apply_patch(src, patch=patch, in_place=True)
|
||||
return Proxy.model_validate(patched)
|
||||
|
||||
|
||||
class ProxyData(ResourceItem[Proxy]):
|
||||
raw: Union[str, dict[str, Any], None] = None
|
||||
v2ray_link: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_name_consistency(self):
|
||||
if self.name != self.data.name:
|
||||
raise ValueError(f"name ({self.name}) must equal data.name ({self.data.name})")
|
||||
return self
|
||||
|
||||
|
||||
class Proxies(ResourceList[ProxyData]):
|
||||
"""Proxies Collection"""
|
||||
pass
|
||||
15
plugins.v2/clashruleprovider/models/proxy/anytlsproxy.py
Normal file
15
plugins.v2/clashruleprovider/models/proxy/anytlsproxy.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
from .tlsmixin import TLSMixin
|
||||
from .networkmixin import NetworkMixin
|
||||
|
||||
|
||||
class AnyTLSProxy(ProxyBase, TLSMixin, NetworkMixin):
|
||||
type: Literal['anytls'] = 'anytls'
|
||||
password: str
|
||||
idle_session_check_interval: Optional[int] = Field(30, alias='idle-session-check-interval')
|
||||
idle_session_timeout: Optional[int] = Field(30, alias='idle-session-timeout')
|
||||
min_idle_session: Optional[int] = Field(0, alias='min-idle-session')
|
||||
7
plugins.v2/clashruleprovider/models/proxy/directproxy.py
Normal file
7
plugins.v2/clashruleprovider/models/proxy/directproxy.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from typing import Literal
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class DirectProxy(ProxyBase):
|
||||
type: Literal['direct'] = 'direct'
|
||||
7
plugins.v2/clashruleprovider/models/proxy/dnsproxy.py
Normal file
7
plugins.v2/clashruleprovider/models/proxy/dnsproxy.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from typing import Literal
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class DnsProxy(ProxyBase):
|
||||
type: Literal['dns'] = 'dns'
|
||||
11
plugins.v2/clashruleprovider/models/proxy/httpproxy.py
Normal file
11
plugins.v2/clashruleprovider/models/proxy/httpproxy.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from typing import Optional, Dict, Literal
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
from .tlsmixin import TLSMixin
|
||||
|
||||
|
||||
class HttpProxy(ProxyBase, TLSMixin):
|
||||
type: Literal['http'] = 'http'
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
26
plugins.v2/clashruleprovider/models/proxy/hysteria2proxy.py
Normal file
26
plugins.v2/clashruleprovider/models/proxy/hysteria2proxy.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class Hysteria2Proxy(ProxyBase):
|
||||
type: Literal['hysteria2'] = 'hysteria2'
|
||||
password: Optional[str] = None
|
||||
obfs: Optional[Literal['salamander']] = None
|
||||
obfs_password: Optional[str] = Field(None, alias='obfs-password')
|
||||
up: Optional[int | str] = None
|
||||
down: Optional[int | str] = None
|
||||
hop_interval: Optional[int] = Field(None, alias='hop-interval')
|
||||
ca: Optional[str] = None
|
||||
ca_str: Optional[str] = Field(None, alias='ca-str')
|
||||
cwnd: Optional[int] = None
|
||||
udp_mtu: Optional[int] = Field(None, alias='udp-mtu')
|
||||
ports: Optional[str] = None
|
||||
|
||||
# QUIC-GO 特殊配置
|
||||
initial_stream_receive_window: Optional[int] = Field(None, alias='initial-stream-receive-window')
|
||||
max_stream_receive_window: Optional[int] = Field(None, alias='max-stream-receive-window')
|
||||
initial_connection_receive_window: Optional[int] = Field(None, alias='initial-connection-receive-window')
|
||||
max_connection_receive_window: Optional[int] = Field(None, alias='max-connection-receive-window')
|
||||
24
plugins.v2/clashruleprovider/models/proxy/hysteriaproxy.py
Normal file
24
plugins.v2/clashruleprovider/models/proxy/hysteriaproxy.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class HysteriaProxy(ProxyBase):
|
||||
type: Literal['hysteria'] = 'hysteria'
|
||||
auth_str: Optional[str] = Field(None, alias='auth-str')
|
||||
auth: Optional[str] = None
|
||||
protocol: Optional[Literal['udp','wechat-video', 'faketcp']] = None
|
||||
up: Optional[int | str] = None
|
||||
down: Optional[int | str] = None
|
||||
obfs: Optional[str] = None
|
||||
obfs_protocol: Optional[str] = Field(None, alias='obfs-protocol')
|
||||
recv_window_conn: Optional[int] = Field(None, alias='recv-window-conn')
|
||||
recv_window: Optional[int] = Field(None, alias='recv-window')
|
||||
disable_mtu_discovery: Optional[bool] = Field(None, alias='disable-mtu-discovery')
|
||||
fast_open: Optional[bool] = Field(None, alias='fast-open')
|
||||
hop_interval: Optional[int] = Field(None, alias='hop-interval')
|
||||
ca: Optional[str] = None
|
||||
ca_str: Optional[str] = Field(None, alias='ca-str')
|
||||
ports: Optional[str] = None
|
||||
25
plugins.v2/clashruleprovider/models/proxy/mieruproxy.py
Normal file
25
plugins.v2/clashruleprovider/models/proxy/mieruproxy.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class MieruProxy(ProxyBase):
|
||||
type: Literal['mieru'] = 'mieru'
|
||||
username: str
|
||||
password: str
|
||||
port_range: Optional[str] = Field(None, alias='port-range')
|
||||
transport: Literal['TCP'] = 'TCP'
|
||||
multiplexing: Optional[Literal[
|
||||
'MULTIPLEXING_OFF', 'MULTIPLEXING_LOW', 'MULTIPLEXING_MIDDLE', 'MULTIPLEXING_HIGH']] = 'MULTIPLEXING_LOW'
|
||||
handshake_mode: Optional[Literal['HANDSHAKE_STANDARD', 'HANDSHAKE_NO_WAIT']] = 'HANDSHAKE_STANDARD'
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_port_config(self):
|
||||
"""Pydantic v2 style model-level validation."""
|
||||
if not getattr(self, 'port', None) and not getattr(self, 'port_range', None):
|
||||
raise ValueError("either 'port' or 'port-range' must be set")
|
||||
if getattr(self, 'port', None) and getattr(self, 'port_range', None):
|
||||
raise ValueError("'port' and 'port-range' cannot be set at the same time")
|
||||
return self
|
||||
36
plugins.v2/clashruleprovider/models/proxy/networkmixin.py
Normal file
36
plugins.v2/clashruleprovider/models/proxy/networkmixin.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from typing import List, Optional, Dict, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HttpOpts(BaseModel):
|
||||
method: Optional[str] = None
|
||||
path: List[str] = ['/']
|
||||
headers: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
|
||||
class H2Opts(BaseModel):
|
||||
host: List[str]
|
||||
path: str = '/'
|
||||
|
||||
|
||||
class GrpcOpts(BaseModel):
|
||||
grpc_service_name: str = Field(..., alias='grpc-service-name')
|
||||
|
||||
|
||||
class WsOpts(BaseModel):
|
||||
path: str = '/'
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
max_early_data: Optional[int] = Field(None, alias='max-early-data')
|
||||
early_data_header_name: Optional[str] = Field(None, alias='early-data-header-name')
|
||||
v2ray_http_upgrade: Optional[bool] = Field(None, alias='v2ray-http-upgrade')
|
||||
v2ray_http_upgrade_fast_open: Optional[bool] = Field(None, alias='v2ray-http-upgrade-fast-open')
|
||||
|
||||
|
||||
class NetworkMixin(BaseModel):
|
||||
# Transport settings
|
||||
network: Optional[Literal['tcp', 'http', 'h2', 'grpc', 'ws', 'kcp']] = None
|
||||
http_opts: Optional[HttpOpts] = Field(None, alias='http-opts')
|
||||
h2_opts: Optional[H2Opts] = Field(None, alias='h2-opts')
|
||||
grpc_opts: Optional[GrpcOpts] = Field(None, alias='grpc-opts')
|
||||
ws_opts: Optional[WsOpts] = Field(None, alias='ws-opts')
|
||||
38
plugins.v2/clashruleprovider/models/proxy/proxybase.py
Normal file
38
plugins.v2/clashruleprovider/models/proxy/proxybase.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SmuxBrutalOpts(BaseModel):
|
||||
enabled: bool = False
|
||||
up: Optional[str] = None
|
||||
down: Optional[str] = None
|
||||
|
||||
|
||||
class Smux(BaseModel):
|
||||
enabled: bool = False
|
||||
protocol: Literal['smux', 'yamux', 'h2mux'] = 'h2mux'
|
||||
max_connections: Optional[int] = Field(None, alias='max-connections')
|
||||
min_streams: Optional[int] = Field(None, alias='min-streams')
|
||||
max_streams: Optional[int] = Field(None, alias='max-streams')
|
||||
statistic: Optional[bool] = None
|
||||
only_tcp: Optional[bool] = Field(None, alias='only-tcp')
|
||||
padding: Optional[bool] = None
|
||||
brutal_opts: Optional[SmuxBrutalOpts] = Field(None, alias='brutal-opts')
|
||||
|
||||
|
||||
class ProxyBase(BaseModel):
|
||||
name: str
|
||||
type: Literal['direct', 'dns', 'http', 'ss', 'ssr', 'mieru', 'snell', 'vmess', 'vless', 'trojan', 'anytls',
|
||||
'hysteria','hysteria2', 'tuic', 'wireguard', 'ssh', 'socks5']
|
||||
server: str
|
||||
port: int
|
||||
ip_version: Optional[Literal['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer']] = Field(None,
|
||||
alias='ip-version')
|
||||
udp: bool = False
|
||||
interface_name: Optional[str] = Field(None, alias='interface-name')
|
||||
routing_mark: Optional[int] = Field(None, alias='routing-mark')
|
||||
tfo: Optional[bool] = None
|
||||
mptcp: Optional[bool] = None
|
||||
dialer_proxy: Optional[str] = Field(None, alias='dialer-proxy')
|
||||
smux: Optional[Smux] = None
|
||||
110
plugins.v2/clashruleprovider/models/proxy/shadowsocksproxy.py
Normal file
110
plugins.v2/clashruleprovider/models/proxy/shadowsocksproxy.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from typing import Optional, Dict, Literal, List, Union
|
||||
|
||||
from pydantic import Field, BaseModel, field_validator, ValidationInfo
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
ShadowsocksCipherType = Literal[
|
||||
# AES 相关
|
||||
'aes-128-ctr', 'aes-192-ctr', 'aes-256-ctr',
|
||||
'aes-128-cfb', 'aes-192-cfb', 'aes-256-cfb',
|
||||
'aes-128-gcm', 'aes-192-gcm', 'aes-256-gcm',
|
||||
'aes-128-com', 'aes-192-com', 'aes-256-com',
|
||||
'aes-128-gcm-siv', 'aes-256-gcm-siv',
|
||||
# CHACHA 相关
|
||||
'chacha20-ietf', 'chacha20', 'xchacha20',
|
||||
'chacha20-ietf-poly1305', 'xchacha20-ietf-poly1305',
|
||||
'chacha8-ietf-poly1305', 'xchacha8-ietf-poly1305',
|
||||
# 2022 Blake3 相关
|
||||
'2022-blake3-aes-128-gcm', '2022-blake3-aes-256-gcm', '2022-blake3-chacha20-poly1305',
|
||||
# LEA 相关
|
||||
'lea-128-gcm', 'lea-192-gcm', 'lea-256-gcm',
|
||||
# 其他
|
||||
'rabbit128-poly1305', 'aegis-128l', 'aegis-256', 'aez-384', 'deoxys-ii-256-128', 'rc4-md5', 'none'
|
||||
]
|
||||
|
||||
|
||||
class ObfsPluginOpts(BaseModel):
|
||||
mode: Literal['tls', 'http']
|
||||
host: Optional[str] = Field(default="bing.com")
|
||||
|
||||
|
||||
class V2rayPluginOpts(BaseModel):
|
||||
mode: Literal['websocket'] = 'websocket'
|
||||
host: Optional[str] = Field(default="bing.com")
|
||||
path: Optional[str] = None
|
||||
tls: Optional[bool] = False
|
||||
fingerprint: Optional[str] = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
skip_cert_verify: Optional[bool] = Field(False, alias='skip-cert-verify')
|
||||
mux: Optional[bool] = True
|
||||
v2ray_http_upgrade: Optional[bool] = Field(False, alias='v2ray-http-upgrade')
|
||||
v2ray_http_upgrade_fast_open: Optional[bool] = Field(False, alias='v2ray-http-upgrade-fast-open')
|
||||
|
||||
|
||||
class GostPluginOpts(BaseModel):
|
||||
mode: Literal['websocket'] = 'websocket'
|
||||
host: Optional[str] = Field(default="bing.com")
|
||||
path: Optional[str] = None
|
||||
tls: Optional[bool] = False
|
||||
fingerprint: Optional[str] = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
skip_cert_verify: Optional[bool] = Field(False, alias='skip-cert-verify')
|
||||
mux: Optional[bool] = True
|
||||
|
||||
|
||||
class ShadowTlsPluginOpts(BaseModel):
|
||||
password: Optional[str] = None
|
||||
host: str
|
||||
fingerprint: Optional[str] = None
|
||||
skip_cert_verify: Optional[bool] = Field(False, alias='skip-cert-verify')
|
||||
version: Optional[Literal[1, 2, 3]] = 2
|
||||
alpn: Optional[List[str]] = None
|
||||
|
||||
|
||||
class RestlsPluginOpts(BaseModel):
|
||||
password: str
|
||||
host: str
|
||||
version_hint: str = Field(alias='version-hint')
|
||||
restls_script: Optional[str] = Field(None, alias='restls-script')
|
||||
|
||||
|
||||
class ShadowsocksProxy(ProxyBase):
|
||||
type: Literal['ss'] = 'ss'
|
||||
cipher: ShadowsocksCipherType
|
||||
password: str
|
||||
udp_over_tcp: Optional[bool] = Field(None, alias='udp-over-tcp')
|
||||
udp_over_tcp_version: Optional[Literal[1, 2]] = Field(1, alias='udp-over-tcp-version')
|
||||
client_fingerprint: Optional[Literal['chrome', 'ios', 'firefox', 'safari']] = Field(None,
|
||||
alias='client-fingerprint')
|
||||
plugin: Optional[Literal['obfs', 'v2ray-plugin', 'shadow-tls', 'restls', 'gost-plugin']] = None
|
||||
plugin_opts: Optional[Union[
|
||||
ObfsPluginOpts,
|
||||
V2rayPluginOpts,
|
||||
GostPluginOpts,
|
||||
ShadowTlsPluginOpts,
|
||||
RestlsPluginOpts,
|
||||
]] = Field(None, alias='plugin-opts')
|
||||
|
||||
|
||||
@field_validator("plugin_opts")
|
||||
@classmethod
|
||||
def validate_plugin_opts(cls, v, info: ValidationInfo):
|
||||
plugin = info.data.get("plugin")
|
||||
if plugin and v:
|
||||
if not isinstance(plugin, str):
|
||||
raise ValueError("plugin must be a string")
|
||||
plugin_model_map = {
|
||||
"obfs": "ObfsPluginOpts",
|
||||
"v2ray-plugin": "V2rayPluginOpts",
|
||||
"gost-plugin": "GostPluginOpts",
|
||||
"shadow-tls": "ShadowTlsPluginOpts",
|
||||
"restls": "RestlsPluginOpts",
|
||||
}
|
||||
|
||||
expected_model = plugin_model_map.get(plugin)
|
||||
if expected_model and v.__class__.__name__ != expected_model:
|
||||
raise ValueError(f"{plugin} plugin requires {expected_model}")
|
||||
|
||||
return v
|
||||
@@ -0,0 +1,14 @@
|
||||
from pydantic import Field
|
||||
from typing import Optional, Literal
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class ShadowsocksRProxy(ProxyBase):
|
||||
type: Literal['ssr'] = 'ssr'
|
||||
cipher: str
|
||||
password: str
|
||||
obfs: Literal['plain', 'http_simple', 'http_post', 'random_head', 'tls1.2_ticket_auth', 'tls1.2_ticket_fastauth']
|
||||
obfs_param: Optional[str] = Field(None, alias='obfs-param')
|
||||
protocol: Literal['origin', 'auth_sha1_v4', 'auth_aes128_md5', 'auth_aes128_sha1', 'auth_chain_a', 'auth_chain_b']
|
||||
protocol_param: Optional[str] = Field(None, alias='protocol-param')
|
||||
17
plugins.v2/clashruleprovider/models/proxy/snellproxy.py
Normal file
17
plugins.v2/clashruleprovider/models/proxy/snellproxy.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class SnellObfsOpts(BaseModel):
|
||||
mode: Optional[Literal['http', 'tls']] = None
|
||||
host: Optional[str] = None
|
||||
|
||||
|
||||
class SnellProxy(ProxyBase):
|
||||
type: Literal['snell'] = 'snell'
|
||||
psk: str
|
||||
version: Optional[Literal[1,2,3]] = 1
|
||||
obfs_opts: Optional[SnellObfsOpts] = Field(None, alias='obfs-opts')
|
||||
10
plugins.v2/clashruleprovider/models/proxy/socks5proxy.py
Normal file
10
plugins.v2/clashruleprovider/models/proxy/socks5proxy.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
from .tlsmixin import TLSMixin
|
||||
|
||||
|
||||
class Socks5Proxy(ProxyBase, TLSMixin):
|
||||
type: Literal['socks5'] = 'socks5'
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
15
plugins.v2/clashruleprovider/models/proxy/sshproxy.py
Normal file
15
plugins.v2/clashruleprovider/models/proxy/sshproxy.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from typing import List, Optional, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
|
||||
class SshProxy(ProxyBase):
|
||||
type: Literal['ssh'] = 'ssh'
|
||||
username: str
|
||||
password: Optional[str] = None
|
||||
private_key: Optional[str] = Field(None, alias='privateKey')
|
||||
private_key_passphrase: Optional[str] = Field(None, alias='private-key-passphrase')
|
||||
host_key: Optional[List[str]] = Field(None, alias='host-key')
|
||||
host_key_algorithms: Optional[List[str]] = Field(None, alias='host-key-algorithms')
|
||||
41
plugins.v2/clashruleprovider/models/proxy/tlsmixin.py
Normal file
41
plugins.v2/clashruleprovider/models/proxy/tlsmixin.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from enum import StrEnum
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ClientFingerprint(StrEnum):
|
||||
chrome = 'chrome'
|
||||
firefox = 'firefox'
|
||||
safari = 'safari'
|
||||
ios = 'ios'
|
||||
android = 'android'
|
||||
edge = 'edge'
|
||||
n360 = '360'
|
||||
qq = 'qq'
|
||||
random = 'random'
|
||||
|
||||
|
||||
class RealityOpts(BaseModel):
|
||||
public_key: str = Field(..., alias='public-key')
|
||||
short_id: Optional[str] = Field(None, alias='short-id')
|
||||
support_x25519mlkem768: Optional[bool] = Field(None, alias='support-x25519mlkem768')
|
||||
|
||||
|
||||
class EchOpts(BaseModel):
|
||||
enable: bool = False
|
||||
config: str
|
||||
|
||||
|
||||
class TLSMixin(BaseModel):
|
||||
"""TLS 配置混入类"""
|
||||
# TLS settings
|
||||
tls: Optional[bool] = None
|
||||
sni: Optional[str] = None
|
||||
servername: Optional[str] = None
|
||||
fingerprint: Optional[str] = None
|
||||
alpn: Optional[List[str]] = None
|
||||
skip_cert_verify: Optional[bool] = Field(None, alias='skip-cert-verify')
|
||||
client_fingerprint: Optional[ClientFingerprint] = Field(None, alias='client-fingerprint')
|
||||
reality_opts: Optional[RealityOpts] = Field(None, alias='reality-opts')
|
||||
ech_opts: Optional[EchOpts] = Field(None, alias='ech-opts')
|
||||
21
plugins.v2/clashruleprovider/models/proxy/trojanproxy.py
Normal file
21
plugins.v2/clashruleprovider/models/proxy/trojanproxy.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
from .tlsmixin import TLSMixin
|
||||
from .networkmixin import NetworkMixin
|
||||
|
||||
|
||||
class TrojanSSOption(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
method: Optional[Literal['aes-128-gcm', 'aes-256-gcm', 'chacha20-ietf-poly1305']] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class TrojanProxy(ProxyBase, TLSMixin, NetworkMixin):
|
||||
type: Literal['trojan'] = 'trojan'
|
||||
password: str
|
||||
ss_opts: Optional[TrojanSSOption] = Field(None, alias='ss-opts')
|
||||
network: Optional[Literal['tcp', 'grpc', 'ws']] = None
|
||||
tls: Optional[bool] = True
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user