diff --git a/.github/workflows/process.yaml b/.github/workflows/process.yaml
index 41c310f122..8dcce0149e 100644
--- a/.github/workflows/process.yaml
+++ b/.github/workflows/process.yaml
@@ -15,9 +15,6 @@ env:
# token
PUSH_TOKEN: ${{ secrets.PUSH_TOKEN }}
- # network reachable
- REACHABLE: ${{ vars.REACHABLE }}
-
# not check connective
SKIP_ALIVE_CHECK: ${{ vars.SKIP_ALIVE_CHECK }}
diff --git a/.gitignore b/.gitignore
index e8256725fb..0de689cdc8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -132,5 +132,5 @@ dmypy.json
cache.db
config.yaml
generate.ini
-subscribe/config/config.json
+config.json
config.local.json
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000000..3ffde9e6d0
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "manager"]
+ path = manager
+ url = https://github.com/wzdnzd/proxy-manager.git
diff --git a/README.md b/README.md
index 0bd90bae99..9cec69432f 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ VMess | Trojan | SS | SSR | Snell | Hysteria2 | VLESS | Hysteria | TUIC | AnyTLS
```bash
# 1. 准备配置文件
-cp subscribe/config/config.default.json my-config.json
+cp subscribe/examples/config.default.json my-config.json
# 2. 设置环境变量
export PUSH_TOKEN=your_github_token
@@ -71,13 +71,13 @@ graph LR
A[开始] --> B{选择方式}
B -->|简单| C[collect.py]
B -->|完整| D[process.py]
-
+
C --> E[自动收集机场]
E --> F[注册账号]
F --> G[获取订阅]
G --> H[验证代理]
H --> I[上传到Gist]
-
+
D --> J[加载配置]
J --> K[多源爬取]
K --> L[聚合处理]
@@ -93,7 +93,7 @@ graph LR
**process.py 配置**:
```json
{
- "domains": [
+ "sites": [
{
"name": "example-airport",
"domain": "example.com",
@@ -121,7 +121,7 @@ graph LR
"items": {
"free-clash": {
"username": "your-username",
- "gistid": "your-gist-id",
+ "gist_id": "your-gist-id",
"filename": "clash.yaml"
}
}
@@ -233,4 +233,4 @@ python subscribe/process.py -s config.json -n 128
[报告问题](https://github.com/wzdnzd/aggregator/issues) · [功能请求](https://github.com/wzdnzd/aggregator/issues) · [贡献代码](https://github.com/wzdnzd/aggregator/pulls)
-
\ No newline at end of file
+
diff --git a/README_CN.md b/README_CN.md
index 190d5a6d7d..c6bce870a3 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -346,7 +346,6 @@ SUBSCRIBE_CONF=https://example.com/config.json # 远程配置文件URL
# 工作模式控制
WORKFLOW_MODE=0 # 0:爬取+聚合 1:仅爬取 2:仅聚合
-REACHABLE=true # 网络连通性检测
SKIP_ALIVE_CHECK=false # 跳过代理活性检查
SKIP_REMARK=false # 跳过备注更新
@@ -386,12 +385,12 @@ python subscribe/collect.py [选项]
-o, --overwrite # 覆盖域名列表
-r, --refresh # 仅刷新现有订阅
-s, --skip # 跳过可用性检查
--c, --chuck # 丢弃需要人机验证的站点
+-c, --skip-captcha # 丢弃需要人机验证的站点
-e, --easygoing # 宽松注册模式
-a, --all # 生成完整 Clash 配置
--v, --vitiate # 忽略默认过滤规则
+-v, --ignore-default-filters # 忽略默认过滤规则
-i, --invisible # 隐藏进度条
--y, --yourself URL # 自定义机场列表URL
+-y, --custom-sites URL # 自定义机场列表URL
-u, --url URL # 测试URL
```
@@ -449,20 +448,20 @@ CUSTOMIZE_LINK=https://example.com # 自定义机场列表URL
## 配置详解
-配置文件是 `process.py` 的核心,位于 `subscribe/config/config.default.json`。
+配置文件是 `process.py` 的核心,位于 `subscribe/examples/config.default.json`。
### 配置文件结构概览
```json
{
- "domains": [...], // 机场域名和订阅配置
+ "sites": [...], // 机场域名和订阅配置
"crawl": {...}, // 爬虫设置和数据源配置
"groups": {...}, // 输出分组和格式转换
"storage": {...} // 存储后端配置
}
```
-### 1. 域名配置 (domains)
+### 1. 站点配置 (sites)
配置机场网站和订阅链接,支持自动注册和订阅管理。
@@ -471,8 +470,8 @@ CUSTOMIZE_LINK=https://example.com # 自定义机场列表URL
| 配置项 | 类型 | 必需性 | 默认值 | 说明 |
| --------- | ------- | ------ | ------ | ------------------------------------------------------- |
| `name` | string | 必需 | `""` | 机场唯一标识符,用于日志和命名 |
-| `sub` | array | 可选 | `[]` | 已有订阅链接列表,配置后将跳过自动注册 |
-| `domain` | string | 条件 | `""` | 机场域名,当 `sub` 为空时必需,用于自动注册获取免费套餐 |
+| `subscribe` | array | 可选 | `[]` | 已有订阅链接列表,配置后将跳过自动注册 |
+| `domain` | string | 条件 | `""` | 机场域名,当 `subscribe` 为空时必需,用于自动注册获取免费套餐 |
| `enable` | boolean | 可选 | `true` | 是否启用此配置 |
| `push_to` | array | 必需 | `[]` | 推送到的分组名称列表,不能为空 |
@@ -483,15 +482,15 @@ CUSTOMIZE_LINK=https://example.com # 自定义机场列表URL
| `rename` | string | 可选 | `""` | 节点重命名规则,支持 `{name}` 占位符 |
| `include` | string | 可选 | `""` | 包含过滤器,正则表达式 |
| `exclude` | string | 可选 | `""` | 排除过滤器,正则表达式 |
-| `ignorede` | boolean | 可选 | `true` | 是否忽略默认排除规则 |
+| `ignore_default_exclude` | boolean | 可选 | `true` | 是否忽略默认排除规则 |
#### 质量控制
| 配置项 | 类型 | 必需性 | 默认值 | 说明 |
| ---------- | ------- | ------ | ------- | -------------------------------- |
-| `liveness` | boolean | 可选 | `true` | 是否进行活性测试 |
+| `check_alive` | boolean | 可选 | `true` | 是否进行活性测试 |
| `rate` | number | 可选 | `2.5` | 最大倍率,超过此值的节点将被丢弃 |
-| `secure` | boolean | 可选 | `false` | 是否要求安全连接(验证SSL证书) |
+| `require_tls` | boolean | 可选 | `false` | 是否要求安全连接(验证SSL证书) |
#### 注册配置
@@ -507,13 +506,13 @@ CUSTOMIZE_LINK=https://example.com # 自定义机场列表URL
```json
{
"renew": {
- "account": [ // 账号列表
+ "accounts": [ // 账号列表
{
"email": "user@example.com", // 登录邮箱
- "passwd": "password123", // 登录密码
+ "password": "password123", // 登录密码
"ticket": { // 工单配置
"enable": true, // 是否启用工单
- "autoreset": false, // 是否自动重置
+ "auto_reset": false, // 是否自动重置
"subject": "账号问题", // 工单标题
"message": "请协助处理", // 工单内容
"level": 1 // 工单等级(1-3)
@@ -524,11 +523,6 @@ CUSTOMIZE_LINK=https://example.com # 自定义机场列表URL
"package": "免费套餐", // 套餐名称
"method": 1, // 支付方式
"coupon_code": "NEWUSER", // 续费优惠码
- "chatgpt": { // ChatGPT检测配置
- "enable": true, // 是否启用检测
- "regex": "ChatGPT|OpenAI", // 检测关键词
- "operate": "IN" // 操作类型(IN/OUT)
- }
}
}
```
@@ -537,22 +531,22 @@ CUSTOMIZE_LINK=https://example.com # 自定义机场列表URL
```json
{
- "domains": [
+ "sites": [
{
"name": "example-airport",
- "sub": ["https://example.com/api/v1/client/subscribe?token=abc123"],
+ "subscribe": ["https://example.com/api/v1/client/subscribe?token=abc123"],
"domain": "example.com",
"enable": true,
"rename": "🚀 {name}",
"include": "香港|新加坡|美国",
"exclude": "过期|失效|测试",
"push_to": ["premium", "backup"],
- "ignorede": true,
- "liveness": true,
- "rate": 2.0,
+ "ignore_default_exclude": true,
+ "check_alive": true,
+ "max_rate": 2.0,
"count": 3,
"coupon": "FREE2024",
- "secure": false
+ "require_tls": false
}
]
}
@@ -568,23 +562,23 @@ CUSTOMIZE_LINK=https://example.com # 自定义机场列表URL
| ------------ | ------- | ------ | ------ | -------------------------- |
| `enable` | boolean | 可选 | `true` | 是否启用爬虫功能 |
| `exclude` | string | 可选 | `""` | 全局排除规则(正则表达式) |
-| `threshold` | number | 可选 | `5` | 失败阈值,超过此值停止尝试 |
-| `singlelink` | boolean | 可选 | `true` | 是否允许单个代理链接 |
+| `max_fails` | number | 可选 | `5` | 失败阈值,超过此值停止尝试 |
+| `include_nodes` | boolean | 可选 | `true` | 是否允许单个代理链接 |
#### 持久化配置 (persist)
| 配置项 | 类型 | 必需性 | 默认值 | 说明 |
| --------- | ------ | ------ | ------------------ | ---------------- |
-| `subs` | string | 可选 | `"crawledsubs"` | 订阅数据存储键名 |
-| `proxies` | string | 可选 | `"crawledproxies"` | 代理数据存储键名 |
+| `subscribe` | string | 可选 | `"crawledsubs"` | 订阅数据存储键名 |
+| `nodes` | string | 可选 | `"crawledproxies"` | 节点快照存储键名 |
-#### 通用配置 (config)
+#### 任务参数 (task)
-应用于所有爬取源的默认配置:
+应用于所有爬取源、后续生成 TaskConfig 时使用的参数:
```json
{
- "config": {
+ "task": {
"rename": "🌐 {name}", // 默认重命名规则
"include": "", // 默认包含规则
"exclude": "测试|过期" // 默认排除规则
@@ -604,17 +598,17 @@ CUSTOMIZE_LINK=https://example.com # 自定义机场列表URL
| `pages` | number | 可选 | `5` | 每个频道爬取的页数 |
| `exclude` | string | 可选 | `""` | 频道级别的排除规则 |
-**频道配置 (users)**:
+**频道配置 (channels)**:
每个频道的具体配置:
```json
{
- "users": {
+ "channels": {
"频道名称": {
"include": "订阅|vmess|trojan", // 包含关键词
"exclude": "付费|广告", // 排除关键词
- "config": { // 频道专用配置
+ "task": { // 频道专用任务参数
"rename": "📱 TG-{name}"
},
"push_to": ["free"] // 推送到的分组
@@ -631,7 +625,7 @@ CUSTOMIZE_LINK=https://example.com # 自定义机场列表URL
"enable": true,
"pages": 3,
"exclude": "spam|ads",
- "users": {
+ "channels": {
"proxy_channel": {
"include": "订阅|subscription|免费",
"exclude": "付费|vip|premium",
@@ -657,7 +651,7 @@ CUSTOMIZE_LINK=https://example.com # 自定义机场列表URL
| `pages` | number | 可选 | `2` | 搜索结果页数 |
| `push_to` | array | 必需 | `[]` | 推送到的分组列表 |
| `exclude` | string | 可选 | `""` | 排除规则 |
-| `spams` | array | 可选 | `[]` | 排除的仓库名称列表 |
+| `exclude_repos` | array | 可选 | `[]` | 排除的仓库名称列表 |
**环境变量**:
```bash
@@ -676,7 +670,7 @@ GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
"pages": 3,
"push_to": ["free"],
"exclude": "test|demo|example",
- "spams": ["spam-repo", "fake-proxy"]
+ "exclude_repos": ["spam-repo", "fake-proxy"]
}
}
```
@@ -689,8 +683,9 @@ GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
| ---------- | ------- | ------ | ------- | -------------------- |
| `enable` | boolean | 可选 | `false` | 是否启用(默认禁用) |
| `exclude` | string | 可选 | `""` | 排除规则 |
-| `limits` | number | 可选 | `100` | 最大搜索结果数 |
-| `notinurl` | array | 可选 | `[]` | 排除的域名列表 |
+| `limit` | number | 可选 | `100` | 最大搜索结果数 |
+| `days` | number | 可选 | `7` | 搜索时间范围(天) |
+| `exclude_sites` | array | 可选 | `[]` | 排除的域名列表 |
| `push_to` | array | 必需 | `[]` | 推送到的分组 |
**Yandex 搜索**:
@@ -699,9 +694,9 @@ GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
| ---------- | ------- | ------ | ------- | -------------------- |
| `enable` | boolean | 可选 | `false` | 是否启用(默认禁用) |
| `exclude` | string | 可选 | `""` | 排除规则 |
-| `within` | number | 可选 | `3` | 搜索时间范围(天) |
+| `days` | number | 可选 | `3` | 搜索时间范围(天) |
| `pages` | number | 可选 | `5` | 搜索页数 |
-| `notinurl` | array | 可选 | `[]` | 排除的域名列表 |
+| `exclude_sites` | array | 可选 | `[]` | 排除的域名列表 |
| `push_to` | array | 必需 | `[]` | 推送到的分组 |
**示例配置**:
@@ -710,16 +705,16 @@ GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
"google": {
"enable": false,
"exclude": "spam|blocked",
- "limits": 50,
- "notinurl": ["spam.com", "blocked.site"],
+ "limit": 50,
+ "exclude_sites": ["spam.com", "blocked.site"],
"push_to": ["free"]
},
"yandex": {
"enable": false,
"exclude": "spam",
- "within": 7,
+ "days": 7,
"pages": 3,
- "notinurl": ["spam.com"],
+ "exclude_sites": ["spam.com"],
"push_to": ["free"]
}
}
@@ -742,10 +737,10 @@ GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
"users": {
"用户名": {
"enable": true, // 是否启用此用户
- "num": 30, // 检查的推文数量
+ "tweets": 30, // 检查的推文数量
"include": "proxy|vpn|节点", // 包含关键词
"exclude": "广告|付费", // 排除关键词
- "config": { // 用户专用配置
+ "task": { // 用户专用任务参数
"rename": "🐦 Twitter-{name}"
},
"push_to": ["free"] // 推送到的分组
@@ -762,7 +757,7 @@ GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
| ----------- | ------- | ------ | ------- | -------------- |
| `enable` | boolean | 可选 | `false` | 是否启用此仓库 |
| `username` | string | 必需 | `""` | GitHub 用户名 |
-| `repo_name` | string | 必需 | `""` | 仓库名称 |
+| `repo` | string | 必需 | `""` | 仓库名称 |
| `commits` | number | 可选 | `3` | 检查的提交数量 |
| `exclude` | string | 可选 | `""` | 排除规则 |
| `push_to` | array | 必需 | `[]` | 推送到的分组 |
@@ -774,7 +769,7 @@ GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
{
"enable": true,
"username": "proxy-user",
- "repo_name": "free-proxy-list",
+ "repo": "free-proxy-list",
"commits": 5,
"exclude": "test|demo",
"push_to": ["free"]
@@ -801,7 +796,7 @@ GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
| 配置项 | 类型 | 必需性 | 默认值 | 说明 |
| ------------- | ------- | ------ | ------ | -------------- |
-| `multiple` | boolean | 可选 | `true` | 是否为批量页面 |
+| `paged` | boolean | 可选 | `true` | 是否为批量页面 |
| `placeholder` | string | 条件 | `""` | URL中的占位符 |
| `start` | number | 可选 | `1` | 起始页码 |
| `end` | number | 可选 | `10` | 结束页码 |
@@ -815,7 +810,7 @@ GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
"url": "https://example.com/proxies",
"include": "vmess://|trojan://|ss://",
"exclude": "过期|失效",
- "config": {
+ "task": {
"rename": "🌍 Web-{name}"
},
"push_to": ["free"]
@@ -823,7 +818,7 @@ GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
{
"enable": true,
"url": "https://example.com/page/{page}",
- "multiple": true,
+ "paged": true,
"placeholder": "{page}",
"start": 1,
"end": 5,
@@ -840,62 +835,15 @@ GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
| 配置项 | 类型 | 必需性 | 默认值 | 说明 |
| -------- | ------- | ------ | ------- | ------------------------------- |
| `enable` | boolean | 可选 | `false` | 是否启用此脚本 |
-| `script` | string | 必需 | `""` | 脚本路径,格式:`文件名#函数名` |
-| `params` | object | 可选 | `{}` | 传递给脚本的参数 |
-
-**参数配置 (params)**:
+| `plugin` | string | 必需 | `""` | 插件名,对应 `crawl/scripts` 下的注册名 |
+| `persist` | string/object | 可选 | | 引用 `storage.items` 名或内联存储项 |
+| `task` | object | 可选 | `{}` | 生成 TaskConfig 的参数 |
-```json
-{
- "params": {
- "persist": { // 持久化配置
- "fileid": "custom_output" // 存储文件ID
- },
- "api_key": "任意需要的配置", // 自定义参数,可以是任意键值对
- "timeout": 30, // 其他自定义配置
- "config": { // 通用配置
- "enable": true,
- "liveness": true,
- "exclude": "test|spam",
- "rename": "🔧 Custom-{name}",
- "push_to": ["premium"]
- }
- }
-}
-```
+公共字段只有 `enable` / `plugin` / `persist` / `task`,其余为插件私有字段。
**脚本开发示例**:
-创建 `scripts/custom_crawler.py`:
-
-```python
-def main_function(params):
- """
- 自定义爬虫函数
-
- Args:
- params: 配置参数字典
-
- Returns:
- list: 返回订阅配置列表
- """
- api_key = params.get("api_key") # 获取自定义配置
- custom_param = params.get("custom_param") # 获取其他参数
- config = params.get("config", {})
-
- # 你的爬取逻辑
- subscriptions = []
-
- # 返回格式
- return [
- {
- "name": "custom-source",
- "sub": "https://example.com/subscribe",
- "push_to": config.get("push_to", []),
- "saved": False # 是否已保存
- }
- ]
-```
+插件放在 `subscribe/crawl/scripts/` 下,实现 `parse(ctx) -> TConfig` 和 `run(config, ctx) -> ChannelResult`,并用 `plugin` 名注册。
**完整示例**:
```json
@@ -903,20 +851,14 @@ def main_function(params):
"scripts": [
{
"enable": true,
- "script": "custom_crawler#main_function",
- "params": {
- "persist": {
- "fileid": "custom_output"
- },
- "api_key": "任意需要的配置值",
- "custom_param": "其他参数",
- "config": {
- "enable": true,
- "liveness": true,
- "exclude": "test|demo",
- "rename": "🔧 API-{name}",
- "push_to": ["premium"]
- }
+ "plugin": "dynamic",
+ "persist": "custom_output",
+ "task": {
+ "enable": true,
+ "check_alive": true,
+ "exclude": "test|demo",
+ "rename": "🔧 API-{name}",
+ "push_to": ["premium"]
}
}
]
@@ -932,7 +874,7 @@ def main_function(params):
| 配置项 | 类型 | 必需性 | 默认值 | 说明 |
| ------- | ------- | ------ | ------ | ------------------------- |
| `emoji` | boolean | 可选 | `true` | 是否添加国家/地区表情符号 |
-| `list` | boolean | 可选 | `true` | 是否生成节点列表模式 |
+| `list_only` | boolean | 可选 | `true` | 转换时只输出代理节点列表 |
#### 输出目标 (targets)
@@ -967,7 +909,7 @@ def main_function(params):
"groups": {
"premium": {
"emoji": true,
- "list": true,
+ "list_only": true,
"targets": {
"clash": "premium-clash",
"v2ray": "premium-v2ray",
@@ -977,12 +919,12 @@ def main_function(params):
"enable": true,
"locate": true,
"residential": false,
- "bits": 2
+ "digits": 2
}
},
"free": {
"emoji": true,
- "list": false,
+ "list_only": false,
"targets": {
"clash": "free-clash",
"v2ray": "free-v2ray"
@@ -990,7 +932,7 @@ def main_function(params):
},
"backup": {
"emoji": false,
- "list": true,
+ "list_only": true,
"targets": {
"singbox": "backup-singbox"
}
@@ -1035,7 +977,7 @@ def main_function(params):
"items": {
"premium-clash": {
"username": "your-github-username",
- "gistid": "your-gist-id",
+ "gist_id": "your-gist-id",
"filename": "premium-clash.yaml"
}
}
@@ -1067,8 +1009,8 @@ PUSH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx # GitHub 个人访问令牌
"items": {
"premium-clash": {
"username": "your-username",
- "folderid": "folder-id",
- "fileid": "file-id"
+ "folder_id": "folder-id",
+ "file_id": "file-id"
}
}
}
@@ -1098,7 +1040,7 @@ PUSH_TOKEN=your_pastegg_api_key
"domain": "https://imperialb.in",
"items": {
"premium-clash": {
- "fileid": "document-id"
+ "file_id": "document-id"
}
}
}
@@ -1122,7 +1064,7 @@ PUSH_TOKEN=your_imperial_api_token
"base": "https://pastefy.app",
"items": {
"premium-clash": {
- "fileid": "paste-id"
+ "file_id": "paste-id"
}
}
}
@@ -1146,7 +1088,7 @@ PUSH_TOKEN=your_pastefy_api_token
"base": "https://qbin.me",
"items": {
"premium-clash": {
- "fileid": "file-id",
+ "file_id": "file-id",
"password": "optional-password",
"expire": 86400
}
@@ -1175,8 +1117,8 @@ PUSH_TOKEN=your_qbin_token
"engine": "local",
"items": {
"premium-clash": {
- "folderid": "output/premium",
- "fileid": "clash.yaml"
+ "folder_id": "output/premium",
+ "file_id": "clash.yaml"
}
}
}
@@ -1200,9 +1142,9 @@ LOCAL_BASEDIR=/path/to/output/directory # 基础输出目录
"storage": {
"engine": "gist",
"items": {
- "premium-clash": {"username": "user", "gistid": "id1", "filename": "premium.yaml"},
- "free-clash": {"username": "user", "gistid": "id2", "filename": "free.yaml"},
- "crawledsubs": {"username": "user", "gistid": "id3", "filename": "subs.json"}
+ "premium-clash": {"username": "user", "gist_id": "id1", "filename": "premium.yaml"},
+ "free-clash": {"username": "user", "gist_id": "id2", "filename": "free.yaml"},
+ "crawledsubs": {"username": "user", "gist_id": "id3", "filename": "subs.json"}
}
}
}
@@ -1394,7 +1336,7 @@ classDiagram
#### 基本工作流程
1. **准备配置**
```bash
-cp subscribe/config/config.default.json my-config.json
+cp subscribe/examples/config.default.json my-config.json
# 编辑 my-config.json 设置您的配置
```
diff --git a/README_EN.md b/README_EN.md
index 3229e5236e..a88770fb72 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -47,7 +47,7 @@ graph TB
A2[Configuration File] --> C[Collect Module]
C --> E[Task Assigner]
end
-
+
subgraph "Storage Services"
Y[GitHub Gist]
Z[PasteGG]
@@ -60,7 +60,7 @@ graph TB
G --> M
G --> H
G --> N
-
+
E --> M
E --> H
@@ -72,19 +72,19 @@ graph TB
J --> P
K --> P
N --> P
-
+
P --> Q
Q -->|Yes| O
Q -->|No| R
O --> R
-
+
R --> S
S --> T
T --> U
U --> V
V --> W
W --> X
-
+
X --> Y
X --> Z
X --> AA
@@ -102,7 +102,7 @@ flowchart TD
E --> F[Quality Filtering]
F --> G[Format Conversion]
G --> H[Push to Storage]
-
+
subgraph Sources [Data Source Types]
A1[Telegram Channels \s*找不到和您查询的“\s*\s*.*?/api/v1/client/subscribe\?token=.*?\s*\s*”相符的内容或信息。\s*
Public Message Crawling]
A2[GitHub Repositories
Code and Issues Search]
@@ -112,7 +112,7 @@ flowchart TD
A6[General Web
Specified URL Crawling]
A7[Script Plugins
Custom Logic]
end
-
+
subgraph Processing [Processing Pipeline]
B1[Content Parsing
Extract Subscription Links]
C1[Connectivity Validation
Check Subscription Validity]
@@ -122,7 +122,7 @@ flowchart TD
G1[Multi-format Conversion
Adapt to Different Clients]
H1[Batch Push
Upload to Storage Backends]
end
-
+
A1 --> B1
A2 --> B1
A3 --> B1
@@ -130,7 +130,7 @@ flowchart TD
A5 --> B1
A6 --> B1
A7 --> B1
-
+
B1 --> C1
C1 --> D1
D1 --> E1
@@ -148,7 +148,7 @@ classDiagram
+load_config() Load Configuration
+execute_tasks() Execute Tasks
}
-
+
class CrawlModule {
+batch_crawl() Batch Crawling
+crawl_telegram() Telegram Crawler
@@ -161,7 +161,7 @@ classDiagram
+validate() Validate Subscription
+collect_airport() Collect Airport
}
-
+
class AirportModule {
+register() Register Account
+get_subscribe() Get Subscription
@@ -171,7 +171,7 @@ classDiagram
+decode() Decode Content
+check_protocol() Check Protocol
}
-
+
class ClashModule {
+generate_config() Generate Config
+filter_proxies() Filter Proxies
@@ -179,7 +179,7 @@ classDiagram
+check() Check Connectivity
+is_mihomo() Check Engine
}
-
+
class SubconverterModule {
+get_filename() Get Filename
+generate_conf() Generate Config
@@ -187,7 +187,7 @@ classDiagram
+getpath() Get Path
+CONVERT_TARGETS Supported Formats
}
-
+
class PushModule {
+push_to() Push Content
+push_file() Push File
@@ -195,44 +195,44 @@ classDiagram
+filter_push() Filter Push
+raw_url() Get Raw URL
}
-
+
class CollectModule {
+assign() Assign Tasks
+aggregate() Aggregate Processing
}
-
+
class WorkflowModule {
+TaskConfig Task Configuration
}
-
+
class UtilsModule {
+http_get() HTTP Request
+trim() String Processing
+extract_domain() Domain Extraction
}
-
+
class LoggerModule {
+logger Logger Instance
}
-
+
ProcessModule --> CrawlModule : Invoke Crawling
ProcessModule --> AirportModule : Invoke Airport
ProcessModule --> ClashModule : Test Proxies
ProcessModule --> SubconverterModule : Format Conversion
ProcessModule --> PushModule : Push Results
-
+
CollectModule --> CrawlModule : Invoke Crawling
CollectModule --> AirportModule : Invoke Airport
CollectModule --> WorkflowModule : Use Configuration
-
+
CrawlModule --> AirportModule : Parse Subscription
CrawlModule --> UtilsModule : Use Utilities
CrawlModule --> LoggerModule : Log Events
-
+
AirportModule --> SubconverterModule : Subscription Conversion
AirportModule --> ClashModule : Verify Proxy
AirportModule --> UtilsModule : Use Utilities
-
+
ClashModule --> UtilsModule : Use Utilities
PushModule --> UtilsModule : Use Utilities
PushModule --> LoggerModule : Log Events
@@ -285,8 +285,6 @@ GIST_LINK=username/gist_id
# Workflow mode (0: crawl+aggregate, 1: crawl only, 2: aggregate only)
WORKFLOW_MODE=0
-# Network settings
-REACHABLE=true
SKIP_ALIVE_CHECK=false
SKIP_REMARK=false
@@ -297,46 +295,46 @@ CUSTOMIZE_LINK=https://your-custom-airport-list.com
## Configuration Guide
### Configuration File Structure
-The main configuration file is located at `subscribe/config/config.default.json`. Here's the complete structure:
+The main configuration file is located at `subscribe/examples/config.default.json`. Here's the complete structure:
```json
{
- "domains": [...], // Domain/subscription configurations
+ "sites": [...], // Domain/subscription configurations
"crawl": {...}, // Crawling settings
- "groups": {...}, // Output group configurations
+ "groups": {...}, // Output group configurations
"storage": {...} // Storage backend settings
}
```
-### 1. Domain Configuration (domains)
+### 1. Site Configuration (sites)
Configure specific airport websites or subscription links:
```json
{
- "domains": [
+ "sites": [
{
"name": "example-site", // Unique identifier
- "sub": ["https://sub1.com"], // Existing subscription URLs, will skip auto-registration if configured
+ "subscribe": ["https://sub1.com"], // Existing subscription URLs, will skip auto-registration if configured
"domain": "example.com", // Airport domain, will attempt auto-registration for free plans when sub is empty and domain is valid
"enable": true, // Enable/disable this config
"rename": "🚀 {name}", // Rename pattern for proxies
"include": "regex_pattern", // Include filter (regex)
"exclude": "regex_pattern", // Exclude filter (regex)
"push_to": ["group1", "group2"], // Target groups
- "ignorede": true, // Ignore default exclude rules
- "liveness": true, // Enable liveness testing
- "rate": 2.5, // Max rate multiplier
+ "ignore_default_exclude": true, // Ignore default exclude rules
+ "check_alive": true, // Enable liveness testing
+ "max_rate": 2.5, // Max rate multiplier
"count": 2, // Number of accounts to register
"coupon": "FREECODE", // Coupon code
- "secure": false, // Require secure connections
+ "require_tls": false, // Require secure connections
"renew": { // Account renewal settings
- "account": [
+ "accounts": [
{
"email": "user@example.com",
- "passwd": "password123",
+ "password": "password123",
"ticket": {
"enable": true,
- "autoreset": false,
+ "auto_reset": false,
"subject": "Account Issue",
"message": "Please help",
"level": 1
@@ -347,11 +345,6 @@ Configure specific airport websites or subscription links:
"package": "free",
"method": 1,
"coupon_code": "DISCOUNT",
- "chatgpt": {
- "enable": true,
- "regex": "ChatGPT|OpenAI",
- "operate": "IN"
- }
}
}
]
@@ -367,18 +360,18 @@ Configure specific airport websites or subscription links:
"crawl": {
"enable": true, // Enable crawling
"exclude": "spam|blocked", // Global exclude pattern
- "threshold": 5, // Max failure threshold
- "singlelink": true, // Allow single proxy links
+ "max_fails": 5, // Max failure threshold
+ "include_nodes": true, // Allow single proxy links
"persist": { // Persistence settings
- "subs": "crawledsubs", // Subscription storage key
- "proxies": "crawledproxies" // Proxy storage key
+ "subscribe": "crawledsubs", // Subscription storage key
+ "nodes": "crawledproxies" // Proxy storage key
},
- "config": { // Global crawl config
+ "task": { // TaskConfig generation params
"rename": "🌐 {name}",
"include": "",
"exclude": ""
},
-
+
#### 2.2 Telegram Crawler
```json
@@ -387,11 +380,11 @@ Configure specific airport websites or subscription links:
"enable": true,
"pages": 5, // Pages to crawl per channel
"exclude": "spam|ads",
- "users": {
+ "channels": {
"channel_name": {
"include": "subscription|sub",
"exclude": "spam",
- "config": {
+ "task": {
"rename": "📱 TG-{name}"
},
"push_to": ["group1"]
@@ -411,7 +404,7 @@ Configure specific airport websites or subscription links:
"pages": 2, // Search pages
"push_to": ["free"],
"exclude": "test|spam",
- "spams": ["spam-repo"] // Excluded repositories
+ "exclude_repos": ["spam-repo"] // Excluded repositories
}
}
```
@@ -432,14 +425,14 @@ GH_TOKEN=your_github_token
"google": {
"enable": false, // Disabled by default
"exclude": "spam",
- "notinurl": ["spam.com"], // Exclude domains
+ "exclude_sites": ["spam.com"], // Exclude domains
"qdr": 7, // Days to search back
- "limits": 100, // Max results
+ "limit": 100, // Max results
"push_to": ["group1"]
},
"yandex": {
"enable": false,
- "within": 2, // Time range (days)
+ "days": 2, // Time range (days)
"pages": 5, // Search pages
"push_to": ["free"]
}
@@ -455,7 +448,7 @@ GH_TOKEN=your_github_token
"users": {
"username": {
"enable": true,
- "num": 30, // Number of tweets to check
+ "tweets": 30, // Number of tweets to check
"include": "proxy|vpn",
"exclude": "spam",
"push_to": ["group1"]
@@ -476,7 +469,7 @@ GH_TOKEN=your_github_token
"url": "https://example.com/proxies",
"include": "vmess://|trojan://",
"exclude": "expired",
- "config": {
+ "task": {
"rename": "🌍 {name}"
},
"push_to": ["group1"]
@@ -492,18 +485,14 @@ GH_TOKEN=your_github_token
"scripts": [
{
"enable": false,
- "script": "custom_crawler#main_function",
- "params": {
- "persist": {
- "fileid": "custom_output"
- },
- "config": {
- "enable": true,
- "liveness": true,
- "exclude": "spam",
- "rename": "🔧 {name}",
- "push_to": ["group1"]
- }
+ "plugin": "dynamic",
+ "persist": "custom_output",
+ "task": {
+ "enable": true,
+ "check_alive": true,
+ "exclude": "spam",
+ "rename": "🔧 {name}",
+ "push_to": ["group1"]
}
}
]
@@ -520,7 +509,7 @@ Define output groups and format conversion:
"groups": {
"premium": { // Group name
"emoji": true, // Add country emojis
- "list": true, // Generate proxy list
+ "list_only": true, // Generate proxy list
"targets": { // Output formats (keys must be supported by subconverter: https://github.com/asdlokj1qpi233/subconverter)
"clash": "premium-clash", // Storage key for Clash format
"singbox": "premium-singbox", // Storage key for SingBox format
@@ -530,12 +519,12 @@ Define output groups and format conversion:
"enable": false,
"locate": true, // Whether to detect node's landing region/country
"residential": false, // Whether to detect if node type is residential broadband
- "bits": 2 // Number of digits to pad node names
+ "digits": 2 // Number of digits to pad node names
}
},
"free": {
"emoji": true,
- "list": false,
+ "list_only": false,
"targets": {
"clash": "free-clash",
"v2ray": "free-v2ray"
@@ -556,7 +545,7 @@ Define output groups and format conversion:
"items": {
"premium-clash": {
"username": "github-username",
- "gistid": "gist-id",
+ "gist_id": "gist-id",
"filename": "clash.yaml"
}
}
@@ -580,8 +569,8 @@ PUSH_TOKEN=your_github_personal_access_token
"items": {
"config-name": {
"username": "username",
- "folderid": "folder-id",
- "fileid": "file-id"
+ "folder_id": "folder-id",
+ "file_id": "file-id"
}
}
}
@@ -603,7 +592,7 @@ PUSH_TOKEN=your_pastegg_api_key
"domain": "https://imperialb.in",
"items": {
"config-name": {
- "fileid": "document-id"
+ "file_id": "document-id"
}
}
}
@@ -624,7 +613,7 @@ PUSH_TOKEN=your_imperial_api_token
"base": "https://pastefy.app",
"items": {
"config-name": {
- "fileid": "paste-id"
+ "file_id": "paste-id"
}
}
}
@@ -645,7 +634,7 @@ PUSH_TOKEN=your_pastefy_api_token
"base": "https://qbin.me",
"items": {
"config-name": {
- "fileid": "file-id",
+ "file_id": "file-id",
"password": "password (optional)",
"expire": 86400
}
@@ -672,17 +661,17 @@ Configure storage backends for output:
"items": { // Storage configurations
"premium-clash": {
"username": "your-username",
- "gistid": "your-gist-id",
+ "gist_id": "your-gist-id",
"filename": "premium-clash.yaml"
},
"premium-v2ray": {
- "username": "your-username",
- "gistid": "your-gist-id",
+ "username": "your-username",
+ "gist_id": "your-gist-id",
"filename": "premium-v2ray.txt"
},
"crawledsubs": { // For persistence
"username": "your-username",
- "gistid": "your-gist-id",
+ "gist_id": "your-gist-id",
"filename": "subscriptions.json"
}
}
@@ -696,8 +685,8 @@ Configure storage backends for output:
"engine": "local",
"items": {
"config-name": {
- "folderid": "output", // Subdirectory
- "fileid": "proxies.yaml" // Filename
+ "folder_id": "output", // Subdirectory
+ "file_id": "proxies.yaml" // Filename
}
}
}
@@ -719,7 +708,7 @@ LOCAL_BASEDIR=/path/to/local/storage # Local storage directory
# Configuration file (optional)
SUBSCRIBE_CONF=https://example.com/config.json # Remote configuration file URL
-# GitHub related
+# GitHub related
GH_TOKEN=your_github_token # GitHub API Token
GH_COOKIE=your_github_session_cookie # GitHub Session Cookie
GIST_PAT=your_personal_access_token # GitHub Personal Access Token
@@ -727,7 +716,6 @@ GIST_LINK=username/gist_id # Gist link (for collect.py)
# Workflow mode
WORKFLOW_MODE=0 # 0:crawl+aggregate 1:crawl only 2:aggregate only
-REACHABLE=true # Network reachability
SKIP_ALIVE_CHECK=false # Skip liveness check
SKIP_REMARK=false # Skip remark update
@@ -750,7 +738,7 @@ sequenceDiagram
participant Clash
participant Convert
participant Push
-
+
User->>Process: Run with config
Process->>Config: Load configuration
Config-->>Process: Return ProcessConfig
@@ -873,36 +861,36 @@ classDiagram
+filter_push()
+raw_url()
}
-
+
class PushToGist {
+api_address: str
+method: PATCH
}
-
+
class PushToPasteGG {
+api_address: str
+method: PATCH
}
-
+
class PushToImperial {
+api_address: str
+method: PATCH
}
-
+
class PushToPastefy {
+api_address: str
+method: PUT
}
-
+
class PushToQBin {
+api_address: str
+method: POST
}
-
+
class PushToLocal {
+method: local
}
-
+
PushTo <|-- PushToGist
PushTo <|-- PushToPasteGG
PushTo <|-- PushToImperial
@@ -916,7 +904,7 @@ classDiagram
#### Basic Workflow
1. **Prepare Configuration**
```bash
-cp subscribe/config/config.default.json my-config.json
+cp subscribe/examples/config.default.json my-config.json
# Edit my-config.json with your settings
```
diff --git a/clash/clash-darwin-amd b/clash/clash-darwin-amd
index 7a6122f093..d48f579cc5 100644
Binary files a/clash/clash-darwin-amd and b/clash/clash-darwin-amd differ
diff --git a/clash/clash-darwin-arm b/clash/clash-darwin-arm
index d7e9f7a1b5..b64be7f2f0 100644
Binary files a/clash/clash-darwin-arm and b/clash/clash-darwin-arm differ
diff --git a/clash/clash-linux-amd b/clash/clash-linux-amd
index 4e4e41bcd3..81322e8721 100644
Binary files a/clash/clash-linux-amd and b/clash/clash-linux-amd differ
diff --git a/clash/clash-linux-arm b/clash/clash-linux-arm
index d6a6c9ad43..96065b0239 100644
Binary files a/clash/clash-linux-arm and b/clash/clash-linux-arm differ
diff --git a/clash/clash-windows-amd.exe b/clash/clash-windows-amd.exe
index a21601b154..0cb4d7c2ce 100644
Binary files a/clash/clash-windows-amd.exe and b/clash/clash-windows-amd.exe differ
diff --git a/manager b/manager
new file mode 160000
index 0000000000..e3d93e51e7
--- /dev/null
+++ b/manager
@@ -0,0 +1 @@
+Subproject commit e3d93e51e762d269546e299490ff114031272e0b
diff --git a/subconverter/subconverter-darwin-amd b/subconverter/subconverter-darwin-amd
index b74b3a0091..e360517aad 100644
Binary files a/subconverter/subconverter-darwin-amd and b/subconverter/subconverter-darwin-amd differ
diff --git a/subconverter/subconverter-darwin-arm b/subconverter/subconverter-darwin-arm
index b74b3a0091..e360517aad 100644
Binary files a/subconverter/subconverter-darwin-arm and b/subconverter/subconverter-darwin-arm differ
diff --git a/subconverter/subconverter-linux-amd b/subconverter/subconverter-linux-amd
index 03dbba7f03..7749222c08 100644
Binary files a/subconverter/subconverter-linux-amd and b/subconverter/subconverter-linux-amd differ
diff --git a/subconverter/subconverter-linux-arm b/subconverter/subconverter-linux-arm
index fd48ed5683..9390376a51 100644
Binary files a/subconverter/subconverter-linux-arm and b/subconverter/subconverter-linux-arm differ
diff --git a/subconverter/subconverter-windows-amd.exe b/subconverter/subconverter-windows-amd.exe
index f2f9d6a3c3..b7dc348272 100644
Binary files a/subconverter/subconverter-windows-amd.exe and b/subconverter/subconverter-windows-amd.exe differ
diff --git a/subscribe/airport.py b/subscribe/airport.py
index b2b6d39b92..c2a1bf7b28 100644
--- a/subscribe/airport.py
+++ b/subscribe/airport.py
@@ -18,15 +18,19 @@
from copy import deepcopy
from dataclasses import dataclass, field
from enum import Enum
+from http.client import HTTPMessage, HTTPResponse
+from urllib.request import Request
import mailtm
import renewal
import utils
import yaml
+from config.models import NodeInput
from logger import logger
+from outbound import verify
import subconverter
-from clash import is_mihomo, verify
+from clash import is_mihomo
EMAILS_DOMAINS = [
"gmail.com",
@@ -71,13 +75,27 @@ class Category(Enum):
# deal with !(?:^|[\s,{])(?:server|ip|ipv6)\s*:\s*)" r'(?PSign in to GitHub
", content, flags=re.I):
- logger.error("[GithubCrawl] session has expired, please provide a valid session and try again")
- return ""
-
- return content
-
-
-def paging(start: int, end: int, peer_page: int) -> list[int]:
- if start > end or peer_page <= 0:
- return []
-
- pages = []
- for i in range(start, end + 1, peer_page):
- pages.append(i // peer_page + 1)
-
- return pages
-
-
-def search_github_issues(page: int, cookie: str) -> list[str]:
- content = search_github(page=page, cookie=cookie, searchtype="Issues", sortedby="created")
- if utils.isblank(content):
- return []
-
- try:
- regex = r'href="(/.*/.*/issues/\d+)">'
- groups = re.findall(regex, content, flags=re.I)
- links = list(set(groups))
- links = [f"https://github.com{x}" for x in links]
- return links
- except:
- return []
-
-
-def search_github_issues_byapi(peer_page: int = 50, page: int = 1) -> list[str]:
- peer_page, page = min(max(peer_page, 1), 100), max(1, page)
- url = f"https://api.github.com/search/issues?q=%22%2Fapi%2Fv1%2Fclient%2Fsubscribe%3Ftoken%3D%22&sort=created&order=desc&per_page={peer_page}&page={page}"
- content = utils.http_get(url=url)
- if utils.isblank(content):
- return []
- try:
- items, links = json.loads(content).get("items", []), set()
- for item in items:
- link = item.get("html_url", "")
- if utils.isblank(link):
- continue
- links.add(link)
-
- return list(links)
- except:
- logger.error("[GithubIssuesCrawl] occur error when search issues from github")
- traceback.print_exc()
- return []
-
-
-def search_github_code_byapi(token: str, peer_page: int = 50, page: int = 1, excludes: list = []) -> list[str]:
- """
- curl -Ls -o response.json -H "Authorization: Bearer
")
- for text in texts:
- address_regex = r'注册地址:)?([^\s\r\n<)]+)"
- words = re.findall(coupon_regex, text, flags=re.M)
- coupon = words[0] if words else ""
-
- candidates[address] = coupon
-
- urls = list(candidates.keys())
- latest = utils.multi_thread_run(func=get_redirect_url, tasks=urls, num_threads=num_thread)
-
- for i, x in enumerate(urls):
- domain = utils.extract_domain(url=latest[i], include_protocal=True)
- if not domain:
- continue
-
- coupon = candidates.get(x, "")
- result[domain] = coupon
- except:
- logger.error(f"[AirPortCollector] occur error when crawl from [{url}], message: \n{traceback.format_exc()}")
-
- logger.info(f"[AirPortCollector] finished crawl from [{url}], found {len(result)} domains")
- return result
-
- def crawl_maomeng() -> dict:
- return run_crawl(
- url="https://maomeng.xyz/2021/06/11/ji-chang-tui-jian-chang-qi-geng-xin",
- separator=r'官网:.*
(?:优惠|白嫖)码:([^<]+)
)?([^\r\n\s]+)(?:(?:[\r\n\s]+)?)?0(?:\s+)?元购买"
-
- tasks = [[x, separator, address_regex, coupon_regex] for x in sorted(articles)]
- items = utils.multi_thread_run(func=run_crawl, tasks=tasks)
-
- result = dict()
- for item in items:
- if item and isinstance(item, dict):
- result.update(item)
-
- # Extract javascript link from peer page and then parse airport urls and coupons
- javascripts = utils.multi_thread_run(
- func=get_links,
- tasks=[
- [x, base, r'href="(/assets/vpn_free_\d+_\d+_[^\r\n\s\"]+.md.[A-Za-z0-9_\-]+.lean.js)"']
- for x in articles
- ],
- )
-
- airports = utils.multi_thread_run(
- func=run_crawl,
- tasks=[
- [
- x[0],
- r'"详细信息"|测试报告"|"官方(群组|频道)|"更新于|"联系方式',
- r'"hyperlink":"(https?://[^\s]+/(?:#/register|auth)\?(?:code|invite)=[^\s]+)"',
- r'{code:"([^\r\n\s]+)"}',
- ]
- for x in javascripts
- if x and isinstance(x, list)
- ],
- )
-
- for item in airports:
- if not item or not isinstance(item, dict):
- continue
-
- result.update(item)
-
- return result
-
- def crawl_jctj(convert: bool = False) -> dict:
- url = "https://raw.githubusercontent.com/hwanz/SSR-V2ray-Trojan-vpn/main/README.md"
- content = utils.http_get(url=url)
- groups = re.findall(r"\[.*\]\((https?:\/\/[^\s\r\n]+)\)[^\r\n]+\d+G.*", content, flags=re.I)
- if not groups:
- return {}
-
- try:
- tasks = [utils.trim(x).lower() for x in groups if x]
- if convert:
- links = utils.multi_thread_run(func=get_redirect_url, tasks=tasks, num_threads=num_thread)
- else:
- links = tasks
-
- result = {utils.extract_domain(url=x, include_protocal=True): "" for x in links if x}
- logger.info(f"[AirPortCollector] finished crawl from [{url}], found {len(result)} domains")
-
- return result
- except:
- logger.error(f"[AirPortCollector] occur error when crawl from [{url}], message: \n{traceback.format_exc()}")
- return {}
-
- def get_redirect_url(url: str, retry: int = 3) -> str:
- if not url or retry <= 0:
- return ""
-
- headers = {
- "User-Agent": utils.USER_AGENT,
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
- "Accept-Encoding": "gzip, deflate, br, zstd",
- "Accept-Language": "zh-CN,zh;q=0.9",
- }
-
- try:
- request = urllib.request.Request(url=url, headers=headers, method="GET")
- response = urllib.request.urlopen(request, timeout=10, context=utils.CTX)
-
- return response.geturl()
- except:
- time.sleep(random.randint(1, 3))
- return get_redirect_url(url=url, retry=retry - 1)
-
- def run_crawl(url: str, separator: str, address_regex: str, coupon_regex: str) -> dict:
- url = utils.trim(url)
- content = utils.http_get(url=url)
- if not content:
- return {}
-
- result = dict()
- try:
- groups = re.split(utils.trim(separator), content, flags=re.M)
- if not groups:
- logger.warning(f"[AirPortCollector] cannot found any domains from [{url}]")
- return {}
-
- for group in groups:
- if not group or not isinstance(group, str):
- continue
-
- words = re.findall(utils.trim(address_regex), group, flags=re.M)
- address = words[0] if words else ""
- if not address:
- continue
-
- words = re.findall(utils.trim(coupon_regex), group, flags=re.M)
- coupon = words[0] if words else ""
-
- domain = utils.extract_domain(url=address, include_protocal=True)
- result[domain] = coupon
- except:
- logger.error(f"[AirPortCollector] occur error when crawl from [{url}], message: \n{traceback.format_exc()}")
-
- logger.info(f"[AirPortCollector] finished crawl from [{url}], found {len(result)} domains")
- return result
-
- def extract_backend_url(domain: str, retry: int = 2) -> str:
- # TODO: exploring a more generalized approach to backend addresses
- def request_once(suffix: str) -> tuple[bool, str]:
- count, suffix = 0, utils.trim(suffix)
- url = urllib.parse.urljoin(domain, suffix)
-
- while count < retry:
- count += 1
-
- try:
- request = urllib.request.Request(url=url, headers=utils.DEFAULT_HTTP_HEADERS, method="GET")
- response = urllib.request.urlopen(request, timeout=6, context=utils.CTX)
-
- word = "" if not suffix else (suffix if suffix.startswith("/") else "/" + suffix)
- if word and not utils.trim(response.geturl()).endswith(word):
- return True, ""
-
- content = response.read()
- try:
- content = str(content, encoding="utf8")
- except:
- content = gzip.decompress(content).decode("utf8")
-
- return False, content
- except urllib.error.HTTPError as e:
- if e.code == 404:
- return False, ""
- except urllib.error.URLError as e:
- if isinstance(e.reason, (socket.gaierror, ssl.SSLError, socket.timeout)):
- return True, ""
- except Exception as e:
- pass
-
- return False, ""
-
- def attempt_env() -> str:
- status, content = request_once(suffix="/env.js")
- if status:
- return terminated
-
- if not content:
- return ""
-
- if groups := re.findall(r"\bhost\b:(?:\s+)?[\"\'](https?://[^\s\r\t]+)[\"\']", content, flags=re.I):
- return utils.trim(groups[0])
-
- groups = re.findall(r"window.routerBase(?:\s+)?=(?:\s+)?['\"](https?://.*)['\"]", content, flags=re.I)
- return groups[0].rstrip("/") if groups and groups[0] else ""
-
- def attempt_zero() -> str:
- status, content = request_once(suffix="/config.json")
- if status:
- return terminated
-
- if not content:
- return ""
-
- try:
- data = json.loads(content)
- # for https://github.com/amyouran/v2board-Zero-Theme
- link = utils.trim(data.get("api_base", ""))
- if not link:
- # for https://github.com/DyAxy/V2B-Theme-Nest
- link = utils.trim(data.get("apiUrl", ""))
-
- return utils.extract_domain(url=link, include_protocal=True)
- except:
- return ""
-
- def attempt_buddy() -> str:
- # for https://github.com/vlesstop/v2board-theme-buddy
- status, content = request_once(suffix="/config.js")
- if status:
- return terminated
-
- group = re.findall(r"\bhost\b:(?:\s+)?[\"\'](https?://[^\s\r\t]+)[\"\']", content, flags=re.I)
- return "" if not group else utils.trim(group[0])
-
- def attempt_aurora() -> str:
- # for https://github.com/krsunm/Aurora
- status, content = request_once(suffix="")
- if status:
- return terminated
-
- group = re.findall(r"\bserverUrl\b:(?:\s+)?[\"\'](https?://[^\s\r\t]+)[\"\']", content, flags=re.I)
- return "" if not group else utils.trim(group[0])
-
- terminated, retry = "terminated", max(retry, 1)
- for func in [attempt_env, attempt_zero, attempt_buddy, attempt_aurora]:
- backend = func()
- if terminated == backend:
- return ""
- if backend:
- return backend
-
- return domain
-
- domains = crawl_channel(channel=channel, page_num=page_num, fun=extract_airport_site)
- candidates = {} if not domains else {utils.extract_domain(x, True): "" for x in domains}
-
- materials = dict()
- jctj = crawl_jctj(convert=False)
- if jctj:
- materials.update(jctj)
-
- ccbh = crawl_ccbh()
- if ccbh:
- materials.update(ccbh)
-
- maomeng = crawl_maomeng()
- if maomeng:
- materials.update(maomeng)
-
- askahh = crawl_askahh()
- if askahh:
- materials.update(askahh)
-
- ygpy = crawl_ygpy()
- if ygpy:
- materials.update(ygpy)
-
- # save to file cause they often contain coupons and require common emails to use
- save_candidates(candidates=materials, filepath=filepath, delimiter=delimiter)
-
- # merge
- candidates.update(materials)
- domains = list(candidates.keys())
-
- # extract real routing base url
- logger.info(f"[AirPortCollector] fetched {len(domains)} airport, start extracting real routing addresses")
- sites = utils.multi_thread_run(
- func=extract_backend_url,
- tasks=domains,
- num_threads=num_thread,
- show_progress=display,
- )
-
- tasks = [[site, rigid, chuck] for site in sites if site]
- records = {sites[i]: candidates.get(domains[i], "") for i in range(len(sites)) if sites[i]}
-
- # check website availability
- logger.info(f"[AirPortCollector] extract real base url finished, start to check it now")
- result = utils.multi_thread_run(func=validate_domain, tasks=tasks, num_threads=num_thread, show_progress=display)
-
- availables = dict()
- for i in range(len(tasks)):
- if not result[i][0]:
- continue
-
- site = tasks[i][0]
- coupon = records.get(site, "")
- availables[site] = {"coupon": coupon, "api_prefix": result[i][1]}
-
- logger.info(f"[AirPortCollector] finished collect airport, availables: {len(availables)}")
- return availables
-
-
-def save_candidates(candidates: dict, filepath: str, delimiter: str) -> None:
- if not candidates or not isinstance(candidates, dict):
- return
-
- filepath = utils.trim(filepath)
- if not filepath:
- return
-
- delimiter = utils.trim(delimiter) or "@#@#"
-
- lines = []
- for k, v in candidates.items():
- text = k
- if v and isinstance(v, str):
- text += f"\t{delimiter}\t{v}"
- elif v and isinstance(v, dict):
- coupon = utils.trim(v.get("coupon", ""))
- invite_code = utils.trim(v.get("invite_code", ""))
- api_prefix = utils.trim(v.get("api_prefix", ""))
-
- text = f"{text}\t{delimiter}\t{coupon}\t{delimiter}\t{invite_code}\t{delimiter}\t{api_prefix}"
- lines.append(text)
-
- utils.write_file(filename=filepath, lines=lines)
-
-
-def validate_domain(url: str, rigid: bool = True, chuck: bool = False) -> tuple[bool, str]:
- try:
- if not url:
- return False, ""
-
- rr = airport.AirPort.get_register_require(domain=url)
- flag = rr.invite or (chuck and rr.recaptcha) or (rigid and rr.whitelist and rr.verify)
- return not flag, rr.api_prefix
- except:
- return False, ""
-
-
-def batch_call(tasks: dict) -> list[dict]:
- if not tasks:
- return []
-
- try:
- num_thread = max(min(len(tasks), 50), 1)
- with multiprocessing.Manager() as manager:
- availables = manager.list()
- processes = []
- semaphore = multiprocessing.Semaphore(num_thread)
- time.sleep(random.randint(1, 3))
- for k, v in tasks.items():
- semaphore.acquire()
- p = multiprocessing.Process(target=call, args=(k, v, availables, semaphore))
- p.start()
- processes.append(p)
- for p in processes:
- p.join()
-
- return list(availables)
- except:
- traceback.print_exc()
- return []
-
-
-def call(script: str, params: dict, availables: ListProxy, semaphore: Semaphore) -> None:
- try:
- if not script:
- return
-
- subscribes = execute_script(script=script, params=params)
- if subscribes and type(subscribes) == list:
- availables.extend(subscribes)
- finally:
- if semaphore is not None and isinstance(semaphore, Semaphore):
- semaphore.release()
-
-
-def execute_script(script: str, params: dict = {}) -> list[dict]:
- try:
- # format: a.b.c#function or a-b.c#_function or a#function and so on
- regex = r"^([a-zA-Z0-9_]+|([0-9a-zA-Z_]+([a-zA-Z0-9_\-]+)?\.)+)[a-zA-Z0-9_\-]+#[a-zA-Z_]+[0-9a-zA-Z_]+$"
- if not re.match(regex, script):
- logger.info(f"[ScriptError] script execute error because script: {script} is invalidate")
- return []
-
- path, func_name = script.split("#", maxsplit=1)
- path = f"scripts.{path}"
- module = importlib.import_module(path)
- if not hasattr(module, func_name):
- logger.error(f"script: {path} not exists function {func_name}")
- return []
-
- func = getattr(module, func_name)
-
- starttime = time.time()
- logger.info(f"[ScriptInfo] start execute script: scripts.{script}")
-
- subscribes = func(params)
- if type(subscribes) != list:
- logger.error(f"[ScriptError] return value error, need a list, but got a {type(subscribes)}")
- return []
-
- endtime = time.time()
- logger.info(
- "[ScriptInfo] finished execute script: scripts.{}, cost: {:.2f}s".format(script, endtime - starttime)
- )
-
- subscribes = [s for s in subscribes if type(s) == dict and s.get("push_to", [])]
- return subscribes
- except:
- logger.error(f"[ScriptError] occur error run script: {script}, message: \n{traceback.format_exc()}")
- return []
diff --git a/subscribe/crawl/__init__.py b/subscribe/crawl/__init__.py
new file mode 100644
index 0000000000..4b727f5766
--- /dev/null
+++ b/subscribe/crawl/__init__.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+
+from crawl.engine import run
+from crawl.helpers import check_status, is_available, naming_task
+
+__all__ = [
+ "run",
+ "check_status",
+ "is_available",
+ "naming_task",
+]
diff --git a/subscribe/crawl/base.py b/subscribe/crawl/base.py
new file mode 100644
index 0000000000..f61863d3f4
--- /dev/null
+++ b/subscribe/crawl/base.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from typing import Any, Generic, TypeVar
+
+from crawl.models import ChannelResult, CrawlContext
+
+TConfig = TypeVar("TConfig")
+
+CHANNELS: dict[str, Channel[Any]] = {}
+
+
+class Channel(ABC, Generic[TConfig]):
+ name: str = ""
+
+ @abstractmethod
+ def crawl(self, config: TConfig, ctx: CrawlContext) -> ChannelResult:
+ raise NotImplementedError
+
+
+def register_channel(channel: Channel[Any]) -> Channel[Any]:
+ CHANNELS[channel.name] = channel
+ return channel
diff --git a/subscribe/crawl/channels/__init__.py b/subscribe/crawl/channels/__init__.py
new file mode 100644
index 0000000000..2d2180dbe9
--- /dev/null
+++ b/subscribe/crawl/channels/__init__.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+
+from importlib import import_module
+
+from crawl.base import CHANNELS
+
+for _name in (
+ "github",
+ "google",
+ "page",
+ "repository",
+ "script",
+ "telegram",
+ "twitter",
+ "yandex",
+):
+ import_module(f"{__name__}.{_name}")
+
+__all__ = ["CHANNELS"]
diff --git a/subscribe/crawl/channels/github.py b/subscribe/crawl/channels/github.py
new file mode 100644
index 0000000000..ac012e2a10
--- /dev/null
+++ b/subscribe/crawl/channels/github.py
@@ -0,0 +1,186 @@
+# -*- coding: utf-8 -*-
+
+import itertools
+import json
+import os
+import re
+import time
+import traceback
+
+import utils
+from config.models import GithubConfig
+from crawl.base import Channel, register_channel
+from crawl.channels.page import PageChannel
+from crawl.models import ChannelResult, CrawlContext
+from logger import logger
+from origin import Origin
+
+
+class GithubChannel(Channel[GithubConfig]):
+ name = "github"
+
+ def crawl(self, config: GithubConfig, ctx: CrawlContext) -> ChannelResult:
+ return crawl_github(config, ctx)
+
+
+def intercept(text: str, excludes: list[str] | None = None) -> bool:
+ if not excludes:
+ return False
+ for regex in excludes:
+ try:
+ if re.search(regex, text, flags=re.I):
+ return True
+ except Exception:
+ logger.error(f"[GithubRepoIntercept] invalid regex pattern: {regex}")
+ return False
+
+
+def paging(start: int, end: int, peer_page: int) -> list[int]:
+ if start > end or peer_page <= 0:
+ return []
+ pages = []
+ for i in range(start, end + 1, peer_page):
+ pages.append(i // peer_page + 1)
+ return pages
+
+
+def search_github(page: int, cookie: str, searchtype: str, sortedby: str) -> str:
+ if page <= 0 or utils.isblank(cookie):
+ return ""
+
+ searchtype = "Code" if utils.isblank(searchtype) else searchtype
+ sortedby = "indexed" if utils.isblank(sortedby) else sortedby
+ query = "%22%2Fapi%2Fv1%2Fclient%2Fsubscribe%3Ftoken%3D%22"
+ if searchtype.lower() == "code":
+ query = "%2F%5C%2Fapi%5C%2Fv1%5C%2Fclient%5C%2Fsubscribe%5C%3Ftoken%3D%5Ba-zA-Z0-9%5D%7B8%2C32%7D%2F"
+
+ url = f"/search?o=desc&p={page}&q={query}&s={sortedby}&type={searchtype}"
+ headers = {
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
+ "Referer": "https://github.com",
+ "User-Agent": utils.USER_AGENT,
+ "Cookie": f"user_session={cookie}",
+ }
+ content = utils.http_get(url=url, headers=headers)
+ if re.search(r"\s*找不到和您查询的“\s*\s*.*?/api/v1/client/subscribe\?token=.*?\s*\s*”相符的内容或信息。\s*
', + content, + flags=re.I, + ): + break + + logger.info( + f"[GoogleCrawl] finished crawl from Google, found {len(result.items)} subscriptions, cost: {time.time() - starttime:.2f}s" + ) + return result + + +register_channel(GoogleChannel()) diff --git a/subscribe/crawl/channels/page.py b/subscribe/crawl/channels/page.py new file mode 100644 index 0000000000..a368cb0fc1 --- /dev/null +++ b/subscribe/crawl/channels/page.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- + +from __future__ import annotations + +from copy import deepcopy + +import utils +from config.models import PageJob, TaskParams +from crawl.base import Channel, register_channel +from crawl.extract import extract_subscribes +from crawl.models import ChannelResult, CrawlContext +from logger import logger +from origin import Origin +from urlvalidator import isurl + + +class PageChannel(Channel[list[PageJob]]): + name = "pages" + + def __init__( + self, + url: str = "", + include: str = "", + exclude: str = "", + push_to: list[str] | None = None, + task: TaskParams | None = None, + headers: dict[str, str] | None = None, + origin: str = Origin.PAGE.name, + skip_cache: bool = False, + ) -> None: + self.url = utils.trim(url) + self.include = include + self.exclude = exclude + self.push_to = list(push_to or []) + self.task = task or TaskParams(push_to=self.push_to) + self.headers = headers + self.origin = origin or Origin.PAGE.name + self.skip_cache = skip_cache + + def crawl(self, config: list[PageJob], ctx: CrawlContext) -> ChannelResult: + from crawl.helpers import fetch_jobs + + jobs = [] + for job in config: + if not job.enable or not job.push_to: + continue + for url in job.expand_urls(): + jobs.append( + PageChannel( + url=url, + include=job.include, + exclude=job.exclude, + push_to=job.push_to, + task=job.task, + headers=job.headers, + origin=job.origin, + skip_cache=job.skip_cache, + ) + ) + return fetch_jobs(jobs, ctx) + + def fetch(self, ctx: CrawlContext, url: str | None = None) -> ChannelResult: + target = utils.trim(url or self.url) + if not target or not isurl(target): + logger.error(f"[PageCrawl] cannot crawl from page: {target}") + return ChannelResult() + headers = deepcopy(self.headers) if self.headers else None + content = utils.http_get(url=target, headers=headers) + if not content: + return ChannelResult() + return extract_subscribes( + content=content, + push_to=self.push_to, + include=self.include, + exclude=self.exclude, + source=self.origin, + task=self.task, + skip_cache=self.skip_cache, + include_nodes=ctx.include_nodes, + ) + + def fetch_many(self, ctx: CrawlContext, urls: list[str] | None = None) -> ChannelResult: + from crawl.helpers import merge_results + + targets = urls if urls is not None else ([self.url] if self.url else []) + targets = [utils.trim(item) for item in targets if utils.trim(item)] + if not targets: + return ChannelResult() + if len(targets) == 1: + return self.fetch(ctx, url=targets[0]) + results = utils.multi_thread_run( + func=self.fetch, + tasks=[[ctx, link] for link in targets], + num_threads=ctx.num_threads, + show_progress=ctx.display, + ) + return merge_results(results) + + +register_channel(PageChannel()) diff --git a/subscribe/crawl/channels/plugins/__init__.py b/subscribe/crawl/channels/plugins/__init__.py new file mode 100644 index 0000000000..b04b34d6fa --- /dev/null +++ b/subscribe/crawl/channels/plugins/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +from importlib import import_module + +from .base import PLUGINS, PluginContext + +for _name in ( + "dynamic", + "fofa", + "gitforks", + "scaner", + "tempairport", + "v2rayfree", + "v2rayse", +): + import_module(f"{__name__}.{_name}") + +__all__ = ["PLUGINS", "PluginContext"] diff --git a/subscribe/crawl/channels/plugins/base.py b/subscribe/crawl/channels/plugins/base.py new file mode 100644 index 0000000000..153daa645a --- /dev/null +++ b/subscribe/crawl/channels/plugins/base.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from config.models import StorageItem, TaskParams +from crawl.models import ChannelResult, CrawlContext +from push import PushTo + +TConfig = TypeVar("TConfig") +PLUGINS: dict[str, ScriptPlugin[Any]] = {} + + +@dataclass +class PluginContext: + crawl: CrawlContext + params: dict[str, object] + task: TaskParams + persist: StorageItem | dict[str, StorageItem] | None + pushtool: PushTo | None + storage_items: dict[str, StorageItem] + + +class ScriptPlugin(ABC, Generic[TConfig]): + name: str = "" + isolate: bool = False + + @abstractmethod + def parse(self, ctx: PluginContext) -> TConfig: + raise NotImplementedError + + @abstractmethod + def run(self, config: TConfig, ctx: PluginContext) -> ChannelResult: + raise NotImplementedError + + +def register_plugin(plugin: ScriptPlugin[Any]) -> ScriptPlugin[Any]: + PLUGINS[plugin.name] = plugin + return plugin diff --git a/subscribe/crawl/channels/plugins/commons.py b/subscribe/crawl/channels/plugins/commons.py new file mode 100644 index 0000000000..6622df5360 --- /dev/null +++ b/subscribe/crawl/channels/plugins/commons.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- + +# @Author : wzdnzd +# @Time : 2022-11-12 + +import json + +from config.models import NodeInput, StorageItem, TaskParams +from crawl.models import ChannelResult, SubItem +from logger import logger +from origin import Origin +from push import PushTo + +from .base import PluginContext + + +def persist(pushtool: PushTo | None, data: dict[str, object] | None, item: StorageItem | None, meta: str = "") -> None: + try: + if ( + data is None + or not isinstance(data, dict) + or not isinstance(pushtool, PushTo) + or not isinstance(item, StorageItem) + or not pushtool.validate(item=item) + ): + logger.debug(f"[{meta}] skip persist subscribes because storage item or data is empty") + return + pushtool.push_to(content=json.dumps(data), item=item, group="subscribes") + except Exception: + logger.error(f"[{meta}] occur error when persist subscribes") + + +def as_channel_result(items: list[dict[str, object]] | None) -> ChannelResult: + result = ChannelResult() + if not items: + return result + urls, uris, proxies = [], [], [] + for item in items: + if not isinstance(item, dict): + continue + subscribe = item.get("subscribe", item.get("sub", "")) + saved = bool(item.get("saved", False)) + checked = bool(item.get("checked", True)) + origin = item.get("origin", Origin.TEMPORARY.name) + task = TaskParams( + push_to=list(item.get("push_to", [])), + rename=item.get("rename"), + name=item.get("name"), + include=item.get("include"), + exclude=item.get("exclude"), + ) + links = subscribe if isinstance(subscribe, list) else [subscribe] + for url in links: + if not url: + continue + urls.append(url) + result.items.append( + SubItem( + url=url, + origin=origin, + task=task, + name=item.get("name", ""), + ready=saved, + persist_only=checked and not saved, + skip_cache=bool(item.get("skip_cache", item.get("nocache", False))), + allow_nonstandard=bool(item.get("allow_nonstandard", item.get("pardon", False))), + ) + ) + extra = item.get("proxies") + if extra and isinstance(extra, list): + if extra and isinstance(extra[0], str): + uris.extend(extra) + elif extra and isinstance(extra[0], dict): + proxies.extend(extra) + result.nodes = NodeInput(subscribe=list(dict.fromkeys(urls)), uris=list(dict.fromkeys(uris)), proxies=proxies) + return result + + +def plugin_params(ctx: PluginContext) -> dict[str, object]: + if not isinstance(ctx, PluginContext): + return {} + params = dict(ctx.params) + params["task"] = ctx.task + params["config"] = ctx.task.to_dict() + if ctx.task.push_to is not None: + params["config"]["push_to"] = list(ctx.task.push_to) + return params diff --git a/subscribe/scripts/dynamic.py b/subscribe/crawl/channels/plugins/dynamic.py similarity index 75% rename from subscribe/scripts/dynamic.py rename to subscribe/crawl/channels/plugins/dynamic.py index e3c302c40c..e1f7e7f9f7 100644 --- a/subscribe/scripts/dynamic.py +++ b/subscribe/crawl/channels/plugins/dynamic.py @@ -7,12 +7,18 @@ from copy import deepcopy from datetime import datetime, timedelta -import crawl import utils +from config.models import TaskParams +from crawl.channels.page import PageChannel +from crawl.helpers import fetch_jobs, is_available +from crawl.models import ChannelResult, CrawlContext from logger import logger from origin import Origin from urlvalidator import isurl +from .base import PluginContext, ScriptPlugin, register_plugin +from .commons import as_channel_result, plugin_params + # github content api prefix GITHUB_CONTENT_API = "https://raw.githubusercontent.com" @@ -20,7 +26,7 @@ DEFAULT_BRANCH = "main" -def format(text: str, date: datetime = None) -> str: +def format(text: str, date: datetime | None = None) -> str: """ Replace all time placeholders in text with current time values @@ -48,7 +54,7 @@ def format(text: str, date: datetime = None) -> str: if not date or not isinstance(date, datetime): date = datetime.now() - def replace(match): + def replace(match: re.Match[str]) -> str: # YYYY, mm, dd, HH, MM, SS placeholder = match.group(1) @@ -83,7 +89,7 @@ def replace(match): return result -def collect_subs(params: dict) -> list[dict]: +def collect_subs(params: dict[str, object], ctx: PluginContext | None = None) -> list[dict[str, object]]: if not params or type(params) != dict: return [] @@ -141,23 +147,28 @@ def collect_subs(params: dict) -> list[dict]: target.update({"sub": url, "saved": True}) materials[url] = target else: - sources.append([url, push_to, include, exclude, config, None, Origin.PAGE]) + sources.append( + PageChannel(url=url, include=include, exclude=exclude, push_to=push_to, origin=Origin.PAGE.name) + ) if sources: - urls = [x[0] for x in sources] + urls = [job.url for job in sources] logger.info(f"[CollectSub] start to collect subscriptions from {len(urls)} urls: {urls}") - - results = utils.multi_thread_run(func=crawl.crawl_single_page, tasks=sources) - for result in results: - if not result or not isinstance(result, dict): - continue - - for k, v in result.items(): - if not k or not v or not isinstance(v, dict): - continue - - v.update({"sub": k, "saved": True}) - materials[k] = v + crawled = fetch_jobs( + sources, + ( + ctx.crawl + if ctx and ctx.crawl + else CrawlContext( + mode=0, include_nodes=True, max_fails=5, exclude="", task=TaskParams(), storage=None, pushtool=None + ) + ), + ) + + for item in crawled.items: + payload = deepcopy(config) + payload.update({"sub": item.url, "saved": True}) + materials[item.url] = payload # filter conditions try: @@ -170,10 +181,23 @@ def collect_subs(params: dict) -> list[dict]: # check availability candidates = list(materials.keys()) tasks = [[x, 2, remain, life] for x in candidates] - masks = utils.multi_thread_run(func=crawl.is_available, tasks=tasks) + masks = utils.multi_thread_run(func=is_available, tasks=tasks) # filter available subscriptions subs = sorted([candidates[i] for i in range(len(masks)) if masks[i]]) logger.info(f"[CollectSub] collect task finished, found {len(subs)} subscriptions") return [materials.get(k) for k in subs] + + +class DynamicPlugin(ScriptPlugin[dict[str, object]]): + name = "dynamic" + + def parse(self, ctx: PluginContext) -> dict[str, object]: + return plugin_params(ctx) + + def run(self, config: dict[str, object], ctx: PluginContext) -> ChannelResult: + return as_channel_result(collect_subs(config, ctx)) + + +register_plugin(DynamicPlugin()) diff --git a/subscribe/scripts/fofa.py b/subscribe/crawl/channels/plugins/fofa.py similarity index 87% rename from subscribe/scripts/fofa.py rename to subscribe/crawl/channels/plugins/fofa.py index 30e8ef3e94..45f8e58c6d 100644 --- a/subscribe/scripts/fofa.py +++ b/subscribe/crawl/channels/plugins/fofa.py @@ -17,11 +17,15 @@ import utils import yaml -from crawl import naming_task +from crawl.helpers import naming_task +from crawl.models import ChannelResult from logger import logger from origin import Origin from urlvalidator import isurl +from .base import PluginContext, ScriptPlugin, register_plugin +from .commons import as_channel_result, plugin_params + def search(exclude: str = "", maxsize: int = sys.maxsize, timesleep: float = 3, timeout: float = 180) -> list[str]: try: @@ -67,7 +71,7 @@ def extract_one(url: str) -> list[str]: regex = r"(?:https?://)?(?:[a-zA-Z0-9\u4e00-\u9fa5\-]+\.)+[a-zA-Z0-9\u4e00-\u9fa5\-]+(?:(?:(?:/index.php)?/api/v1/client/subscribe\?token=[a-zA-Z0-9]{16,32})|(?:/link/[a-zA-Z0-9]+\?(?:sub|mu|clash)=\d)|(?:/(?:s|sub)/[a-zA-Z0-9]{32}))" - headers = {"User-Agent": "Clash.Meta; Mihomo"} + headers = {"User-Agent": f"{utils.USER_AGENT}; Clash.Meta; Mihomo; Shadowrocket;"} subscriptions, content = [], "" count, retry = 0, 2 @@ -119,15 +123,15 @@ def extract_one(url: str) -> list[str]: return subscriptions -def recall(params: dict) -> list: - def inwrap(sub: str, nocache: bool = True, pardon: bool = False) -> dict: +def recall(params: dict[str, object]) -> list[dict[str, object]]: + def inwrap(sub: str, nocache: bool = True, pardon: bool = False) -> dict[str, object]: config = deepcopy(params.get("config", {})) config["sub"] = sub config["saved"] = False config["checked"] = False config["nocache"] = nocache config["pardon"] = pardon - config["name"] = naming_task(link) + config["name"] = naming_task(sub) config["origin"] = Origin.FOFA.name config["push_to"] = list(set(config.get("push_to", []))) @@ -167,3 +171,16 @@ def inwrap(sub: str, nocache: bool = True, pardon: bool = False) -> dict: logger.info(f"[FOFA] search finished, found {len(tasks)} candidates to be check, cost: {cost}") return tasks + + +class FofaPlugin(ScriptPlugin[dict[str, object]]): + name = "fofa" + + def parse(self, ctx: PluginContext) -> dict[str, object]: + return plugin_params(ctx) + + def run(self, config: dict[str, object], ctx: PluginContext) -> ChannelResult: + return as_channel_result(recall(config)) + + +register_plugin(FofaPlugin()) diff --git a/subscribe/scripts/gitforks.py b/subscribe/crawl/channels/plugins/gitforks.py similarity index 77% rename from subscribe/scripts/gitforks.py rename to subscribe/crawl/channels/plugins/gitforks.py index da4fd37726..0e00d00106 100644 --- a/subscribe/scripts/gitforks.py +++ b/subscribe/crawl/channels/plugins/gitforks.py @@ -9,13 +9,19 @@ import time from copy import deepcopy -import crawl -import push import utils +from config.models import StorageItem, TaskParams +from crawl.channels.page import PageChannel +from crawl.helpers import fetch_jobs, is_available, naming_task +from crawl.models import ChannelResult, CrawlContext from logger import logger from origin import Origin +from push import PushTo from urlvalidator import isurl +from .base import PluginContext, ScriptPlugin, register_plugin +from .commons import as_channel_result, plugin_params + # github rest api prefix GITHUB_API = "https://api.github.com" @@ -60,7 +66,7 @@ def query_forks_count(username: str, repository: str, retry: int = 3) -> int: return -1 -def query_forks(username: str, repository: str, page: int, peer: int = 100, sort: str = "newest") -> dict: +def query_forks(username: str, repository: str, page: int, peer: int = 100, sort: str = "newest") -> dict[str, object]: username = utils.trim(username) repository = utils.trim(repository) @@ -106,9 +112,9 @@ def query_forks(username: str, repository: str, page: int, peer: int = 100, sort return subscriptions -def collect_subs(params: dict) -> list[dict]: - def update_conf(config: dict, sub: str, name: str = "") -> dict: - name = crawl.naming_task(url=sub) if not name else name +def collect_subs(params: dict[str, object], ctx: PluginContext | None = None) -> list[dict[str, object]]: + def update_conf(config: dict[str, object], sub: str, name: str = "") -> dict[str, object]: + name = naming_task(url=sub) if not name else name item = deepcopy(config) item.update({"name": name, "sub": sub, "saved": True}) @@ -131,16 +137,12 @@ def github_warp(ghproxy: str, url: str) -> str: logger.error(f"[GithubFork] cannot list forks from github due to username or repository is empty") return [] - # used to store subscriptions - storage = params.get("storage", {}) - if not storage or type(storage) != dict: - logger.error(f"[GithubFork] cannot fetch subscriptions due to invalid storage config") + if ctx is not None and not isinstance(ctx, PluginContext): return [] - - persist = storage.get("items", {}) - pushtool = push.get_instance(config=push.PushConfig.from_dict(storage)) - if not pushtool.validate(config=persist): - logger.error(f"[GithubFork] cannot fetch subscriptions due to invalid persist config") + pushtool = ctx.pushtool if ctx else None + persist = ctx.persist if ctx else None + if not isinstance(pushtool, PushTo) or not isinstance(persist, StorageItem) or not pushtool.validate(item=persist): + logger.error("[GithubFork] cannot fetch subscriptions due to invalid persist config") return [] # only keep subscriptions, usually used when there are too many nodes to save to the remote service @@ -159,7 +161,7 @@ def github_warp(ghproxy: str, url: str) -> str: materials, tasks = {}, [] # load old subscriptions - content = utils.http_get(url=pushtool.raw_url(config=persist), timeout=30) + content = utils.http_get(url=pushtool.raw_url(item=persist), timeout=30) urls = re.findall(r"^https?:\/\/[^\s]+", content, flags=re.M) for url in urls: url = github_warp(ghproxy=ghproxy, url=url) @@ -226,25 +228,30 @@ def github_warp(ghproxy: str, url: str) -> str: proxy = github_warp(ghproxy=ghproxy, url=proxy) materials[proxy] = update_conf(config=config, sub=proxy, name=name) for sub in subs: - tasks.append([sub, push_to, include, exclude, config, None, Origin.PAGE]) + tasks.append( + PageChannel(url=sub, include=include, exclude=exclude, push_to=push_to, origin=Origin.PAGE.name) + ) # crawl all subscriptions from subscriptions.txt - results = utils.multi_thread_run(func=crawl.crawl_single_page, tasks=tasks) - for result in results: - if not result or not isinstance(result, dict): - continue - - for k, v in result.items(): - if not k or not v or not isinstance(v, dict): - continue - - v.update({"sub": k, "saved": True}) - materials[k] = v + crawled = fetch_jobs( + tasks, + ( + ctx.crawl + if ctx + else CrawlContext( + mode=0, include_nodes=True, max_fails=5, exclude="", task=TaskParams(), storage=None, pushtool=None + ) + ), + ) + for item in crawled.items: + payload = deepcopy(config) + payload.update({"sub": item.url, "saved": True}) + materials[item.url] = payload # check availability candidates = list(materials.keys()) tasks = [[x, 2, remain, life] for x in candidates] - masks = utils.multi_thread_run(func=crawl.is_available, tasks=tasks) + masks = utils.multi_thread_run(func=is_available, tasks=tasks) # filter available subscriptions effective_subs = sorted([candidates[i] for i in range(len(masks)) if masks[i]]) @@ -253,6 +260,19 @@ def github_warp(ghproxy: str, url: str) -> str: # save result if effective_subs: content = "\n".join(effective_subs) - pushtool.push_to(content=content, config=persist, group="gitfork") + pushtool.push_to(content=content, item=persist, group="gitfork") return [] if only_sublink else [materials.get(k) for k in effective_subs] + + +class GitForksPlugin(ScriptPlugin[dict[str, object]]): + name = "gitforks" + + def parse(self, ctx: PluginContext) -> dict[str, object]: + return plugin_params(ctx) + + def run(self, config: dict[str, object], ctx: PluginContext) -> ChannelResult: + return as_channel_result(collect_subs(config, ctx)) + + +register_plugin(GitForksPlugin()) diff --git a/subscribe/scripts/scaner.py b/subscribe/crawl/channels/plugins/scaner.py similarity index 85% rename from subscribe/scripts/scaner.py rename to subscribe/crawl/channels/plugins/scaner.py index 99e99bfd76..27cbc7e1c1 100644 --- a/subscribe/scripts/scaner.py +++ b/subscribe/crawl/channels/plugins/scaner.py @@ -13,10 +13,15 @@ import warnings from copy import deepcopy -import push import utils import yaml +from config.models import StorageItem +from crawl.models import ChannelResult from logger import logger +from push import PushTo + +from .base import PluginContext, ScriptPlugin, register_plugin +from .commons import as_channel_result, plugin_params warnings.filterwarnings("ignore") @@ -36,7 +41,7 @@ """ -def convert(chars: bytes) -> list: +def convert(chars: bytes) -> list[dict[str, object]]: if chars is None or b"" == chars: return [] try: @@ -77,7 +82,7 @@ def convert(chars: bytes) -> list: return [] -def parse_vmess(node: dict, uuid: str) -> dict: +def parse_vmess(node: dict[str, object], uuid: str) -> dict[str, object] | None: if not uuid: return None @@ -128,7 +133,7 @@ def parse_vmess(node: dict, uuid: str) -> dict: return result -def login(url, params, headers, retry) -> str: +def login(url: str, params: dict[str, object], headers: dict[str, str], retry: int) -> str: try: data = urllib.parse.urlencode(params).encode(encoding="UTF8") request = urllib.request.Request(url, data=data, headers=headers, method="POST") @@ -148,7 +153,7 @@ def login(url, params, headers, retry) -> str: return login(url, params, headers, retry) if retry > 0 else "" -def register(url: str, params: dict, retry: int) -> bool: +def register(url: str, params: dict[str, object], retry: int) -> bool: try: data = urllib.parse.urlencode(params).encode(encoding="UTF8") request = urllib.request.Request(url, data=data, method="POST", headers=HEADER) @@ -171,7 +176,7 @@ def register(url: str, params: dict, retry: int) -> bool: return register(url, params, retry) if retry > 0 else False -def get_cookie(text) -> str: +def get_cookie(text: str | None) -> str: regex = "(__cfduid|uid|email|key|ip|expire_in)=(.+?);" if not text: return "" @@ -186,7 +191,7 @@ def fetch_nodes( domain: str, email: str, passwd: str, - headers: dict = None, + headers: dict[str, str] | None = None, retry: int = 3, subflag: bool = False, ) -> bytes: @@ -236,7 +241,7 @@ def check(domain: str) -> bool: return False -def get_payload(email: str, passwd: str) -> dict: +def get_payload(email: str, passwd: str) -> dict[str, object]: if not email: email = utils.random_chars(length=8, punctuation=False) + "@gmail.com" if not passwd: @@ -253,7 +258,7 @@ def get_payload(email: str, passwd: str) -> dict: } -def scanone(domain: str, email: str, passwd: str) -> list: +def scanone(domain: str, email: str, passwd: str) -> list[dict[str, object]]: # 获取机场所有节点信息 content = get_userinfo(domain=domain, email=email, passwd=passwd, subflag=False, verify=True) @@ -306,7 +311,7 @@ def get_userinfo(domain: str, email: str, passwd: str, subflag: bool, verify: bo return fetch_nodes(domain=domain, email=email, passwd=passwd, subflag=subflag) -def filter_task(tasks: dict) -> list: +def filter_task(tasks: dict[str, dict[str, object]]) -> list[list[str]]: if not tasks or type(tasks) != dict: return [] @@ -329,7 +334,7 @@ def filter_task(tasks: dict) -> list: return configs -def scan(params: dict) -> list: +def scan(params: dict[str, object], ctx: PluginContext | None = None) -> list[dict[str, object]]: if not params or type(params) != dict: return [] @@ -339,31 +344,47 @@ def scan(params: dict) -> list: return [] config = params.get("config", {}) - storage = params.get("storage", {}) - if not storage or type(storage) != dict: - logger.error(f"[ScanerError] cannot scan proxies bcause storage config is invalidate") + if ctx is not None and not isinstance(ctx, PluginContext): return [] - - persist = storage.get("items", {}) - pushtool = push.get_instance(config=push.PushConfig.from_dict(storage)) - - if not pushtool.validate(config=persist) or not config or type(config) != dict or not config.get("push_to"): - logger.error(f"[ScanerError] cannot scan proxies bcause missing some parameters") + pushtool = ctx.pushtool if ctx else None + persist = ctx.persist if ctx else None + if ( + not isinstance(pushtool, PushTo) + or not isinstance(persist, StorageItem) + or not pushtool.validate(item=persist) + or not config + or not isinstance(config, dict) + or not config.get("push_to") + ): + logger.error("[ScanerError] cannot scan proxies bcause missing some parameters") return [] results = utils.multi_process_run(func=scanone, tasks=tasks) proxies = list(itertools.chain.from_iterable(results)) if proxies: content = yaml.dump(data={"proxies": proxies}, allow_unicode=True) - pushtool.push_to(content=content, config=persist, group="scaner") + pushtool.push_to(content=content, item=persist, group="scaner") else: domains = ",".join(x[0] for x in tasks) logger.info(f"[ScanerError] cannot found any proxies, domains=[{domains}]") - config["sub"] = [pushtool.raw_url(config=persist)] + config["sub"] = [pushtool.raw_url(item=persist)] config["name"] = "loophole" if not config.get("name", "") else config.get("name") config["push_to"] = list(set(config["push_to"])) config["saved"] = True logger.info(f"[ScanerInfo] scan finished, found {len(proxies)} proxies") return [config] + + +class ScanerPlugin(ScriptPlugin[dict[str, object]]): + name = "scaner" + + def parse(self, ctx: PluginContext) -> dict[str, object]: + return plugin_params(ctx) + + def run(self, config: dict[str, object], ctx: PluginContext) -> ChannelResult: + return as_channel_result(scan(config, ctx)) + + +register_plugin(ScanerPlugin()) diff --git a/subscribe/scripts/tempairport.py b/subscribe/crawl/channels/plugins/tempairport.py similarity index 60% rename from subscribe/scripts/tempairport.py rename to subscribe/crawl/channels/plugins/tempairport.py index 76737a03fb..5aac04f769 100644 --- a/subscribe/scripts/tempairport.py +++ b/subscribe/crawl/channels/plugins/tempairport.py @@ -6,25 +6,34 @@ import json from copy import deepcopy -import push import utils from airport import AirPort, issspanel -from crawl import is_available +from config.models import NodeInput, StorageItem +from crawl.helpers import is_available +from crawl.models import ChannelResult from logger import logger +from push import PushTo from urlvalidator import isurl from . import commons, scaner +from .base import PluginContext, ScriptPlugin, register_plugin +from .commons import as_channel_result, plugin_params def register( - domain: str, subtype: int = 1, coupon: str = "", rigid: bool = True, chuck: bool = False, invite_code: str = "" + domain: str, + subtype: int = 1, + coupon: str = "", + allow_gmail_alias: bool = False, + skip_captcha: bool = False, + invite_code: str = "", ) -> AirPort: url = utils.extract_domain(url=domain, include_protocal=True) if not isurl(url=url): logger.error(f"[TempSubError] cannot register because domain=[{domain}] is invalidate") return None - airport = AirPort(name=domain.split("//")[1], site=url, sub="", coupon=coupon) + airport = AirPort(name=domain.split("//")[1], site=url, nodes=NodeInput(), coupon=coupon) if issspanel(domain=url): email = utils.random_chars(length=8, punctuation=False) + "@gmail.com" passwd = utils.random_chars(length=10, punctuation=True) @@ -35,32 +44,39 @@ def register( airport.username = email airport.password = passwd - airport.sub = suburl + airport.nodes.subscribe = suburl else: - airport.get_subscribe(retry=3, rigid=rigid, chuck=chuck, invite_code=invite_code) + airport.get_subscribe( + retry=3, + allow_gmail_alias=allow_gmail_alias, + skip_captcha=skip_captcha, + invite_code=invite_code, + ) return airport -def fetchsub(params: dict) -> list: +def fetchsub(params: dict[str, object], ctx: PluginContext | None = None) -> list[dict[str, object]]: if not params or type(params) != dict: return [] config = params.get("config", {}) - storage = params.get("storage", {}) - if not storage or type(storage) != dict: - logger.error(f"[TempSubError] cannot fetch subscribes bcause storage config is invalidate") + if ctx is not None and not isinstance(ctx, PluginContext): return [] - - persist = storage.get("items", {}) - push_config = push.PushConfig.from_dict(storage) - - threshold = max(params.get("threshold", 1), 1) - if not persist or not config or type(config) != dict or not config.get("push_to"): - logger.error(f"[TempSubError] cannot fetch subscribes bcause not found arguments 'persist' or 'push_to'") + pushtool = ctx.pushtool if ctx else None + persist = ctx.persist if ctx else None + threshold = max(int(params.get("threshold", 1) or 1), 1) + if ( + not isinstance(pushtool, PushTo) + or not isinstance(persist, StorageItem) + or not pushtool.validate(item=persist) + or not isinstance(config, dict) + or not config.get("push_to") + ): + logger.error("[TempSubError] cannot fetch subscribes bcause not found arguments 'persist' or 'push_to'") return [] - exists, unregisters, unknowns, data = load(config=push_config, persist=persist, retry=params.get("retry", True)) + exists, unregisters, unknowns, data = load(pushtool=pushtool, persist=persist, retry=params.get("retry", True)) if not exists and not unregisters and unknowns: logger.warning(f"[TempSubError] skip fetchsub because cannot get any valid config") return [] @@ -75,34 +91,36 @@ def fetchsub(params: dict) -> list: if not task: task = data.get("unknowns", {}).get(airport.ref, {}) - if not airport.available or not airport.sub: + subscribe = airport.nodes.subscribe_list() + subscribe_url = subscribe[0] if subscribe else "" + if not airport.available or not subscribe_url: logger.error( f"[TempSubInfo] cannot get subscribe because domain=[{airport.ref}] forced validation or need pay" ) - if not utils.isblank(airport.sub): + if subscribe_url: logger.warning( - f"[TempSubInfo] renew error, domain: {airport.ref} username: {airport.username} password: {airport.password} sub: {airport.sub}" + f"[TempSubInfo] renew error, domain: {airport.ref} username: {airport.username} password: {airport.password} sub: {subscribe_url}" ) - defeat = task.get("defeat", 0) + 1 + defeat = task.get("errors", task.get("defeat", 0)) + 1 if defeat > threshold: task["enable"] = False - task["defeat"] = defeat + task["errors"] = defeat unknowns[airport.ref] = task else: task.update( { - "sub": airport.sub, + "subscribe": subscribe_url, "username": airport.username, "password": airport.password, - "defeat": 0, + "errors": 0, } ) exists[airport.ref] = task # persist subscribes payload = {"usables": exists, "unknowns": unknowns} - commons.persist(config=push_config, data=payload, persist=persist) + commons.persist(pushtool=pushtool, data=payload, item=persist) if not exists: logger.info(f"[TempSubInfo] fetchsub finished, cannot found any subscribes") @@ -114,7 +132,7 @@ def fetchsub(params: dict) -> list: continue item = deepcopy(config) - item["sub"] = subscribe.get("sub") + item["sub"] = subscribe.get("subscribe", subscribe.get("sub")) if "config" in subscribe: item.update(subscribe.get("config")) @@ -128,12 +146,13 @@ def fetchsub(params: dict) -> list: return results -def load(config: push.PushConfig, persist: dict, retry: bool = False) -> tuple[dict, list, dict, dict]: - pushtool = push.get_instance(config=config) - if not pushtool.validate(config=persist): +def load( + pushtool: PushTo | None, persist: StorageItem | None, retry: bool = False +) -> tuple[dict[str, object], list[object], dict[str, object], dict[str, object]]: + if not isinstance(pushtool, PushTo) or not isinstance(persist, StorageItem) or not pushtool.validate(item=persist): return {}, [], {}, {} - url = pushtool.raw_url(config=persist) + url = pushtool.raw_url(item=persist) try: content = utils.http_get(url=url) data = json.loads(content) @@ -153,15 +172,15 @@ def load(config: push.PushConfig, persist: dict, retry: bool = False) -> tuple[d v = unknowns.get(k, {}) if v and v.get("enable", True): # 包含订阅,再次检测,否则重新注册 - if not utils.isblank(v.get("sub", "")): + if not utils.isblank(v.get("subscribe", v.get("sub", ""))): exists[k] = v else: coupon = v.get("coupon", "") - rigid = v.get("rigid", True) - chuck = v.get("chuck", False) + allow_gmail_alias = bool(v.get("allow_gmail_alias", False)) + skip_captcha = bool(v.get("skip_captcha", False)) invite_code = v.get("invite_code", "") - unregisters.append([k, v.get("type", 1), coupon, rigid, chuck, invite_code]) + unregisters.append([k, v.get("type", 1), coupon, allow_gmail_alias, skip_captcha, invite_code]) unknowns.pop(k, None) @@ -170,7 +189,7 @@ def load(config: push.PushConfig, persist: dict, retry: bool = False) -> tuple[d if not v or not v.get("enable", True): continue domains.append(k) - subscribes.append([v.get("sub", ""), 2, 0.5, 1.0]) + subscribes.append([v.get("subscribe", v.get("sub", "")), 2, 0.5, 1.0]) if not domains: return exists, unregisters, unknowns, rawdata @@ -189,3 +208,16 @@ def load(config: push.PushConfig, persist: dict, retry: bool = False) -> tuple[d return exists, unregisters, unknowns, rawdata except: return {}, [], {}, {} + + +class TempAirportPlugin(ScriptPlugin[dict[str, object]]): + name = "tempairport" + + def parse(self, ctx: PluginContext) -> dict[str, object]: + return plugin_params(ctx) + + def run(self, config: dict[str, object], ctx: PluginContext) -> ChannelResult: + return as_channel_result(fetchsub(config, ctx)) + + +register_plugin(TempAirportPlugin()) diff --git a/subscribe/scripts/v2rayfree.py b/subscribe/crawl/channels/plugins/v2rayfree.py similarity index 76% rename from subscribe/scripts/v2rayfree.py rename to subscribe/crawl/channels/plugins/v2rayfree.py index 4f4890b9d3..903476b27a 100644 --- a/subscribe/scripts/v2rayfree.py +++ b/subscribe/crawl/channels/plugins/v2rayfree.py @@ -12,11 +12,15 @@ import urllib import urllib.request -import push import utils +from config.models import StorageItem +from crawl.models import ChannelResult from logger import logger +from push import PushTo from . import commons +from .base import PluginContext, ScriptPlugin, register_plugin +from .commons import as_channel_result, plugin_params def fetch(email: str, retry: int = 2) -> str: @@ -67,7 +71,7 @@ def fetch(email: str, retry: int = 2) -> str: return fetch(email, retry - 1) -def getrss(params: dict) -> list: +def getrss(params: dict[str, object], ctx: PluginContext | None = None) -> list[dict[str, object]]: if not params or type(params) != dict: return [] @@ -80,23 +84,19 @@ def getrss(params: dict) -> list: logger.error(f"[V2RayFreeError] cannot fetch subscribes bcause missing some parameters") return [] - include = params.get("include", "").strip() - storage = params.get("storage", {}) - if not storage or type(storage) != dict: - logger.error(f"[V2RayFreeError] cannot fetch subscribes bcause storage config is invalidate") + include = str(params.get("include", "") or "").strip() + if ctx is not None and not isinstance(ctx, PluginContext): return [] - - persist = storage.get("items", {}) - push_config = push.PushConfig.from_dict(storage) - - exists = load(config=push_config, persist=persist) + pushtool = ctx.pushtool if ctx else None + persist = ctx.persist if ctx else None + exists = load(pushtool=pushtool, persist=persist) emails = [x for x in emails if x not in exists.keys()] results, subscribes = utils.multi_thread_run(func=fetch, tasks=emails), [] exists.update(filter(data=dict(zip(emails, results)))) # persist subscribes - commons.persist(config=push_config, data=exists, persist=persist) + commons.persist(pushtool=pushtool, data=exists, item=persist) results = list(exists.values()) results.extend(config.get("sub", [])) @@ -123,12 +123,11 @@ def getrss(params: dict) -> list: return [config] -def load(config: push.PushConfig, persist: dict) -> dict: - pushtool = push.get_instance(config=config) - if not pushtool.validate(config=persist): +def load(pushtool: PushTo | None, persist: StorageItem | None) -> dict[str, object]: + if not isinstance(pushtool, PushTo) or not isinstance(persist, StorageItem) or not pushtool.validate(item=persist): return {} - url = pushtool.raw_url(config=persist) + url = pushtool.raw_url(item=persist) try: content = utils.http_get(url=url) data = json.loads(content) @@ -137,7 +136,7 @@ def load(config: push.PushConfig, persist: dict) -> dict: return {} -def filter(data: dict) -> dict: +def filter(data: dict[str, object]) -> dict[str, object]: if not data or type(data) != dict: return {} @@ -163,3 +162,16 @@ def check(subscribe: str) -> bool: ) is not None ) + + +class V2RayFreePlugin(ScriptPlugin[dict[str, object]]): + name = "v2rayfree" + + def parse(self, ctx: PluginContext) -> dict[str, object]: + return plugin_params(ctx) + + def run(self, config: dict[str, object], ctx: PluginContext) -> ChannelResult: + return as_channel_result(getrss(config, ctx)) + + +register_plugin(V2RayFreePlugin()) diff --git a/subscribe/scripts/v2rayse.py b/subscribe/crawl/channels/plugins/v2rayse.py similarity index 87% rename from subscribe/scripts/v2rayse.py rename to subscribe/crawl/channels/plugins/v2rayse.py index ad284047e0..66a4556b44 100644 --- a/subscribe/scripts/v2rayse.py +++ b/subscribe/crawl/channels/plugins/v2rayse.py @@ -15,19 +15,24 @@ from datetime import datetime, timedelta, timezone from xml.etree import ElementTree -import push import utils import workflow import yaml from airport import AirPort -from crawl import naming_task +from config.models import StorageItem +from crawl.helpers import naming_task +from crawl.models import ChannelResult from executable import which_bin from logger import logger from origin import Origin +from push import PushTo import subconverter from clash import QuotedStr, quoted_scalar +from .base import PluginContext, ScriptPlugin, register_plugin +from .commons import as_channel_result, plugin_params + # outbind type SUPPORT_TYPE = [ "ss", @@ -50,9 +55,6 @@ # last modified key name LAST_MODIFIED = "lastModified" -# whether enable special protocols -SPECIAL_PROTOCOLS = AirPort.enable_special_protocols() - def current_time(utc: bool = True) -> datetime: now = datetime.now(timezone.utc) @@ -76,7 +78,7 @@ def get_dates(last: datetime) -> list[str]: return dates -def detect(proxies: list, nopublic: bool, exclude: str, ignore: str, repeat: int) -> bool: +def detect(proxies: list[dict[str, object]], nopublic: bool, exclude: str, ignore: str, repeat: int) -> bool: exclude = utils.trim(text=exclude) ignore = utils.trim(text=ignore) repeat = max(1, repeat) @@ -215,7 +217,7 @@ def fetchone( text=content, program=subconverter, artifact=name, - special=SPECIAL_PROTOCOLS, + special=AirPort.enable_special_protocols(), throw=True, ) @@ -243,7 +245,7 @@ def fetchone( return proxies, list(set(subscriptions)) if subscriptions else [] -def fetch(params: dict) -> list: +def fetch(params: dict[str, object], ctx: PluginContext | None = None) -> list[dict[str, object]]: if not params or type(params) != dict: return [] @@ -252,12 +254,18 @@ def fetch(params: dict) -> list: logger.error(f"[V2RaySE] skip collect data due to parameter 'url' missing") return [] - storage = params.get("storage", {}) - pushtool = push.get_instance(config=push.PushConfig.from_dict(storage)) - - persist = storage.get("items", {}) - if not persist or type(persist) != dict or not pushtool.validate(config=persist.get("proxies", {})): - logger.error(f"[V2RaySE] invalid persist config, please check it and try again") + if ctx is not None and not isinstance(ctx, PluginContext): + return [] + pushtool = ctx.pushtool if ctx else None + persist = ctx.persist if ctx and isinstance(ctx.persist, dict) else {} + proxies_store = persist.get("proxies") + modified_store = persist.get("modified") + if ( + not isinstance(pushtool, PushTo) + or not isinstance(proxies_store, StorageItem) + or not pushtool.validate(item=proxies_store) + ): + logger.error("[V2RaySE] invalid persist config, please check it and try again") return [] nopublic = params.get("nopublic", True) @@ -271,11 +279,7 @@ def fetch(params: dict) -> list: mixed = utils.trim(params.get("format", "clash")).lower() != "clash" display = params.get("display", False) - # storage config - proxies_store = persist.get("proxies", {}) - modified_store = persist.get("modified", {}) - - history_url = pushtool.raw_url(config=modified_store) + history_url = pushtool.raw_url(item=modified_store) if modified_store else "" last = last_history(url=history_url, interval=interval) dates, manual = params.get("dates", []), True @@ -367,20 +371,33 @@ def fetch(params: dict) -> list: filename = os.path.join(os.path.dirname(datapath), "data", "v2rayse.txt") utils.write_file(filename=filename, lines=content) - success = pushtool.push_to(content=content or " ", config=proxies_store, group="v2rayse") + success = pushtool.push_to(content=content or " ", item=proxies_store, group="v2rayse") if not success: return tasks # save last modified time - if not manual and pushtool.validate(config=modified_store): + if not manual and pushtool.validate(item=modified_store): content = json.dumps({LAST_MODIFIED: begin}) - pushtool.push_to(content=content, config=modified_store, group="modified") + pushtool.push_to(content=content, item=modified_store, group="modified") config = params.get("config", {}) - config["sub"] = pushtool.raw_url(config=proxies_store) + config["sub"] = pushtool.raw_url(item=proxies_store) config["saved"] = True config["name"] = "v2rayse" if not config.get("name", "") else config.get("name") config["push_to"] = list(set(config.get("push_to", []))) tasks.append(config) return tasks + + +class V2RaySEPlugin(ScriptPlugin[dict[str, object]]): + name = "v2rayse" + + def parse(self, ctx: PluginContext) -> dict[str, object]: + return plugin_params(ctx) + + def run(self, config: dict[str, object], ctx: PluginContext) -> ChannelResult: + return as_channel_result(fetch(config, ctx)) + + +register_plugin(V2RaySEPlugin()) diff --git a/subscribe/crawl/channels/repository.py b/subscribe/crawl/channels/repository.py new file mode 100644 index 0000000000..19740a90bd --- /dev/null +++ b/subscribe/crawl/channels/repository.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- + +import json +import time + +import utils +from config.models import RepoConfig, TaskParams +from crawl.base import Channel, register_channel +from crawl.extract import extract_subscribes +from crawl.helpers import merge_results +from crawl.models import ChannelResult, CrawlContext +from logger import logger +from origin import Origin + + +class RepositoryChannel(Channel[list[RepoConfig]]): + name = "repositories" + + def crawl(self, config: list[RepoConfig], ctx: CrawlContext) -> ChannelResult: + return crawl_github_repo(config, ctx) + + +def crawl_single_repo( + username: str, repo: str, push_to: list[str], commits: int, exclude: str, include_nodes: bool +) -> ChannelResult: + if not username or not repo: + logger.error(f"cannot crawl from github, username: {username}\trepo: {repo}") + return ChannelResult() + + commits = max(1, commits) + url = f"https://api.github.com/repos/{username.strip()}/{repo.strip()}/commits?per_page={commits}" + content = utils.http_get(url=url) + if not content: + return ChannelResult() + + result = ChannelResult() + task = TaskParams(push_to=list(push_to)) + try: + records = json.loads(content) + for item in records: + payload = utils.http_get(url=item.get("url", "")) + if not payload: + continue + commit = json.loads(payload) + for file in commit.get("files", []): + result.merge( + extract_subscribes( + content=file.get("patch", ""), + push_to=push_to, + source=Origin.REPO.name, + exclude=exclude, + task=task, + include_nodes=include_nodes, + ) + ) + return result + except Exception: + logger.error(f"[GithubCrawl] crawl from github error, username: {username}\trepo: {repo}") + return ChannelResult() + + +def crawl_github_repo(repos: list[RepoConfig], ctx: CrawlContext) -> ChannelResult: + if not repos: + return ChannelResult() + + starttime = time.time() + params = [] + for item in repos: + if not item.enable: + continue + username = utils.trim(item.username) + repo = utils.trim(item.repo) + if not username or not repo or not item.push_to: + continue + params.append([username, repo, item.push_to, max(item.commits, 1), item.exclude, ctx.include_nodes]) + + results = utils.multi_thread_run(func=crawl_single_repo, tasks=params, num_threads=ctx.num_threads) + result = merge_results(results) + logger.info( + f"[RepoCrawl] finished crawl from Repositorie, found {len(result.items)} subscriptions, cost: {time.time() - starttime:.2f}s" + ) + return result + + +register_channel(RepositoryChannel()) diff --git a/subscribe/crawl/channels/script.py b/subscribe/crawl/channels/script.py new file mode 100644 index 0000000000..a900803968 --- /dev/null +++ b/subscribe/crawl/channels/script.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- + +from config.models import ScriptJob +from crawl.base import Channel, register_channel +from crawl.channels.plugins import PLUGINS, PluginContext +from crawl.models import ChannelResult, CrawlContext +from logger import logger + + +class ScriptChannel(Channel[list[ScriptJob]]): + name = "scripts" + + def crawl(self, config: list[ScriptJob], ctx: CrawlContext) -> ChannelResult: + merged = ChannelResult() + + if not isinstance(ctx, CrawlContext): + return merged + storage_items = ctx.storage.items if ctx.storage is not None else {} + for job in config: + if not isinstance(job, ScriptJob) or not job.enable: + continue + plugin = PLUGINS.get(job.plugin) + if plugin is None: + continue + persist = None + if isinstance(job.persist, str): + persist = storage_items.get(job.persist) + elif isinstance(job.persist, dict): + resolved = {} + for key, value in job.persist.items(): + resolved[key] = storage_items.get(value) if isinstance(value, str) else value + persist = resolved + pctx = PluginContext( + crawl=ctx, + params=job.options, + task=job.task, + persist=persist, + pushtool=ctx.pushtool, + storage_items=storage_items, + ) + try: + parsed = plugin.parse(pctx) + merged.merge(plugin.run(parsed, pctx)) + except Exception as exc: + logger.error(f"[ScriptError] plugin {job.plugin} failed: {exc}") + return merged + + +register_channel(ScriptChannel()) diff --git a/subscribe/crawl/channels/telegram.py b/subscribe/crawl/channels/telegram.py new file mode 100644 index 0000000000..a6c5868759 --- /dev/null +++ b/subscribe/crawl/channels/telegram.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- + +import itertools +import re +import time +from dataclasses import replace + +import utils +from config.models import TelegramChannelConfig, TelegramConfig +from crawl.base import Channel, register_channel +from crawl.extract import extract_subscribes +from crawl.helpers import is_reachable, merge_results +from crawl.models import ChannelResult, CrawlContext +from logger import logger +from origin import Origin + + +class TelegramChannel(Channel[TelegramConfig]): + name = "telegram" + + def crawl(self, config: TelegramConfig, ctx: CrawlContext) -> ChannelResult: + if not is_reachable("https://t.me"): + logger.warning("[TelegramCrawl] skip because telegram is unreachable") + return ChannelResult() + return crawl_telegram(config, ctx) + + +def crawl_telegram(config: TelegramConfig, ctx: CrawlContext) -> ChannelResult: + starttime = time.time() + params = [] + for name, item in config.channels.items(): + if not item.push_to: + continue + exclude = item.exclude + if config.exclude: + exclude = f"{exclude}|{config.exclude}".removeprefix("|") + if exclude != item.exclude: + item = replace(item, exclude=exclude) + params.append([name, item, config.pages]) + page_groups = utils.multi_thread_run(func=_telegram_pages, tasks=params, num_threads=ctx.num_threads) + tasks = list(itertools.chain.from_iterable(page_groups)) + jobs = [[url, item, ctx.include_nodes] for url, item in tasks] + results = utils.multi_thread_run(func=_crawl_telegram_page, tasks=jobs, num_threads=ctx.num_threads) + result = merge_results(results) + logger.info( + f"[TelegramCrawl] finished crawl from Telegram, found {len(result.items)} subscriptions, cost: {time.time() - starttime:.2f}s" + ) + return result + + +def _telegram_pages(channel: str, config: TelegramChannelConfig, pages: int) -> list[list]: + if pages <= 1: + return [[f"https://t.me/s/{channel}", config]] + + count = get_telegram_pages(channel=channel) + if count == 0: + return [] + + arrays = range(count, -1, -100) + pages = min(pages, len(arrays)) + return [[f"https://t.me/s/{channel}?before={item}", config] for item in arrays[:pages]] + + +def get_telegram_pages(channel: str) -> int: + if not channel or not channel.strip(): + return 0 + + url = f"https://t.me/s/{channel}" + content = utils.http_get(url=url) + before = 0 + try: + regex = rf'' + groups = re.findall(regex, content) + before = int(groups[0]) if groups else before + except Exception: + logger.error(f"[CrawlError] cannot count page num, chanel: {channel}") + return before + + +def _crawl_telegram_page(url: str, config: TelegramChannelConfig, include_nodes: bool) -> ChannelResult: + if not url or not config.push_to: + return ChannelResult() + content = utils.http_get(url=url) + if not content: + return ChannelResult() + return extract_subscribes( + content=content, + push_to=config.push_to, + include=config.include, + exclude=config.exclude, + source=Origin.TELEGRAM.name, + task=config.task, + reversed=True, + include_nodes=include_nodes, + ) + + +register_channel(TelegramChannel()) diff --git a/subscribe/crawl/channels/twitter.py b/subscribe/crawl/channels/twitter.py new file mode 100644 index 0000000000..06707da29b --- /dev/null +++ b/subscribe/crawl/channels/twitter.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- + +import json +import re +import time +import urllib.error +import urllib.parse +import urllib.request + +import utils +from config.models import TwitterConfig +from crawl.base import Channel, register_channel +from crawl.channels.page import PageChannel +from crawl.helpers import fetch_jobs, is_reachable +from crawl.models import ChannelResult, CrawlContext +from logger import logger +from origin import Origin + + +class TwitterChannel(Channel[TwitterConfig]): + name = "twitter" + + def crawl(self, config: TwitterConfig, ctx: CrawlContext) -> ChannelResult: + if not is_reachable("https://twitter.com"): + logger.warning("[TwitterCrawl] skip because twitter is unreachable") + return ChannelResult() + return crawl_twitter(config, ctx) + + +def extract_twitter_cookies(retry: int = 2) -> str: + if retry <= 0: + return "" + + headers = None + try: + request = urllib.request.Request(url="https://twitter.com/", headers=utils.DEFAULT_HTTP_HEADERS) + response = urllib.request.urlopen(request, timeout=10, context=utils.CTX) + headers = response.headers + except urllib.error.HTTPError as exc: + if exc.code != 302: + return extract_twitter_cookies(retry=retry - 1) + headers = exc.headers + except (urllib.error.URLError, TimeoutError): + return extract_twitter_cookies(retry=retry - 1) + + if not headers or "set-cookie" not in headers: + return "" + + regex = "(guest_id|guest_id_ads|guest_id_marketing|personalization_id)=(.+?);" + content = ";".join(headers.get_all("set-cookie", "")) + groups = re.findall(regex, content, flags=re.I) + return ";".join(["=".join(item) for item in groups]).strip() + + +def get_guest_token() -> str: + cookies = extract_twitter_cookies(retry=3) + if not cookies: + logger.error("[TwitterCrawl] cannot extract Twitter cookies") + return "" + + headers = { + "User-Agent": utils.USER_AGENT, + "Cookie": cookies, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + } + content = utils.http_get(url="https://twitter.com/", headers=headers) + if not content: + return "" + matcher = re.findall("gt=([0-9]{19})", content, flags=re.I) + return matcher[0] if matcher else "" + + +def username_to_id(username: str, headers: dict[str, str]) -> str: + if utils.isblank(username): + return "" + + if not headers or "X-Guest-Token" not in headers: + guest_token = get_guest_token() + if not guest_token: + return "" + headers = { + "User-Agent": utils.USER_AGENT, + "Authorization": "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA", + "X-Guest-Token": guest_token, + "Content-Type": "application/json", + } + + variables = {"screen_name": username.lower().strip(), "withSafetyModeUserFields": True} + features = { + "blue_business_profile_image_shape_enabled": True, + "responsive_web_graphql_exclude_directive_enabled": True, + "verified_phone_label_enabled": False, + "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False, + "responsive_web_graphql_timeline_navigation_enabled": True, + } + payload = urllib.parse.urlencode({"variables": json.dumps(variables), "features": json.dumps(features)}) + url = f"https://twitter.com/i/api/graphql/sLVLhk0bGj3MVFEKTdax1w/UserByScreenName?{payload}" + try: + content = utils.http_get(url=url, headers=headers) + if not content: + return "" + data = json.loads(content).get("data", {}).get("user", {}).get("result", "") + return data.get("rest_id", "") + except Exception: + logger.error(f"[TwitterCrawl] cannot query uid by username=[{username}]") + return "" + + +def crawl_twitter(config: TwitterConfig, ctx: CrawlContext) -> ChannelResult: + if not config or not config.users: + return ChannelResult() + + guest_token, starttime = get_guest_token(), time.time() + if not guest_token: + logger.error("[TwitterCrawl] cannot extract X-Guest-Token from twitter") + return ChannelResult() + + headers = { + "User-Agent": utils.USER_AGENT, + "Authorization": "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA", + "X-Guest-Token": guest_token, + "Content-Type": "application/json", + } + features = { + "blue_business_profile_image_shape_enabled": True, + "responsive_web_graphql_exclude_directive_enabled": True, + "verified_phone_label_enabled": False, + "responsive_web_graphql_timeline_navigation_enabled": True, + "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False, + "tweetypie_unmention_optimization_enabled": True, + "vibe_api_enabled": True, + "responsive_web_edit_tweet_api_enabled": True, + "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True, + "view_counts_everywhere_api_enabled": True, + "longform_notetweets_consumption_enabled": True, + "tweet_awards_web_tipping_enabled": False, + "freedom_of_speech_not_reach_fetch_enabled": True, + "standardized_nudges_misinfo": True, + "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": False, + "interactive_text_enabled": True, + "responsive_web_text_conversations_enabled": False, + "longform_notetweets_rich_text_read_enabled": True, + "responsive_web_enhance_cards_enabled": False, + } + + candidates = {key: value for key, value in config.users.items() if key and value.enable and value.push_to} + if not candidates: + return ChannelResult() + + params = [[key, headers] for key in candidates.keys()] + uids = utils.multi_thread_run(func=username_to_id, tasks=params, num_threads=ctx.num_threads) + jobs = [] + for index, uid in enumerate(uids): + if not uid: + continue + config = candidates.get(params[index][0], {}) + count = config.tweets + variables = { + "userId": uid, + "count": min(max(count, 1), 100), + "includePromotedContent": False, + "withClientEventToken": False, + "withBirdwatchNotes": False, + "withVoice": True, + "withV2Timeline": True, + } + payload = urllib.parse.urlencode({"variables": json.dumps(variables), "features": json.dumps(features)}) + url = f"https://twitter.com/i/api/graphql/P7qs2Sf7vu1LDKbzDW9FSA/UserMedia?{payload}" + jobs.append( + PageChannel( + url=url, + include=config.include, + exclude=config.exclude, + push_to=config.push_to, + task=config.task, + headers=headers, + origin=Origin.TWITTER.name, + ) + ) + + result = fetch_jobs(jobs, ctx) + logger.info( + f"[TwitterCrawl] finished crawl from Twitter, found {len(result.items)} subscriptions, cost: {time.time() - starttime:.2f}s" + ) + return result + + +register_channel(TwitterChannel()) diff --git a/subscribe/crawl/channels/yandex.py b/subscribe/crawl/channels/yandex.py new file mode 100644 index 0000000000..89fa2826b0 --- /dev/null +++ b/subscribe/crawl/channels/yandex.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +import re +import time +import urllib.parse + +import utils +from config.models import TaskParams, YandexConfig +from crawl.base import Channel, register_channel +from crawl.helpers import is_reachable +from crawl.models import ChannelResult, CrawlContext +from logger import logger +from origin import Origin + + +class YandexChannel(Channel[YandexConfig]): + name = "yandex" + + def crawl(self, config: YandexConfig, ctx: CrawlContext) -> ChannelResult: + if not is_reachable("https://yandex.com"): + logger.warning("[YandexCrawl] skip because yandex is unreachable") + return ChannelResult() + return crawl_yandex(config) + + +def crawl_yandex(config: YandexConfig) -> ChannelResult: + reject, query = "", urllib.parse.quote("/api/v1/client/subscribe?token=") + if config.exclude_sites: + items = list(set([re.escape(utils.trim(item).lower()) for item in config.exclude_sites if utils.trim(item)])) + reject = "|".join(items) + + url = f'https://yandex.com/search/?text="{query}"&lr=10599&cee=1' + if config.days > 0: + url = f"{url}&within={config.days}" + + starttime = time.time() + headers = { + "User-Agent": utils.USER_AGENT, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Accept-Encoding": "gzip", + } + + content = utils.http_get(url=url, headers=headers) + pages = max(1, config.pages) + if content: + regex = r'(\d+)' + groups = re.findall(regex, content, flags=re.I) + if groups: + pages = min(pages, max([int(item) for item in groups])) + + task = TaskParams(push_to=list(config.push_to)) + result = ChannelResult() + for page in range(0, pages): + content = utils.http_get(url=f"{url}&p={page}", headers=headers) + if not content: + logger.error(f"[YandexCrawl] cannot get content from page: {page}") + continue + + groups = re.findall(r"[^<]*(?:优惠|白嫖)码:([^<]+)
]*class=["\'][^"\']*\blanguage-json\b[^"\']*["\'][^>]*>(.*?)\s*'
+ groups = re.findall(pattern, content, flags=re.I | re.S)
+ if not groups:
+ return {}
+
+ for group in groups:
+ payload = utils.trim(group)
+ if not payload:
+ continue
+
+ payload = re.sub(r"<[^>]+>", "", payload, flags=re.I | re.S)
+ payload = html.unescape(payload)
+
+ try:
+ data = json.loads(payload)
+ except Exception:
+ continue
+
+ if isinstance(data, dict) and data:
+ return data
+
+ return {}
+
+
+class IPLarkLibrary(IPLibrary):
+ name = "iplark"
+
+ def fetch(
+ self, port: int, request: RequestFn, max_retries: int = 2, timeout: int = 12, ip: str = ""
+ ) -> dict[str, object]:
+ response = self._get(request, port, "https://iplark.com/ipapi/public/ipinfo", max_retries, timeout)
+ return response if isinstance(response, dict) else {}
+
+ def classify(self, data: dict[str, object]) -> IPClassifyResult:
+ data = data if isinstance(data, dict) else {}
+ node_type = utils.trim(data.get("type", "")).lower()
+ if node_type == "isp":
+ company_type, asn_type = "isp", "isp"
+ elif node_type == "business":
+ company_type, asn_type = "business", "business"
+ else:
+ company_type, asn_type = "hosting", "hosting"
+
+ return IPClassifyResult(
+ country_code=utils.trim(data.get("country_code", "")).upper(),
+ company_type=company_type,
+ asn_type=asn_type,
+ raw=data,
+ )
+
+
+class IPInfoLibrary(IPLibrary):
+ name = "ipinfo"
+ needs_egress_ip = True
+
+ def fetch(
+ self, port: int, request: RequestFn, max_retries: int = 2, timeout: int = 12, ip: str = ""
+ ) -> dict[str, object]:
+ ip = self._resolve_egress_ip(port, request, max_retries=max_retries, timeout=timeout, ip=ip)
+ if not ip:
+ return {}
+
+ url = f"https://ipinfo.io/widget/demo/{ip}"
+ response = self._get(request, port, url, max_retries, timeout)
+ if not isinstance(response, dict):
+ return {}
+
+ data = response.get("data", response)
+ return data if isinstance(data, dict) else {}
+
+ def classify(self, data: dict[str, object]) -> IPClassifyResult:
+ data = data if isinstance(data, dict) else {}
+ company = data.get("company", {}) if isinstance(data.get("company"), dict) else {}
+ asn = data.get("asn", {}) if isinstance(data.get("asn"), dict) else {}
+ return IPClassifyResult(
+ country_code=utils.trim(data.get("country", "")).upper(),
+ company_type=utils.trim(company.get("type", "")).lower(),
+ asn_type=utils.trim(asn.get("type", "")).lower(),
+ raw=data,
+ )
+
+
+class IPApiLibrary(IPLibrary):
+ name = "ipapi"
+
+ def __init__(self, api_key: str = "") -> None:
+ self.api_key = utils.trim(api_key)
+
+ def fetch(
+ self, port: int, request: RequestFn, max_retries: int = 2, timeout: int = 12, ip: str = ""
+ ) -> dict[str, object]:
+ url = "https://api.ipapi.is"
+ if self.api_key:
+ url += f"?key={self.api_key}"
+
+ response = self._get(request, port, url, max_retries, timeout)
+ return response if isinstance(response, dict) else {}
+
+ def classify(self, data: dict[str, object]) -> IPClassifyResult:
+ data = data if isinstance(data, dict) else {}
+ location = data.get("location", {}) if isinstance(data.get("location"), dict) else {}
+ company = data.get("company", {}) if isinstance(data.get("company"), dict) else {}
+ asn = data.get("asn", {}) if isinstance(data.get("asn"), dict) else {}
+ return IPClassifyResult(
+ country_code=utils.trim(location.get("country_code", "")).upper(),
+ company_type=utils.trim(company.get("type", "")).lower(),
+ asn_type=utils.trim(asn.get("type", "")).lower(),
+ raw=data,
+ )
+
+
+PROVIDERS_ORDER = ["ipnetcoffee", "meowvps", "ippure", "ip2location", "ipinfo"]
+
+LIBRARIES = {
+ "ipnetcoffee": IPNetCoffeeLibrary,
+ "meowvps": MeowVPSLibrary,
+ "ippure": IPPureLibrary,
+ "ip2location": IP2LocationLibrary,
+ "iplark": IPLarkLibrary,
+ "ipinfo": IPInfoLibrary,
+ "ipapi": IPApiLibrary,
+}
+
+
+def get_providers(preferred: str) -> list[str]:
+ library = utils.trim(preferred).lower()
+ if library not in PROVIDERS_ORDER:
+ if library != "":
+ logger.warning(f"IP library {library} is not be supported")
+
+ library = "ipnetcoffee"
+
+ return [library] + [item for item in PROVIDERS_ORDER if item != library]
+
+
+def create_library(name: str, api_key: str = "") -> IPLibrary:
+ key = utils.trim(name).lower()
+ if key not in LIBRARIES:
+ key = "ipnetcoffee"
+
+ cls = LIBRARIES[key]
+ if cls is IPApiLibrary:
+ return IPApiLibrary(api_key=api_key)
+
+ return cls()
diff --git a/subscribe/location.py b/subscribe/location.py
index 1a00668397..54cb63b0d5 100644
--- a/subscribe/location.py
+++ b/subscribe/location.py
@@ -3,42 +3,56 @@
# @Author : wzdnzd
# @Time : 2024-07-12
+import gzip
+import http.client
import json
import math
import os
import random
import re
import socket
+import ssl
import subprocess
-import sys
import time
-import urllib
+import urllib.parse
from collections import defaultdict
from dataclasses import dataclass
+from functools import partial
+from typing import Optional
import utils
import yaml
from executable import which_bin
from geoip2 import database
+from iplibrary import create_library, get_providers, resolve_egress_ipv4
from logger import logger
from clash import is_mihomo
@dataclass
-class ProxyInfo:
- """Proxy query result information"""
+class GeoInfo:
+ """Country and CDN attributes of an IP"""
- name: str = ""
country: str = ""
+ is_cdn: bool = False
+
+
+@dataclass
+class ProxyInfo(GeoInfo):
+ """Proxy query result, including geo attributes"""
+
+ name: str = ""
ip_type: str = ""
+ score: Optional[int] = None
+ provider: str = ""
@dataclass
class ProxyQueryResult:
"""Complete proxy query result"""
- proxy: dict
+ proxy: dict[str, object]
result: ProxyInfo
success: bool
@@ -296,6 +310,34 @@ class ProxyQueryResult:
}
+# Pattern for CDN providers and Loyalsoldier custom ISO codes
+CDN_PATTERN = r"cloudflare|cloudfront|fastly|google"
+_CDN_NAME_RE = re.compile(CDN_PATTERN, flags=re.I)
+
+
+def is_cdn_label(value: str) -> bool:
+ """Return True if a country name or ISO code refers to a CDN instead of a location"""
+ text = utils.trim(value)
+ return bool(text and _CDN_NAME_RE.search(text))
+
+
+def _mark_cdn(proxy: dict[str, object]) -> None:
+ if isinstance(proxy, dict):
+ proxy["cdn"] = True
+
+
+def _is_cdn_proxy(proxy: dict[str, object]) -> bool:
+ return isinstance(proxy, dict) and (bool(proxy.get("cdn")) or is_cdn_label(str(proxy.get("name", ""))))
+
+
+def _remove_temp_flags(proxies: list[dict]) -> list[dict]:
+ for proxy in proxies:
+ if isinstance(proxy, dict):
+ proxy.pop("cdn", None)
+ proxy.pop("renamed", None)
+ return proxies
+
+
def download_mmdb(repo: str, target: str, filepath: str, retry: int = 3) -> bool:
"""
Download GeoLite2-City.mmdb from github release
@@ -387,51 +429,54 @@ def load_mmdb(
return database.Reader(filepath)
-def query_ip_country(ip: str, reader: database.Reader) -> str:
+def lookup_ip_geo(ip: str, reader: database.Reader) -> GeoInfo:
"""
Query country information for an IP address using mmdb database
- Args:
- ip: The IP address to query
- reader: The mmdb database reader
-
- Returns:
- The country name in Chinese
+ CDN ranges such as Cloudflare are reported via is_cdn and never as a country
"""
if not ip or not reader:
- return ""
+ return GeoInfo()
try:
# fake ip
if ip.startswith("198.18.0."):
logger.warning("cannot get geolocation because IP address is faked")
- return ""
+ return GeoInfo()
response = reader.country(ip)
+ names = response.country.names or {}
+ iso_code = utils.trim(response.country.iso_code).upper()
+ country = utils.trim(names.get("zh-CN", ""))
- # Try to get country name in Chinese
- country = response.country.names.get("zh-CN", "")
-
- # If Chinese name is not available, try to convert ISO code to Chinese country name
- if not country and response.country.iso_code:
- iso_code = response.country.iso_code
- # Try to get Chinese country name from ISO code mapping
+ if not country and iso_code:
country = ISO_TO_CHINESE.get(iso_code, iso_code)
- # Special handling for well-known IPs
- if not country:
- if ip == "1.1.1.1" or ip == "1.0.0.1":
- country = "Cloudflare"
- elif ip.startswith("8.8.8.") or ip.startswith("8.8.4."):
- country = "Google"
+ well_known_cdn = ip in ("1.1.1.1", "1.0.0.1") or ip.startswith("8.8.8.") or ip.startswith("8.8.4.")
+ if well_known_cdn or is_cdn_label(iso_code) or is_cdn_label(country):
+ return GeoInfo(is_cdn=True)
- return country
+ return GeoInfo(country=country)
except Exception as e:
logger.error(f"query ip country failed, ip: {ip}, error: {str(e)}")
- return ""
+ return GeoInfo()
-def locate_by_geoip(proxy: dict, reader: database.Reader) -> dict:
+def query_ip_country(ip: str, reader: database.Reader) -> str:
+ """
+ Query country information for an IP address using mmdb database
+
+ Args:
+ ip: The IP address to query
+ reader: The mmdb database reader
+
+ Returns:
+ The country name in Chinese
+ """
+ return lookup_ip_geo(ip, reader).country
+
+
+def locate_by_geoip(proxy: dict[str, object], reader: database.Reader) -> dict[str, object]:
if not proxy or not isinstance(proxy, dict):
return None
@@ -446,258 +491,82 @@ def locate_by_geoip(proxy: dict, reader: database.Reader) -> dict:
return proxy
ip = socket.gethostbyname(address)
- country = query_ip_country(ip, reader)
-
- if country:
- proxy["name"] = country
+ geo = lookup_ip_geo(ip, reader)
+ if geo.is_cdn:
+ _mark_cdn(proxy)
+ logger.debug(f"server IP belongs to CDN, skip as location, address: {address}")
+ elif geo.country:
+ proxy["name"] = geo.country
proxy["renamed"] = True
else:
- logger.warning(f"cannot get geolocation and rename, address: {address}")
+ logger.warning(f"cannot get geolocation and name, address: {address}")
except Exception as e:
logger.error(f"query ip geolocation failed, address: {address}, error: {str(e)}")
return proxy
-# Cache for checked port statuses
-_PORT_STATUS_CACHE = {}
-_AVAILABLE_PORTS = set()
-
-
-def get_listening_ports() -> set:
- """Get the set of listening ports in the system, cross-platform compatible"""
- listening_ports = set()
-
- try:
- # Windows system
- if os.name == "nt":
- try:
- # Use 'cp437' encoding to handle Windows command line output
- output = subprocess.check_output("netstat -an", shell=True).decode("cp437", errors="replace")
- for line in output.split("\n"):
- if "LISTENING" in line:
- parts = line.split()
- if len(parts) >= 2:
- addr_port = parts[1]
- if ":" in addr_port:
- try:
- port = int(addr_port.split(":")[-1])
- listening_ports.add(port)
- except ValueError:
- pass
- except Exception as e:
- logger.warning(f"Windows netstat command failed: {str(e)}")
- return listening_ports
-
- # macOS system
- elif sys.platform == "darwin":
- try:
- output = subprocess.check_output("lsof -i -P -n | grep LISTEN", shell=True).decode(
- "utf-8", errors="replace"
- )
- for line in output.split("\n"):
- if ":" in line:
- try:
- port_part = line.split(":")[-1].split(" ")[0]
- port = int(port_part)
- listening_ports.add(port)
- except (ValueError, IndexError):
- pass
- except Exception as e:
- logger.warning(f"macOS lsof command failed: {str(e)}")
- return listening_ports
-
- # Linux and other systems
- else:
- # Try using ss command (newer Linux systems)
- try:
- output = subprocess.check_output("ss -tuln", shell=True).decode("utf-8", errors="replace")
- for line in output.split("\n"):
- if "LISTEN" in line:
- parts = line.split()
- for part in parts:
- if ":" in part:
- try:
- port = int(part.split(":")[-1])
- listening_ports.add(port)
- except ValueError:
- pass
- except Exception as e:
- logger.warning(f"Linux ss command failed, trying netstat: {str(e)}")
- # Fall back to netstat command (older Linux systems)
- try:
- output = subprocess.check_output("netstat -tuln", shell=True).decode("utf-8", errors="replace")
- for line in output.split("\n"):
- if "LISTEN" in line:
- parts = line.split()
- for part in parts:
- if ":" in part:
- try:
- port = int(part.split(":")[-1])
- listening_ports.add(port)
- except ValueError:
- pass
- except Exception as e:
- logger.warning(f"Linux netstat command also failed: {str(e)}")
- return listening_ports
- except Exception as e:
- logger.warning(f"Failed to get listening ports: {str(e)}")
-
- return listening_ports
-
-
-def scan_ports_batch(start_port: int, count: int = 100) -> dict:
- """Batch scan port statuses, return a dictionary of port statuses"""
- global _PORT_STATUS_CACHE, _AVAILABLE_PORTS
-
- # Create a list of ports to scan (excluding ports with known status)
- ports_to_scan = [p for p in range(start_port, start_port + count) if p not in _PORT_STATUS_CACHE]
-
- if not ports_to_scan:
- # If all ports are already cached, return cached results directly
- return {p: _PORT_STATUS_CACHE.get(p, True) for p in range(start_port, start_port + count)}
-
- # Use a more efficient way to check ports in batch
- results = {}
+class PortReservation:
+ """Reserve local TCP ports by binding them without listen/connect."""
- try:
- # Get the ports that are currently listening in the system
- listening_ports = get_listening_ports()
-
- # Update results
- for port in ports_to_scan:
- in_use = port in listening_ports
- results[port] = in_use
- _PORT_STATUS_CACHE[port] = in_use
- if not in_use:
- _AVAILABLE_PORTS.add(port)
- except Exception as e:
- logger.warning(f"Batch port scanning failed, falling back to individual port checks: {str(e)}")
- # If batch checking fails, fall back to individual port checks
- for port in ports_to_scan:
- in_use = check_single_port(port)
- results[port] = in_use
- _PORT_STATUS_CACHE[port] = in_use
- if not in_use:
- _AVAILABLE_PORTS.add(port)
-
- # Merge cached and newly scanned results
- return {
- **{
- p: _PORT_STATUS_CACHE.get(p, True) for p in range(start_port, start_port + count) if p in _PORT_STATUS_CACHE
- },
- **results,
- }
+ def __init__(self) -> None:
+ self._sockets = []
+ def reserve(self, n: int) -> list[int]:
+ if n <= 0:
+ return []
-def check_single_port(port: int) -> bool:
- """Helper function for checking a single port, checks if the port is listening"""
- try:
- # Use socket to check TCP port
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.settimeout(0.2)
- result = sock.connect_ex(("127.0.0.1", port))
- sock.close()
- if result == 0:
- return True
-
- # Also check IPv6
+ ports = []
try:
- sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
- sock.settimeout(0.2)
- result = sock.connect_ex(("::1", port))
- sock.close()
- return result == 0
- except:
- pass
-
- return False
- except:
- # Assume port is not in use when an error occurs
- return False
-
-
-def is_port_in_use(port: int) -> bool:
- """Check if a port is in use (using cache)"""
- global _PORT_STATUS_CACHE, _AVAILABLE_PORTS
-
- # If port is known to be available, return directly
- if port in _AVAILABLE_PORTS:
- return False
-
- # If port status is already cached, return directly
- if port in _PORT_STATUS_CACHE:
- return _PORT_STATUS_CACHE[port]
-
- # Otherwise check the port and cache the result
- in_use = check_single_port(port)
- _PORT_STATUS_CACHE[port] = in_use
- if not in_use:
- _AVAILABLE_PORTS.add(port)
- return in_use
+ for _ in range(n):
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.bind(("127.0.0.1", 0))
+ self._sockets.append(sock)
+ ports.append(sock.getsockname()[1])
+ return ports
+ except Exception:
+ self.close()
+ raise
+
+ def close(self) -> None:
+ sockets, self._sockets = self._sockets, []
+ for sock in sockets:
+ try:
+ sock.close()
+ except Exception:
+ pass
-def generate_mihomo_config(proxies: list[dict]) -> tuple[dict, dict]:
+def generate_mihomo_config(proxies: list[dict], listener_ports: list[int], mixed_port: int) -> tuple[dict, dict]:
"""Generate mihomo configuration for the given proxies"""
- # Base configuration
config = {
- "mixed-port": 7890,
+ "mixed-port": mixed_port,
"allow-lan": True,
"mode": "global",
"log-level": "error",
+ "ipv6": False,
+ "tcp-concurrent": True,
"proxies": proxies,
"dns": {
"enable": True,
- "enhanced-mode": "fake-ip",
- "fake-ip-range": "198.18.0.1/16",
+ "ipv6": False,
+ "enhanced-mode": "redir-host",
"default-nameserver": ["114.114.114.114", "223.5.5.5", "8.8.8.8"],
- "nameserver": ["https://doh.pub/dns-query"],
+ "nameserver": ["114.114.114.114", "8.8.8.8"],
},
"listeners": [],
}
- # Record the port assigned to each proxy
records = dict()
-
- # If there are no proxies, return directly
if not proxies:
return config, records
- # Pre-scan ports in batch to improve efficiency
- start_port = 32001
-
- # Scan enough ports to ensure there are sufficient available ports
- port_count = len(proxies) * 2
- port_status = scan_ports_batch(start_port, port_count)
-
- # Find all available ports
- available_ports = [p for p, in_use in port_status.items() if not in_use]
-
- # If available ports are insufficient, scan more ports
- if len(available_ports) < len(proxies):
- additional_ports = scan_ports_batch(start_port + port_count, port_count * 2)
- available_ports.extend([p for p, in_use in additional_ports.items() if not in_use])
-
- # Assign an available port to each proxy
for index, proxy in enumerate(proxies):
- if index < len(available_ports):
- port = available_ports[index]
- else:
- # If available ports are insufficient, use traditional method to find available ports
- port = start_port + port_count + index
- max_attempts = 1000
- attempts = 0
-
- while is_port_in_use(port) and attempts < max_attempts:
- port += 1
- attempts += 1
-
- if attempts >= max_attempts:
- logger.warning(
- f"Could not find an available port for proxy {proxy['name']} after {max_attempts} attempts"
- )
- continue
+ if index >= len(listener_ports):
+ logger.warning(f"No reserved port for proxy {proxy['name']}")
+ continue
+ port = listener_ports[index]
listener = {
"name": f"http-{index}",
"type": "http",
@@ -712,13 +581,233 @@ def generate_mihomo_config(proxies: list[dict]) -> tuple[dict, dict]:
return config, records
+def _idna_host(host: str) -> str:
+ host = utils.trim(host)
+ if not host:
+ return host
+ try:
+ return host.encode("idna").decode("ascii")
+ except Exception:
+ return host
+
+
+def _origin_headers(url: str, extra: dict[str, str] | None = None) -> dict[str, object]:
+ parsed = urllib.parse.urlparse(url)
+ base = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else ""
+ result = {
+ "User-Agent": utils.USER_AGENT,
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Accept-Encoding": "identity",
+ "Cache-Control": "no-cache",
+ "Pragma": "no-cache",
+ "Connection": "close",
+ "Referer": f"{base}/" if base else url,
+ "Origin": base if base else url,
+ }
+ if extra and isinstance(extra, dict):
+ for key, value in extra.items():
+ name = utils.trim(str(key))
+ if not name:
+ continue
+ if value is None:
+ result.pop(name, None)
+ else:
+ result[name] = value
+ return result
+
+
+def _proxy_ssl_context() -> ssl.SSLContext:
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+ ctx.options |= ssl.OP_NO_TICKET
+ if hasattr(ssl, "OP_IGNORE_UNEXPECTED_EOF"):
+ ctx.options |= ssl.OP_IGNORE_UNEXPECTED_EOF
+ try:
+ ctx.set_alpn_protocols(["http/1.1"])
+ except Exception:
+ pass
+ return ctx
+
+
+def _recv_until(sock: socket.socket, marker: bytes, limit: int = 65536) -> bytes:
+ buf = bytearray()
+ while marker not in buf:
+ chunk = sock.recv(min(4096, max(1, limit - len(buf))))
+ if not chunk:
+ raise OSError(f"tunnel: incomplete proxy response: {bytes(buf)!r}")
+ buf += chunk
+ if len(buf) > limit:
+ raise OSError("tunnel: proxy response too large")
+ index = buf.find(marker) + len(marker)
+ if index < len(buf):
+ raise OSError("tunnel: unexpected data after CONNECT")
+ return bytes(buf[:index])
+
+
+def _tls_server_hostname(host: str) -> Optional[str]:
+ text = _idna_host(host)
+ if not text:
+ return None
+ try:
+ socket.inet_pton(socket.AF_INET, text)
+ return None
+ except OSError:
+ pass
+ if ":" in text:
+ try:
+ socket.inet_pton(socket.AF_INET6, text.strip("[]"))
+ return None
+ except OSError:
+ pass
+ return text
+
+
+def _open_http_tunnel(sock: socket.socket, host: str, port: int) -> None:
+ host = _idna_host(host)
+ target = f"{host}:{port}"
+ # Do not send Connection/Proxy-Connection: close. Mihomo hijacks after 200;
+ # a close flag makes Windows RST the socket (WinError 10054) at TLS.
+ sock.sendall(f"CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n".encode("ascii"))
+ head = _recv_until(sock, b"\r\n\r\n")
+ status_line = head.split(b"\r\n", 1)[0].decode("iso-8859-1", "replace")
+ parts = status_line.split(" ", 2)
+ if len(parts) < 2:
+ raise OSError(f"tunnel: invalid CONNECT response: {status_line}")
+ try:
+ code = int(parts[1])
+ except ValueError as e:
+ raise OSError(f"tunnel: invalid CONNECT status: {status_line}") from e
+ if code != 200:
+ raise OSError(f"tunnel: CONNECT failed: {status_line}")
+
+
+def _assert_tunnel_open(sock: socket.socket, wait: float = 0.2) -> None:
+ """Fail fast if the CONNECT already died; otherwise wait out mihomo's peek."""
+ previous = sock.gettimeout()
+ sock.settimeout(max(wait, 0.01))
+ try:
+ peeked = sock.recv(1, socket.MSG_PEEK)
+ if not peeked:
+ raise OSError("tunnel: closed before TLS")
+ except (TimeoutError, socket.timeout):
+ return
+ except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError) as e:
+ raise OSError(f"tunnel: closed before TLS ({e})") from e
+ finally:
+ sock.settimeout(previous)
+
+
+def _http_get_on_sock(sock: socket.socket, host: str, path: str, headers: dict[str, str]) -> tuple[int, str, bytes]:
+ host = _idna_host(host)
+ lines = [f"GET {path} HTTP/1.1", f"Host: {host}"]
+ sent = {"host"}
+ for key, value in (headers or {}).items():
+ name = utils.trim(str(key))
+ if not name or name.lower() in sent or name.lower().startswith("proxy-"):
+ continue
+ lines.append(f"{name}: {value}")
+ sent.add(name.lower())
+ if "connection" not in sent:
+ lines.append("Connection: close")
+
+ sock.sendall(("\r\n".join(lines) + "\r\n\r\n").encode("latin-1"))
+ response = http.client.HTTPResponse(sock, method="GET")
+ try:
+ response.begin()
+ return response.status, utils.trim(response.getheader("Location", "")), response.read()
+ finally:
+ try:
+ response.close()
+ except Exception:
+ pass
+
+
+def _decode_body(body: bytes) -> str:
+ if not body:
+ return ""
+ if body[:2] == b"\x1f\x8b":
+ try:
+ body = gzip.decompress(body)
+ except Exception:
+ pass
+ for encoding in ("utf-8", "gbk"):
+ try:
+ return body.decode(encoding)
+ except Exception:
+ continue
+ return body.decode("utf-8", "replace")
+
+
+def _request_through_proxy(
+ port: int, url: str, headers: dict[str, str], timeout: int, redirects: int = 3
+) -> tuple[int, bytes]:
+ """
+ Fetch URL via mihomo HTTP inbound.
+
+ http:// → absolute-form GET (plain HTTP proxy).
+ https:// → CONNECT then TLS in Python. Do not send `GET https://...`:
+ mihomo handles that with client.Do + TLS on net.Pipe, which races the
+ outbound dial and comes back as HTTP 502.
+ """
+ parsed = urllib.parse.urlparse(url)
+ scheme = utils.trim(parsed.scheme).lower()
+ host = parsed.hostname
+ if not host or scheme not in ("http", "https"):
+ raise OSError(f"http: unsupported url: {url}")
+
+ dst_port = parsed.port or (443 if scheme == "https" else 80)
+ path = parsed.path or "/"
+ if parsed.query:
+ path += "?" + parsed.query
+
+ sock = socket.create_connection(("127.0.0.1", port), timeout=timeout)
+ wrapped = None
+ try:
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+ sock.settimeout(timeout)
+ target = sock
+ request_path = url
+ if scheme == "https":
+ _open_http_tunnel(sock, host, dst_port)
+ _assert_tunnel_open(sock)
+ try:
+ wrapped = _proxy_ssl_context().wrap_socket(
+ sock,
+ server_hostname=_tls_server_hostname(host),
+ suppress_ragged_eofs=True,
+ )
+ except OSError as e:
+ raise OSError(f"tls: {e}") from e
+ target = wrapped
+ request_path = path
+ status, location, body = _http_get_on_sock(target, host, request_path, headers)
+ finally:
+ for item in (wrapped, sock):
+ if item is None:
+ continue
+ try:
+ item.close()
+ except Exception:
+ pass
+
+ if status in (301, 302, 303, 307, 308) and redirects > 0 and location:
+ text = urllib.parse.urljoin(url, location)
+ if text.lower().startswith("http"):
+ return _request_through_proxy(port, text, _origin_headers(text, headers), timeout, redirects - 1)
+
+ return status, body
+
+
def make_proxy_request(
port: int,
url: str,
max_retries: int = 5,
timeout: int = 10,
- headers: dict = None,
+ headers: dict[str, str] = None,
deserialize: bool = True,
+ quiet: bool = False,
) -> tuple[bool, dict]:
"""
Make an HTTP request through a proxy and return the response
@@ -739,41 +828,26 @@ def make_proxy_request(
logger.warning("No port provided for proxy")
return False, {}
- # Configure the proxy for the request
- proxy_url = f"http://127.0.0.1:{port}"
- proxies_config = {"http": proxy_url, "https": proxy_url}
-
- # Configure proxy handler
- proxy_handler = urllib.request.ProxyHandler(proxies_config)
-
- # Build opener with proxy handler
- opener = urllib.request.build_opener(proxy_handler)
- if headers and isinstance(headers, dict):
- opener.addheaders = [(k, v) for k, v in headers.items() if k]
- else:
- opener.addheaders = [
- ("User-Agent", utils.USER_AGENT),
- ("Accept", "application/json"),
- ("Connection", "close"),
- ]
-
- # Try to get response with retry and backoff
+ default_headers = _origin_headers(url, headers)
attempt, success, data = 0, False, None
while not success and attempt < max(max_retries, 1):
try:
- # Random sleep to avoid being blocked by the API (increasing with each retry)
if attempt > 0:
wait_time = min(2**attempt * random.uniform(0.5, 1.5), 6)
time.sleep(wait_time)
- # Make request
- response = opener.open(url, timeout=timeout)
- if response.getcode() == 200:
- content = response.read().decode("utf-8")
- data = json.loads(content) if deserialize else content
- success = True
+ status, body = _request_through_proxy(port, url, default_headers, timeout)
+ if status != 200:
+ raise OSError(f"http: status {status}")
+ content = _decode_body(body)
+ data = json.loads(content) if deserialize else content
+ success = True
except Exception as e:
- logger.warning(f"Attempt {attempt+1} failed to request {url} through proxy port {port}: {str(e)}")
+ message = f"Attempt {attempt+1} failed to request {url} through proxy port {port}: {str(e)}"
+ if quiet or attempt + 1 < max(max_retries, 1):
+ logger.debug(message)
+ else:
+ logger.warning(message)
attempt += 1
@@ -781,44 +855,52 @@ def make_proxy_request(
def get_ipv4(port: int, max_retries: int = 5) -> str:
- """
- Get the IPv4 address by accessing https://api.ipify.org?format=json through a proxy
-
- Args:
- port: The port of the proxy
- max_retries: Maximum number of retry attempts
-
- Returns:
- The IPv4 address or empty string if failed
- """
+ """Get the egress IPv4 address through a proxy listener."""
if not port:
logger.warning("No port provided for proxy")
return ""
- success, data = make_proxy_request(port=port, url="https://api.ipify.org?format=json", max_retries=max_retries)
- return data.get("ip", "") if success else ""
+ return resolve_egress_ipv4(port, make_proxy_request, max_retries=max_retries, timeout=10)
+
+
+def _wait_listener(port: int, process: subprocess.Popen, timeout: int = 20) -> None:
+ deadline = time.time() + max(timeout, 1)
+ while time.time() < deadline:
+ if process.poll() is not None:
+ raise RuntimeError(f"mihomo exited before becoming ready, code={process.returncode}")
+ try:
+ sock = socket.create_connection(("127.0.0.1", port), timeout=1)
+ sock.close()
+ return
+ except OSError:
+ time.sleep(0.2)
+ raise RuntimeError(f"mihomo listener 127.0.0.1:{port} not ready after {timeout}s")
# Online API services for IP location
LOCATION_API_SERVICES = [
{"url": "https://ipinfo.io", "country_key": "country"},
- {"url": "https://ipapi.co/json/", "country_key": "country_code"},
+ {"url": "https://api.ip2location.io", "country_key": "country_code"},
{"url": "https://ipwho.is", "country_key": "country_code"},
- {"url": "https://freeipapi.com/api/json", "country_key": "countryCode"},
- {"url": "https://api.country.is", "country_key": "country"},
+ {"url": "https://free.freeipapi.com/api/json", "country_key": "countryCode"},
{"url": "https://api.ip.sb/geoip", "country_key": "country_code"},
]
-# Pattern for CDN providers
-CDN_PATTERN = r"cloudflare|cloudfront|fastly|google"
-
-def random_delay(min_delay: float = 0.01, max_delay: float = 0.5):
+def random_delay(min_delay: float = 0.01, max_delay: float = 0.5) -> None:
"""Random delay to avoid API rate limiting"""
time.sleep(random.uniform(min_delay, max_delay))
-def check_residential(proxy: dict, port: int, api_key: str = "", use_ipinfo: bool = True) -> ProxyQueryResult:
+def check_residential(
+ proxy: dict[str, object],
+ port: int,
+ api_key: str = "",
+ ip_library: str = "ipnetcoffee",
+ reader: database.Reader = None,
+ max_retries: int = 2,
+ timeout: int = 12,
+) -> ProxyQueryResult:
"""
Check if a proxy is residential by making a request through it
@@ -826,40 +908,14 @@ def check_residential(proxy: dict, port: int, api_key: str = "", use_ipinfo: boo
proxy: The proxy information dict
port: The port of the proxy
api_key: Optional API key for ipapi.is. Uses free tier if not provided
- use_ipinfo: Whether to use ipinfo.io instead of ipapi.is, defaults to True
+ ip_library: IP query provider, supported: ipnetcoffee/meowvps/ippure/ip2location/iplark/ipinfo/ipapi
+ reader: Optional mmdb reader used to detect CDN egress IPs
+ max_retries: Retry count for provider queries
+ timeout: Timeout in seconds for provider queries
Returns:
ProxyQueryResult: Complete proxy query result
"""
-
- def _get_ipapi_url(key: str = "") -> str:
- url, key = "https://api.ipapi.is", utils.trim(key)
- if key:
- url += f"?key={key}"
- return url
-
- def _get_ipinfo_url(port: int, name: str) -> str:
- # First, get the IP address
- success, content = make_proxy_request(
- port=port,
- url="https://ipinfo.io/ip",
- max_retries=2,
- timeout=15,
- deserialize=False,
- )
- if not success or not content:
- logger.warning(f"Failed to get IP from ipinfo.io for proxy {name}")
- return ""
-
- # Extract IP from response
- ip = utils.trim(content)
- if not ip:
- logger.warning(f"Invalid IP address from ipinfo.io for proxy {name}")
- return ""
-
- # Now get detailed information using the IP
- return f"https://ipinfo.io/widget/demo/{ip}"
-
name = proxy.get("name", "")
result = ProxyInfo(name=name)
@@ -867,49 +923,91 @@ def _get_ipinfo_url(port: int, name: str) -> str:
logger.warning(f"No port found for proxy {name}")
return ProxyQueryResult(proxy=proxy, result=result, success=False)
- # Random delay to avoid being blocked by the API
random_delay()
try:
- url = ""
- if use_ipinfo:
- url = _get_ipinfo_url(port=port, name=name)
-
- if not url:
- url = _get_ipapi_url(key=api_key)
- use_ipinfo = False
-
- # Call API for IP information through the proxy
- success, response = make_proxy_request(port=port, url=url, max_retries=2, timeout=12)
-
- # Parse data from response
- if success:
- try:
- data = response.get("data", {}) if use_ipinfo else response
+ providers = get_providers(ip_library)
+ classified, provider = None, ""
+ egress_ip = None
+ request = partial(make_proxy_request, quiet=True)
+
+ def _cached_egress_ip() -> str:
+ nonlocal egress_ip
+ if egress_ip is None:
+ egress_ip = resolve_egress_ipv4(
+ port,
+ request,
+ max_retries=max_retries,
+ timeout=max(timeout, 15),
+ )
+ if not egress_ip:
+ logger.debug(f"Failed to get egress IP for proxy {name}")
+ return egress_ip
- # Extract country code from data
- if use_ipinfo:
- country_code = data.get("country", "")
+ if reader:
+ ip = _cached_egress_ip()
+ if ip and lookup_ip_geo(ip, reader).is_cdn:
+ result.is_cdn = True
+ _mark_cdn(proxy)
+ logger.debug(f"Egress IP for proxy {name} belongs to CDN, continue locating")
+ return ProxyQueryResult(proxy=proxy, result=result, success=False)
+
+ for idx, item in enumerate(providers):
+ library = create_library(item, api_key=api_key)
+ ip = _cached_egress_ip() if library.needs_egress_ip else ""
+ if library.needs_egress_ip and not ip:
+ if idx < len(providers) - 1:
+ fallback = providers[idx + 1]
+ logger.debug(f"Skip {item} for proxy {name}, no egress IP, trying fallback: {fallback}")
else:
- country_code = data.get("location", {}).get("country_code", "")
+ logger.debug(f"Skip {item} for proxy {name}, no egress IP")
+ continue
- result.country = ISO_TO_CHINESE.get(country_code, "") if country_code else ""
+ data = library.fetch(port, request, max_retries=max_retries, timeout=timeout, ip=ip)
+ if data:
+ provider = item
+ classified = library.classify(data)
+ logger.debug(f"IP info for proxy {name} successfully retrieved, provider: {provider}")
+ break
- company_type = data.get("company", {}).get("type", "")
- asn_type = data.get("asn", {}).get("type", "")
+ if idx < len(providers) - 1:
+ fallback = providers[idx + 1]
+ logger.debug(f"Failed to query {item} for proxy {name}, trying fallback: {fallback}")
+ else:
+ logger.debug(f"Failed to query {item} for proxy {name}")
- # Check if it's residential (both company and asn type should be "isp")
+ if classified:
+ try:
+ result.provider = utils.trim(provider)
+ country_code = utils.trim(classified.country_code).upper()
+ if country_code:
+ result.country = ISO_TO_CHINESE.get(country_code, "")
+
+ if not result.country:
+ raw = classified.raw or {}
+ result.country = utils.trim(
+ raw.get("country_zh", "") or raw.get("country", "") or raw.get("country_name", "")
+ )
+
+ company_type = utils.trim(classified.company_type).lower()
+ asn_type = utils.trim(classified.asn_type).lower()
if company_type == "isp" and asn_type == "isp":
result.ip_type = "isp"
elif company_type in ["business", "isp"] and asn_type in ["business", "isp"]:
result.ip_type = "business"
+ result.score = classified.score
except Exception as e:
- logger.error(f"Error parsing {url} response for proxy {name}: {str(e)}")
+ logger.error(f"Error parsing response for proxy {name}: {str(e)}")
else:
- logger.warning(f"Failed to query {url} for proxy {name}")
+ logger.warning(f"Failed to query residential info for proxy {name} with providers: {providers}")
+
+ if is_cdn_label(result.country):
+ result.is_cdn = True
+ _mark_cdn(proxy)
+ logger.debug(f"Residential country for proxy {name} is CDN, continue locating")
+ return ProxyQueryResult(proxy=proxy, result=result, success=False)
- # Determine if query was successful
flag = result.country != "" or result.ip_type != ""
return ProxyQueryResult(proxy=proxy, result=result, success=flag)
@@ -918,90 +1016,67 @@ def _get_ipinfo_url(port: int, name: str) -> str:
return ProxyQueryResult(proxy=proxy, result=result, success=False)
-def locate_by_ipinfo(proxy: dict, port: int, reader: database.Reader = None) -> ProxyQueryResult:
+def locate_by_ipinfo(proxy: dict[str, object], port: int, reader: database.Reader = None) -> ProxyQueryResult:
"""Check the location of a single proxy by making a request through it"""
+ name = proxy.get("name", "")
+
+ is_cdn = _is_cdn_proxy(proxy)
- def _create_failed_result(reason: str = "") -> ProxyQueryResult:
- """Helper to create failed query result"""
- name = proxy.get("name", "")
+ def _failed(reason: str = "") -> ProxyQueryResult:
if reason:
logger.warning(f"Location query failed for proxy {name}: {reason}")
- return ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=name), success=False)
+ if is_cdn:
+ _mark_cdn(proxy)
+ return ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=name, is_cdn=is_cdn), success=False)
- def _create_success_result(country: str) -> ProxyQueryResult:
- """Helper to create successful query result"""
- info = ProxyInfo(name=proxy.get("name", ""))
- info.country = country
+ def _success(country: str) -> ProxyQueryResult:
+ info = ProxyInfo(name=name, country=country)
return ProxyQueryResult(proxy=proxy, result=info, success=True)
- def _try_local_mmdb_lookup() -> str:
- """Attempt to get country from local MMDB database"""
- if not reader:
- return ""
+ if not port:
+ return _failed("No port specified")
- ip = get_ipv4(port=port, max_retries=2)
- if ip:
- return query_ip_country(ip, reader) or ""
- return ""
+ random_delay()
- def _try_online_api_services() -> str:
- """Attempt to get country from online API services with retry logic"""
- retries = 3
+ try:
+ if reader:
+ ip = get_ipv4(port=port, max_retries=2)
+ geo = lookup_ip_geo(ip, reader) if ip else GeoInfo()
+ if geo.is_cdn:
+ is_cdn = True
+ _mark_cdn(proxy)
+ logger.debug(f"Egress IP for proxy {name} belongs to CDN, try online APIs")
+ elif geo.country:
+ logger.debug(f"Location found via MMDB for proxy {name}: {geo.country}")
+ return _success(geo.country)
+ retries = 3
for attempt in range(retries):
- # Select a random service for this attempt
service = random.choice(LOCATION_API_SERVICES)
-
- # Make the API request
- success, data = make_proxy_request(port=port, url=service["url"], max_retries=1, timeout=12)
-
+ success, data = make_proxy_request(port=port, url=service["url"], max_retries=1, timeout=12, quiet=True)
if success and data:
- # Parse country code from response
- key = service["country_key"]
- code = data.get(key, "")
-
+ code = data.get(service["country_key"], "")
if code:
- # Convert to Chinese country name
- return ISO_TO_CHINESE.get(code, code)
+ country = ISO_TO_CHINESE.get(code, code)
+ if is_cdn_label(code) or is_cdn_label(country):
+ is_cdn = True
+ _mark_cdn(proxy)
+ logger.debug(f"API country for proxy {name} is CDN, continue locating")
+ else:
+ logger.debug(f"Location found via API for proxy {name}: {country}")
+ return _success(country)
- # Handle retry delay for failed attempts
if attempt < retries - 1:
delay = min(2**attempt * random.uniform(1, 2), 6)
- logger.warning(
- f"API attempt {attempt+1} failed for proxy {proxy.get('name', '')} "
- f"using {service['url']}, retrying in {delay:.2f}s"
+ logger.debug(
+ f"API attempt {attempt+1} failed for proxy {name} using {service['url']}, retrying in {delay:.2f}s"
)
time.sleep(delay)
- return ""
-
- # Validate input parameters
- if not port:
- return _create_failed_result("No port specified")
-
- # Apply rate limiting
- random_delay()
-
- # Main location detection logic
- try:
- # Strategy 1: Try local MMDB database first (faster and more reliable)
- country = _try_local_mmdb_lookup()
- if country:
- logger.debug(f"Location found via MMDB for proxy {proxy.get('name', '')}: {country}")
- return _create_success_result(country)
-
- # Strategy 2: Fall back to online API services
- country = _try_online_api_services()
- if country:
- logger.debug(f"Location found via API for proxy {proxy.get('name', '')}: {country}")
- return _create_success_result(country)
-
- # No location detected from any source
- return _create_failed_result("Unable to determine location from any source")
-
+ return _failed("Unable to determine location from any source")
except Exception as e:
- logger.error(f"Unexpected error during location query for {proxy.get('name', '')}: {str(e)}")
- return _create_failed_result(f"Exception: {str(e)}")
+ logger.error(f"Unexpected error during location query for {name}: {str(e)}")
+ return _failed(f"Exception: {str(e)}")
def batch_query(
@@ -1011,8 +1086,6 @@ def batch_query(
show_progress: bool = True,
description: str = "Querying",
digits: int = 2,
- reader: database.Reader = None,
- api_key: str = "",
) -> list[ProxyQueryResult]:
"""
Run mihomo to query proxies information using the specified function
@@ -1024,84 +1097,87 @@ def batch_query(
show_progress: Whether to show progress
description: Description for progress display
digits: Number of digits for proxy naming
- reader: Optional mmdb reader for locate_by_ipinfo
- api_key: Optional API key for check_residential function
Returns:
List of ProxyQueryResult with complete information
"""
- if not proxies or not is_mihomo():
+ if not proxies:
return []
- # Rename proxies for consistent naming
- nodes = rename(proxies, digits, False)
+ if not is_mihomo():
+ return [
+ ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=proxy.get("name", "")), success=False)
+ for proxy in proxies
+ ]
- logger.info(f"Generate clash listeners configuration for {len(nodes)} proxies")
- # Generate mihomo configuration
- config, records = generate_mihomo_config(nodes)
+ nodes = rename(proxies, digits, False)
+ failed = [ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=proxy["name"]), success=False) for proxy in nodes]
- # Save the configuration to clash/config.yaml in the project directory
workspace = os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), "clash")
- config_path = os.path.join(workspace, "config.yaml")
- with open(config_path, "w", encoding="utf-8") as f:
- yaml.dump(config, f, allow_unicode=True)
-
- logger.info(f"Mihomo configuration saved to {config_path}")
-
- # Check if we can find the mihomo binary
mihomo_bin = os.path.join(workspace, which_bin()[0])
if not os.path.exists(mihomo_bin) or not os.path.isfile(mihomo_bin):
logger.error("Mihomo binary not found, skipping proxy check")
- return [ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=proxy["name"]), success=False) for proxy in nodes]
+ return failed
- # Make the binary executable
utils.chmod(mihomo_bin)
- # Start mihomo with the configuration
- logger.info(f"Starting mihomo with configuration {config_path}")
+ logger.info(f"Generate clash listeners configuration for {len(nodes)} proxies")
+ reservation = PortReservation()
process = None
try:
- # Run mihomo in background
+ ports = reservation.reserve(1 + len(nodes))
+ mixed_port, listener_ports = ports[0], ports[1:]
+ config, records = generate_mihomo_config(nodes, listener_ports, mixed_port)
+
+ config_path = os.path.join(workspace, "config.yaml")
+ with open(config_path, "w", encoding="utf-8") as f:
+ yaml.dump(config, f, allow_unicode=True)
+
+ logger.info(f"Mihomo configuration saved to {config_path}")
+ reservation.close()
+
+ logger.info(f"Starting mihomo with configuration {config_path}")
process = subprocess.Popen(
[mihomo_bin, "-d", workspace, "-f", config_path],
stdout=subprocess.DEVNULL,
- stderr=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
)
- # Wait longer to ensure mihomo is fully started
logger.info("Waiting for mihomo to start...")
- time.sleep(8)
+ _wait_listener(mixed_port, process)
+ if listener_ports:
+ _wait_listener(listener_ports[0], process, timeout=10)
- # Create proxy info mapping for task generation
mappings = {proxy["name"]: proxy for proxy in nodes}
-
- # Generate tasks for each proxy
- if reader is not None:
- # For locate_by_ipinfo which needs reader parameter
- tasks = [(mappings[name], port, reader) for name, port in records.items() if name in mappings]
- elif api_key:
- # For check_residential with API key
- tasks = [(mappings[name], port, api_key) for name, port in records.items() if name in mappings]
- else:
- # For check_residential without API key
- tasks = [(mappings[name], port) for name, port in records.items() if name in mappings]
-
- # Check proxies using the specified function
- results = utils.multi_thread_run(
- func=func,
- tasks=tasks,
- num_threads=num_threads,
- show_progress=show_progress,
- description=description,
+ tasks = [(mappings[name], port) for name, port in records.items() if name in mappings]
+ results = (
+ utils.multi_thread_run(
+ func=func,
+ tasks=tasks,
+ num_threads=num_threads,
+ show_progress=show_progress,
+ description=description,
+ )
+ or []
)
- return results
+ queried, normalized = set(), []
+ for item in results:
+ if not item:
+ continue
+ normalized.append(item)
+ queried.add(item.proxy.get("name"))
+
+ for proxy in nodes:
+ if proxy["name"] not in queried:
+ normalized.append(ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=proxy["name"]), success=False))
+ return normalized
except Exception as e:
logger.error(f"Error during mihomo check: {str(e)}")
- return [ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=proxy["name"]), success=False) for proxy in nodes]
+ return failed
finally:
- # Always try to kill the mihomo process
+ reservation.close()
if process:
try:
process.terminate()
@@ -1110,13 +1186,16 @@ def batch_query(
pass
-def process_query_results(results: list[ProxyQueryResult], strategy: str) -> tuple[list[dict], list[dict]]:
+def process_query_results(
+ results: list[ProxyQueryResult], strategy: str, score: bool = False
+) -> tuple[list[dict], list[dict]]:
"""
Process proxy query results
Args:
results: List of query results
strategy: Processing strategy ('residential' or 'location')
+ score: Whether to prefix node names with provider and trust score
Returns:
tuple: (list of successful proxies, list of failed proxies)
@@ -1124,28 +1203,36 @@ def process_query_results(results: list[ProxyQueryResult], strategy: str) -> tup
successes, fails = [], []
for item in results:
- if item.success and item.result.country:
+ if not item:
+ continue
+
+ country = utils.trim(item.result.country) if item.result else ""
+ is_cdn = bool(item.result and item.result.is_cdn) or is_cdn_label(country)
+ if is_cdn:
+ _mark_cdn(item.proxy)
+
+ if item.success and country and not is_cdn:
# Copy proxy info to avoid modifying original data
proxy = item.proxy.copy()
if strategy == "residential":
# Residential IP check strategy
- name = item.result.country
+ name = country
if item.result.ip_type == "isp":
name += "家宽"
elif item.result.ip_type == "business":
name += "商宽"
-
- proxy["name"] = name
- successes.append(proxy)
- elif strategy == "location":
- # Location check strategy
- proxy["name"] = item.result.country
- successes.append(proxy)
else:
- # Unknown strategy, use query result directly
- proxy["name"] = item.result.country
- successes.append(proxy)
+ # Location check or unknown strategy
+ name = country
+
+ if score and item.result.score is not None:
+ source = utils.trim(item.result.provider).upper()
+ if source:
+ name = f"[{source}|{str(item.result.score).zfill(3)}] {name}"
+
+ proxy["name"] = name
+ successes.append(proxy)
else:
# Failed query proxies
fails.append(item.proxy)
@@ -1161,36 +1248,46 @@ def regularize(
show_progress: bool = True,
locate: bool = False,
residential: bool = False,
+ ip_library: str = "",
digits: int = 2,
+ score: bool = False,
) -> list[dict]:
if not proxies or not isinstance(proxies, list):
return proxies
# Phase 1: Residential check if necessary
successes, fails = [], []
+ reader = None
if residential:
- logger.info(f"Starting residential check for {len(proxies)} proxies")
-
- # Enable locate if residential check is enabled
locate = True
+ if residential or locate:
+ directory = utils.trim(directory)
+ if not directory:
+ directory = os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), "data")
+ reader = load_mmdb(directory=directory, repo="Loyalsoldier/geoip", filename="Country.mmdb", update=update)
+ if not reader:
+ logger.error("cannot load mmdb: Country.mmdb")
+
+ if residential:
+ logger.info(f"Starting residential check for {len(proxies)} proxies")
+
# Get https://api.ipapi.is API key from environment variable
api_key = utils.trim(os.environ.get("IPAPI_IS_API_KEY", ""))
# Use mihomo to check for residential proxies
results = batch_query(
proxies=proxies,
- func=check_residential,
+ func=partial(check_residential, api_key=api_key, ip_library=ip_library, reader=reader),
num_threads=num_threads,
show_progress=show_progress,
description="Checking residential",
digits=digits,
- api_key=api_key,
)
# Process residential check results
- successes, fails = process_query_results(results, "residential")
+ successes, fails = process_query_results(results, "residential", score=score)
logger.info(f"Residential check completed: {len(successes)} successful, {len(fails)} failed")
else:
fails = proxies
@@ -1199,60 +1296,50 @@ def regularize(
if locate and fails:
logger.info(f"Starting location check for {len(fails)} proxies")
- # Initialize reader for locate functionality and load mmdb database if available
- directory = utils.trim(directory)
- if not directory:
- directory = os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), "data")
-
- repo, filename = "Loyalsoldier/geoip", "Country.mmdb"
- reader = load_mmdb(directory=directory, repo=repo, filename=filename, update=update)
- if not reader:
- logger.error(f"Skipping location check due to cannot load mmdb: {filename}")
-
unconfirmed = list()
if reader:
# Try local mmdb lookup first
- tasks = [[p, reader] for p in fails if p and isinstance(p, dict)]
+ sources = [p for p in fails if p and isinstance(p, dict)]
+ tasks = [[p, reader] for p in sources]
mmdb_results = utils.multi_thread_run(locate_by_geoip, tasks, num_threads, show_progress, "")
- # Separate confirmed and unconfirmed proxies by regex
- regex = f"中国|{CDN_PATTERN}"
-
- for proxy in mmdb_results:
- if proxy.pop("renamed", False) and not re.search(regex, proxy["name"], flags=re.I):
- # Add to successes list if confirmed by mmdb lookup
- successes.append(proxy)
+ for source, proxy in zip(sources, mmdb_results or []):
+ node = proxy if proxy and isinstance(proxy, dict) else source
+ name = str(node.get("name", ""))
+ cdn = bool(node.get("cdn")) or is_cdn_label(name)
+ if cdn:
+ _mark_cdn(node)
+ if node.pop("renamed", False) and "中国" not in name and not cdn:
+ successes.append(node)
else:
- # Add to unconfirmed list if not confirmed by mmdb lookup
- unconfirmed.append(proxy)
+ unconfirmed.append(node)
else:
# No mmdb available, treat all as unconfirmed
unconfirmed = fails
- # For unconfirmed proxies, use online API services to get location info (fallback)
+ # For unconfirmed proxies, use online API services to get location info
if unconfirmed:
logger.info(f"Using online API services for {len(unconfirmed)} unconfirmed proxies")
# Use mihomo to check IP locations
query_results = batch_query(
proxies=unconfirmed,
- func=locate_by_ipinfo,
+ func=partial(locate_by_ipinfo, reader=reader),
num_threads=num_threads,
show_progress=show_progress,
description="Querying location",
digits=digits,
- reader=reader,
)
# Process location check results and handle CDN proxies
- query_successes, query_fails = process_query_results(query_results, "location")
+ query_successes, query_fails = process_query_results(query_results, "location", score=score)
# Add query successes to final results
successes.extend(query_successes)
- # Handle CDN proxies that failed location check
+ # CDN nodes without a real country fall back to US
for proxy in query_fails:
- if re.search(CDN_PATTERN, proxy["name"], flags=re.I):
+ if _is_cdn_proxy(proxy):
logger.warning(f"Failed to get location for proxy {proxy['name']}, assume it's in US")
proxy["name"] = "美国"
@@ -1264,7 +1351,7 @@ def regularize(
successes.extend(fails)
# Return final results
- return rename(proxies=successes, digits=digits, shuffle=True)
+ return rename(proxies=_remove_temp_flags(successes), digits=digits, shuffle=True)
def rename(proxies: list[dict], digits: int = 2, shuffle: bool = False) -> list[dict]:
diff --git a/subscribe/logger.py b/subscribe/logger.py
index be0122ccb8..c805d761fd 100644
--- a/subscribe/logger.py
+++ b/subscribe/logger.py
@@ -16,7 +16,7 @@
class Logger:
- def __init__(self):
+ def __init__(self) -> None:
self._logger = logging.getLogger()
if not self._logger.handlers:
self.formatter = logging.Formatter(fmt=DEFAULT_LOG_FMT, datefmt=DEFAULT_LOG_DATETIME_FORMAT)
@@ -28,20 +28,20 @@ def __init__(self):
if sys.version_info.major == 2:
logging.getLogger("requests").setLevel(logging.WARNING)
- def _get_file_handler(self, filename):
+ def _get_file_handler(self, filename: str) -> logging.FileHandler:
"""返回一个文件日志handler"""
file_handler = logging.FileHandler(filename=filename, encoding="utf8")
file_handler.setFormatter(self.formatter)
return file_handler
- def _get_console_handler(self):
+ def _get_console_handler(self) -> logging.StreamHandler:
"""返回一个输出到终端日志handler"""
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(self.formatter)
return console_handler
@property
- def logger(self):
+ def logger(self) -> logging.Logger:
return self._logger
diff --git a/subscribe/mailtm.py b/subscribe/mailtm.py
index 88492b67cc..dc1c91a17a 100644
--- a/subscribe/mailtm.py
+++ b/subscribe/mailtm.py
@@ -48,16 +48,16 @@ class TemporaryMail(object):
def __init__(self) -> None:
self.api_address = ""
- def get_domains_list(self) -> list:
+ def get_domains_list(self) -> list[str]:
raise NotImplementedError
def get_account(self, retry: int = 3) -> Account:
raise NotImplementedError
- def get_messages(self, account: Account) -> list:
+ def get_messages(self, account: Account) -> list[Message]:
raise NotImplementedError
- def monitor_account(self, account: Account, timeout: int = 300, sleep: int = 3) -> Message:
+ def monitor_account(self, account: Account, timeout: int = 300, sleep: int = 3) -> Message | None:
"""keep waiting for new messages"""
if not account:
return None
@@ -122,7 +122,7 @@ def __init__(self) -> None:
"User-Agent": utils.USER_AGENT,
}
- def get_domains_list(self) -> list:
+ def get_domains_list(self) -> list[str]:
content, count = "", 1
while not content and count <= 3:
count += 1
@@ -180,7 +180,7 @@ def get_account(self, retry: int = 3) -> Account:
except:
return self.get_account(retry=retry - 1)
- def get_messages(self, account: Account) -> list:
+ def get_messages(self, account: Account) -> list[Message]:
if not account:
return []
@@ -261,7 +261,7 @@ class SnapMail(TemporaryMail):
def __init__(self) -> None:
self.api_address = "https://snapmail.cc"
- def get_domains_list(self) -> list:
+ def get_domains_list(self) -> list[str]:
domains = ["snapmail.cc", "lista.cc", "xxxhi.cc"]
# content = utils.http_get(
# url="https://www.snapmail.cc/scripts/controllers/addEmailBox.js", retry=1
@@ -287,7 +287,7 @@ def get_account(self, retry: int = 3) -> Account:
return Account(address=address)
- def get_messages(self, account: Account) -> list:
+ def get_messages(self, account: Account) -> list[Message]:
if not account:
return []
@@ -351,7 +351,7 @@ class LinShiEmail(TemporaryMail):
def __init__(self) -> None:
self.api_address = "https://linshiyouxiang.net"
- def get_domains_list(self) -> list:
+ def get_domains_list(self) -> list[str]:
content = utils.http_get(url=self.api_address)
if not content:
return []
@@ -370,7 +370,7 @@ def get_account(self, retry: int = 3) -> Account:
return Account(address=address)
- def get_messages(self, account: Account) -> list:
+ def get_messages(self, account: Account) -> list[Message]:
if not account:
return []
@@ -415,7 +415,7 @@ def __init__(self) -> None:
self.api_address = "https://api.mail.tm"
self.auth_headers = {}
- def get_domains_list(self) -> list:
+ def get_domains_list(self) -> list[str]:
headers = {"Accept": "application/ld+json"}
try:
content = utils.http_get(url=f"{self.api_address}/domains?page=1", headers=headers)
@@ -450,7 +450,7 @@ def _make_account_request(self, endpoint: str, address: str, password: str, retr
except:
return self._make_account_request(endpoint=endpoint, address=address, password=password, retry=retry - 1)
- def _generate_jwt(self, address: str, password: str, retry: int = 3):
+ def _generate_jwt(self, address: str, password: str, retry: int = 3) -> dict[str, object] | None:
jwt = self._make_account_request(endpoint="token", address=address, password=password, retry=retry)
if not jwt:
logger.error(f"[JWTError] generate jwt token failed, domain: {self.api_address}")
@@ -479,7 +479,7 @@ def get_account(self, retry: int = 3) -> Account:
return account
- def get_messages(self, account: Account) -> list:
+ def get_messages(self, account: Account) -> list[Message]:
"""download a list of messages currently in the account."""
if not account or not self.auth_headers:
return []
@@ -566,7 +566,7 @@ def __init__(self) -> None:
"User-Agent": utils.USER_AGENT,
}
- def get_domains_list(self) -> list:
+ def get_domains_list(self) -> list[str]:
content = utils.http_get(url=self.api_address)
if not content:
return []
@@ -611,7 +611,7 @@ def get_account(self, retry: int = 3) -> Account:
username, domain = address.split("@", maxsplit=1)
return self._make_account_request(username=username, domain=domain, retry=retry)
- def get_messages(self, account: Account) -> list:
+ def get_messages(self, account: Account) -> list[Message]:
if not account:
return []
@@ -654,7 +654,7 @@ def __init__(self, onlygmail: bool = False) -> None:
"Referer": "https://www.emailnator.com/",
}
- def get_domains_list(self) -> list:
+ def get_domains_list(self) -> list[str]:
# unable to obtain the supported email domain through web api
return ["gmail.com", "googlemail.com", "smartnator.com", "psnator.com", "tmpmailtor.com", "mydefipet.live"]
@@ -715,7 +715,7 @@ def get_account(self, retry: int = 3) -> Account:
except:
return self.get_account(retry=retry - 1)
- def get_messages(self, account: Account) -> list:
+ def get_messages(self, account: Account) -> list[Message]:
if not account:
return []
try:
diff --git a/subscribe/outbound/__init__.py b/subscribe/outbound/__init__.py
new file mode 100644
index 0000000000..d72599d120
--- /dev/null
+++ b/subscribe/outbound/__init__.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+
+from outbound.anytls import AnyTLSVerifier
+from outbound.base import proxy_exists, register, verify
+from outbound.builtin import DirectVerifier, DnsVerifier, RejectVerifier, RematchVerifier
+from outbound.common import QuotedStr, endpoint_key, quoted_scalar
+from outbound.hysteria import Hysteria2Verifier, HysteriaVerifier
+from outbound.http import HttpVerifier, Socks5Verifier
+from outbound.mieru import MieruVerifier
+from outbound.overlay import EasyTierVerifier, TailscaleVerifier, ZeroTierVerifier
+from outbound.quic import ShadowQuicVerifier, TuicVerifier
+from outbound.shadowsocks import ShadowsocksRVerifier, ShadowsocksVerifier
+from outbound.snell import SnellVerifier
+from outbound.ssh import SshVerifier
+from outbound.sudoku import SudokuVerifier
+from outbound.trojan import TrojanVerifier
+from outbound.tunnel import GostRelayVerifier, TrustTunnelVerifier
+from outbound.vless import VlessVerifier
+from outbound.vmess import VmessVerifier
+from outbound.vpn import MasqueVerifier, OpenVPNVerifier, WireGuardVerifier
+
+register(
+ ShadowsocksVerifier(),
+ ShadowsocksRVerifier(),
+ SnellVerifier(),
+ VmessVerifier(),
+ VlessVerifier(),
+ TrojanVerifier(),
+ HttpVerifier(),
+ Socks5Verifier(),
+ AnyTLSVerifier(),
+ HysteriaVerifier(),
+ Hysteria2Verifier(),
+ TuicVerifier(),
+ ShadowQuicVerifier(),
+ SshVerifier(),
+ MieruVerifier(),
+ SudokuVerifier(),
+ TrustTunnelVerifier(),
+ GostRelayVerifier(),
+ WireGuardVerifier(),
+ OpenVPNVerifier(),
+ MasqueVerifier(),
+ EasyTierVerifier(),
+ TailscaleVerifier(),
+ ZeroTierVerifier(),
+ DirectVerifier(),
+ DnsVerifier(),
+ RejectVerifier(),
+ RematchVerifier(),
+)
+
+__all__ = ["QuotedStr", "endpoint_key", "proxy_exists", "quoted_scalar", "verify"]
diff --git a/subscribe/outbound/anytls.py b/subscribe/outbound/anytls.py
new file mode 100644
index 0000000000..03821566fc
--- /dev/null
+++ b/subscribe/outbound/anytls.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import ensure_non_negative_number
+from outbound.tls import verify_optional_str_dict
+
+
+class AnyTLSVerifier(OutboundVerifier):
+ type_name = "anytls"
+ mihomo_only = True
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ for field in ("idle-session-check-interval", "idle-session-timeout", "min-idle-session"):
+ if not ensure_non_negative_number(item, field):
+ return False
+ if not verify_optional_str_dict(item, "shadow-tls-opts"):
+ return False
+ if not verify_optional_str_dict(item, "restls-opts"):
+ return False
+ if not verify_optional_str_dict(item, "jls-opts"):
+ return False
+ return True
diff --git a/subscribe/outbound/base.py b/subscribe/outbound/base.py
new file mode 100644
index 0000000000..b41794f12d
--- /dev/null
+++ b/subscribe/outbound/base.py
@@ -0,0 +1,118 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+import utils
+from outbound.common import (
+ check_common_optional,
+ check_optional_port,
+ check_required_port,
+ endpoint_key,
+ finalize_auth,
+ normalize_name,
+ normalize_server,
+)
+
+
+class VerifyContext:
+ def __init__(self, is_mihomo: bool = True) -> None:
+ self.is_mihomo = is_mihomo
+
+
+class OutboundVerifier(ABC):
+ type_name: str = ""
+ mihomo_only: bool = False
+ require_server: bool = True
+ require_port: bool = True
+
+ def verify(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ if self.mihomo_only and not ctx.is_mihomo:
+ return False
+
+ item.pop("dialer-proxy", None)
+
+ if not normalize_name(item):
+ return False
+
+ if (self.needs_server(item) or item.get("server")) and not normalize_server(item):
+ return False
+
+ if self.needs_port(item):
+ if not check_required_port(item, item.get("type", self.type_name)):
+ return False
+ elif not check_optional_port(item, item.get("type", self.type_name)):
+ return False
+
+ if not check_common_optional(item):
+ return False
+ if not self.verify_fields(item, ctx):
+ return False
+ return finalize_auth(item, self.auth_field(item))
+
+ def needs_server(self, item: dict[str, object]) -> bool:
+ return self.require_server
+
+ def needs_port(self, item: dict[str, object]) -> bool:
+ return self.require_port
+
+ @abstractmethod
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ raise NotImplementedError
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return "password"
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ field = self.auth_field(item)
+ secret = item.get(field, "") if field else ""
+ return (self.type_name, secret)
+
+
+VERIFIERS: dict[str, OutboundVerifier] = {}
+
+
+def get_verifier(type_name: str) -> OutboundVerifier | None:
+ return VERIFIERS.get(utils.trim(str(type_name or "")).lower())
+
+
+def register(*verifiers: OutboundVerifier) -> None:
+ for verifier in verifiers:
+ VERIFIERS[verifier.type_name] = verifier
+
+
+def verify(item: dict[str, object], is_mihomo: bool = True) -> bool:
+ if not item or type(item) != dict or "type" not in item:
+ return False
+
+ verifier = get_verifier(str(item.get("type", "")))
+ if verifier is None:
+ return False
+
+ try:
+ return verifier.verify(item, VerifyContext(is_mihomo=is_mihomo))
+ except Exception:
+ return False
+
+
+def proxy_exists(proxy: dict[str, object], hosts: dict[str, list[dict[str, object]]]) -> bool:
+ if not proxy:
+ return True
+ if not hosts:
+ return False
+
+ existing = hosts.get(endpoint_key(proxy), [])
+ if not existing:
+ return False
+
+ protocol = utils.trim(str(proxy.get("type", ""))).lower()
+ if protocol in ("http", "socks5"):
+ return True
+
+ verifier = get_verifier(protocol)
+ if verifier is None:
+ return False
+
+ current = verifier.duplicate_key(proxy)
+ return any(p.get("type") == protocol and verifier.duplicate_key(p) == current for p in existing)
diff --git a/subscribe/outbound/builtin.py b/subscribe/outbound/builtin.py
new file mode 100644
index 0000000000..ec327d0470
--- /dev/null
+++ b/subscribe/outbound/builtin.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import ensure_str
+
+
+class _BuiltinVerifier(OutboundVerifier):
+ mihomo_only = True
+ require_server = False
+ require_port = False
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ return True
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return None
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (self.type_name, item.get("name", ""))
+
+
+class DirectVerifier(_BuiltinVerifier):
+ type_name = "direct"
+
+
+class DnsVerifier(_BuiltinVerifier):
+ type_name = "dns"
+
+
+class RejectVerifier(_BuiltinVerifier):
+ type_name = "reject"
+
+
+class RematchVerifier(_BuiltinVerifier):
+ type_name = "rematch"
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ return ensure_str(item, "target-rematch-name") and ensure_str(item, "target-sub-rule")
diff --git a/subscribe/outbound/common.py b/subscribe/outbound/common.py
new file mode 100644
index 0000000000..d8c74c2818
--- /dev/null
+++ b/subscribe/outbound/common.py
@@ -0,0 +1,231 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import ipaddress
+import re
+
+import utils
+
+BOOL_FIELDS = ("udp", "tls", "skip-cert-verify", "tfo", "mptcp")
+IP_VERSIONS = ("dual", "ipv4", "ipv6", "ipv4-prefer", "ipv6-prefer")
+CONGESTION_CONTROLLERS = ("cubic", "bbr", "new_reno")
+BBR_PROFILES = ("", "standard", "conservative", "aggressive")
+TRAFFIC_PATTERN = re.compile(r"^\d+(\.\d+)?(\s+)?([kmgt]?bps)?$", re.I)
+
+
+class QuotedStr(str):
+ pass
+
+
+def quoted_scalar(dumper: object, data: object) -> object:
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style='"')
+
+
+def wrap(text: object) -> str:
+ if utils.is_number(text):
+ text = str(text)
+ return utils.trim(text)
+
+
+def normalize_name(item: dict[str, object]) -> bool:
+ name = str(item.get("name", "")).strip()
+ if not name:
+ return False
+ item["name"] = name
+ return True
+
+
+def normalize_server(item: dict[str, object]) -> bool:
+ server = str(item.get("server", "")).strip().lower()
+ if not server:
+ return False
+
+ if server.startswith("::"):
+ # ipv6 addresses starting with "::" can break yaml loading
+ try:
+ server = ipaddress.IPv6Address(server).exploded
+ except Exception:
+ return False
+
+ item["server"] = server
+ return True
+
+
+def check_ports(port: object, ranges: object, protocol: str) -> bool:
+ protocol = utils.trim(protocol).lower()
+
+ try:
+ flag = 0 < int(port) <= 65535
+ if not flag or protocol not in ["hysteria", "hysteria2"] or not ranges:
+ return flag
+ except Exception:
+ return False
+
+ return parse_port_ranges(ranges)
+
+
+def parse_port_ranges(ranges: object) -> bool:
+ text = wrap(ranges)
+ if not text:
+ return False
+
+ nums = re.split(r"/|,", text)
+ if not nums:
+ return False
+
+ for num in nums:
+ start, end = num, num
+ if "-" in num:
+ start, end = num.split("-", maxsplit=1)
+ try:
+ start, end = int(start), int(end)
+ if start <= 0 or start > 65535 or end <= 0 or end > 65535 or start > end:
+ return False
+ except Exception:
+ return False
+
+ return True
+
+
+def check_required_port(item: dict[str, object], protocol: str) -> bool:
+ return check_ports(item.get("port", ""), item.get("ports", None), protocol)
+
+
+def check_optional_port(item: dict[str, object], protocol: str) -> bool:
+ if "port" not in item or item.get("port") in (None, ""):
+ return True
+ return check_ports(item.get("port", ""), item.get("ports", None), protocol)
+
+
+def check_common_optional(item: dict[str, object]) -> bool:
+ if "uuid" in item and not utils.verify_uuid(item.get("uuid")):
+ return False
+
+ for attribute in ["servername", "sni"]:
+ if attribute in item and type(item[attribute]) != str:
+ return False
+
+ for attribute in BOOL_FIELDS:
+ if attribute in item and type(item[attribute]) != bool:
+ return False
+
+ if "ip-version" in item:
+ version = utils.trim(str(item.get("ip-version", "")))
+ if version and version not in IP_VERSIONS:
+ return False
+
+ if "alpn" in item and type(item["alpn"]) != list:
+ return False
+
+ return True
+
+
+def finalize_auth(item: dict[str, object], field: str | None) -> bool:
+ if not field:
+ return True
+ if not item.get(field, ""):
+ return False
+ if utils.is_number(item[field]):
+ item[field] = QuotedStr(item[field])
+ return True
+
+
+def quote_numeric_fields(item: dict[str, object], fields: tuple[str, ...]) -> None:
+ for field in fields:
+ if field in item and item.get(field, "") != "" and utils.is_number(item[field]):
+ item[field] = QuotedStr(item[field])
+
+
+def ensure_non_negative_number(item: dict[str, object], field: str) -> bool:
+ if field not in item:
+ return True
+ if not utils.is_number(item[field]) or int(item[field]) < 0:
+ return False
+ return True
+
+
+def ensure_number(item: dict[str, object], field: str) -> bool:
+ if field not in item:
+ return True
+ return utils.is_number(item[field])
+
+
+def ensure_bool(item: dict[str, object], field: str) -> bool:
+ if field not in item:
+ return True
+ return type(item[field]) == bool
+
+
+def ensure_str(item: dict[str, object], field: str) -> bool:
+ if field not in item:
+ return True
+ return type(item[field]) == str
+
+
+def is_valid_ip(text: str) -> bool:
+ text = utils.trim(text)
+ if not text:
+ return False
+ try:
+ ipaddress.ip_address(text)
+ return True
+ except Exception:
+ return False
+
+
+def verify_traffic(item: dict[str, object], field: str) -> bool:
+ if field not in item:
+ return True
+
+ traffic = item.get(field, "")
+ if traffic == "null":
+ item.pop(field)
+ return True
+
+ if traffic and utils.is_number(traffic):
+ traffic = str(traffic)
+ item[field] = traffic
+
+ return bool(TRAFFIC_PATTERN.match(utils.trim(traffic)))
+
+
+def verify_congestion_controller(item: dict[str, object]) -> bool:
+ if "congestion-controller" not in item:
+ return True
+ return item.get("congestion-controller") in CONGESTION_CONTROLLERS
+
+
+def verify_bbr_profile(item: dict[str, object]) -> bool:
+ if "bbr-profile" not in item:
+ return True
+ profile = wrap(item.get("bbr-profile", ""))
+ return profile in BBR_PROFILES
+
+
+def verify_hop_interval(item: dict[str, object]) -> bool:
+ if "hop-interval" not in item:
+ return True
+ value = item.get("hop-interval")
+ if utils.is_number(value):
+ return float(value) >= 0
+
+ text = wrap(value)
+ if not text or "," in text:
+ return False
+ if "-" not in text:
+ return False
+ start, end = text.split("-", maxsplit=1)
+ try:
+ start, end = float(start), float(end)
+ return start > 0 and end >= start
+ except Exception:
+ return False
+
+
+def endpoint_key(item: dict[str, object]) -> str:
+ server = item.get("server")
+ port = item.get("port")
+ if server:
+ return f"{server}:{port}"
+ return f"{item.get('type')}:{item.get('name')}"
diff --git a/subscribe/outbound/http.py b/subscribe/outbound/http.py
new file mode 100644
index 0000000000..f31f3a96c4
--- /dev/null
+++ b/subscribe/outbound/http.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import utils
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import QuotedStr
+
+
+class _UserPassVerifier(OutboundVerifier):
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ for field in ("username", "password"):
+ value = item.get(field, None)
+ if not value:
+ continue
+ if not isinstance(value, str) and not utils.is_number(value):
+ return False
+ if utils.is_number(value):
+ item[field] = QuotedStr(value)
+ else:
+ item[field] = utils.trim(value)
+ return True
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return None
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (self.type_name,)
+
+
+class HttpVerifier(_UserPassVerifier):
+ type_name = "http"
+
+
+class Socks5Verifier(_UserPassVerifier):
+ type_name = "socks5"
diff --git a/subscribe/outbound/hysteria.py b/subscribe/outbound/hysteria.py
new file mode 100644
index 0000000000..b0668ddd5f
--- /dev/null
+++ b/subscribe/outbound/hysteria.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import (
+ ensure_bool,
+ ensure_number,
+ ensure_str,
+ parse_port_ranges,
+ verify_bbr_profile,
+ verify_hop_interval,
+ verify_traffic,
+ wrap,
+)
+
+HYSTERIA_PROTOCOLS = ("udp", "wechat-video", "faketcp")
+HYSTERIA2_OBFS = ("salamander", "gecko")
+
+
+class HysteriaVerifier(OutboundVerifier):
+ type_name = "hysteria"
+ mihomo_only = True
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ if not verify_traffic(item, "up") or not verify_traffic(item, "down"):
+ return False
+ for field in ("ca", "ca-str", "auth-str", "auth_str", "obfs"):
+ if not ensure_str(item, field):
+ return False
+ for field in ("disable_mtu_discovery", "fast-open"):
+ if not ensure_bool(item, field):
+ return False
+ if "protocol" in item and wrap(item.get("protocol", "")) not in HYSTERIA_PROTOCOLS:
+ return False
+ if "ports" in item and not parse_port_ranges(item.get("ports")):
+ return False
+ for field in ("recv_window_conn", "recv-window-conn", "recv_window", "recv-window"):
+ if not ensure_number(item, field):
+ return False
+ return True
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return "auth-str" if "auth-str" in item else "auth_str"
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ field = self.auth_field(item)
+ return (self.type_name, item.get(field, ""))
+
+
+class Hysteria2Verifier(OutboundVerifier):
+ type_name = "hysteria2"
+ mihomo_only = True
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ if not verify_traffic(item, "up") or not verify_traffic(item, "down"):
+ return False
+ for field in ("ca", "ca-str", "obfs-password"):
+ if not ensure_str(item, field):
+ return False
+ if "ports" in item and not parse_port_ranges(item.get("ports")):
+ return False
+ if not verify_hop_interval(item) or not verify_bbr_profile(item):
+ return False
+
+ obfs = wrap(item.get("obfs", ""))
+ if obfs:
+ if obfs not in HYSTERIA2_OBFS:
+ return False
+ if not wrap(item.get("obfs-password", "")):
+ return False
+ return True
diff --git a/subscribe/outbound/mieru.py b/subscribe/outbound/mieru.py
new file mode 100644
index 0000000000..aee210ee7e
--- /dev/null
+++ b/subscribe/outbound/mieru.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import parse_port_ranges, quote_numeric_fields, wrap
+
+MIERU_TRANSPORTS = ("TCP", "UDP")
+MIERU_MULTIPLEXING = (
+ "",
+ "MULTIPLEXING_OFF",
+ "MULTIPLEXING_LOW",
+ "MULTIPLEXING_MIDDLE",
+ "MULTIPLEXING_HIGH",
+)
+MIERU_HANDSHAKE = ("", "HANDSHAKE_STANDARD", "HANDSHAKE_NO_WAIT")
+
+
+class MieruVerifier(OutboundVerifier):
+ type_name = "mieru"
+ mihomo_only = True
+ require_port = False
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ has_port = "port" in item and item.get("port") not in (None, "")
+ has_range = bool(wrap(item.get("port-range", "")))
+ if has_port == has_range:
+ return False
+ if has_range and not parse_port_ranges(item.get("port-range")):
+ return False
+ if not wrap(item.get("username", "")):
+ return False
+ quote_numeric_fields(item, ("username",))
+
+ transport = wrap(item.get("transport", "TCP")).upper() or "TCP"
+ if transport not in MIERU_TRANSPORTS:
+ return False
+ if "multiplexing" in item and wrap(item.get("multiplexing", "")) not in MIERU_MULTIPLEXING:
+ return False
+ if "handshake-mode" in item and wrap(item.get("handshake-mode", "")) not in MIERU_HANDSHAKE:
+ return False
+ return True
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (self.type_name, item.get("username", ""), item.get("password", ""))
diff --git a/subscribe/outbound/overlay.py b/subscribe/outbound/overlay.py
new file mode 100644
index 0000000000..d85e8535c6
--- /dev/null
+++ b/subscribe/outbound/overlay.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import wrap
+
+
+class EasyTierVerifier(OutboundVerifier):
+ type_name = "easytier"
+ mihomo_only = True
+ require_server = False
+ require_port = False
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ if not wrap(item.get("network-name", "")):
+ return False
+ peers = item.get("peers")
+ listeners = item.get("listeners")
+ has_peers = isinstance(peers, list) and any(wrap(p) if not isinstance(p, dict) else True for p in peers)
+ has_listeners = isinstance(listeners, list) and listeners
+ return bool(has_peers or has_listeners)
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return None
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (self.type_name, item.get("network-name", ""), item.get("network-secret", ""))
+
+
+class TailscaleVerifier(OutboundVerifier):
+ type_name = "tailscale"
+ mihomo_only = True
+ require_server = False
+ require_port = False
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ return True
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return None
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (self.type_name, item.get("auth-key", ""), item.get("hostname", ""), item.get("control-url", ""))
+
+
+class ZeroTierVerifier(OutboundVerifier):
+ type_name = "zerotier"
+ mihomo_only = True
+ require_server = False
+ require_port = False
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ network = wrap(item.get("network", ""))
+ return len(network) == 16
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return None
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (self.type_name, item.get("network", ""), item.get("identity-secret", ""))
diff --git a/subscribe/outbound/quic.py b/subscribe/outbound/quic.py
new file mode 100644
index 0000000000..392ed9d065
--- /dev/null
+++ b/subscribe/outbound/quic.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import (
+ ensure_bool,
+ ensure_number,
+ is_valid_ip,
+ quote_numeric_fields,
+ verify_bbr_profile,
+ verify_congestion_controller,
+ verify_traffic,
+ wrap,
+)
+
+TUIC_UDP_RELAY_MODES = ("native", "quic")
+SHADOWQUIC_VERSIONS = ("v1", "v2")
+
+
+class TuicVerifier(OutboundVerifier):
+ type_name = "tuic"
+ mihomo_only = True
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ token = wrap(item.get("token", ""))
+ uuid = wrap(item.get("uuid", ""))
+ password = wrap(item.get("password", ""))
+
+ if token:
+ if uuid or password:
+ return False
+ item["token"] = token
+ elif uuid:
+ if not password:
+ return False
+ item["password"] = password
+ else:
+ return False
+
+ for field in ("disable-sni", "reduce-rtt", "fast-open"):
+ if not ensure_bool(item, field):
+ return False
+ for field in ("heartbeat-interval", "request-timeout", "max-udp-relay-packet-size", "max-open-streams"):
+ if not ensure_number(item, field):
+ return False
+ if "udp-relay-mode" in item and item["udp-relay-mode"] not in TUIC_UDP_RELAY_MODES:
+ return False
+ if not verify_congestion_controller(item) or not verify_bbr_profile(item):
+ return False
+ if "ip" in item and not is_valid_ip(item.get("ip", "")):
+ return False
+ return True
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return "token" if wrap(item.get("token", "")) else "uuid"
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ if wrap(item.get("token", "")):
+ return (self.type_name, item.get("token", ""))
+ return (self.type_name, item.get("uuid", ""))
+
+
+class ShadowQuicVerifier(OutboundVerifier):
+ type_name = "shadowquic"
+ mihomo_only = True
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ if not wrap(item.get("username", "")):
+ return False
+ quote_numeric_fields(item, ("username",))
+ if not verify_congestion_controller(item) or not verify_bbr_profile(item):
+ return False
+ if not verify_traffic(item, "up") or not verify_traffic(item, "down"):
+ return False
+ if "quic-versions" in item:
+ versions = item.get("quic-versions")
+ if type(versions) != list:
+ return False
+ for version in versions:
+ if wrap(version) not in SHADOWQUIC_VERSIONS:
+ return False
+ return True
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (self.type_name, item.get("username", ""), item.get("password", ""))
diff --git a/subscribe/outbound/shadowsocks.py b/subscribe/outbound/shadowsocks.py
new file mode 100644
index 0000000000..091fda5192
--- /dev/null
+++ b/subscribe/outbound/shadowsocks.py
@@ -0,0 +1,161 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import base64
+import re
+
+import utils
+from outbound.base import OutboundVerifier, VerifyContext
+
+COMMON_SS_SUPPORTED_CIPHERS = [
+ "aes-128-gcm",
+ "aes-192-gcm",
+ "aes-256-gcm",
+ "aes-128-cfb",
+ "aes-192-cfb",
+ "aes-256-cfb",
+ "aes-128-ctr",
+ "aes-192-ctr",
+ "aes-256-ctr",
+ "rc4-md5",
+ "chacha20-ietf",
+ "xchacha20",
+ "chacha20-ietf-poly1305",
+ "xchacha20-ietf-poly1305",
+]
+
+# reference: https://github.com/SagerNet/sing-shadowsocks2/blob/dev/shadowaead_2022/method.go
+MIHOMO_SS_SUPPORTED_CIPHERS_SALT_LEN = {
+ "2022-blake3-aes-128-gcm": 16,
+ "2022-blake3-aes-256-gcm": 32,
+ "2022-blake3-chacha20-poly1305": 32,
+}
+
+MIHOMO_SS_SUPPORTED_CIPHERS = (
+ COMMON_SS_SUPPORTED_CIPHERS
+ + list(MIHOMO_SS_SUPPORTED_CIPHERS_SALT_LEN.keys())
+ + [
+ "aes-128-ccm",
+ "aes-192-ccm",
+ "aes-256-ccm",
+ "aes-128-gcm-siv",
+ "aes-256-gcm-siv",
+ "chacha20",
+ "chacha8-ietf-poly1305",
+ "xchacha8-ietf-poly1305",
+ "lea-128-gcm",
+ "lea-192-gcm",
+ "lea-256-gcm",
+ "rabbit128-poly1305",
+ "aegis-128l",
+ "aegis-256",
+ "aez-384",
+ "deoxys-ii-256-128",
+ "none",
+ ]
+)
+
+SSR_SUPPORTED_CIPHERS = COMMON_SS_SUPPORTED_CIPHERS + ["dummy", "none"]
+SSR_SUPPORTED_OBFS = [
+ "plain",
+ "http_simple",
+ "http_post",
+ "random_head",
+ "tls1.2_ticket_auth",
+ "tls1.2_ticket_fastauth",
+]
+SSR_SUPPORTED_PROTOCOL = [
+ "origin",
+ "auth_sha1_v4",
+ "auth_aes128_md5",
+ "auth_aes128_sha1",
+ "auth_chain_a",
+ "auth_chain_b",
+]
+
+CLASH_SS_PLUGINS = ("", "obfs", "v2ray-plugin")
+MIHOMO_SS_PLUGINS = CLASH_SS_PLUGINS + ("shadow-tls", "restls", "gost-plugin", "kcptun", "jls")
+
+
+def verify_ss_2022_password(cipher: str, password: str) -> bool:
+ password = utils.trim(password)
+ if not password:
+ return False
+
+ words = password.split(":")
+ if cipher == "2022-blake3-chacha20-poly1305" and len(words) > 1:
+ return False
+
+ key_len = MIHOMO_SS_SUPPORTED_CIPHERS_SALT_LEN.get(cipher)
+ if not key_len:
+ return False
+
+ for word in words:
+ if not word or not re.fullmatch(r"[A-Za-z0-9+/]+=*$", word) or len(word) % 4 != 0:
+ return False
+ try:
+ text = base64.b64decode(word, validate=True)
+ except Exception:
+ return False
+ if len(text) != key_len:
+ return False
+
+ return True
+
+
+class ShadowsocksVerifier(OutboundVerifier):
+ type_name = "ss"
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ ciphers = MIHOMO_SS_SUPPORTED_CIPHERS if ctx.is_mihomo else COMMON_SS_SUPPORTED_CIPHERS
+ if item.get("cipher") not in ciphers:
+ return False
+
+ if item["cipher"] in MIHOMO_SS_SUPPORTED_CIPHERS_SALT_LEN:
+ if not verify_ss_2022_password(item["cipher"], str(item.get("password", ""))):
+ return False
+
+ plugin = item.get("plugin", "")
+ plugins = MIHOMO_SS_PLUGINS if ctx.is_mihomo else CLASH_SS_PLUGINS
+ if plugin not in plugins:
+ return False
+ if not plugin:
+ return True
+
+ plugin_opts = item.get("plugin-opts", {})
+ if plugin_opts in (None, ""):
+ plugin_opts = {}
+ if type(plugin_opts) != dict:
+ return False
+
+ mode = plugin_opts.get("mode", "")
+ if plugin == "jls":
+ return all(utils.trim(str(plugin_opts.get(k, ""))) for k in ("host", "username", "password"))
+ if plugin == "restls":
+ return all(utils.trim(str(plugin_opts.get(k, ""))) for k in ("host", "password", "version-hint"))
+ if plugin == "shadow-tls":
+ return bool(utils.trim(str(plugin_opts.get("host", ""))))
+ if plugin == "kcptun":
+ return True
+ if plugin == "obfs":
+ return mode in ("tls", "http")
+ if plugin in ("v2ray-plugin", "gost-plugin"):
+ return mode == "websocket"
+ return False
+
+
+class ShadowsocksRVerifier(OutboundVerifier):
+ type_name = "ssr"
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ if item.get("cipher") not in SSR_SUPPORTED_CIPHERS:
+ return False
+ if item.get("obfs") not in SSR_SUPPORTED_OBFS:
+ return False
+ if item.get("protocol") not in SSR_SUPPORTED_PROTOCOL:
+ return False
+ return True
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (self.type_name, str(item.get("protocol-param", "")).lower())
diff --git a/subscribe/outbound/snell.py b/subscribe/outbound/snell.py
new file mode 100644
index 0000000000..f817911d2c
--- /dev/null
+++ b/subscribe/outbound/snell.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import utils
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import wrap
+
+SNELL_OBFS_MODES = ("", "http", "tls", "shadow-tls", "restls", "jls")
+
+
+class SnellVerifier(OutboundVerifier):
+ type_name = "snell"
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ if "version" in item and not utils.is_number(item["version"]):
+ return False
+
+ version = int(item.get("version", 1))
+ if version < 1 or version > 5:
+ return False
+
+ # v1/v2 do not support UDP; v3/v4/v5 do
+ if version < 3:
+ item.pop("udp", None)
+
+ if "obfs-opts" not in item:
+ return True
+
+ obfs_opts = item.get("obfs-opts", {})
+ if not obfs_opts or type(obfs_opts) != dict:
+ return False
+ if "mode" not in obfs_opts:
+ return True
+
+ mode = wrap(obfs_opts.get("mode", ""))
+ return mode in SNELL_OBFS_MODES
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return "psk"
diff --git a/subscribe/outbound/ssh.py b/subscribe/outbound/ssh.py
new file mode 100644
index 0000000000..4da5fc539e
--- /dev/null
+++ b/subscribe/outbound/ssh.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import quote_numeric_fields, wrap
+
+
+class SshVerifier(OutboundVerifier):
+ type_name = "ssh"
+ mihomo_only = True
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ if not wrap(item.get("username", "")):
+ return False
+ quote_numeric_fields(item, ("username",))
+ has_password = bool(wrap(item.get("password", "")))
+ has_key = bool(wrap(item.get("private-key", "")) or wrap(item.get("private_key", "")))
+ return has_password or has_key
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ if wrap(item.get("password", "")):
+ return "password"
+ return None
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (
+ self.type_name,
+ item.get("username", ""),
+ item.get("password", ""),
+ item.get("private-key", "") or item.get("private_key", ""),
+ )
diff --git a/subscribe/outbound/sudoku.py b/subscribe/outbound/sudoku.py
new file mode 100644
index 0000000000..f689d64375
--- /dev/null
+++ b/subscribe/outbound/sudoku.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import utils
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import ensure_bool, quote_numeric_fields, wrap
+
+SUDOKU_AEAD = ("", "chacha20-poly1305", "aes-128-gcm", "none")
+SUDOKU_TABLES = ("", "prefer_ascii", "prefer_entropy", "up_ascii_down_entropy", "up_entropy_down_ascii")
+SUDOKU_MULTIPLEX = ("", "off", "auto", "on")
+HTTPMASK_MODES = ("", "legacy", "stream", "poll", "auto", "ws")
+
+
+class SudokuVerifier(OutboundVerifier):
+ type_name = "sudoku"
+ mihomo_only = True
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ if not wrap(item.get("key", "")):
+ return False
+ quote_numeric_fields(item, ("key",))
+ if "aead-method" in item and wrap(item.get("aead-method", "")) not in SUDOKU_AEAD:
+ return False
+ if "table-type" in item and wrap(item.get("table-type", "")) not in SUDOKU_TABLES:
+ return False
+ if "multiplex" in item and wrap(item.get("multiplex", "")) not in SUDOKU_MULTIPLEX:
+ return False
+ for field in ("padding-min", "padding-max"):
+ if field in item:
+ if not utils.is_number(item[field]):
+ return False
+ value = int(item[field])
+ if value < 0 or value > 100:
+ return False
+ if "padding-min" in item and "padding-max" in item and int(item["padding-min"]) > int(item["padding-max"]):
+ return False
+ if not ensure_bool(item, "enable-pure-downlink"):
+ return False
+ if "httpmask" in item:
+ httpmask = item.get("httpmask")
+ if type(httpmask) != dict:
+ return False
+ if "mode" in httpmask and wrap(httpmask.get("mode", "")) not in HTTPMASK_MODES:
+ return False
+ return True
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return "key"
diff --git a/subscribe/outbound/tls.py b/subscribe/outbound/tls.py
new file mode 100644
index 0000000000..648722ccb8
--- /dev/null
+++ b/subscribe/outbound/tls.py
@@ -0,0 +1,78 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import base64
+import re
+
+import utils
+from outbound.common import QuotedStr, wrap
+
+
+def verify_reality_public_key(public_key: str) -> bool:
+ # mihomo uses base64.RawURLEncoding and requires 32 bytes
+ public_key = utils.trim(public_key)
+ if not public_key or not re.fullmatch(r"[A-Za-z0-9_-]+", public_key):
+ return False
+
+ try:
+ decoded = base64.urlsafe_b64decode(public_key + "=" * (-len(public_key) % 4))
+ except Exception:
+ return False
+
+ if len(decoded) != 32:
+ return False
+
+ canonical = base64.urlsafe_b64encode(decoded).decode("utf-8").rstrip("=")
+ return canonical == public_key
+
+
+def verify_reality_opts(item: dict[str, object], required: bool = False) -> bool:
+ if "reality-opts" not in item:
+ return not required
+
+ reality_opts = item.get("reality-opts", {})
+ if not reality_opts or type(reality_opts) != dict:
+ return False
+ if "public-key" not in reality_opts or type(reality_opts["public-key"]) != str:
+ return False
+
+ content = utils.trim(reality_opts["public-key"])
+ if not verify_reality_public_key(content):
+ return False
+ reality_opts["public-key"] = content
+
+ if "short-id" not in reality_opts:
+ return True
+
+ short_id = reality_opts["short-id"]
+ if type(short_id) != str:
+ if utils.is_number(short_id):
+ short_id = str(short_id)
+ else:
+ return False
+
+ if short_id:
+ try:
+ sib = bytes.fromhex(short_id)
+ if len(sib) > 8:
+ return False
+ except ValueError:
+ return False
+
+ reality_opts["short-id"] = QuotedStr(short_id)
+ return True
+
+
+def verify_optional_str_dict(item: dict[str, object], field: str, required_keys: tuple[str, ...] = ()) -> bool:
+ if field not in item:
+ return True
+ opts = item.get(field)
+ if type(opts) != dict:
+ return False
+ for key in required_keys:
+ if key not in opts:
+ return False
+ if type(opts.get(key)) != str or not wrap(opts.get(key)):
+ return False
+ return True
diff --git a/subscribe/outbound/transport.py b/subscribe/outbound/transport.py
new file mode 100644
index 0000000000..8847b11cf6
--- /dev/null
+++ b/subscribe/outbound/transport.py
@@ -0,0 +1,301 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import utils
+from outbound.common import wrap
+
+# mihomo ParseRange uses strconv.Atoi
+XHTTP_RANGE_MAX = 2**63 - 1
+XHTTP_RANGE_FIELDS = (
+ "sc-max-each-post-bytes",
+ "sc-min-posts-interval-ms",
+ "x-padding-bytes",
+ "uplink-chunk-size",
+ "session-length",
+)
+XHTTP_RANGE_POSITIVE_MAX = set(["sc-max-each-post-bytes", "sc-min-posts-interval-ms"])
+XHTTP_REUSE_RANGE_FIELDS = (
+ "max-concurrency",
+ "max-connections",
+ "c-max-reuse-times",
+ "h-max-request-times",
+ "h-max-reusable-secs",
+)
+XHTTP_MODES = ("auto", "stream-one", "stream-up", "packet-up")
+MKCP_HEADERS = ("", "none", "srtp", "utp", "wechat-video", "dtls", "wireguard")
+
+
+def parse_xhttp_range_bound(text: str) -> int | None:
+ text = utils.trim(text)
+ if not text:
+ return None
+
+ if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
+ try:
+ value = int(text)
+ except Exception:
+ return None
+ else:
+ try:
+ number = float(text)
+ except Exception:
+ return None
+ if number != number or number == float("inf") or number == float("-inf"):
+ return None
+ value = int(number)
+ if value != number:
+ return None
+
+ if abs(value) > XHTTP_RANGE_MAX:
+ return None
+ return value
+
+
+def normalize_xhttp_range(value: object, allow_negative: bool = False) -> int | None:
+ if isinstance(value, bool) or value is None:
+ return None
+ if isinstance(value, int):
+ if (value < 0 and not allow_negative) or abs(value) > XHTTP_RANGE_MAX:
+ return None
+ return str(value)
+ if isinstance(value, float):
+ if value != value or value == float("inf") or value == float("-inf"):
+ return None
+ if value < 0 and not allow_negative:
+ return None
+ number = int(value)
+ if number != value or abs(number) > XHTTP_RANGE_MAX:
+ return None
+ return str(number)
+
+ text = utils.trim(str(value))
+ if not text:
+ return ""
+
+ bound = parse_xhttp_range_bound(text)
+ if bound is not None:
+ if bound < 0 and not allow_negative:
+ return None
+ return str(bound)
+
+ if text.count("-") != 1:
+ return None
+ left, right = text.split("-", 1)
+ min_val, max_val = parse_xhttp_range_bound(left), parse_xhttp_range_bound(right)
+ if min_val is None or max_val is None or max_val < min_val:
+ return None
+ if min_val < 0 and not allow_negative:
+ return None
+ if min_val == max_val:
+ return str(min_val)
+ return f"{min_val}-{max_val}"
+
+
+def apply_xhttp_range_field(
+ container: dict[str, object], key: str, min_positive: bool = False, max_positive: bool = False
+) -> bool:
+ if key not in container:
+ return True
+
+ value = container[key]
+ if value is None or (isinstance(value, str) and not utils.trim(value)):
+ container.pop(key, None)
+ return True
+
+ normalized = normalize_xhttp_range(value)
+ if normalized is None:
+ return False
+ if not normalized:
+ container.pop(key, None)
+ return True
+
+ parts = normalized.split("-")
+ min_val, max_val = int(parts[0]), int(parts[-1])
+ if min_positive and min_val <= 0:
+ return False
+ if max_positive and max_val <= 0:
+ return False
+
+ container[key] = min_val if len(parts) == 1 else normalized
+ return True
+
+
+def verify_xhttp_reuse_settings(settings: dict[str, object]) -> bool:
+ if type(settings) != dict:
+ return False
+
+ for key in XHTTP_REUSE_RANGE_FIELDS:
+ if not apply_xhttp_range_field(settings, key):
+ return False
+
+ if "h-keep-alive-period" not in settings:
+ return True
+
+ value = settings["h-keep-alive-period"]
+ if value is None or (isinstance(value, str) and not utils.trim(value)):
+ settings.pop("h-keep-alive-period", None)
+ return True
+
+ # wiki allows negatives such as -1 to disable keepalive
+ normalized = normalize_xhttp_range(value, allow_negative=True)
+ if not normalized or "-" in normalized[1:]:
+ return False
+
+ settings["h-keep-alive-period"] = int(normalized)
+ return True
+
+
+def verify_ws_opts(item: dict[str, object], network: str) -> bool:
+ if "ws-opts" not in item:
+ return True
+ if network != "ws":
+ return False
+
+ ws_opts = item.get("ws-opts", {})
+ if not ws_opts or type(ws_opts) != dict:
+ return False
+ if "path" in ws_opts and type(ws_opts["path"]) != str:
+ return False
+ if "headers" in ws_opts and type(ws_opts["headers"]) != dict:
+ return False
+ return True
+
+
+def verify_grpc_opts(item: dict[str, object], network: str) -> bool:
+ if "grpc-opts" not in item:
+ return True
+ if network != "grpc":
+ return False
+
+ grpc_opts = item.get("grpc-opts", {})
+ if not grpc_opts or type(grpc_opts) != dict:
+ return False
+ if "grpc-service-name" in grpc_opts and type(grpc_opts["grpc-service-name"]) != str:
+ return False
+ return True
+
+
+def verify_http_opts(item: dict[str, object], network: str) -> bool:
+ if "http-opts" not in item:
+ return True
+ if network != "http":
+ return False
+
+ http_opts = item.get("http-opts", {})
+ if not http_opts or type(http_opts) != dict:
+ return False
+ if "path" in http_opts and type(http_opts["path"]) != list:
+ return False
+ if "headers" in http_opts:
+ headers = http_opts.get("headers", {})
+ if not isinstance(headers, dict):
+ return False
+ for key, value in headers.items():
+ if not isinstance(key, str):
+ return False
+ if key.lower() == "host" and not isinstance(value, list):
+ return False
+ return True
+
+
+def verify_h2_opts(item: dict[str, object], network: str) -> bool:
+ if "h2-opts" not in item:
+ return True
+ if network != "h2":
+ return False
+
+ h2_opts = item.get("h2-opts", {})
+ if not h2_opts or type(h2_opts) != dict:
+ return False
+ if "host" in h2_opts and type(h2_opts["host"]) != list:
+ return False
+ return True
+
+
+def verify_mkcp_opts(item: dict[str, object], network: str) -> bool:
+ if "mkcp-opts" not in item:
+ return True
+ if network not in ("mkcp", "kcp"):
+ return False
+
+ mkcp_opts = item.get("mkcp-opts", {})
+ if type(mkcp_opts) != dict:
+ return False
+ if "header" in mkcp_opts:
+ header = wrap(mkcp_opts.get("header", ""))
+ if header not in MKCP_HEADERS:
+ return False
+ return True
+
+
+def verify_mekya_opts(item: dict[str, object], network: str) -> bool:
+ if "mekya-opts" not in item:
+ return True
+ if network != "mekya":
+ return False
+
+ mekya_opts = item.get("mekya-opts", {})
+ if not mekya_opts or type(mekya_opts) != dict:
+ return False
+ if "url" in mekya_opts and type(mekya_opts["url"]) != str:
+ return False
+ if "kcp" in mekya_opts and type(mekya_opts["kcp"]) != dict:
+ return False
+ return True
+
+
+def verify_xhttp_opts(item: dict[str, object], network: str) -> bool:
+ if "xhttp-opts" not in item:
+ return True
+ if network != "xhttp":
+ return False
+
+ xhttp_opts = item.get("xhttp-opts", {})
+ if not xhttp_opts or type(xhttp_opts) != dict:
+ return False
+ if "path" in xhttp_opts and type(xhttp_opts["path"]) != str:
+ return False
+ if "host" in xhttp_opts and type(xhttp_opts["host"]) != str:
+ return False
+
+ if "mode" in xhttp_opts:
+ xhttp_mode = wrap(xhttp_opts.get("mode", ""))
+ if xhttp_mode and xhttp_mode not in XHTTP_MODES:
+ return False
+ if "headers" in xhttp_opts and type(xhttp_opts["headers"]) != dict:
+ return False
+
+ for key in XHTTP_RANGE_FIELDS:
+ min_positive = key == "session-length"
+ max_positive = key in XHTTP_RANGE_POSITIVE_MAX
+ if not apply_xhttp_range_field(xhttp_opts, key, min_positive=min_positive, max_positive=max_positive):
+ return False
+
+ if "reuse-settings" in xhttp_opts and not verify_xhttp_reuse_settings(xhttp_opts.get("reuse-settings")):
+ return False
+ if "download-settings" in xhttp_opts:
+ download_settings = xhttp_opts.get("download-settings")
+ if type(download_settings) != dict:
+ return False
+ if "reuse-settings" in download_settings and not verify_xhttp_reuse_settings(
+ download_settings.get("reuse-settings")
+ ):
+ return False
+ return True
+
+
+def normalize_httpupgrade(item: dict[str, object]) -> str:
+ network = wrap(item.get("network", ""))
+ if network != "httpupgrade":
+ return network
+
+ item["network"] = "ws"
+ ws_opts = item.get("ws-opts")
+ if ws_opts is None or ws_opts == "":
+ ws_opts = {}
+ item["ws-opts"] = ws_opts
+ if type(ws_opts) != dict:
+ return "ws"
+ ws_opts["v2ray-http-upgrade"] = True
+ return "ws"
diff --git a/subscribe/outbound/trojan.py b/subscribe/outbound/trojan.py
new file mode 100644
index 0000000000..b9ce0cef91
--- /dev/null
+++ b/subscribe/outbound/trojan.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import wrap
+from outbound.tls import verify_reality_opts
+from outbound.transport import verify_grpc_opts, verify_ws_opts
+
+
+class TrojanVerifier(OutboundVerifier):
+ type_name = "trojan"
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ network = wrap(item.get("network", ""))
+ if network and network not in ("tcp", "ws", "grpc"):
+ return False
+
+ # Trojan outbound no longer has flow; drop leftover XTLS values
+ item.pop("flow", None)
+
+ if not verify_ws_opts(item, network):
+ return False
+ if not verify_grpc_opts(item, network):
+ return False
+ if ctx.is_mihomo and not verify_reality_opts(item):
+ return False
+
+ if "ss-opts" in item:
+ ss_opts = item.get("ss-opts")
+ if type(ss_opts) != dict:
+ return False
+ return True
diff --git a/subscribe/outbound/tunnel.py b/subscribe/outbound/tunnel.py
new file mode 100644
index 0000000000..93b5a3e2ba
--- /dev/null
+++ b/subscribe/outbound/tunnel.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import (
+ quote_numeric_fields,
+ verify_bbr_profile,
+ verify_congestion_controller,
+ wrap,
+)
+
+
+class TrustTunnelVerifier(OutboundVerifier):
+ type_name = "trusttunnel"
+ mihomo_only = True
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ if not wrap(item.get("username", "")):
+ return False
+ quote_numeric_fields(item, ("username",))
+ if not verify_congestion_controller(item) or not verify_bbr_profile(item):
+ return False
+ return True
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (self.type_name, item.get("username", ""), item.get("password", ""))
+
+
+class GostRelayVerifier(OutboundVerifier):
+ type_name = "gost-relay"
+ mihomo_only = True
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ quote_numeric_fields(item, ("username", "password"))
+ return True
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return None
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (self.type_name, item.get("username", ""), item.get("password", ""))
diff --git a/subscribe/outbound/vless.py b/subscribe/outbound/vless.py
new file mode 100644
index 0000000000..0244664d24
--- /dev/null
+++ b/subscribe/outbound/vless.py
@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import base64
+import re
+
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import wrap
+from outbound.tls import verify_reality_opts
+from outbound.transport import (
+ verify_grpc_opts,
+ verify_h2_opts,
+ verify_http_opts,
+ verify_ws_opts,
+ verify_xhttp_opts,
+)
+
+VLESS_MLKEM_X25519_PLUS_PREFIX = "mlkem768x25519plus"
+VLESS_MLKEM_X25519_PLUS_MODES = ("native", "xorpub", "random")
+VLESS_MLKEM_X25519_PLUS_RTTS = ("1rtt", "0rtt")
+VLESS_MLKEM_X25519_PLUS_PADDING_LIMIT = 20
+VLESS_MLKEM_X25519_PLUS_KEY_SIZES = (32, 1184)
+VLESS_VISION_FLOW = "xtls-rprx-vision"
+
+
+def verify_vless_encryption(encryption: str) -> bool:
+ if not encryption or encryption == "none":
+ return True
+
+ parts = encryption.split(".")
+ if (
+ len(parts) < 4
+ or parts[0] != VLESS_MLKEM_X25519_PLUS_PREFIX
+ or parts[1] not in VLESS_MLKEM_X25519_PLUS_MODES
+ or parts[2] not in VLESS_MLKEM_X25519_PLUS_RTTS
+ ):
+ return False
+
+ for key in parts[3:]:
+ if len(key) < VLESS_MLKEM_X25519_PLUS_PADDING_LIMIT:
+ continue
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", key):
+ return False
+
+ try:
+ content = key + "=" * (-len(key) % 4)
+ decoded = base64.urlsafe_b64decode(content)
+ except Exception:
+ return False
+
+ if len(decoded) not in VLESS_MLKEM_X25519_PLUS_KEY_SIZES:
+ return False
+
+ return True
+
+
+class VlessVerifier(OutboundVerifier):
+ type_name = "vless"
+ mihomo_only = True
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ encryption = wrap(item.get("encryption", ""))
+ if not verify_vless_encryption(encryption):
+ return False
+
+ network = wrap(item.get("network", "tcp")) or "tcp"
+ if network not in ("ws", "tcp", "grpc", "http", "h2", "xhttp"):
+ return False
+
+ if "flow" in item:
+ flow = wrap(item.get("flow", ""))
+ if flow:
+ # mihomo truncates flow to 16 chars, so vision-udp443 is valid
+ if flow[:16] != VLESS_VISION_FLOW:
+ return False
+ item["flow"] = VLESS_VISION_FLOW
+
+ if not verify_ws_opts(item, network):
+ return False
+ if not verify_grpc_opts(item, network):
+ return False
+ if not verify_http_opts(item, network):
+ return False
+ if not verify_h2_opts(item, network):
+ return False
+ if not verify_reality_opts(item):
+ return False
+ if not verify_xhttp_opts(item, network):
+ return False
+ return True
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return "uuid"
diff --git a/subscribe/outbound/vmess.py b/subscribe/outbound/vmess.py
new file mode 100644
index 0000000000..b38b903012
--- /dev/null
+++ b/subscribe/outbound/vmess.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import utils
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.tls import verify_reality_opts
+from outbound.transport import (
+ normalize_httpupgrade,
+ verify_grpc_opts,
+ verify_h2_opts,
+ verify_http_opts,
+ verify_mekya_opts,
+ verify_mkcp_opts,
+ verify_ws_opts,
+)
+
+VMESS_CIPHERS = ["auto", "aes-128-gcm", "chacha20-poly1305", "none"]
+VMESS_NETWORKS = ("tcp", "ws", "h2", "http", "grpc", "mkcp", "kcp", "mekya")
+
+
+class VmessVerifier(OutboundVerifier):
+ type_name = "vmess"
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ network = normalize_httpupgrade(item)
+ if not network:
+ for key, value in (
+ ("ws-opts", "ws"),
+ ("http-opts", "http"),
+ ("h2-opts", "h2"),
+ ("grpc-opts", "grpc"),
+ ("mkcp-opts", "mkcp"),
+ ("mekya-opts", "mekya"),
+ ):
+ if key in item:
+ network = value
+ break
+ else:
+ network = "tcp" if ctx.is_mihomo else "ws"
+
+ allowed = VMESS_NETWORKS if ctx.is_mihomo else ("ws", "h2", "http", "grpc")
+ if network not in allowed:
+ return False
+
+ ciphers = VMESS_CIPHERS + ["zero"] if ctx.is_mihomo else VMESS_CIPHERS
+ if item.get("cipher") not in ciphers:
+ return False
+ if "alterId" not in item or not utils.is_number(item["alterId"]):
+ return False
+
+ if not verify_h2_opts(item, network):
+ return False
+ if not verify_http_opts(item, network):
+ return False
+ if not verify_ws_opts(item, network):
+ return False
+ if "grpc-opts" in item:
+ if not ctx.is_mihomo:
+ return False
+ if not verify_grpc_opts(item, network):
+ return False
+ if ctx.is_mihomo:
+ if not verify_mkcp_opts(item, network):
+ return False
+ if not verify_mekya_opts(item, network):
+ return False
+ if not verify_reality_opts(item):
+ return False
+ return True
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return "uuid"
diff --git a/subscribe/outbound/vpn.py b/subscribe/outbound/vpn.py
new file mode 100644
index 0000000000..81d3a6645c
--- /dev/null
+++ b/subscribe/outbound/vpn.py
@@ -0,0 +1,110 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+from outbound.base import OutboundVerifier, VerifyContext
+from outbound.common import wrap
+
+OPENVPN_PROTOS = ("", "udp", "tcp")
+OPENVPN_CIPHERS = (
+ "",
+ "AES-128-GCM",
+ "AES-192-GCM",
+ "AES-256-GCM",
+ "AES-128-CBC",
+ "AES-192-CBC",
+ "AES-256-CBC",
+ "AES-CBC",
+ "CHACHA20-POLY1305",
+)
+OPENVPN_AUTH = ("", "MD5", "SHA1", "SHA256", "SHA384", "SHA512")
+OPENVPN_COMP = ("", "yes", "no", "adaptive")
+OPENVPN_CIPHERS_UPPER = {c.upper() for c in OPENVPN_CIPHERS}
+MASQUE_NETWORKS = ("", "quic", "h2", "h3-l4proxy")
+
+
+class WireGuardVerifier(OutboundVerifier):
+ type_name = "wireguard"
+ mihomo_only = True
+
+ def needs_server(self, item: dict[str, object]) -> bool:
+ peers = item.get("peers")
+ return not (isinstance(peers, list) and peers)
+
+ def needs_port(self, item: dict[str, object]) -> bool:
+ return self.needs_server(item)
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ if not wrap(item.get("private-key", "")):
+ return False
+ peers = item.get("peers")
+ if isinstance(peers, list) and peers:
+ for peer in peers:
+ if type(peer) != dict:
+ return False
+ if not wrap(peer.get("public-key", "")):
+ return False
+ if "allowed-ips" not in peer:
+ return False
+ return True
+ return bool(wrap(item.get("public-key", "")))
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return None
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (self.type_name, item.get("private-key", ""), item.get("public-key", ""))
+
+
+class OpenVPNVerifier(OutboundVerifier):
+ type_name = "openvpn"
+ mihomo_only = True
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ if not wrap(item.get("ca", "")):
+ return False
+ if "proto" in item and wrap(item.get("proto", "")).lower() not in OPENVPN_PROTOS:
+ return False
+ if "cipher" in item and wrap(item.get("cipher", "")).upper() not in OPENVPN_CIPHERS_UPPER:
+ return False
+ if "auth" in item and wrap(item.get("auth", "")).upper() not in OPENVPN_AUTH:
+ return False
+ if "comp-lzo" in item and wrap(item.get("comp-lzo", "")) not in OPENVPN_COMP:
+ return False
+
+ has_user = bool(wrap(item.get("username", "")) and wrap(item.get("password", "")))
+ has_cert = bool(wrap(item.get("cert", "")) and wrap(item.get("key", "")))
+ return has_user or has_cert
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ if wrap(item.get("password", "")):
+ return "password"
+ return None
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (
+ self.type_name,
+ item.get("username", ""),
+ item.get("password", ""),
+ item.get("cert", ""),
+ item.get("key", ""),
+ )
+
+
+class MasqueVerifier(OutboundVerifier):
+ type_name = "masque"
+ mihomo_only = True
+
+ def verify_fields(self, item: dict[str, object], ctx: VerifyContext) -> bool:
+ if not wrap(item.get("private-key", "")) or not wrap(item.get("public-key", "")):
+ return False
+ network = wrap(item.get("network", ""))
+ if network not in MASQUE_NETWORKS:
+ return False
+ return True
+
+ def auth_field(self, item: dict[str, object]) -> str | None:
+ return None
+
+ def duplicate_key(self, item: dict[str, object]) -> tuple[str, object]:
+ return (self.type_name, item.get("private-key", ""), item.get("public-key", ""))
diff --git a/subscribe/pipeline.py b/subscribe/pipeline.py
new file mode 100644
index 0000000000..7b6b1a0567
--- /dev/null
+++ b/subscribe/pipeline.py
@@ -0,0 +1,234 @@
+# -*- coding: utf-8 -*-
+
+import os
+import random
+import subprocess
+import time
+
+import utils
+import workflow
+import yaml
+from airport import AirPort
+from config.models import GroupConfig, NodeInput, SiteConfig
+from logger import logger
+from origin import Origin
+from workflow import TaskConfig, exists
+
+import clash
+import subconverter
+
+
+def assign_sites(
+ sites: list[SiteConfig],
+ groups: dict[str, GroupConfig],
+ retry: int,
+ bin_name: str,
+ allow_gmail_alias: bool = False,
+ special_protocols: bool | None = None,
+) -> tuple[list[TaskConfig], dict[str, list[int]]]:
+ tasks, grouped = [], {}
+ retry, globalid = max(1, retry), 0
+ if special_protocols is None:
+ special_protocols = AirPort.enable_special_protocols()
+
+ for site in sites or []:
+ if not isinstance(site, SiteConfig) or not site.enable:
+ continue
+ name = utils.trim(site.name).lower()
+ domain = utils.trim(site.domain).lower()
+ subscribe = site.nodes.subscribe_list()
+ if len(subscribe) >= 2:
+ subscribe = list(dict.fromkeys(subscribe))
+
+ count = min(max(1, int(site.count)), 10)
+ if subscribe:
+ count = len(subscribe)
+ if site.renew and site.renew.accounts:
+ count = len(site.renew.accounts)
+
+ source = site.origin
+ if not source:
+ source = Origin.TEMPORARY.name if not domain else Origin.OWNED.name
+ site.origin = source
+ if source != Origin.TEMPORARY.name:
+ site.errors = max(site.errors, 0) + 1
+ if name:
+ site.name = name.rsplit("-", maxsplit=1)[0]
+
+ if not name or (not domain and site.nodes.empty()) or count <= 0:
+ continue
+
+ if site.nodes.uris or site.nodes.proxies:
+ globalid += 1
+ task = TaskConfig(
+ name=name,
+ taskid=globalid,
+ domain=domain,
+ nodes=NodeInput(
+ subscribe=list(subscribe),
+ uris=list(site.nodes.uris),
+ proxies=list(site.nodes.proxies),
+ ),
+ index=-1,
+ retry=retry,
+ max_rate=site.max_rate,
+ bin_name=bin_name,
+ renew=(
+ site.renew.jobs(coupon=site.coupon, api_prefix=site.api_prefix)[0]
+ if site.renew and site.renew.accounts
+ else None
+ ),
+ rename=site.rename,
+ exclude=site.exclude,
+ include=site.include,
+ check_alive=site.check_alive,
+ coupon=site.coupon,
+ require_tls=site.require_tls,
+ ignore_default_exclude=site.ignore_default_exclude,
+ allow_gmail_alias=allow_gmail_alias,
+ skip_captcha=site.skip_captcha,
+ special_protocols=special_protocols,
+ invite_code=site.invite_code,
+ api_prefix=site.api_prefix,
+ )
+ if not exists(tasks=tasks, task=task):
+ tasks.append(task)
+ for push_name in site.push_to:
+ grouped.setdefault(push_name, []).append(globalid)
+ continue
+
+ for index in range(count):
+ globalid += 1
+ sub = subscribe[index] if index < len(subscribe) else ""
+ renew = None
+ if site.renew:
+ jobs = site.renew.jobs(coupon=site.coupon, api_prefix=site.api_prefix)
+ if index < len(jobs):
+ renew = jobs[index]
+ task = TaskConfig(
+ name=name,
+ taskid=globalid,
+ domain=domain,
+ nodes=NodeInput(subscribe=sub),
+ index=-1 if count == 1 else index + 1,
+ retry=retry,
+ max_rate=site.max_rate,
+ bin_name=bin_name,
+ renew=renew,
+ rename=site.rename,
+ exclude=site.exclude,
+ include=site.include,
+ check_alive=site.check_alive,
+ coupon=site.coupon,
+ require_tls=site.require_tls,
+ ignore_default_exclude=site.ignore_default_exclude,
+ allow_gmail_alias=allow_gmail_alias,
+ skip_captcha=site.skip_captcha,
+ special_protocols=special_protocols,
+ invite_code=site.invite_code,
+ api_prefix=site.api_prefix,
+ )
+ if exists(tasks=tasks, task=task):
+ continue
+ tasks.append(task)
+ for push_name in site.push_to:
+ if push_name not in groups:
+ logger.error(f"cannot found push config, name=[{push_name}]\tsite=[{name}]")
+ continue
+ grouped.setdefault(push_name, []).append(globalid)
+
+ return tasks, grouped
+
+
+def execute_tasks(tasks: list[TaskConfig]) -> list[tuple[int, list[dict[str, object]]]]:
+ return utils.multi_process_run(func=workflow.executewrapper, tasks=tasks)
+
+
+def check_alive_proxies(
+ proxies: list[dict[str, object]],
+ clash_bin: str,
+ workspace: str,
+ filename: str = "config.yaml",
+ timeout: int = 5000,
+ test_url: str = "https://www.google.com/generate_204",
+ delay: int = 5000,
+ num_threads: int = 64,
+ display: bool = True,
+ skip: bool = False,
+ group: str = "",
+) -> list[dict[str, object]]:
+ if not proxies:
+ return []
+
+ proxies = clash.generate_config(workspace, proxies, filename)
+ if skip:
+ return proxies
+
+ checks, nochecks = workflow.liveness_fillter(proxies=proxies)
+ if not checks:
+ return nochecks
+
+ binpath = os.path.join(workspace, clash_bin)
+ utils.chmod(binpath)
+ logger.info(f"startup clash now, workspace: {workspace}, config: {filename}")
+ process = subprocess.Popen([binpath, "-d", workspace, "-f", os.path.join(workspace, filename)])
+ logger.info(f"clash start success, begin check proxies, group: {group}\tcount: {len(checks)}")
+ time.sleep(random.randint(5, 8))
+ params = [
+ [item, clash.EXTERNAL_CONTROLLER, timeout, test_url, delay, False] for item in checks if isinstance(item, dict)
+ ]
+ masks = utils.multi_thread_run(func=clash.check, tasks=params, num_threads=num_threads, show_progress=display)
+ try:
+ process.terminate()
+ except Exception:
+ logger.error(f"terminate clash process error, group: {group}")
+
+ availables = [checks[i] for i in range(len(checks)) if masks[i]]
+ nochecks.extend(availables)
+ logger.info(
+ f"proxies check finished, total: {len(checks)}, alive: {len(availables)}, dead: {len(checks) - len(availables)}"
+ )
+ return nochecks
+
+
+def convert_proxies(
+ proxies: list[dict[str, object]],
+ subconverter_bin: str,
+ workspace: str,
+ source_file: str,
+ dest_file: str,
+ artifact: str,
+ target: str,
+ emoji: bool = True,
+ list_only: bool = True,
+ ignore_exclude: bool = False,
+) -> str:
+ filepath = os.path.join(workspace, source_file)
+ with open(filepath, "w+", encoding="utf8") as handle:
+ yaml.add_representer(clash.QuotedStr, clash.quoted_scalar)
+ yaml.dump({"proxies": proxies}, handle, allow_unicode=True)
+
+ generate_conf = os.path.join(workspace, "generate.ini")
+ if os.path.exists(generate_conf) and os.path.isfile(generate_conf):
+ os.remove(generate_conf)
+
+ success = subconverter.generate_conf(
+ filepath=generate_conf,
+ name=artifact,
+ source=source_file,
+ dest=dest_file,
+ target=target,
+ emoji=emoji,
+ list_only=list_only,
+ ignore_exclude=ignore_exclude,
+ )
+ if not success:
+ return ""
+ if not subconverter.convert(binname=subconverter_bin, artifact=artifact):
+ return ""
+
+ converted = os.path.join(workspace, dest_file)
+ if not os.path.exists(converted) or not os.path.isfile(converted):
+ return ""
+ with open(converted, "r", encoding="utf8") as handle:
+ return handle.read()
diff --git a/subscribe/process.py b/subscribe/process.py
index cc98014986..2df8b06377 100644
--- a/subscribe/process.py
+++ b/subscribe/process.py
@@ -5,29 +5,24 @@
import argparse
import base64
-import copy
import itertools
import json
import os
-import random
import re
-import subprocess
import sys
import time
import traceback
-from copy import deepcopy
-from dataclasses import dataclass, field
-import crawl
import executable
import location
+import pipeline
import push
import utils
import workflow
import yaml
from airport import AirPort
+from config.models import NodeInput, ProcessConfig, StorageItem
from logger import logger
-from origin import Origin
from workflow import TaskConfig
import clash
@@ -36,25 +31,9 @@
PATH = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
-@dataclass
-class ProcessConfig(object):
- # task list
- tasks: list[dict] = field(default_factory=list)
-
- # crawl config
- crawl: dict = field(default_factory=dict)
-
- # persist config
- storage: dict = field(default_factory=dict)
-
- # groups config
- groups: dict[dict] = field(default_factory=dict)
-
- # update config
- update: dict = field(default_factory=dict)
-
- # max acceptable delay
- delay: int = 5000
+def parse_workflow_mode(raw: str | None = None) -> int:
+ text = utils.trim(raw if raw is not None else os.environ.get("WORKFLOW_MODE", "0"))
+ return 0 if not text.isdigit() else min(max(int(text), 0), 2)
def load_configs(
@@ -62,256 +41,56 @@ def load_configs(
only_check: bool = False,
num_threads: int = 0,
display: bool = True,
+ retry: int = 3,
+ mode: int = 0,
) -> ProcessConfig:
- def parse_config(config: dict) -> None:
- tasks.extend(config.get("domains", []))
- groups.update(config.get("groups", {}))
- update_conf.update(config.get("update", {}))
- crawl_conf.update(config.get("crawl", {}))
- storage.update(config.get("storage", {}))
-
- push_conf = deepcopy(storage)
- push_conf.pop("items", None)
-
- nonlocal delay
- delay = min(delay, max(config.get("delay", sys.maxsize), 50))
-
- if only_check:
- return
-
- # global exclude
- params["exclude"] = crawl_conf.get("exclude", "")
-
- # persistence configuration
- persist = {k: storage.get("items", {}).get(v, {}) for k, v in crawl_conf.get("persist", {}).items()}
- params["storage"] = {"items": persist, **push_conf}
-
- params["config"] = crawl_conf.get("config", {})
- params["enable"] = crawl_conf.get("enable", True)
- params["singlelink"] = crawl_conf.get("singlelink", False)
-
- threshold = max(crawl_conf.get("threshold", 1), 1)
- params["threshold"] = threshold
- spiders = deepcopy(crawl_conf)
-
- # spider's config for telegram
- telegram_conf = spiders.get("telegram", {})
- users = telegram_conf.pop("users", {})
- telegram_conf["pages"] = max(telegram_conf.get("pages", 1), 1)
- if telegram_conf.pop("enable", True) and users:
- enabled_users, common_exclude = {}, telegram_conf.pop("exclude", "")
- for k, v in users.items():
- exclude = v.get("exclude", "").strip()
- v["exclude"] = f"{exclude}|{common_exclude}".removeprefix("|") if common_exclude else exclude
- v["push_to"] = list(set(v.get("push_to", [])))
-
- enabled_users[k] = v
- telegram_conf["users"] = enabled_users
- params["telegram"] = telegram_conf
-
- # spider's config for google
- google_conf = spiders.get("google", {})
- push_to = list(set(google_conf.get("push_to", [])))
- if google_conf.pop("enable", True) and push_to:
- google_conf["push_to"] = push_to
- params["google"] = google_conf
-
- # spider's config for yandex
- yandex_conf = spiders.get("yandex", {})
- push_to = list(set(yandex_conf.get("push_to", [])))
- if yandex_conf.pop("enable", True) and push_to:
- yandex_conf["push_to"] = push_to
- params["yandex"] = yandex_conf
-
- # spider's config for github
- github_conf = spiders.get("github", {})
- push_to = list(set(github_conf.get("push_to", [])))
- spams = list(set(github_conf.get("spams", [])))
- if github_conf.pop("enable", True) and push_to:
- github_conf["pages"] = max(github_conf.get("pages", 1), 1)
- github_conf["push_to"] = push_to
- github_conf["spams"] = spams
- params["github"] = github_conf
-
- # spider's config for twitter
- twitter_conf = spiders.get("twitter", {})
- users = twitter_conf.pop("users", {})
- if twitter_conf.pop("enable", True) and users:
- enabled_users = {}
- for k, v in users.items():
- if utils.isblank(k) or not v or type(v) != dict or not v.pop("enable", True):
- continue
-
- v["push_to"] = list(set(v.get("push_to", [])))
- enabled_users[k] = v
-
- params["twitter"] = enabled_users
-
- # spider's config for github's repositories
- repo_conf, repositories = spiders.get("repositories", []), {}
- for repo in repo_conf:
- enable = repo.pop("enable", True)
- username = repo.get("username", "").strip()
- repo_name = repo.get("repo_name", "").strip()
- if not enable or not username or not repo_name:
- continue
-
- key = "/".join([username, repo_name])
- push_to = list(set(repo.get("push_to", [])))
- repo["username"] = username
- repo["repo_name"] = repo_name
- repo["commits"] = max(repo.get("commits", 3), 1)
- repo["push_to"] = push_to
-
- repositories[key] = repo
- params["repositories"] = repositories
-
- # spider's config for specified page
- pages_conf, pages = spiders.get("pages", []), {}
- for page in pages_conf:
- enable = page.pop("enable", True)
- url = page.get("url", "")
- push_to = list(set(page.get("push_to", [])))
- if not enable or not url or not push_to:
- continue
-
- multiple = page.pop("multiple", False)
- if not multiple:
- page["push_to"] = push_to
- if isinstance(url, str):
- pages[url] = page
- elif isinstance(url, list):
- for u in url:
- u = utils.trim(u)
- if u:
- pages[u] = page
- else:
- placeholder = utils.trim(page.pop("placeholder", ""))
- if not placeholder or placeholder not in url or not isinstance(url, str):
- continue
-
- # page number range
- start, end = -1, -1
- try:
- start = int(page.pop("start", 1))
- end = int(page.pop("end", 1))
- except:
- pass
-
- if start < 0 or end < start:
- continue
-
- for i in range(start, end + 1):
- copypage = deepcopy(page)
- link = url.replace(placeholder, str(i))
- copypage["url"] = link
- copypage["push_to"] = push_to
- pages[link] = copypage
-
- params["pages"] = pages
-
- # spider's config for scripts
- scripts_conf, scripts = spiders.get("scripts", []), {}
-
- for script in scripts_conf:
- enable = script.pop("enable", True)
- path = script.pop("script", "").strip()
- if not enable or not path:
- continue
-
- task_conf = script.get("params", {})
- if not isinstance(task_conf, dict):
- task_conf = {}
-
- # record storge
- task_conf["storage"] = {"items": task_conf.pop("persist", {}), **push_conf}
-
- scripts[path] = task_conf
- params["scripts"] = scripts
-
- def verify(storage: dict, groups: dict) -> bool:
- if not isinstance(storage, dict) or not isinstance(groups, dict):
- return False
-
- pushtool = push.get_instance(config=push.PushConfig.from_dict(storage))
- if not isinstance(storage.get("items", {}), dict):
- logger.error(f"cannot found any valid storage config")
- return False
-
- items = pushtool.filter_push(config=storage.get("items", {}))
- for name, group in groups.items():
- name = utils.trim(name)
-
- if not name or not isinstance(group, dict):
- logger.error(f"invalid group config, name: {name}")
- return False
-
- targets = group.get("targets", {})
- if not targets or not isinstance(targets, dict):
- logger.error(f"group {name} should contain at least one type conversion")
- return False
-
- for category, storage_name in targets.items():
- category = utils.trim(category).lower()
- if category not in subconverter.CONVERT_TARGETS:
- logger.error(f"group {name} contains unsupported conversion type: {category}")
- return False
-
- storage_name = utils.trim(storage_name)
- if storage_name not in items:
- logger.error(f"missing storage configuration for group {name} to convert type to {category}")
- return False
-
- return True
-
- tasks, delay, storage, groups = [], sys.maxsize, {}, {}
- params, crawl_conf, update_conf = {}, {}, {}
+ from crawl.engine import run
+ raw = {}
try:
if re.match(
r"^(https?:\/\/(([a-zA-Z0-9]+-?)+[a-zA-Z0-9]+\.)+[a-zA-Z]+)(:\d+)?(\/.*)?(\?.*)?(#.*)?$",
url,
):
headers = {"User-Agent": utils.USER_AGENT, "Referer": url}
- content = utils.http_get(url=url, headers=headers)
+ content = utils.http_get(url=url, headers=headers, retry=max(retry, 1), timeout=120)
if not content:
logger.error(f"cannot fetch config from remote, url: {utils.hide(url=url)}")
else:
os.environ["SUBSCRIBE_CONF"] = url
- parse_config(json.loads(content))
+ raw = json.loads(content)
else:
localfile = os.path.abspath(url)
if os.path.exists(localfile) and os.path.isfile(localfile):
- config = json.loads(open(localfile, "r", encoding="utf8").read())
+ raw = json.loads(open(localfile, "r", encoding="utf8").read())
os.environ["SUBSCRIBE_CONF"] = localfile
- parse_config(config)
- # check configuration
- if not verify(storage=storage, groups=groups):
- raise ValueError(f"there are some errors in the configuration, please check and confirm")
-
- # execute crawl tasks
- if params:
- result = crawl.batch_crawl(conf=params, num_threads=num_threads, display=display)
- tasks.extend(result)
+ config = ProcessConfig.parse(raw or {})
+ pushtool = push.get_instance(config.storage)
+ config.verify(pushtool)
+ crawl_enabled = bool(config.crawl and config.crawl.enable)
+ if not only_check:
+ if mode == 1 and not crawl_enabled:
+ logger.warning("exit process because mode=1 and crawling task is disabled")
+ sys.exit(0)
+ if crawl_enabled:
+ config.sites.extend(
+ run(config.crawl, storage=config.storage, num_threads=num_threads, display=display, mode=mode)
+ )
+ if mode == 1:
+ sys.exit(0)
+ return config
except SystemExit as e:
if e.code != 0:
logger.error("parse configuration failed due to process abnormally exits")
-
sys.exit(e.code)
- except:
+ except ValueError as e:
+ logger.error(f"invalid configuration: {e}")
+ sys.exit(0)
+ except Exception:
logger.error(f"occur error when load task config:\n{traceback.format_exc()}")
sys.exit(0)
- return ProcessConfig(
- tasks=tasks,
- crawl=crawl_conf,
- storage=storage,
- groups=groups,
- update=update_conf,
- delay=delay,
- )
-
def assign(
pc: ProcessConfig,
@@ -319,188 +98,54 @@ def assign(
bin_name: str,
remain: bool,
pushtool: push.PushTo,
- only_check=False,
+ only_check: bool = False,
rigid: bool = True,
-) -> tuple[list[TaskConfig], dict, list]:
- if not isinstance(pc, ProcessConfig):
- return [], {}, []
-
- tasks, groups, arrays = [], {}, []
- retry, globalid = max(1, retry), 0
-
- # 是否允许特殊协议
- special_protocols = AirPort.enable_special_protocols()
-
- sites = [] if not isinstance(pc.tasks, list) else deepcopy(pc.tasks)
- for site in sites:
- if not site:
- continue
-
- name = site.get("name", "").strip().lower()
- domain = site.get("domain", "").strip().lower()
-
- # 订阅地址,支持单个或多个
- subscribe = site.get("sub", "")
- if isinstance(subscribe, str):
- subscribe = [subscribe.strip()]
- subscribe = [s for s in subscribe if s.strip() != ""]
- if len(subscribe) >= 2:
- subscribe = list(set(subscribe))
-
- # 节点倍率超过该值将会被丢弃
- rate = float(site.get("rate", 3.0))
-
- # 需要注册账号的个数
- num = min(max(1, int(site.get("count", 1))), 10)
-
- # 如果订阅链接不为空,num为订阅链接数
- num = len(subscribe) if subscribe else num
-
- # 组名列表
- push_names = site.get("push_to", [])
-
- # 失败次数,超过该值将不再尝试注册
- errors = max(site.get("errors", 0), 0) + 1
-
- # 来源类别
- source = site.get("origin", "")
-
- # 重命名规则,正常正则表达式
- rename = site.get("rename", "")
-
- # 排除匹配到的节点,支持正则表达式
- exclude = site.get("exclude", "").strip()
-
- # 仅保留匹配到的节点,支持正则表达式
- include = site.get("include", "").strip()
-
- # 是否检查 ChatGPT 的连通性
- chatgpt = site.get("chatgpt", {})
-
- # 是否对节点测活
- liveness = site.get("liveness", True)
-
- # 拒绝跳过证书验证
- disable_insecure = site.get("secure", False)
-
- # 优惠码
- coupon = utils.trim(site.get("coupon", ""))
+ special_protocols: bool | None = None,
+) -> tuple[list[TaskConfig], dict[str, list[int]]]:
+ if not isinstance(pc, ProcessConfig) or not isinstance(pushtool, push.PushTo):
+ return [], {}
- # 邀请码
- invite_code = utils.trim(site.get("invite_code", ""))
+ special_protocols = AirPort.enable_special_protocols(special_protocols)
- # 覆盖subconverter默认exclude规则
- ignoreder = site.get("ignorede", False)
-
- # 需要人机验证时是否直接放弃
- chuck = site.get("chuck", False)
-
- # 接口地址前缀
- api_prefix = site.get("api_prefix", "")
-
- if not source:
- source = Origin.TEMPORARY.name if not domain else Origin.OWNED.name
- site["origin"] = source
-
- if source != Origin.TEMPORARY.name:
- site["errors"] = errors
-
- site["name"] = name.rsplit(crawl.SEPARATOR, maxsplit=1)[0]
- arrays.append(site)
-
- renews = copy.deepcopy(site.get("renew", {}))
- accounts = renews.pop("account", [])
-
- # 如果renew不为空,num为配置的renew账号数
- num = len(accounts) if accounts else num
-
- if not site.get("enable", True) or "" == name or ("" == domain and not subscribe) or num <= 0:
- continue
-
- for i in range(num):
- index = -1 if num == 1 else i + 1
- sub = subscribe[i] if subscribe else ""
- renew = {"coupon_code": coupon} if coupon else {}
-
- globalid += 1
- if accounts:
- renew.update(accounts[i])
- renew.update(renews)
-
- if renew and api_prefix:
- renew["api_prefix"] = api_prefix
-
- task = TaskConfig(
- name=name,
- taskid=globalid,
- domain=domain,
- sub=sub,
- index=index,
- retry=retry,
- rate=rate,
- bin_name=bin_name,
- renew=renew,
- rename=rename,
- exclude=exclude,
- include=include,
- chatgpt=chatgpt,
- liveness=liveness,
- coupon=coupon,
- disable_insecure=disable_insecure,
- ignorede=ignoreder,
- rigid=rigid,
- chuck=chuck,
- special_protocols=special_protocols,
- invite_code=invite_code,
- api_prefix=api_prefix,
- )
- found = workflow.exists(tasks=tasks, task=task)
- if found:
- continue
-
- tasks.append(task)
- for push_name in push_names:
- if push_name not in pc.groups:
- logger.error(f"cannot found push config, name=[{push_name}]\tsite=[{name}]")
- continue
-
- taskids = groups.get(push_name, [])
- taskids.append(globalid)
- groups[push_name] = taskids
+ tasks, groups = pipeline.assign_sites(
+ sites=pc.sites,
+ groups=pc.groups,
+ retry=retry,
+ bin_name=bin_name,
+ allow_gmail_alias=not rigid,
+ special_protocols=special_protocols,
+ )
if (remain or only_check) and pc.groups:
if only_check:
- # clean all extra tasks
- tasks, groups, globalid = [], {k: [] for k in groups.keys()}, 0
+ tasks, groups, globalid = [], {k: [] for k in pc.groups.keys()}, 0
+ else:
+ globalid = max([task.taskid for task in tasks], default=0)
- for k, v in pc.groups.items():
- taskids = groups.get(k, [])
- targets = v.get("targets", {})
- if not targets:
+ for name, group in pc.groups.items():
+ taskids = groups.get(name, [])
+ if not group.targets:
continue
-
- # get the first conversion configuration for each group
- values = list(targets.values())
- config = pc.storage.get("items", {}).get(values[0], {})
- subscribe = pushtool.raw_url(config=config)
- if k not in groups or not subscribe:
+ values = list(group.targets.values())
+ item = pc.storage.items.get(values[0])
+ subscribe = pushtool.raw_url(item=item) if item else ""
+ if name not in groups or not subscribe:
continue
-
globalid += 1
tasks.append(
TaskConfig(
- name=f"remains-{k}",
+ name=f"remains-{name}",
taskid=globalid,
- sub=subscribe,
+ nodes=NodeInput(subscribe=subscribe),
index=-1,
- retry=retry,
+ retry=max(1, retry),
bin_name=bin_name,
special_protocols=special_protocols,
)
)
taskids.append(globalid)
- groups[k] = taskids
- return tasks, groups, arrays
+ groups[name] = taskids
+ return tasks, groups
def aggregate(args: argparse.Namespace) -> None:
@@ -509,6 +154,7 @@ def aggregate(args: argparse.Namespace) -> None:
clash_bin, subconverter_bin = executable.which_bin()
display = not args.invisible
+ retry = min(max(1, args.retry), 10)
# parse config
server = utils.trim(args.server) or os.environ.get("SUBSCRIBE_CONF", "").strip()
@@ -517,14 +163,14 @@ def aggregate(args: argparse.Namespace) -> None:
only_check=args.check,
num_threads=args.num,
display=display,
+ retry=retry,
+ mode=args.mode,
)
- storages = process_config.storage or {}
- pushtool = push.get_instance(config=push.PushConfig.from_dict(storages))
- retry = min(max(1, args.retry), 10)
+ pushtool = push.get_instance(process_config.storage)
# generate tasks
- tasks, groups, sites = assign(
+ tasks, groups = assign(
pc=process_config,
retry=retry,
bin_name=subconverter_bin,
@@ -532,6 +178,7 @@ def aggregate(args: argparse.Namespace) -> None:
pushtool=pushtool,
only_check=args.check,
rigid=not args.flexible,
+ special_protocols=args.special_protocols,
)
if not tasks:
logger.error("cannot found any valid config, exit")
@@ -550,8 +197,9 @@ def aggregate(args: argparse.Namespace) -> None:
data = results[i]
if not data or data[0] < 0 or not data[1]:
# not contain any proxy
- if tasks[i] and tasks[i].sub:
- subscribes[tasks[i].sub] = False
+ if tasks[i]:
+ for url in tasks[i].nodes.subscribe_list():
+ subscribes[url] = False
continue
datasets[data[0]] = data[1]
@@ -568,59 +216,21 @@ def aggregate(args: argparse.Namespace) -> None:
continue
workspace = os.path.join(PATH, "clash")
- binpath = os.path.join(workspace, clash_bin)
filename = "config.yaml"
- proxies = clash.generate_config(workspace, proxies, filename)
-
- # filer
- skip = utils.trim(os.environ.get("SKIP_ALIVE_CHECK", "false")).lower() in ["true", "1"]
- nochecks, starttime = proxies, time.time()
-
- if not skip:
- checks, nochecks = workflow.liveness_fillter(proxies=proxies)
- if checks:
- # executable
- utils.chmod(binpath)
-
- logger.info(f"startup clash now, workspace: {workspace}, config: {filename}")
- process = subprocess.Popen(
- [
- binpath,
- "-d",
- workspace,
- "-f",
- os.path.join(workspace, filename),
- ]
- )
-
- logger.info(f"clash start success, begin check proxies, group: {k}\tcount: {len(checks)}")
- time.sleep(random.randint(5, 8))
-
- params = [
- [p, clash.EXTERNAL_CONTROLLER, args.timeout, args.url, process_config.delay, False]
- for p in checks
- if isinstance(p, dict)
- ]
-
- # check
- masks = utils.multi_thread_run(
- func=clash.check,
- tasks=params,
- num_threads=args.num,
- show_progress=display,
- )
-
- # close clash client
- try:
- process.terminate()
- except:
- logger.error(f"terminate clash process error, group: {k}")
-
- availables = [checks[i] for i in range(len(checks)) if masks[i]]
- nochecks.extend(availables)
-
- dead = len(checks) - len(availables)
- logger.info(f"proxies check finished, total: {len(checks)}, alive: {len(availables)}, dead: {dead}")
+ starttime = time.time()
+ nochecks = pipeline.check_alive_proxies(
+ proxies=proxies,
+ clash_bin=clash_bin,
+ workspace=workspace,
+ filename=filename,
+ timeout=args.timeout,
+ test_url=args.url,
+ delay=process_config.delay,
+ num_threads=args.num,
+ display=display,
+ skip=args.skip_alive_check,
+ group=k,
+ )
for item in nochecks:
item.pop("sub", "")
@@ -629,26 +239,22 @@ def aggregate(args: argparse.Namespace) -> None:
logger.error(f"cannot fetch any proxy, group=[{k}], cost: {time.time()-starttime:.2f}s")
continue
- group_conf = process_config.groups.get(k, {})
- emoji = group_conf.get("emoji", True)
- list_only = group_conf.get("list", True)
-
- regularize = group_conf.get("regularize", {})
- if regularize and isinstance(regularize, dict) and regularize.get("enable", False):
- locate = regularize.get("locate", False)
- residential = regularize.get("residential", False)
- try:
- bits = max(1, int(regularize.get("bits", 2)))
- except:
- bits = 2
+ group = process_config.groups.get(k)
+ if not group:
+ continue
+ emoji = group.emoji
+ list_only = group.list_only
+ if group.regularize and group.regularize.enable:
nochecks = location.regularize(
proxies=nochecks,
num_threads=args.num,
show_progress=display,
- locate=locate,
- residential=residential,
- digits=bits,
+ locate=group.regularize.locate and not args.skip_alive_check,
+ residential=group.regularize.residential and not args.skip_alive_check,
+ ip_library=group.regularize.library,
+ digits=max(1, group.regularize.digits),
+ score=group.regularize.score,
)
source_file, data = "config.yaml", {"proxies": nochecks}
@@ -657,7 +263,7 @@ def aggregate(args: argparse.Namespace) -> None:
yaml.add_representer(clash.QuotedStr, clash.quoted_scalar)
yaml.dump(data, f, allow_unicode=True)
- targets = group_conf.get("targets", {})
+ targets = group.targets
for target, storage_name in targets.items():
persisted, content = False, " "
@@ -703,7 +309,7 @@ def aggregate(args: argparse.Namespace) -> None:
# save to remote server
persisted = pushtool.push_to(
content=content,
- config=process_config.storage.get("items", {}).get(storage_name, {}),
+ item=process_config.storage.items.get(storage_name, StorageItem()),
group=f"{k}::{target}",
)
@@ -720,21 +326,27 @@ def aggregate(args: argparse.Namespace) -> None:
cost = "{:.2f}s".format(time.time() - starttime)
logger.info(f"group [{k}] process finished, count: {len(nochecks)}, cost: {cost}")
- config = {
- "domains": sites,
- "crawl": process_config.crawl,
- "groups": process_config.groups,
- "storage": process_config.storage,
- "update": process_config.update,
- }
- skip_remark = utils.trim(os.environ.get("SKIP_REMARK", "false")).lower() in ["true", "1"]
-
- workflow.refresh(config=config, push=pushtool, alives=dict(subscribes), skip_remark=skip_remark)
+ workflow.refresh(
+ config=process_config,
+ push=pushtool,
+ alives=dict(subscribes),
+ skip_remark=args.skip_remark,
+ )
if __name__ == "__main__":
- parser = argparse.ArgumentParser()
+ env_parser = argparse.ArgumentParser(add_help=False)
+ env_parser.add_argument(
+ "-e",
+ "--environment",
+ type=str,
+ default=".env",
+ help="environment file name",
+ )
+ env_args, _ = env_parser.parse_known_args()
+ utils.load_dotenv(env_args.environment)
+ parser = argparse.ArgumentParser(parents=[env_parser])
parser.add_argument(
"-c",
"--check",
@@ -744,15 +356,6 @@ def aggregate(args: argparse.Namespace) -> None:
help="only check proxies are alive",
)
- parser.add_argument(
- "-e",
- "--environment",
- type=str,
- required=False,
- default=".env",
- help="environment file name",
- )
-
parser.add_argument(
"-f",
"--flexible",
@@ -771,6 +374,15 @@ def aggregate(args: argparse.Namespace) -> None:
help="don't show check progress bar",
)
+ parser.add_argument(
+ "-m",
+ "--mode",
+ type=int,
+ choices=[0, 1, 2],
+ default=parse_workflow_mode(),
+ help="workflow mode: 0=crawl+aggregate, 1=crawl only, 2=aggregate only",
+ )
+
parser.add_argument(
"-n",
"--num",
@@ -803,10 +415,31 @@ def aggregate(args: argparse.Namespace) -> None:
"--server",
type=str,
required=False,
- default="",
+ default=utils.trim(os.environ.get("SUBSCRIBE_CONF", "")),
help="remote config file",
)
+ parser.add_argument(
+ "--skip-alive-check",
+ action=argparse.BooleanOptionalAction,
+ default=utils.env_bool("SKIP_ALIVE_CHECK", False),
+ help="skip proxy liveness check",
+ )
+
+ parser.add_argument(
+ "--skip-remark",
+ action=argparse.BooleanOptionalAction,
+ default=utils.env_bool("SKIP_REMARK", False),
+ help="skip remark update for crawled subscriptions",
+ )
+
+ parser.add_argument(
+ "--special-protocols",
+ action=argparse.BooleanOptionalAction,
+ default=utils.env_bool("ENABLE_SPECIAL_PROTOCOLS", True),
+ help="include special protocols such as vless and hysteria",
+ )
+
parser.add_argument(
"-t",
"--timeout",
@@ -826,6 +459,4 @@ def aggregate(args: argparse.Namespace) -> None:
)
args = parser.parse_args()
- utils.load_dotenv(args.environment)
-
aggregate(args=args)
diff --git a/subscribe/push.py b/subscribe/push.py
index 89b5558663..6a42e4e776 100644
--- a/subscribe/push.py
+++ b/subscribe/push.py
@@ -8,10 +8,10 @@
import traceback
import urllib
import urllib.request
-from dataclasses import dataclass
from http.client import HTTPResponse
import utils
+from config.models import StorageConfig, StorageItem
from logger import logger
from urlvalidator import isurl
@@ -48,7 +48,7 @@ def _storage(self, content: str, filename: str, folder: str = "") -> bool:
except:
return False
- def push_file(self, filepath: str, config: dict, group: str = "", retry: int = 5) -> bool:
+ def push_file(self, filepath: str, item: StorageItem, group: str = "", retry: int = 5) -> bool:
if not os.path.exists(filepath) or not os.path.isfile(filepath):
logger.error(f"[PushFileError] file {filepath} not found")
return False
@@ -57,17 +57,17 @@ def push_file(self, filepath: str, config: dict, group: str = "", retry: int = 5
with open(filepath, "r", encoding="utf8") as f:
content = f.read()
- return self.push_to(content=content, config=config, group=group, retry=retry)
+ return self.push_to(content=content, item=item, group=group, retry=retry)
- def push_to(self, content: str, config: dict, group: str = "", retry: int = 5, **kwargs) -> bool:
- if not self.validate(config=config):
+ def push_to(self, content: str, item: StorageItem, group: str = "", retry: int = 5, **kwargs: object) -> bool:
+ if not self.validate(item=item):
logger.error(f"[PushError] push config is invalidate, domain: {self.name}")
return False
- if config.get("local", ""):
- self._storage(content=content, filename=config.get("local"))
+ if item.local:
+ self._storage(content=content, filename=item.local)
- url, data, headers = self._generate_payload(content=content, config=config)
+ url, data, headers = self._generate_payload(content=content, item=item)
payload = kwargs.get("payload", None)
if payload and isinstance(payload, dict):
try:
@@ -108,26 +108,26 @@ def push_to(self, content: str, config: dict, group: str = "", retry: int = 5, *
retry -= 1
if retry > 0:
- return self.push_to(content, config, group, retry)
+ return self.push_to(content, item, group, retry)
return False
def _is_success(self, response: HTTPResponse) -> bool:
return response and response.getcode() == 200
- def _generate_payload(self, content: str, config: dict) -> tuple[str, str, dict]:
+ def _generate_payload(self, content: str, item: StorageItem) -> tuple[str, str, dict]:
raise NotImplementedError
def _error_handler(self, group: str = "") -> None:
logger.error(f"[PushError]: group=[{group}], name: {self.name}, error message: \n{traceback.format_exc()}")
- def validate(self, config: dict) -> bool:
+ def validate(self, item: StorageItem) -> bool:
raise NotImplementedError
- def filter_push(self, config: dict) -> dict:
+ def filter_push(self, items: dict[str, StorageItem]) -> dict[str, StorageItem]:
raise NotImplementedError
- def raw_url(self, config: dict) -> str:
+ def raw_url(self, item: StorageItem) -> str:
raise NotImplementedError
@@ -150,18 +150,18 @@ def __init__(self, token: str, base: str = "", domain: str = "") -> None:
self.domain = domain
self.api_address = f"{base}/v1/pastes"
- def validate(self, config: dict) -> bool:
- if not config or type(config) != dict:
+ def validate(self, item: StorageItem) -> bool:
+ if not isinstance(item, StorageItem):
return False
- folderid = config.get("folderid", "")
- fileid = config.get("fileid", "")
+ folder_id = item.folder_id
+ file_id = item.file_id
- return "" != self.token.strip() and "" != folderid.strip() and "" != fileid.strip()
+ return "" != self.token.strip() and "" != folder_id.strip() and "" != file_id.strip()
- def _generate_payload(self, content: str, config: dict) -> tuple[str, str, dict]:
- folderid = config.get("folderid", "")
- fileid = config.get("fileid", "")
+ def _generate_payload(self, content: str, item: StorageItem) -> tuple[str, str, dict]:
+ folder_id = item.folder_id
+ file_id = item.file_id
headers = {
"Authorization": f"Key {self.token}",
@@ -169,7 +169,7 @@ def _generate_payload(self, content: str, config: dict) -> tuple[str, str, dict]
"User-Agent": utils.USER_AGENT,
}
data = json.dumps({"content": {"format": "text", "value": content}}).encode("UTF8")
- url = f"{self.api_address}/{folderid}/files/{fileid}"
+ url = f"{self.api_address}/{folder_id}/files/{file_id}"
return url, data, headers
@@ -179,26 +179,26 @@ def _is_success(self, response: HTTPResponse) -> bool:
def _error_handler(self, group: str = "") -> None:
logger.error(f"[PushError]: group=[{group}], name: {self.name}, error message: \n{traceback.format_exc()}")
- def filter_push(self, config: dict) -> dict:
+ def filter_push(self, items: dict[str, StorageItem]) -> dict[str, StorageItem]:
records = {}
- for k, v in config.items():
- if self.token and v.get("folderid", "") and v.get("fileid", "") and v.get("username", ""):
+ for k, v in items.items():
+ if self.token and v.folder_id and v.file_id and v.username:
records[k] = v
return records
- def raw_url(self, config: dict) -> str:
- if not config or type(config) != dict:
+ def raw_url(self, item: StorageItem) -> str:
+ if not isinstance(item, StorageItem):
return ""
- fileid = config.get("fileid", "")
- folderid = config.get("folderid", "")
- username = config.get("username", "")
+ file_id = item.file_id
+ folder_id = item.folder_id
+ username = item.username
- if not fileid or not folderid or not username:
+ if not file_id or not folder_id or not username:
return ""
- return f"{self.domain}/p/{username}/{folderid}/files/{fileid}/raw"
+ return f"{self.domain}/p/{username}/{folder_id}/files/{file_id}/raw"
class PushToDevbin(PushToPasteGG):
@@ -215,23 +215,23 @@ def __init__(self, token: str, base: str = "") -> None:
self.domain = base
self.api_address = f"{base}/api/v3/paste"
- def validate(self, config: dict) -> bool:
- if not config or type(config) != dict:
+ def validate(self, item: StorageItem) -> bool:
+ if not isinstance(item, StorageItem):
return False
- fileid = config.get("fileid", "")
- return "" != self.token.strip() and "" != fileid.strip()
+ file_id = item.file_id
+ return "" != self.token.strip() and "" != file_id.strip()
- def filter_push(self, config: dict) -> dict:
+ def filter_push(self, items: dict[str, StorageItem]) -> dict[str, StorageItem]:
records = {}
- for k, v in config.items():
- if v.get("fileid", "") and self.token:
+ for k, v in items.items():
+ if v.file_id and self.token:
records[k] = v
return records
- def _generate_payload(self, content: str, config: dict) -> tuple[str, str, dict]:
- fileid = config.get("fileid", "")
+ def _generate_payload(self, content: str, item: StorageItem) -> tuple[str, str, dict]:
+ file_id = item.file_id
headers = {
"Authorization": self.token,
@@ -239,19 +239,19 @@ def _generate_payload(self, content: str, config: dict) -> tuple[str, str, dict]
"Accept": "*/*",
}
data = json.dumps({"content": content, "syntaxName": "auto"}).encode("UTF8")
- url = f"{self.api_address}/{fileid}"
+ url = f"{self.api_address}/{file_id}"
return url, data, headers
def _is_success(self, response: HTTPResponse) -> bool:
return response and response.getcode() == 201
- def raw_url(self, config: dict) -> str:
- if not config or type(config) != dict or not config.get("fileid", ""):
+ def raw_url(self, item: StorageItem) -> str:
+ if not isinstance(item, StorageItem) or not item.file_id:
return ""
- fileid = config.get("fileid", "")
- return f"{self.domain}/Raw/{fileid}"
+ file_id = item.file_id
+ return f"{self.domain}/Raw/{file_id}"
class PushToPastefy(PushToDevbin):
@@ -269,8 +269,8 @@ def __init__(self, token: str, base: str = "") -> None:
self.domain = base
self.api_address = f"{base}/api/v2/paste"
- def _generate_payload(self, content: str, config: dict) -> tuple[str, str, dict]:
- fileid = config.get("fileid", "")
+ def _generate_payload(self, content: str, item: StorageItem) -> tuple[str, str, dict]:
+ file_id = item.file_id
headers = {
"Authorization": f"Bearer {self.token}",
@@ -279,7 +279,7 @@ def _generate_payload(self, content: str, config: dict) -> tuple[str, str, dict]
"User-Agent": utils.USER_AGENT,
}
data = json.dumps({"content": content}).encode("UTF8")
- url = f"{self.api_address}/{fileid}"
+ url = f"{self.api_address}/{file_id}"
return url, data, headers
@@ -295,15 +295,15 @@ def _is_success(self, response: HTTPResponse) -> bool:
def _error_handler(self, group: str = "") -> None:
logger.error(f"[PushError]: group=[{group}], name: {self.name}, error message: \n{traceback.format_exc()}")
- def raw_url(self, config: dict) -> str:
- if not config or type(config) != dict:
+ def raw_url(self, item: StorageItem) -> str:
+ if not isinstance(item, StorageItem):
return ""
- fileid = utils.trim(config.get("fileid", ""))
- if not fileid:
+ file_id = utils.trim(item.file_id)
+ if not file_id:
return ""
- return f"{self.domain}/{fileid}/raw"
+ return f"{self.domain}/{file_id}/raw"
class PushToImperial(PushToPasteGG):
@@ -325,30 +325,30 @@ def __init__(self, token: str, base: str = "", domain: str = "") -> None:
self.domain = domain
self.api_address = f"{base}/v1/document"
- def raw_url(self, config: dict) -> str:
- if not self.validate(config):
+ def raw_url(self, item: StorageItem) -> str:
+ if not self.validate(item):
return ""
- fileid = config.get("fileid", "")
- return f"{self.domain}/r/{fileid}"
+ file_id = item.file_id
+ return f"{self.domain}/r/{file_id}"
- def validate(self, config: dict) -> bool:
- if not config or type(config) != dict:
+ def validate(self, item: StorageItem) -> bool:
+ if not isinstance(item, StorageItem):
return False
- fileid = config.get("fileid", "")
- return "" != self.token.strip() and "" != fileid.strip()
+ file_id = item.file_id
+ return "" != self.token.strip() and "" != file_id.strip()
- def filter_push(self, config: dict) -> dict:
+ def filter_push(self, items: dict[str, StorageItem]) -> dict[str, StorageItem]:
records = {}
- for k, v in config.items():
- if v.get("fileid", "") and self.token:
+ for k, v in items.items():
+ if v.file_id and self.token:
records[k] = v
return records
- def _generate_payload(self, content: str, config: dict) -> tuple[str, str, dict]:
- fileid = config.get("fileid", "")
+ def _generate_payload(self, content: str, item: StorageItem) -> tuple[str, str, dict]:
+ file_id = item.file_id
headers = {
"Authorization": self.token,
@@ -357,7 +357,7 @@ def _generate_payload(self, content: str, config: dict) -> tuple[str, str, dict]
"User-Agent": utils.USER_AGENT,
}
- data = json.dumps({"id": fileid, "content": content}).encode("UTF8")
+ data = json.dumps({"id": file_id, "content": content}).encode("UTF8")
return self.api_address, data, headers
def _is_success(self, response: HTTPResponse) -> bool:
@@ -375,27 +375,27 @@ def __init__(self) -> None:
super().__init__(token="")
self.name = "local"
- def validate(self, config: dict) -> bool:
- return config is not None and config.get("fileid", "")
+ def validate(self, item: StorageItem) -> bool:
+ return isinstance(item, StorageItem) and bool(item.file_id)
- def push_to(self, content: str, config: dict, group: str = "", retry: int = 5) -> bool:
- folder = config.get("folderid", "")
- filename = config.get("fileid", "")
+ def push_to(self, content: str, item: StorageItem, group: str = "", retry: int = 5) -> bool:
+ folder = item.folder_id
+ filename = item.file_id
success = self._storage(content=content, filename=filename, folder=folder)
message = "successed" if success else "failed"
logger.info(f"[PushInfo] push subscribes information to {self.name} {message}, group=[{group}]")
return success
- def filter_push(self, config: dict) -> dict:
- return {k: v for k, v in config.items() if v.get("fileid", "")}
+ def filter_push(self, items: dict[str, StorageItem]) -> dict[str, StorageItem]:
+ return {k: v for k, v in items.items() if isinstance(v, StorageItem) and v.file_id}
- def raw_url(self, config: dict) -> str:
- if not config or type(config) != dict:
+ def raw_url(self, item: StorageItem) -> str:
+ if not isinstance(item, StorageItem):
return ""
- fileid = config.get("fileid", "")
- folderid = config.get("folderid", "")
- filepath = os.path.abspath(os.path.join(folderid, fileid))
+ file_id = item.file_id
+ folder_id = item.folder_id
+ filepath = os.path.abspath(os.path.join(folder_id, file_id))
return f"{utils.FILEPATH_PROTOCAL}{filepath}"
@@ -408,20 +408,20 @@ def __init__(self, token: str) -> None:
self.domain = "https://gist.githubusercontent.com"
self.method = "PATCH"
- def validate(self, config: dict) -> bool:
- if not isinstance(config, dict):
+ def validate(self, item: StorageItem) -> bool:
+ if not isinstance(item, StorageItem):
return False
- gistid = config.get("gistid", "")
- filename = config.get("filename", "")
+ gist_id = item.gist_id
+ filename = item.filename
- return "" != self.token.strip() and "" != gistid.strip() and "" != filename.strip()
+ return "" != self.token.strip() and "" != gist_id.strip() and "" != filename.strip()
- def _generate_payload(self, content: str, config: dict) -> tuple[str, str, dict]:
- gistid = config.get("gistid", "")
- filename = config.get("filename", "")
+ def _generate_payload(self, content: str, item: StorageItem) -> tuple[str, str, dict]:
+ gist_id = item.gist_id
+ filename = item.filename
- url = f"{self.api_address}/{gistid}"
+ url = f"{self.api_address}/{gist_id}"
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {self.token}",
@@ -436,29 +436,25 @@ def _generate_payload(self, content: str, config: dict) -> tuple[str, str, dict]
def _is_success(self, response: HTTPResponse) -> bool:
return response and response.getcode() == 200
- def filter_push(self, config: dict) -> dict:
- if not self.token or not isinstance(config, dict):
+ def filter_push(self, items: dict[str, StorageItem]) -> dict[str, StorageItem]:
+ if not self.token or not isinstance(items, dict):
return {}
- return {
- k: v
- for k, v in config.items()
- if k and isinstance(v, dict) and v.get("gistid", "") and v.get("filename", "")
- }
+ return {k: v for k, v in items.items() if k and isinstance(v, StorageItem) and v.gist_id and v.filename}
- def raw_url(self, config: dict) -> str:
- if not config or type(config) != dict:
+ def raw_url(self, item: StorageItem) -> str:
+ if not isinstance(item, StorageItem):
return ""
- username = utils.trim(config.get("username", ""))
- gistid = utils.trim(config.get("gistid", ""))
- revision = utils.trim(config.get("revision", ""))
- filename = utils.trim(config.get("filename", ""))
+ username = utils.trim(item.username)
+ gist_id = utils.trim(item.gist_id)
+ revision = utils.trim(item.revision)
+ filename = utils.trim(item.filename)
- if not username or not gistid or not filename:
+ if not username or not gist_id or not filename:
return ""
- prefix = f"{self.domain}/{username}/{gistid}"
+ prefix = f"{self.domain}/{username}/{gist_id}"
if revision:
return f"{prefix}/raw/{revision}/{filename}"
@@ -480,17 +476,17 @@ def __init__(self, token: str, base: str = "") -> None:
self.domain = base
self.api_address = f"{base}/save"
- def validate(self, config: dict) -> bool:
- if not config or type(config) != dict:
+ def validate(self, item: StorageItem) -> bool:
+ if not isinstance(item, StorageItem):
return False
- fileid = config.get("fileid", "")
- return "" != self.token.strip() and "" != utils.trim(fileid)
+ file_id = item.file_id
+ return "" != self.token.strip() and "" != utils.trim(file_id)
- def _generate_payload(self, content: str, config: dict) -> tuple[str, str, dict]:
- fileid = config.get("fileid", "")
- password = config.get("password", "")
- expire = config.get("expire", 0)
+ def _generate_payload(self, content: str, item: StorageItem) -> tuple[str, str, dict]:
+ file_id = item.file_id
+ password = item.password
+ expire = item.expire
headers = {
"Cookie": f"token={self.token}",
@@ -501,7 +497,7 @@ def _generate_payload(self, content: str, config: dict) -> tuple[str, str, dict]
if isinstance(expire, int) and expire > 0:
headers["x-expire"] = str(expire)
- url = f"{self.api_address}/{fileid}"
+ url = f"{self.api_address}/{file_id}"
if password:
url = f"{url}/{password}"
@@ -517,25 +513,25 @@ def _is_success(self, response: HTTPResponse) -> bool:
except:
return False
- def filter_push(self, config: dict) -> dict:
+ def filter_push(self, items: dict[str, StorageItem]) -> dict[str, StorageItem]:
records = {}
- for k, v in config.items():
- if v.get("fileid", "") and self.token:
+ for k, v in items.items():
+ if v.file_id and self.token:
records[k] = v
return records
- def raw_url(self, config: dict) -> str:
- if not config or type(config) != dict:
+ def raw_url(self, item: StorageItem) -> str:
+ if not isinstance(item, StorageItem):
return ""
- fileid = utils.trim(config.get("fileid", ""))
- password = utils.trim(config.get("password", ""))
+ file_id = utils.trim(item.file_id)
+ password = utils.trim(item.password)
- if not fileid:
+ if not file_id:
return ""
- url = f"{self.domain}/r/{fileid}"
+ url = f"{self.domain}/r/{file_id}"
if password:
url = f"{url}/{password}"
@@ -545,52 +541,22 @@ def raw_url(self, config: dict) -> str:
SUPPORTED_ENGINES = set(["gist", "imperial", "pastefy", "pastegg", "qbin"] + [LOCAL_STORAGE])
-@dataclass
-class PushConfig(object):
- # storage type
- engine: str = ""
-
- # storage token
- token: str = ""
-
- # storage base address
- base: str = ""
-
- # storage domain address
- domain: str = ""
-
- @classmethod
- def from_dict(cls, data: dict) -> "PushConfig":
- if not data or type(data) != dict:
- return None
-
- engine = utils.trim(data.get("engine", ""))
- if engine not in SUPPORTED_ENGINES:
- return None
-
- token = utils.trim(data.get("token", ""))
- base = utils.trim(data.get("base", ""))
- domain = utils.trim(data.get("domain", ""))
-
- return cls(engine=engine, token=token, base=base, domain=domain)
-
-
-def get_instance(config: PushConfig) -> PushTo:
- if not config or not isinstance(config, PushConfig):
- raise ValueError("[PushError] invalid push config")
+def get_instance(storage: StorageConfig) -> PushTo:
+ if not isinstance(storage, StorageConfig):
+ raise ValueError("[PushError] invalid storage config")
- engine = utils.trim(config.engine)
- if not engine:
+ engine = utils.trim(storage.engine)
+ if engine not in SUPPORTED_ENGINES:
raise ValueError(f"[PushError] unknown storge type: {engine}")
- token = utils.trim(config.token or os.environ.get("PUSH_TOKEN", ""))
+ token = utils.trim(storage.token or os.environ.get("PUSH_TOKEN", ""))
if engine != LOCAL_STORAGE and not token:
- raise ValueError(f"[PushError] not found 'PUSH_TOKEN' in environment variables, please check it and try again")
+ raise ValueError("[PushError] not found 'PUSH_TOKEN' in environment variables, please check it and try again")
if engine == "gist":
return PushToGist(token=token)
- base, domain = utils.trim(config.base), utils.trim(config.domain)
+ base, domain = utils.trim(storage.base), utils.trim(storage.domain)
if engine == "imperial":
return PushToImperial(token=token, base=base, domain=domain)
elif engine == "pastefy":
diff --git a/subscribe/renewal.py b/subscribe/renewal.py
index 1fc42cc3ac..5f264abb2b 100644
--- a/subscribe/renewal.py
+++ b/subscribe/renewal.py
@@ -19,6 +19,7 @@
from datetime import datetime
import utils
+from config.models import RenewJob
from logger import logger
warnings.filterwarnings("ignore")
@@ -86,7 +87,9 @@ def get_cookies(
return utils.extract_cookie(text), authorization
-def generate_headers(domain: str, cookies: str, authorization: str, headers: dict = None) -> dict:
+def generate_headers(
+ domain: str, cookies: str, authorization: str, headers: dict[str, str] | None = None
+) -> dict[str, str]:
if not headers:
headers = {"user-agent": utils.USER_AGENT}
@@ -100,7 +103,9 @@ def generate_headers(domain: str, cookies: str, authorization: str, headers: dic
return headers
-def login(url: str, params: dict, headers: dict, retry: int = 3, jsonify: bool = False) -> tuple[str, str]:
+def login(
+ url: str, params: dict[str, object], headers: dict[str, str], retry: int = 3, jsonify: bool = False
+) -> tuple[str, str]:
if not params:
logger.error("[RenewalError] cannot login because parameters is empty")
return "", ""
@@ -137,7 +142,7 @@ def login(url: str, params: dict, headers: dict, retry: int = 3, jsonify: bool =
return "", ""
-def order(url: str, params: dict, headers: dict, retry: int = 3, jsonify: bool = False) -> str:
+def order(url: str, params: dict[str, object], headers: dict[str, str], retry: int = 3, jsonify: bool = False) -> str:
try:
if jsonify:
headers["Content-Type"] = "application/json"
@@ -165,7 +170,7 @@ def order(url: str, params: dict, headers: dict, retry: int = 3, jsonify: bool =
logger.error("[OrderError] URL: {}".format(utils.extract_domain(url)))
-def fetch(url: str, headers: dict, retry: int = 3) -> str:
+def fetch(url: str, headers: dict[str, str], retry: int = 3) -> str:
try:
request = urllib.request.Request(url, headers=headers, method="GET")
response = urllib.request.urlopen(request, timeout=10, context=utils.CTX)
@@ -189,7 +194,9 @@ def fetch(url: str, headers: dict, retry: int = 3) -> str:
logger.error("[FetchError] URL: {}".format(utils.extract_domain(url)))
-def payment(url: str, params: dict, headers: dict, retry: int = 3, jsonify: bool = False) -> bool:
+def payment(
+ url: str, params: dict[str, object], headers: dict[str, str], retry: int = 3, jsonify: bool = False
+) -> bool:
try:
data = urllib.parse.urlencode(params).encode(encoding="UTF8")
if jsonify:
@@ -220,13 +227,13 @@ def payment(url: str, params: dict, headers: dict, retry: int = 3, jsonify: bool
def checkout(
domain: str,
coupon: str,
- headers: dict,
+ headers: dict[str, str],
planid: int = -1,
retry: int = 3,
link: str = "",
api_prefix: str = "",
jsonify: bool = False,
-) -> dict:
+) -> dict[str, object]:
if utils.isblank(domain) or utils.isblank(coupon):
return {}
@@ -274,7 +281,7 @@ def checkout(
def get_payment_method(
domain: str, cookies: str, authorization: str = "", retry: int = 3, api_prefix: str = ""
-) -> list:
+) -> list[dict[str, object]]:
if not domain or (not cookies and not authorization):
logger.error(f"query payment method error, cookies and authorization is empty, domain: {domain}")
return []
@@ -294,7 +301,7 @@ def get_payment_method(
return []
-def unclosed_ticket(domain: str, headers: dict, api_prefix: str = "") -> tuple[int, int, str]:
+def unclosed_ticket(domain: str, headers: dict[str, str], api_prefix: str = "") -> tuple[int, int, str]:
if utils.isblank(domain) or not headers:
logger.info(f"[TicketError] cannot fetch tickets because invalidate arguments, domain: {domain}")
return -1, -1, ""
@@ -319,7 +326,7 @@ def unclosed_ticket(domain: str, headers: dict, api_prefix: str = "") -> tuple[i
def close_ticket(
- domain: str, tid: int, headers: dict, retry: int = 3, api_prefix: str = "", jsonify: bool = False
+ domain: str, tid: int, headers: dict[str, str], retry: int = 3, api_prefix: str = "", jsonify: bool = False
) -> bool:
if utils.isblank(domain) or tid < 0 or not headers or retry <= 0:
logger.info(f"[TicketError] cannot close ticket because invalidate arguments, domain: {domain}, tid: {tid}")
@@ -359,7 +366,7 @@ def close_ticket(
def submit_ticket(
domain: str,
cookies: str,
- ticket: dict,
+ ticket: dict[str, object],
authorization: str = "",
retry: int = 3,
api_prefix: str = "",
@@ -514,7 +521,7 @@ def get_free_plan(
return None
-def isfree(planid: str, package: str, price: float, discount: dict) -> bool:
+def isfree(planid: str, package: str, price: float, discount: dict[str, object]) -> bool:
# 不存在的套餐
if utils.isblank(planid) or utils.isblank(package) or price is None:
return False
@@ -610,7 +617,7 @@ def get_subscribe_info(
def flow(
domain: str,
- params: dict,
+ params: dict[str, object],
reset: bool = False,
retry: int = 3,
cookies: str = "",
@@ -697,18 +704,18 @@ def flow(
return success
-def add_traffic_flow(domain: str, params: dict, jsonify: bool = False) -> str:
- if not domain or not params:
- logger.error(f"[RenewalError] invalidate arguments")
+def add_traffic_flow(domain: str, job: RenewJob, jsonify: bool = False) -> str:
+ if not domain or not isinstance(job, RenewJob) or not job.account:
+ logger.error("[RenewalError] invalidate arguments")
return ""
try:
- email = base64.b64decode(params.get("email", "")).decode()
- password = base64.b64decode(params.get("passwd", "")).decode()
+ email = base64.b64decode(job.account.email).decode() if job.account.email else ""
+ password = base64.b64decode(job.account.password).decode() if job.account.password else ""
if utils.isblank(email) or utils.isblank(password):
logger.info(f"[RenewalError] email or password cannot be empty, domain: {domain}")
return ""
- api_prefix = params.get("api_prefix", "")
+ api_prefix = job.api_prefix
cookies, authorization = get_cookies(
domain=domain,
username=email,
@@ -726,10 +733,10 @@ def add_traffic_flow(domain: str, params: dict, jsonify: bool = False) -> str:
logger.info(f"[RenewalError] cannot fetch subscribe information")
return ""
- plan_id = params.get("plan_id", subscribe.plan_id)
- package = params.get("package", subscribe.package)
- coupon_code = params.get("coupon_code", "")
- method = params.get("method", -1)
+ plan_id = job.plan_id if job.plan_id is not None else subscribe.plan_id
+ package = job.package or subscribe.package
+ coupon_code = job.coupon_code
+ method = job.method if job.method is not None else -1
if method <= 0:
methods = get_payment_method(
domain=domain,
@@ -753,7 +760,7 @@ def add_traffic_flow(domain: str, params: dict, jsonify: bool = False) -> str:
"jsonify": jsonify,
}
- renew = params.get("enable", True)
+ renew = job.enable
if renew and subscribe.reset_enable and subscribe.used_rate >= 0.8:
success = flow(
domain=domain,
@@ -789,20 +796,17 @@ def add_traffic_flow(domain: str, params: dict, jsonify: bool = False) -> str:
f"skip renew traffic plan, domain: {domain}\trenew: {renew}\tenable: {subscribe.renew_enable}\texpired-days: {subscribe.expired_days}"
)
- # 提交工单重置流量
- ticket = params.get("ticket", {})
- if ticket and type(ticket) == dict:
- enable = ticket.pop("enable", True)
- autoreset = ticket.pop("autoreset", False)
+ ticket = job.account.ticket
+ if ticket:
# 过期时间 <= 5 或者 流量使用例 >= 0.8 或者 重置日期 <= 1 且不会自动重置时提交工单
- if enable and (
+ if ticket.enable and (
(subscribe.expired_days <= 5 or subscribe.used_rate >= 0.8)
- or (not autoreset and subscribe.reset_day <= 1)
+ or (not ticket.auto_reset and subscribe.reset_day <= 1)
):
success = submit_ticket(
domain=domain,
cookies=cookies,
- ticket=ticket,
+ ticket=ticket.to_dict(),
authorization=authorization,
api_prefix=api_prefix,
jsonify=jsonify,
diff --git a/subscribe/scripts/__init__.py b/subscribe/scripts/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/subscribe/scripts/commons.py b/subscribe/scripts/commons.py
deleted file mode 100644
index 84b9119312..0000000000
--- a/subscribe/scripts/commons.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# @Author : wzdnzd
-# @Time : 2022-11-12
-
-import json
-
-import push
-from logger import logger
-
-
-def persist(config: push.PushConfig, data: dict, persist: dict, meta: str = "") -> None:
- try:
- pushtool = push.get_instance(config=config)
- if data is None or type(data) != dict or not pushtool.validate(config=persist):
- logger.debug(f"[{meta}] skip persist subscibes because fileid or data is empty")
- return
-
- pushtool.push_to(content=json.dumps(data), config=persist, group="subscribes")
- except:
- logger.error(f"[{meta}] occur error when persist subscribes")
diff --git a/subscribe/scripts/purefast.py b/subscribe/scripts/purefast.py
deleted file mode 100644
index eefaf167a7..0000000000
--- a/subscribe/scripts/purefast.py
+++ /dev/null
@@ -1,430 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# @Author : wzdnzd
-# @Time : 2022-12-07
-
-import concurrent.futures
-import gzip
-import json
-import os
-import random
-import re
-import sys
-import time
-import urllib
-import urllib.parse
-import urllib.request
-import warnings
-from base64 import b64decode
-from http.client import HTTPResponse
-from typing import Any
-from urllib.error import HTTPError
-from urllib.request import OpenerDirector
-
-import utils
-from logger import logger
-
-warnings.filterwarnings("ignore")
-from http import cookiejar
-
-
-def login(
- url: str,
- opener: OpenerDirector,
- cookies: cookiejar.CookieJar,
- params: dict,
- headers: dict,
- endtime: int,
- retry: int = 3,
-) -> tuple[bool, dict]:
- if utils.isblank(url) or not params or retry <= 0 or checkconn(opener, cookies):
- logger.error(f"[PFVPNLoginError] cannot login, url: {url}, retry: {retry}")
- return False, {}
-
- if not headers:
- headers = {
- "user-agent": utils.USER_AGENT,
- "accept": "application/json, text/javascript, */*; q=0.01",
- "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
- "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
- "accept-encoding": "gzip, deflate",
- "referer": url,
- "x-requested-with": "XMLHttpRequest",
- }
-
- try:
- successed, skip, count = False, False, 25
- while not successed and count > 0 and time.time() < endtime:
- count -= 1
- data = urllib.parse.urlencode(params).encode(encoding="UTF8")
- request = urllib.request.Request(url, data=data, headers=headers, method="POST")
- response = opener.open(request, timeout=10)
- cookie = response.getheader("Set-Cookie")
-
- if response.getcode() == 200:
- if not skip and not utils.isblank(specified_cookie(cookie, "ge_ua_p", False)):
- skip, headers = bypass(
- url=url,
- opener=opener,
- cookies=cookies,
- endtime=endtime,
- content=read(response),
- headers=headers,
- retry=3,
- starttime=time.time(),
- )
- continue
-
- cookie = get_cookie(cookie)
- successed = not utils.isblank(cookie)
- if successed:
- cookie = add_or_replace(headers.get("cookie", ""), cookie)
- headers["cookie"] = cookie
- break
-
- time.sleep(random.randint(5, 15) / 10)
- return successed, headers
- except:
- return login(url, opener, cookies, params, headers, endtime, retry - 1)
-
-
-def checkin(
- url: str,
- opener: OpenerDirector,
- cookies: cookiejar.CookieJar,
- headers: dict,
- endtime: int,
- retry: int = 3,
-) -> bool:
- if utils.isblank(url) or not headers or retry <= 0 or checkconn(opener, cookies):
- logger.error(f"[PFVPNError] cannot checkin, url: {url}, retry: {retry}")
- return False
- try:
- successed, skip, count = False, False, 25
- while not skip and not successed and count > 0 and time.time() < endtime:
- count -= 1
- request = urllib.request.Request(url, headers=headers, method="POST")
- response = opener.open(request, timeout=10)
-
- if response.getcode() == 200:
- if not utils.isblank(specified_cookie(response.getheader("Set-Cookie"), "ge_ua_p", False)):
- skip, headers = bypass(
- url=url,
- opener=opener,
- cookies=cookies,
- endtime=endtime,
- content=read(response),
- headers=headers,
- retry=3,
- starttime=time.time(),
- )
- continue
-
- content = read(response)
- try:
- data = json.loads(content)
- successed = data.get("ret", 0) == 1
- if successed:
- message = data.get("msg", "")
- logger.info(f"[PFVPN] checkin successed, message: {message}")
- break
- except:
- logger.error(f"[PFVPNError] checkin failed, message: {content}")
-
- time.sleep(random.randint(5, 15) / 10)
- return successed
- except HTTPError as e:
- if e.status == 307:
- cookie = specified_cookie(e.headers["Set-Cookie"], "WAF_VALIDATOR_ID", True)
- headers["cookie"] = add_or_replace(source=headers.get("cookie", ""), dest=cookie)
- headers["x-cache"] = "BYPASS"
- return checkin(url, opener, cookies, headers, endtime, retry - 1)
- except:
- return checkin(url, opener, cookies, headers, endtime, retry - 1)
-
-
-def get_cookie(text: str) -> str:
- regex = "(__cfduid|uid|email|key|ip|expire_in)=(.+?);"
- if utils.isblank(text):
- return ""
-
- content = re.findall(regex, text)
- cookie = ";".join(["=".join(x) for x in content]).strip()
-
- return cookie
-
-
-def run(domain: str, params: dict, timeout: int) -> bool:
- domain = utils.extract_domain(url=domain, include_protocal=True)
- if not domain:
- logger.error(f"[PFVPNError] cannot checkin because domain: {domain} is invalidate")
- return False
-
- login_url = domain + params.get("login", "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/auth/login")
- checkin_url = domain + params.get("checkin", "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/user/checkin")
-
- email = params.get("username", "").strip()
- username = email.split("@", maxsplit=1)[0]
- passwd = params.get("password", "").strip()
- try:
- if utils.isblank(email) or utils.isblank(passwd):
- logger.error(f"[PFVPNError] skip checkin for username=[{username}]")
- return False
-
- passwd = b64decode(passwd)
- except:
- logger.error(f"[PFVPNError] username=[{username}], password error, please encoding it with base64")
- return False
-
- user_info = {"email": email, "passwd": passwd, "code": ""}
- opener, cookies = build_opener()
- starttime, endtime = time.time(), time.time() + max(timeout, 5)
-
- successed, headers = login(login_url, opener, cookies, user_info, None, endtime, 3)
- if not successed:
- logger.error(f"[PFVPNError] login failed, skip checkin, username: {username}")
- return successed
-
- headers["referer"] = domain + "/user"
- headers["content-type"] = "application/json"
- successed = checkin(checkin_url, opener, cookies, headers, endtime, 5)
-
- cost = round(time.time() - starttime, 2)
- logger.info(f"[PFVPNInfo] finished checkin, username: {username}, result: {successed}, cost: {cost}s")
-
- return successed
-
-
-def calsum(cpk: str, nonce: int) -> int:
- if utils.isblank(cpk):
- return -1
-
- num = 0
- for i in range(len(cpk)):
- c = cpk[i]
- if c.isalnum():
- num += ord(c) * (nonce + i)
- return num
-
-
-def aboartable_run(domain: str, params: dict, timeout: int = 180) -> None:
- timeout = max(timeout, 0)
- with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
- try:
- future = executor.submit(run, domain, params, timeout)
- future.result(timeout=timeout)
- except concurrent.futures.TimeoutError:
- username = params.get("username", "").split("@", maxsplit=1)[0]
- logger.error(f"[PFVPNError] checkin task aborting due to timeout {timeout}s, username: {username}")
- executor.shutdown(wait=False, cancel_futures=True)
-
-
-def bypass(
- url: str,
- opener: OpenerDirector,
- cookies: cookiejar.CookieJar,
- endtime: int,
- content: str = "",
- headers: dict = None,
- retry: int = 3,
- starttime: int = -1,
-) -> tuple[bool, dict]:
- if utils.isblank(url) or retry <= 0 or checkconn(opener=opener, cookies=cookies):
- return False, headers
-
- if not headers:
- headers = {
- "user-agent": utils.USER_AGENT,
- "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
- "accept-language": "zh-CN,zh;q=0.9",
- "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
- "accept-encoding": "gzip, deflate",
- "referer": url,
- "x-requested-with": "XMLHttpRequest",
- }
-
- try:
- if utils.isblank(content):
- request = urllib.request.Request(url, headers=headers)
- response = opener.open(request, timeout=10)
- starttime = time.time()
- if response.getcode() >= 400:
- return False, headers
- content = read(response)
-
- groups = re.findall(r'var\s+cpk(?:\s+)?=(?:\s+)?"(.*)"', content, re.I)
- cpkname = groups[0] if groups else "ge_ua_p"
-
- groups = re.findall(r'var\s+step(?:\s+)?=(?:\s+)?"(.*)"', content, re.I)
- step = groups[0] if groups else "prev"
-
- groups = re.findall(r"var\s+nonce(?:\s+)?=(?:\s+)?(\d+);", content, re.I)
- nonce = int(groups[0]) if groups else -1
-
- cpkvalue = specified_cookie(cookies, cpkname, False)
- if utils.isblank(cpkvalue) or utils.isblank(step) or nonce < 0:
- return False, headers
-
- time.sleep(max(5 - (time.time() - starttime), 0))
- sumval = calsum(cpk=cpkvalue, nonce=nonce)
- data = urllib.parse.urlencode({"sum": sumval, "nonce": nonce}).encode(encoding="UTF8")
- headers["x-ge-ua-step"] = step
- headers["cookie"] = add_or_replace(source=headers.get("cookie", ""), dest=f"{cpkname}={cpkvalue}")
- request = urllib.request.Request(url, data=data, headers=headers, method="POST")
-
- successed, count = False, 20
- while not successed and count > 0 and time.time() < endtime:
- count -= 1
-
- response = opener.open(request, timeout=10)
- if response.getcode() == 200:
- text = read(response)
- if not utils.isblank(text):
- successed = json.loads(text).get("ok", False)
- if successed:
- break
-
- time.sleep(random.randint(3, 8) / 10)
-
- headers.pop("x-ge-ua-step", "")
- # remove ge_ua_p from cookie
- cookie = add_or_replace(source=headers.get("cookie", ""), dest=f"{cpkname}=")
- headers["cookie"] = cookie
-
- if successed:
- # add or replace ge_ua_key and lang
- guk = specified_cookie(response.getheader("Set-Cookie"), "ge_ua_key", concat=True)
- cookie = add_or_replace(source=cookie, dest=f"{guk}; lang=zh-cn")
- headers["cookie"] = cookie
- return successed, headers
- except:
- return bypass(
- url=url,
- opener=opener,
- cookies=cookies,
- endtime=endtime,
- content=content,
- headers=headers,
- retry=retry - 1,
- starttime=starttime,
- )
-
-
-def specified_cookie(items: Any, key: str, concat: bool = False) -> str:
- value = ""
-
- if not items or utils.isblank(key):
- return value
-
- if type(items) == cookiejar.CookieJar:
- for cookie in items:
- if key == cookie.name:
- value = cookie.value
- break
-
- elif type(items) == str:
- for cookie in items.split(";"):
- words = cookie.split("=", maxsplit=1)
- if len(words) != 2:
- continue
- if key == words[0]:
- value = words[1]
- break
-
- return f"{key}={value}" if concat and not utils.isblank(value) else value
-
-
-def add_or_replace(source: str, dest: str) -> str:
- def covertto(content: str) -> dict:
- targets = {}
- if utils.isblank(content):
- return targets
-
- for text in content.split(";"):
- text = text.strip()
- words = text.split("=", maxsplit=1)
- if len(words) != 2 or utils.isblank(words[0]):
- continue
- targets[words[0]] = words[1]
-
- return targets
-
- if utils.isblank(dest):
- return source
-
- raws, others = covertto(source), covertto(dest)
- raws.update(others)
- items = [f"{k}={v}" for k, v in raws.items() if not utils.isblank(v)]
- return "; ".join(items)
-
-
-def read(response: HTTPResponse) -> str:
- if not response or type(response) != HTTPResponse:
- return ""
- try:
- content = response.read()
- try:
- content = gzip.decompress(content).decode("utf8")
- except:
- content = str(content, encoding="utf8")
- return content
- except:
- return ""
-
-
-def loadconf(filename: str = "") -> dict:
- domain = os.environ.get("PFVPN_DOMAIN", "https://purefast.net")
- ustr = os.environ.get("PFVPN_USERNAMES", "")
- pstr = os.environ.get("PFVPN_PASSWORDS", "")
-
- configs = {}
- try:
- if utils.isblank(domain) or utils.isblank(ustr) or utils.isblank(pstr):
- if not utils.isblank(filename) and os.path.exists(filename) and os.path.isfile(filename):
- configs = json.loads(open(filename, "r").read())
- else:
- configs["domain"] = domain
- usernames, passwords = ustr.split(";"), pstr.split(";")
- if len(usernames) != len(passwords):
- logger.warning(f"[PFVPNError] the number of usernames and the number of passwords do not match")
- else:
- accounts = []
- for i in range(len(usernames)):
- accounts.append(
- {
- "username": usernames[i].strip(),
- "password": passwords[i].strip(),
- }
- )
- configs["accounts"] = accounts
- except:
- logger.error(f"[PFVPNError] loading config error, filename: {filename}")
-
- return configs
-
-
-def build_opener() -> tuple[OpenerDirector, cookiejar.CookieJar]:
- cookies = cookiejar.CookieJar()
- cookie_handle = urllib.request.HTTPCookieProcessor(cookies)
- http_handle = urllib.request.HTTPHandler()
- https_handle = urllib.request.HTTPSHandler()
- opener = urllib.request.build_opener(http_handle, https_handle, cookie_handle)
-
- return opener, cookies
-
-
-def checkconn(opener: OpenerDirector, cookies: cookiejar.CookieJar) -> bool:
- return opener is None or type(opener) != OpenerDirector or cookies is None or type(cookies) != cookiejar.CookieJar
-
-
-def main(filepath: str) -> None:
- config = loadconf(filename=filepath)
- domain = config.get("domain", "https://purefast.net")
- accounts = config.get("accounts", [])
-
- if utils.isblank(domain) or not accounts:
- logger.error(f"[PFVPNError] skip checkin because cannot found any valid config, exit")
- sys.exit(1)
-
- params = [[domain, x, 300] for x in accounts if x]
- utils.multi_thread_run(func=aboartable_run, tasks=params, show_progress=True)
diff --git a/subscribe/subconverter.py b/subscribe/subconverter.py
index fa4b552ad0..72266f24a7 100644
--- a/subscribe/subconverter.py
+++ b/subscribe/subconverter.py
@@ -7,31 +7,13 @@
from threading import Lock
import utils
+from config.models import CONVERT_TARGETS
from logger import logger
PATH = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
FILE_LOCK = Lock()
-CONVERT_TARGETS = [
- "clash",
- "v2ray",
- "singbox",
- "mixed",
- "clashr",
- "quan",
- "quanx",
- "loon",
- "ss",
- "sssub",
- "ssd",
- "ssr",
- "surfboard",
- "surge",
- # "surge&ver=2",
- # "surge&ver=3",
-]
-
def get_filename(target: str) -> str:
target = utils.trim(target).lower()
@@ -103,7 +85,7 @@ def generate_conf(
lines.extend(["emoji=false", "add_emoji=false"])
if ignore_exclude:
- lines.append("exclude=流量|过期|剩余|时间|Expire|Traffic")
+ lines.append("exclude=[到过]期|Expire|Traffic|剩余流量|时间|官网|产品|联系")
lines.append("\n")
content = "\n".join(lines)
diff --git a/subscribe/utils.py b/subscribe/utils.py
index 957731a715..cf488518f4 100644
--- a/subscribe/utils.py
+++ b/subscribe/utils.py
@@ -35,7 +35,7 @@
CTX.verify_mode = ssl.CERT_NONE
USER_AGENT = (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
)
@@ -43,16 +43,28 @@
FILEPATH_PROTOCAL = "file:///"
-# ChatGPT 标识
-CHATGPT_FLAG = "-GPT"
-
-
DEFAULT_HTTP_HEADERS = {
"User-Agent": USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
}
+_SUSPICIOUS_URL_RE = re.compile(
+ r"(?i)("
+ r"(?:^|//)(?:[^/]*\.)?(?:speedtest|librespeed|fast\.com|cachefly\.net|thinkbroadband\.com|speed\.cloudflare\.com)"
+ r"|/__down(?:\?|$)"
+ r"|\.(?:zip|iso|exe|mp4|mkv|avi|tar|tgz|gz|7z|rar|bin|img|dmg|apk|msi|pdf|"
+ r"css|js|png|jpe?g|gif|svg|webp|woff2?|ico|map|torrent)(?:\?|$)"
+ r")"
+)
+
+
+def is_suspicious_url(url: str) -> bool:
+ if not url or not isinstance(url, str):
+ return False
+ return bool(_SUSPICIOUS_URL_RE.search(url.strip()))
+
+
def random_chars(length: int, punctuation: bool = False) -> str:
length = max(length, 1)
if punctuation:
@@ -65,14 +77,14 @@ def random_chars(length: int, punctuation: bool = False) -> str:
def http_get(
url: str,
- headers: dict = None,
- params: dict = None,
+ headers: dict[str, str] | None = None,
+ params: dict[str, str] | None = None,
retry: int = 3,
proxy: str = "",
interval: float = 0,
timeout: float = 10,
trace: bool = False,
- max_size=None,
+ max_size: int | None = None,
) -> str:
if not isurl(url=url):
logger.error(f"invalid url: {url}")
@@ -190,7 +202,7 @@ def extract_cookie(text: str) -> str:
return cookie
-def cmd(command: list, output: bool = False) -> tuple[bool, str]:
+def cmd(command: list[str], output: bool = False) -> tuple[bool, str]:
if command is None or len(command) == 0:
return False, ""
@@ -288,7 +300,7 @@ def encoding_url(url: str) -> str:
return url
-def write_file(filename: str, lines: list) -> bool:
+def write_file(filename: str, lines: list[str] | str) -> bool:
if not filename or not lines:
logger.error(f"filename or lines is empty, filename: {filename}")
return False
@@ -356,6 +368,13 @@ def load_dotenv(enviroment: str = ".env") -> None:
os.environ[k] = v
+def env_bool(name: str, default: bool = False) -> bool:
+ raw = trim(os.environ.get(name, ""))
+ if not raw:
+ return default
+ return raw.lower() in ["true", "1"]
+
+
def hide(url: str) -> str:
# len('http://') equals 7
if isblank(url) or len(url) < 7:
@@ -413,12 +432,12 @@ def http_error_302(
def http_post(
url: str,
- headers: dict = None,
- params: dict = {},
+ headers: dict[str, str] | None = None,
+ params: dict[str, object] | None = None,
retry: int = 3,
timeout: float = 6,
allow_redirects: bool = True,
-) -> HTTPResponse:
+) -> HTTPResponse | None:
if params is None or type(params) != dict or retry <= 0:
return None
@@ -481,7 +500,7 @@ def url_complete(url: str, secret: bool = False) -> str:
return url
-def load_emoji_pattern(filepath: str = "") -> dict:
+def load_emoji_pattern(filepath: str = "") -> dict[str, str]:
filepath = trim(filepath)
if not filepath:
workspace = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
@@ -509,7 +528,7 @@ def load_emoji_pattern(filepath: str = "") -> dict:
return patterns
-def get_emoji(text: str, patterns: dict, default: str = "") -> str:
+def get_emoji(text: str, patterns: dict[str, str], default: str = "") -> str:
if not patterns or type(patterns) != dict or not text or type(text) != str:
return default
@@ -530,7 +549,7 @@ def get_subpath(api_prefix: str, default: str = "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/api/v1/") -> str:
return path
-def multi_process_run(func: typing.Callable, tasks: list) -> list:
+def multi_process_run(func: typing.Callable[..., object], tasks: list[object]) -> list[object]:
if not func or not isinstance(func, typing.Callable):
logger.error(f"skip execute due to func is not callable")
return []
@@ -566,12 +585,12 @@ def multi_process_run(func: typing.Callable, tasks: list) -> list:
def multi_thread_run(
- func: typing.Callable,
- tasks: list,
- num_threads: int = None,
+ func: typing.Callable[..., object],
+ tasks: list[object],
+ num_threads: int | None = None,
show_progress: bool = False,
description: str = "",
-) -> list:
+) -> list[object]:
if not func or not tasks or not isinstance(tasks, list):
return []
diff --git a/subscribe/workflow.py b/subscribe/workflow.py
index 83f4a4ef85..56d142f1ba 100644
--- a/subscribe/workflow.py
+++ b/subscribe/workflow.py
@@ -6,11 +6,12 @@
import json
import os
import re
-from dataclasses import dataclass
+from dataclasses import dataclass, field
import renewal
import utils
from airport import ANOTHER_API_PREFIX, AirPort
+from config.models import NodeInput, ProcessConfig, RenewJob, SiteConfig
from logger import logger
from origin import Origin
from push import PushTo
@@ -18,83 +19,41 @@
@dataclass
class TaskConfig:
- # 任务名
name: str
-
- # subconverter程序名
bin_name: str
-
- # 任务编号
taskid: int = -1
-
- # 网址域名
domain: str = ""
-
- # 订阅地址
- sub: str = ""
-
- # 任务编号
+ nodes: NodeInput = field(default_factory=NodeInput)
index: int = 1
-
- # 失败重试次数
retry: int = 3
-
- # 最高允许倍率
- rate: float = 20.0
-
- # 套餐续期配置
- renew: dict = None
-
- # 优惠码
+ max_rate: float = 20.0
+ renew: RenewJob | None = None
coupon: str = ""
-
- # 节点重命名规则
rename: str = ""
-
- # 节点排除规则
exclude: str = ""
include: str = ""
-
- # ChatGPT连通性测试节点过滤规则
- chatgpt: dict = None
-
- # 是否检测节点存活状态
- liveness: bool = True
-
- # 是否强制开启 tls 及阻止跳过证书验证
- disable_insecure: bool = False
-
- # 覆盖subconverter默认exclude规则
- ignorede: bool = False
-
- # 是否允许特殊协议
+ check_alive: bool = True
+ require_tls: bool = False
+ ignore_default_exclude: bool = False
special_protocols: bool = False
-
- # 对于具有邮箱域名白名单且需要验证码的情况,是否使用 Gmail 别名邮箱尝试,为 True 时表示不使用
- rigid: bool = True
-
- # 是否丢弃可能需要人机验证的站点
- chuck: bool = False
-
- # 邀请码
+ allow_gmail_alias: bool = False
+ skip_captcha: bool = False
invite_code: str = ""
-
- # 接口地址前缀,如 /api/v1/ 或 /api?scheme=
api_prefix: str = "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/api/v1/"
-def execute(task_conf: TaskConfig) -> list:
+def execute(task_conf: TaskConfig) -> list[dict[str, object]]:
if not task_conf or not isinstance(task_conf, TaskConfig):
return []
obj = AirPort(
name=task_conf.name,
site=task_conf.domain,
- sub=task_conf.sub,
+ nodes=task_conf.nodes,
rename=task_conf.rename,
exclude=task_conf.exclude,
include=task_conf.include,
- liveness=task_conf.liveness,
+ check_alive=task_conf.check_alive,
coupon=task_conf.coupon,
api_prefix=task_conf.api_prefix,
)
@@ -105,30 +64,32 @@ def execute(task_conf: TaskConfig) -> list:
if task_conf.renew:
sub_url = renewal.add_traffic_flow(
domain=obj.ref,
- params=task_conf.renew,
+ job=task_conf.renew,
jsonify=obj.api_prefix == ANOTHER_API_PREFIX,
)
if sub_url and not obj.registed:
obj.registed = True
- obj.sub = sub_url
-
- cookie, authorization = obj.get_subscribe(
- retry=task_conf.retry,
- rigid=task_conf.rigid,
- chuck=task_conf.chuck,
- invite_code=task_conf.invite_code,
- )
+ obj.nodes.subscribe = sub_url
+
+ cookie, authorization = "", ""
+ if task_conf.nodes.empty() and task_conf.domain:
+ cookie, authorization = obj.get_subscribe(
+ retry=task_conf.retry,
+ allow_gmail_alias=task_conf.allow_gmail_alias,
+ skip_captcha=task_conf.skip_captcha,
+ invite_code=task_conf.invite_code,
+ )
proxies = obj.parse(
cookie=cookie,
auth=authorization,
retry=task_conf.retry,
- rate=task_conf.rate,
+ rate=task_conf.max_rate,
bin_name=task_conf.bin_name,
- disable_insecure=task_conf.disable_insecure,
- ignore_exclude=task_conf.ignorede,
- chatgpt=task_conf.chatgpt,
+ require_tls=task_conf.require_tls,
+ ignore_exclude=task_conf.ignore_default_exclude,
special_protocols=task_conf.special_protocols,
+ nodes=task_conf.nodes,
)
logger.info(
@@ -138,7 +99,7 @@ def execute(task_conf: TaskConfig) -> list:
return proxies
-def executewrapper(task_conf: TaskConfig) -> tuple[int, list]:
+def executewrapper(task_conf: TaskConfig) -> tuple[int, list[dict[str, object]]]:
if not task_conf:
return (-1, [])
@@ -147,7 +108,7 @@ def executewrapper(task_conf: TaskConfig) -> tuple[int, list]:
return (taskid, proxies)
-def liveness_fillter(proxies: list) -> tuple[list, list]:
+def liveness_fillter(proxies: list[dict[str, object]]) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
if not list:
return [], []
@@ -161,13 +122,12 @@ def liveness_fillter(proxies: list) -> tuple[list, list]:
checks.append(p)
else:
p.pop("sub", "")
- p.pop("chatgpt", False)
nochecks.append(p)
return checks, nochecks
-def cleanup(filepath: str = "", filenames: list = []) -> None:
+def cleanup(filepath: str = "", filenames: list[str] | None = None) -> None:
if not filepath or not filenames:
return
@@ -177,7 +137,7 @@ def cleanup(filepath: str = "", filenames: list = []) -> None:
os.remove(filename)
-def dedup_task(tasks: list) -> list:
+def dedup_task(tasks: list[TaskConfig]) -> list[TaskConfig]:
if not tasks:
return []
items = []
@@ -188,7 +148,7 @@ def dedup_task(tasks: list) -> list:
return items
-def exists(tasks: list, task: TaskConfig) -> bool:
+def exists(tasks: list[TaskConfig], task: TaskConfig) -> bool:
if not isinstance(task, TaskConfig):
logger.error(f"[DedupError] need type 'TaskConfig' but got type '{type(task)}'")
return True
@@ -197,8 +157,10 @@ def exists(tasks: list, task: TaskConfig) -> bool:
found = False
for item in tasks:
- if task.sub != "":
- if task.sub == item.sub:
+ left = task.nodes.subscribe_list()
+ right = item.nodes.subscribe_list()
+ if left:
+ if left == right:
found = True
else:
if task.domain == item.domain and task.index == item.index:
@@ -211,83 +173,79 @@ def exists(tasks: list, task: TaskConfig) -> bool:
item.exclude = "|".join([item.exclude, task.exclude]).removeprefix("|")
if task.include:
item.include = "|".join([item.include, task.include]).removeprefix("|")
- break
+ break
return found
-def merge_config(configs: list) -> list:
- def judge_exists(raw: dict, target: dict) -> bool:
- if not raw or not target:
- return False
+def _subscribe_key(site: SiteConfig) -> str | list[str]:
+ subs = site.nodes.subscribe_list()
+ if len(subs) <= 1:
+ return subs[0] if subs else ""
+ return subs
+
- rsub = raw.get("sub").strip()
- tsub = target.get("sub", "")
- if not tsub:
+def merge_config(sites: list[SiteConfig]) -> list[SiteConfig]:
+ def judge_exists(raw: SiteConfig, target: SiteConfig) -> bool:
+ rsubs = raw.nodes.subscribe_list()
+ tsubs = target.nodes.subscribe_list()
+ rsub = rsubs[0] if rsubs else ""
+ if not tsubs:
if rsub:
return False
- return raw.get("domain", "").strip() == target.get("domain", "").strip()
- if isinstance(tsub, str):
- return rsub == tsub.strip()
- for sub in tsub:
- if rsub == sub.strip():
- return True
- return False
+ return utils.trim(raw.domain) == utils.trim(target.domain)
+ return rsub in tsubs
- if not configs:
+ if not sites:
return []
items = []
- for conf in configs:
- if not isinstance(conf, dict):
- logger.error(f"[MergeError] need type 'dict' but got type '{type(conf)}'")
+ for site in sites:
+ if not isinstance(site, SiteConfig):
+ logger.error(f"[MergeError] need type 'SiteConfig' but got type '{type(site)}'")
continue
- sub = conf.get("sub", "")
- if isinstance(sub, list) and len(sub) <= 1:
- sub = sub[0] if sub else ""
+ sub = _subscribe_key(site)
+ if isinstance(sub, str):
+ site.nodes.subscribe = sub
- # 人工维护配置,无需合并
- if isinstance(sub, list) or conf.get("renew", {}):
- items.append(conf)
+ if isinstance(sub, list) or site.renew:
+ items.append(site)
continue
found = False
- conf["sub"] = sub
for item in items:
- found = judge_exists(raw=conf, target=item)
+ found = judge_exists(raw=site, target=item)
if found:
- if conf.get("errors", 0) > item.get("errors", 0):
- item["errors"] = conf.get("errors", 0)
- if item.get("debut", False):
- item["debut"] = conf.get("debut", False)
- if not item.get("rename", ""):
- item["rename"] = conf.get("rename", "")
- if conf.get("exclude", ""):
- item["exclude"] = "|".join([item.get("exclude", ""), conf.get("exclude", "")]).removeprefix("|")
- if conf.get("include", ""):
- item["include"] = "|".join([item.get("include", ""), conf.get("include", "")]).removeprefix("|")
-
+ if site.errors > item.errors:
+ item.errors = site.errors
+ if item.debut:
+ item.debut = site.debut
+ if not item.rename:
+ item.rename = site.rename
+ if site.exclude:
+ item.exclude = "|".join([item.exclude, site.exclude]).removeprefix("|")
+ if site.include:
+ item.include = "|".join([item.include, site.include]).removeprefix("|")
break
-
if not found:
- items.append(conf)
-
+ items.append(site)
return items
-def refresh(config: dict, push: PushTo, alives: dict, filepath: str = "", skip_remark: bool = False) -> None:
- if not config or not push:
+def refresh(
+ config: ProcessConfig, push: PushTo, alives: dict[str, bool] | None, filepath: str = "", skip_remark: bool = False
+) -> None:
+ if not isinstance(config, ProcessConfig) or not isinstance(push, PushTo):
logger.error("[UpdateError] cannot update remote config because content is empty")
return
- # mark invalid crawled subscription
invalidsubs = None if (skip_remark or not alives) else [k for k, v in alives.items() if not v]
- if invalidsubs:
- crawledsub = config.get("crawl", {}).get("persist", {}).get("subs", "")
- threshold = max(config.get("threshold", 1), 1)
- pushconf = config.get("groups", {}).get(crawledsub, {})
- if push.validate(config=pushconf):
- url = push.raw_url(config=pushconf)
+ if invalidsubs and config.crawl:
+ crawledsub = config.crawl.persist.subscribe
+ threshold = max(config.crawl.max_fails, 1)
+ pushconf = config.storage.items.get(crawledsub)
+ if push.validate(item=pushconf):
+ url = push.raw_url(item=pushconf)
content = utils.http_get(url=url)
try:
data, count = json.loads(content), 0
@@ -295,57 +253,56 @@ def refresh(config: dict, push: PushTo, alives: dict, filepath: str = "", skip_r
record = data.pop(sub, None)
if not record:
continue
-
- defeat = record.get("defeat", 0) + 1
+ errors = record.get("errors", 0) + 1
count += 1
- if defeat <= threshold and standard_sub(url=sub):
- record["defeat"] = defeat
+ if errors <= threshold and standard_sub(url=sub):
+ record["errors"] = errors
data[sub] = record
-
if count > 0:
content = json.dumps(data)
- push.push_to(content=content, config=pushconf, group="crawled-remark")
+ push.push_to(content=content, item=pushconf, group="crawled-remark")
logger.info(f"[UpdateInfo] found {count} invalid crawled subscriptions")
- except:
- logger.error(f"[UpdateError] remark invalid crawled subscriptions failed")
+ except Exception:
+ logger.error("[UpdateError] remark invalid crawled subscriptions failed")
- update_conf = config.get("update", {})
- if not update_conf.get("enable", False):
+ if not config.update.enable:
logger.debug("[UpdateError] skip update remote config because enable=[False]")
return
- if not push.validate(config=update_conf):
- logger.error(f"[UpdateError] update config is invalidate")
+ if not push.validate(item=config.update.item):
+ logger.error("[UpdateError] update config is invalidate")
return
- domains = merge_config(configs=config.get("domains", []))
+ sites_conf = merge_config(sites=config.sites)
if alives:
sites = []
- for item in domains:
- source = item.get("origin", "")
- sub = item.get("sub", "")
- if isinstance(sub, list) and len(sub) <= 1:
- sub = sub[0] if sub else ""
- if source in [Origin.TEMPORARY.name, Origin.OWNED.name] or isinstance(sub, list) or alives.get(sub, False):
- item.pop("errors", None)
- item.pop("debut", None)
+ for item in sites_conf:
+ if not item.enable:
+ sites.append(item)
+ continue
+ sub = _subscribe_key(item)
+ source = item.origin
+ if (
+ source in [Origin.TEMPORARY.name, Origin.OWNED.name]
+ or isinstance(sub, list)
+ or (isinstance(sub, str) and alives.get(sub, False))
+ ):
+ item.errors = 0
+ item.debut = False
sites.append(item)
continue
-
- errors = item.get("errors", 1)
expire = Origin.get_expire(source)
- if errors < expire and not item.get("debut", False):
- item.pop("debut", None)
+ if item.errors < expire and not item.debut:
+ item.debut = False
sites.append(item)
+ config.sites = sites
+ sites_conf = sites
- config["domains"] = sites
- domains = config.get("domains", [])
-
- if not domains:
- logger.error("[UpdateError] skip update remote config because domians is empty")
+ if not sites_conf:
+ logger.error("[UpdateError] skip update remote config because sites is empty")
return
- content = json.dumps(config)
+ content = json.dumps(config.to_dict())
if filepath:
directory = os.path.abspath(os.path.dirname(filepath))
os.makedirs(directory, exist_ok=True)
@@ -353,7 +310,7 @@ def refresh(config: dict, push: PushTo, alives: dict, filepath: str = "", skip_r
f.write(content)
f.flush()
- push.push_to(content=content, config=update_conf, group="update")
+ push.push_to(content=content, item=config.update.item, group="update")
def standard_sub(url: str) -> bool:
diff --git a/tools/clean.py b/tools/clean.py
index dc7301cdb0..21f5c481ca 100644
--- a/tools/clean.py
+++ b/tools/clean.py
@@ -223,7 +223,7 @@ def main(args: argparse.Namespace) -> None:
elif country:
name = country
else:
- print("cannot get geolocation and rename because IP address is faked")
+ print("cannot get geolocation and name because IP address is faked")
item["name"] = name
except Exception:
diff --git a/tools/proxy-gen.py b/tools/proxy-gen.py
new file mode 100644
index 0000000000..26a275d610
--- /dev/null
+++ b/tools/proxy-gen.py
@@ -0,0 +1,335 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""Generate dynamic HTTP/SOCKS proxy nodes from CLI options"""
+
+from __future__ import annotations
+
+import argparse
+import ipaddress
+import json
+import os
+import re
+import secrets
+import string
+import sys
+from dataclasses import dataclass
+
+SCHEMES = ("socks5", "socks5h", "http", "https")
+FORMATS = ("uri", "clash")
+NAMING_MODES = ("sid", "index", "none")
+SID_CHARS = string.ascii_letters + string.digits
+KEYWORD_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
+DOMAIN_LABEL = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
+POSITIVE_INT = re.compile(r"^[1-9]\d*$")
+ILLEGAL_CHARS = set(":@#/?%")
+ISO_CODES = frozenset("""
+ AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ
+ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
+ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ
+ DE DJ DK DM DO DZ
+ EC EE EG EH ER ES ET
+ FI FJ FK FM FO FR
+ GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY
+ HK HM HN HR HT HU
+ ID IE IL IM IN IO IQ IR IS IT
+ JE JM JO JP
+ KE KG KH KI KM KN KP KR KW KY KZ
+ LA LB LC LI LK LR LS LT LU LV LY
+ MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ
+ NA NC NE NF NG NI NL NO NP NR NU NZ
+ OM
+ PA PE PF PG PH PK PL PM PN PR PS PT PW PY
+ QA
+ RE RO RS RU RW
+ SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ
+ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ
+ UA UG UM US UY UZ
+ VA VC VE VG VI VN VU
+ WF WS
+ XK
+ YE YT
+ ZA ZM ZW
+ """.split())
+
+
+class ValidationError(ValueError):
+ pass
+
+
+@dataclass
+class Config:
+ scheme: str
+ username: str
+ password: str
+ host: str
+ port: int
+ count: int
+ region_key: str
+ country: str
+ state_key: str
+ state: str
+ session_key: str
+ sid_length: int
+ duration_key: str
+ duration: int
+ naming_mode: str
+ output_format: str
+ output_path: str
+
+
+def trim(value: object | None) -> str:
+ return "" if value is None else str(value).strip()
+
+
+def require_text(value: object | None, name: str) -> str:
+ text = trim(value)
+ if not text:
+ raise ValidationError(f"{name} is required")
+ if any(ch.isspace() for ch in text):
+ raise ValidationError(f"{name} must not contain whitespace")
+ if any(ch in ILLEGAL_CHARS for ch in text):
+ raise ValidationError(f"{name} contains illegal character")
+ return text
+
+
+def require_keyword(value: object | None, name: str, required: bool = True) -> str:
+ text = trim(value)
+ if not text:
+ if required:
+ raise ValidationError(f"{name} is required")
+ return ""
+ if any(ch.isspace() for ch in text):
+ raise ValidationError(f"{name} must not contain whitespace")
+ if not KEYWORD_PATTERN.fullmatch(text):
+ raise ValidationError(f"{name} may contain only letters, digits, '-' and '_'")
+ return text
+
+
+def require_integer(value: object | None, name: str, maximum: int | None = None) -> int:
+ text = trim(value)
+ if not POSITIVE_INT.fullmatch(text):
+ raise ValidationError(f"{name} must be an integer > 0")
+ number = int(text)
+ if maximum is not None and number > maximum:
+ raise ValidationError(f"{name} must be <= {maximum}")
+ return number
+
+
+def require_host(value: object | None) -> str:
+ host = require_text(value, "host")
+ try:
+ ipaddress.IPv4Address(host)
+ return host
+ except ValueError:
+ pass
+ hostname = host[:-1] if host.endswith(".") else host
+ if not hostname or len(hostname) > 253:
+ raise ValidationError("host must be a domain or IPv4 address")
+ labels = hostname.split(".")
+ if not all(DOMAIN_LABEL.fullmatch(label) for label in labels):
+ raise ValidationError("host must be a domain or IPv4 address")
+ return host
+
+
+def require_country(value: object | None) -> str:
+ text = trim(value)
+ if not text:
+ return ""
+ country = text.upper()
+ if country not in ISO_CODES:
+ raise ValidationError("country must be an ISO 3166-1 alpha-2 code")
+ return country
+
+
+def require_choice(value: object | None, name: str, options: tuple[str, ...], default: str) -> str:
+ text = trim(value).lower()
+ if not text:
+ return default
+ if text not in options:
+ raise ValidationError(f"{name} must be one of {', '.join(options)}")
+ return text
+
+
+def generate_sids(count: int, length: int) -> list[str]:
+ capacity = len(SID_CHARS) ** length
+ if count > capacity:
+ raise ValidationError(f"count {count} exceeds unique sid space {capacity} for sid-length {length}")
+ randomizer = secrets.SystemRandom()
+ if length == 1:
+ return randomizer.sample(list(SID_CHARS), count)
+ seen: set[str] = set()
+ sids: list[str] = []
+ while len(sids) < count:
+ sid = "".join(randomizer.choice(SID_CHARS) for _ in range(length))
+ if sid in seen:
+ continue
+ seen.add(sid)
+ sids.append(sid)
+ return sids
+
+
+def build_username(config: Config, sid: str) -> str:
+ parts = [config.username]
+ if config.country:
+ parts.extend((config.region_key, config.country))
+ if config.state:
+ parts.extend((config.state_key, config.state))
+ parts.extend((config.session_key, sid, config.duration_key, str(config.duration)))
+ return "-".join(parts)
+
+
+def build_node_name(config: Config, sid: str, index: int, index_width: int) -> str:
+ if config.naming_mode == "none":
+ return ""
+ suffix = sid if config.naming_mode == "sid" else f"{index:0{index_width}d}"
+ return f"{config.country}-{suffix}" if config.country else suffix
+
+
+def format_uri(config: Config, username: str, node_name: str) -> str:
+ uri = f"{config.scheme}://{username}:{config.password}@{config.host}:{config.port}"
+ return f"{uri}#{node_name}" if node_name else uri
+
+
+def format_clash(config: Config, username: str, node_name: str) -> str:
+ proxy_type = "http" if config.scheme in ("http", "https") else "socks5"
+ proxy = {
+ "name": node_name,
+ "type": proxy_type,
+ "server": config.host,
+ "port": config.port,
+ "username": username,
+ "password": config.password,
+ }
+ if config.scheme in ("socks5", "socks5h"):
+ proxy["udp"] = True
+ proxy["tls"] = True
+ proxy["skip-cert-verify"] = False
+ elif config.scheme == "https":
+ proxy["tls"] = True
+ proxy["skip-cert-verify"] = False
+ return json.dumps(proxy, ensure_ascii=False, separators=(",", ":"))
+
+
+def parse_config(args: argparse.Namespace) -> Config:
+ scheme = require_choice(args.scheme, "scheme", SCHEMES, "socks5")
+ username = require_text(args.username, "username")
+ password = require_text(args.password, "password")
+ host = require_host(args.host)
+ port = require_integer(args.port, "port", maximum=65535)
+ count = require_integer(args.count, "count")
+ region_key = require_keyword(args.region_key, "region-key")
+ country = require_country(args.country)
+ state_key = require_keyword(args.state_key, "state-key", required=False)
+ state = require_keyword(args.state, "state", required=False)
+ session_key = require_keyword(args.session_key, "session-key")
+ sid_length = require_integer(args.sid_length, "sid-length", maximum=64)
+ duration_key = require_keyword(args.duration_key, "duration-key")
+ duration = require_integer(args.duration, "duration")
+ naming_mode = require_choice(args.naming, "naming", NAMING_MODES, "sid")
+ output_format = require_choice(args.format, "format", FORMATS, "uri")
+ output_path = trim(args.output)
+
+ if bool(state_key) != bool(state):
+ raise ValidationError("state-key and state must be provided together")
+ if (state_key or state) and not country:
+ raise ValidationError("state requires country")
+ if naming_mode == "none" and output_format != "uri":
+ raise ValidationError("naming=none is only supported when format=uri")
+
+ return Config(
+ scheme=scheme,
+ username=username,
+ password=password,
+ host=host,
+ port=port,
+ count=count,
+ region_key=region_key,
+ country=country,
+ state_key=state_key,
+ state=state,
+ session_key=session_key,
+ sid_length=sid_length,
+ duration_key=duration_key,
+ duration=duration,
+ naming_mode=naming_mode,
+ output_format=output_format,
+ output_path=output_path,
+ )
+
+
+def generate_nodes(config: Config) -> list[str]:
+ sids = generate_sids(config.count, config.sid_length)
+ index_width = len(str(config.count))
+ nodes: list[str] = []
+ for index, sid in enumerate(sids, start=1):
+ username = build_username(config, sid)
+ node_name = build_node_name(config, sid, index, index_width)
+ if config.output_format == "clash":
+ nodes.append(f" - {format_clash(config, username, node_name)}")
+ else:
+ nodes.append(format_uri(config, username, node_name))
+ if config.output_format == "clash":
+ return ["proxies:", *nodes]
+ return nodes
+
+
+def write_output(nodes: list[str], output_path: str) -> None:
+ content = "\n".join(nodes)
+ if content:
+ content += "\n"
+ if not output_path:
+ sys.stdout.write(content)
+ return
+ path = os.path.abspath(output_path)
+ directory = os.path.dirname(path)
+ if directory and not os.path.isdir(directory):
+ raise ValidationError(f"output directory not found: {directory}")
+ with open(path, "w", encoding="utf-8", newline="\n") as output_file:
+ output_file.write(content)
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Generate dynamic HTTP/SOCKS proxy nodes",
+ formatter_class=argparse.RawTextHelpFormatter,
+ epilog=(
+ "example:\n"
+ " python proxies-gen.py --username alice --password secret --host gate.example.com --port 1080 --count 10 "
+ "--session-key sid --sid-length 8 --duration-key t --country US --state-key st --state california"
+ ),
+ allow_abbrev=False,
+ )
+ parser.add_argument("--scheme", default="socks5", help="socks5, socks5h, http or https")
+ parser.add_argument("--username", required=True, help="base username")
+ parser.add_argument("--password", required=True, help="password")
+ parser.add_argument("--host", required=True, help="domain or IPv4 address")
+ parser.add_argument("--port", required=True, help="port, 1-65535")
+ parser.add_argument("--count", required=True, help="number of nodes")
+ parser.add_argument("--region-key", default="region", help="region keyword, default: region")
+ parser.add_argument("--country", default="", help="ISO 3166-1 alpha-2 country code")
+ parser.add_argument("--state-key", default="", help="state or city keyword, e.g. st")
+ parser.add_argument("--state", default="", help="state or city name")
+ parser.add_argument("--session-key", required=True, help="session keyword, e.g. sid")
+ parser.add_argument("--sid-length", required=True, help="random sid length")
+ parser.add_argument("--duration-key", required=True, help="session duration keyword, e.g. t")
+ parser.add_argument("--duration", default="5", help="session duration, default: 5")
+ parser.add_argument("--naming", default="sid", help="node name style: sid, index or none, none only for uri")
+ parser.add_argument("--format", default="uri", help="uri or clash")
+ parser.add_argument("--output", default="", help="output file, default: stdout")
+ return parser
+
+
+def main() -> None:
+ parser = build_parser()
+ try:
+ config = parse_config(parser.parse_args())
+ write_output(generate_nodes(config), config.output_path)
+ except ValidationError as error:
+ parser.error(str(error))
+ except OSError as error:
+ parser.error(str(error))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/socks-checker.py b/tools/socks-checker.py
index c7bbb26388..7f8e4a8b37 100644
--- a/tools/socks-checker.py
+++ b/tools/socks-checker.py
@@ -11,13 +11,15 @@
import argparse
import asyncio
+import html
import ipaddress
+import json
import re
import sys
from dataclasses import dataclass
from datetime import datetime
-from typing import Dict, List, Optional, Tuple
-from urllib.parse import urlparse
+from typing import Any, Callable, Dict, List, Optional, Tuple
+from urllib.parse import quote, urlparse
import aiohttp
import yaml
@@ -276,6 +278,97 @@
}
+def country_flag_emoji(country_code: str) -> str:
+ if not country_code or len(country_code) != 2:
+ return ""
+
+ code = country_code.upper()
+ if not code.isalpha():
+ return ""
+
+ return chr(0x1F1E6 + ord(code[0]) - ord("A")) + chr(0x1F1E6 + ord(code[1]) - ord("A"))
+
+
+def country_name_zh(country_code: str) -> str:
+ if not country_code:
+ return ""
+
+ return COUNTRY_NAME_ZH.get(country_code.upper(), "")
+
+
+CHINA_PROVINCE_SUFFIXES = (
+ "特别行政区",
+ "维吾尔自治区",
+ "壮族自治区",
+ "回族自治区",
+ "自治区",
+ "省",
+ "市",
+)
+CHINA_PROVINCE_ALIASES = {
+ "anhui": "安徽",
+ "beijing": "北京",
+ "chongqing": "重庆",
+ "fujian": "福建",
+ "gansu": "甘肃",
+ "guangdong": "广东",
+ "guangxi": "广西",
+ "guangxizhuang": "广西",
+ "guizhou": "贵州",
+ "hainan": "海南",
+ "hebei": "河北",
+ "heilongjiang": "黑龙江",
+ "henan": "河南",
+ "hubei": "湖北",
+ "hunan": "湖南",
+ "innermongolia": "内蒙古",
+ "jiangsu": "江苏",
+ "jiangxi": "江西",
+ "jilin": "吉林",
+ "liaoning": "辽宁",
+ "neimenggu": "内蒙古",
+ "neimongol": "内蒙古",
+ "ningxia": "宁夏",
+ "ningxiahuizu": "宁夏",
+ "qinghai": "青海",
+ "shaanxi": "陕西",
+ "shandong": "山东",
+ "shanghai": "上海",
+ "shanxi": "山西",
+ "sichuan": "四川",
+ "tianjin": "天津",
+ "tibet": "西藏",
+ "xinjiang": "新疆",
+ "xinjianguygur": "新疆",
+ "xinjianguyghur": "新疆",
+ "xizang": "西藏",
+ "yunnan": "云南",
+ "zhejiang": "浙江",
+}
+CHINA_MAINLAND_PROVINCES = frozenset(CHINA_PROVINCE_ALIASES.values())
+CHINA_PROVINCE_ALIAS_PREFIXES = tuple(
+ sorted(CHINA_PROVINCE_ALIASES.items(), key=lambda item: len(item[0]), reverse=True)
+)
+CHINA_PROVINCE_EN_SUFFIXES = (
+ "autonomousregion",
+ "municipality",
+ "province",
+ "region",
+ "sheng",
+ "city",
+)
+
+REGION_KEYS = set(["province", "province_name", "region", "region_name", "state", "state_name"])
+
+
+def short_company_name(value: str) -> str:
+ if not value:
+ return "UNKNOWN"
+
+ parts = [part for part in re.split(r"[\s,\.\-_@;:]+", value.strip()) if part]
+ return parts[0].upper() if parts else "UNKNOWN"
+
+
@dataclass
class ProxyInfo:
protocol: str
@@ -309,6 +402,820 @@ def from_proxy(cls, proxy_info: ProxyInfo) -> "TestResult":
)
+@dataclass
+class IpLookupResult:
+ ip: Optional[str]
+ data: Optional[Dict]
+ error: Optional[str] = None
+
+
+class IPLibrary:
+ name: str = ""
+
+ def __init__(self):
+ self._caches: Dict[str, str] = {}
+
+ async def lookup(
+ self, session: aiohttp.ClientSession, proxy_info: ProxyInfo, retries: int, timeout: int
+ ) -> IpLookupResult:
+ data, error = await self._fetch(session, proxy_info, retries, timeout)
+ if not data:
+ host = "" if not proxy_info else proxy_info.host
+ return IpLookupResult(None, None, error or f"Failed to get IP info from {self.name}, host: {host}")
+
+ return self._verify(data, self.name)
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ raise NotImplementedError
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, proxy_info: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ raise NotImplementedError
+
+ @staticmethod
+ def _build_headers(url: str) -> Dict[str, str]:
+ result = urlparse(url)
+ base = f"{result.scheme}://{result.netloc}" if result.scheme and result.netloc else ""
+
+ return {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Cache-Control": "no-cache",
+ "Pragma": "no-cache",
+ "Connection": "close",
+ "Referer": f"{base}/" if base else url,
+ "Origin": base if base else url,
+ }
+
+ async def _make_request(
+ self,
+ session: aiohttp.ClientSession,
+ url: str,
+ retries: int,
+ timeout: int,
+ headers: Optional[Dict[str, str]] = None,
+ deserialize: bool = True,
+ parser: Optional[Callable[[str], Any]] = None,
+ ) -> Tuple[Optional[Any], Optional[str]]:
+ default_headers = self._build_headers(url)
+ if headers and isinstance(headers, dict):
+ default_headers.update({k: v for k, v in headers.items() if k and v is not None})
+
+ error = None
+ for attempt in range(1, retries + 1):
+ try:
+ async with session.get(
+ url,
+ headers=default_headers,
+ timeout=aiohttp.ClientTimeout(total=timeout),
+ ) as response:
+ if response.status == 200:
+ content = await response.text()
+
+ if parser is not None:
+ data = parser(content)
+ if data:
+ return data, None
+
+ error = "Invalid response payload"
+ elif deserialize:
+ try:
+ data = json.loads(content)
+ except Exception:
+ data = None
+
+ if isinstance(data, dict):
+ return data, None
+
+ error = "Invalid JSON response"
+ else:
+ return content, None
+
+ else:
+ error = f"HTTP {response.status}"
+ except asyncio.TimeoutError:
+ error = "Timeout"
+ except Exception as e:
+ error = str(e)[:100]
+
+ if attempt < retries:
+ await asyncio.sleep(attempt)
+
+ return None, error
+
+ @staticmethod
+ def _verify(data: Dict, source: str) -> IpLookupResult:
+ address = (data.get("ip") or "").strip()
+ if not address:
+ return IpLookupResult(None, None, f"Invalid IP from {source}")
+
+ try:
+ ipaddress.ip_address(address)
+ except ValueError:
+ return IpLookupResult(None, None, f"Invalid IP from {source}, ip: {address}")
+
+ return IpLookupResult(address, data, None)
+
+ @staticmethod
+ def _format_remark(
+ country_code: str,
+ country: str,
+ label: str,
+ include_asn_name: bool,
+ company_name: str,
+ detail: str = "",
+ ) -> str:
+ flag = country_flag_emoji(country_code)
+ base = f"{flag} {country}{label}".strip()
+
+ if include_asn_name and company_name:
+ if detail:
+ return f"{base} [{company_name}::{detail}]".strip()
+
+ return f"{base} [{company_name}]".strip()
+
+ return base
+
+ async def _resolve_country(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ country_code: str,
+ country: str,
+ retries: int,
+ timeout: int,
+ data: Optional[Dict] = None,
+ ) -> str:
+ country_code = (country_code or "").upper()
+ if country_code != "CN" or not ip:
+ return country
+
+ if ip in self._caches:
+ return self._caches[ip]
+
+ province = self._extract_province(data)
+ if not province:
+ return "中国"
+
+ resolved = f"中国{province}"
+ self._caches[ip] = resolved
+ return resolved
+
+ def _extract_province(self, data: Optional[Dict]) -> str:
+ if not isinstance(data, dict):
+ return ""
+
+ candidates: List[Any] = []
+ for key in REGION_KEYS:
+ candidates.append(data.get(key))
+
+ for parent_key in ("location", "geo"):
+ nested = data.get(parent_key)
+ if isinstance(nested, dict):
+ for key in REGION_KEYS:
+ candidates.append(nested.get(key))
+
+ for value in candidates:
+ province = self._normalize_province(value)
+ if province:
+ return province
+
+ return ""
+
+ @staticmethod
+ def _province_alias_key(province: str) -> str:
+ return re.sub(r"[^a-z0-9]+", "", province.lower())
+
+ @staticmethod
+ def _match_province_alias(key: str) -> str:
+ if not key or key in {"china", "cn", "mainlandchina", "unknown", "na", "null", "none"}:
+ return ""
+
+ candidates = [key]
+ for suffix in CHINA_PROVINCE_EN_SUFFIXES:
+ if key.endswith(suffix):
+ stripped = key[: -len(suffix)]
+ if stripped:
+ candidates.append(stripped)
+
+ for candidate in candidates:
+ province = CHINA_PROVINCE_ALIASES.get(candidate)
+ if province:
+ return province
+
+ for candidate in candidates:
+ for alias, province in CHINA_PROVINCE_ALIAS_PREFIXES:
+ if candidate.startswith(alias):
+ return province
+
+ return ""
+
+ @classmethod
+ def _normalize_province(cls, province: Any) -> str:
+ if isinstance(province, dict):
+ for key in ("name", "name_en", "en", "value"):
+ value = cls._normalize_province(province.get(key))
+ if value:
+ return value
+
+ return ""
+
+ if not isinstance(province, str):
+ return ""
+
+ province = province.strip()
+ if not province:
+ return ""
+
+ if province.lower() in {"-", "n/a", "na", "none", "null", "unknown"}:
+ return ""
+
+ for suffix in CHINA_PROVINCE_SUFFIXES:
+ if province.endswith(suffix):
+ province = province[: -len(suffix)].strip()
+ break
+
+ if re.search(r"[\u4e00-\u9fff]", province):
+ return province if province in CHINA_MAINLAND_PROVINCES else ""
+
+ key = cls._province_alias_key(province)
+ return cls._match_province_alias(key)
+
+
+class IPInfoLibrary(IPLibrary):
+ name = "ipinfo"
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ country_code = (data.get("country") or "").upper()
+ flag = country_flag_emoji(country_code)
+
+ asn_info = data.get("asn", {}) or {}
+ company_info = data.get("company", {}) or {}
+ asn_type = (asn_info.get("type") or "").lower()
+ company_type = (company_info.get("type") or "").lower()
+
+ asn_name = (asn_info.get("domain") or "").strip()
+ if not asn_name or re.match(r"^as\d+\.", asn_name, flags=re.I):
+ asn_name = (asn_info.get("name") or "").strip()
+
+ company_name = short_company_name(asn_name)
+
+ if asn_type == "isp" and company_type == "isp":
+ label = "家宽"
+ elif asn_type == "isp" or company_type == "isp":
+ label = "商宽"
+ elif asn_type == "edu" or company_type == "edu":
+ label = "教育"
+ else:
+ label = ""
+
+ country = await self._resolve_country(
+ session=session,
+ ip=ip,
+ country_code=country_code,
+ country=country_name_zh(country_code) or country_code or "未知",
+ retries=retries,
+ timeout=timeout,
+ data=data,
+ )
+ base = f"{flag} {country}{label}".strip()
+ if include_asn_name and company_name:
+ return f"{base} [{company_name}]".strip()
+
+ return base
+
+ @staticmethod
+ def _is_ipv4(host: str) -> bool:
+ if not host:
+ return False
+ try:
+ return isinstance(ipaddress.ip_address(host), ipaddress.IPv4Address)
+ except ValueError:
+ return False
+
+ async def _resolve_ip(self, session: aiohttp.ClientSession, host: str, retries: int, timeout: int) -> Optional[str]:
+ if self._is_ipv4(host):
+ return host
+
+ url = "https://ipinfo.io/ip"
+ for attempt in range(1, retries + 1):
+ try:
+ async with session.get(
+ url,
+ headers=self._build_headers(url),
+ timeout=aiohttp.ClientTimeout(total=timeout),
+ ) as response:
+ if response.status == 200:
+ text = (await response.text()).strip()
+ try:
+ ipaddress.ip_address(text)
+ return text
+ except ValueError:
+ pass
+ except asyncio.TimeoutError:
+ pass
+ except Exception:
+ pass
+
+ if attempt < retries:
+ await asyncio.sleep(attempt)
+
+ return None
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, proxy_info: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ host = proxy_info.host if proxy_info else ""
+ address = await self._resolve_ip(session, host, retries, timeout)
+ if not address:
+ return None, f"Failed to get IP from ipinfo.io/ip, host: {host}"
+
+ url = f"https://ipinfo.io/widget/demo/{address}"
+ data, error = await self._make_request(session, url, retries, timeout)
+ if not data:
+ return None, error or f"Failed to get IP info from ipinfo.io, ip: {address}"
+
+ return data.get("data", data), None
+
+
+class IPPureLibrary(IPLibrary):
+ name = "ippure"
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ residential = data.get("isResidential")
+ label = "家宽" if residential is True else ""
+
+ country_code = (data.get("countryCode") or "").upper()
+ country = await self._resolve_country(
+ session=session,
+ ip=ip,
+ country_code=country_code,
+ country=country_name_zh(country_code) or (data.get("country") or "未知"),
+ retries=retries,
+ timeout=timeout,
+ data=data,
+ )
+
+ company_name = short_company_name(data.get("asOrganization") or "")
+ score = str(data.get("fraudScore")).zfill(3) if "fraudScore" in data else "NUL"
+
+ # broadcast or native
+ categroy = "NUL" if "isBroadcast" not in data else "B" if data.get("isBroadcast") else "N"
+
+ return self._format_remark(
+ country_code=country_code,
+ country=country,
+ label=label,
+ include_asn_name=include_asn_name,
+ company_name=company_name,
+ detail=f"{score}::{categroy}",
+ )
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, _: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ url = "https://my.ippure.com/v1/info"
+ return await self._make_request(session, url, retries, timeout)
+
+
+class IP2LocationLibrary(IPLibrary):
+ name = "ip2location"
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ as_info = data.get("as_info") or {}
+
+ usage_type = (data.get("usage_type") or "").strip().lower()
+ as_usage_type = ((as_info.get("as_usage_type") if isinstance(as_info, dict) else "") or "").strip().lower()
+
+ check = lambda usage: usage.startswith("isp") or usage == "mob"
+ label = "家宽" if check(usage_type) and check(as_usage_type) else ""
+
+ country_code = (data.get("country_code") or "").upper()
+ country = await self._resolve_country(
+ session=session,
+ ip=ip,
+ country_code=country_code,
+ country=country_name_zh(country_code)
+ or data.get("country_name")
+ or data.get("country", {}).get("name", "")
+ or "未知",
+ retries=retries,
+ timeout=timeout,
+ data=data,
+ )
+
+ provider = (data.get("as", "") or data.get("isp", "") or "").strip()
+ if not provider and as_info and isinstance(as_info, dict):
+ provider = (as_info.get("as_name", "") or as_info.get("as_domain", "")).strip()
+ if not provider:
+ provider = data.get("domain", "").strip() or ""
+
+ company_name = short_company_name(provider)
+ score = str(data.get("fraud_score")).zfill(3) if "fraud_score" in data else "NUL"
+
+ return self._format_remark(
+ country_code=country_code,
+ country=country,
+ label=label,
+ include_asn_name=include_asn_name,
+ company_name=company_name,
+ detail=score,
+ )
+
+ @staticmethod
+ def _extract_data(content: str) -> Dict:
+ if not content or not isinstance(content, str):
+ return {}
+
+ pattern = r']*class=["\'][^"\']*\blanguage-json\b[^"\']*["\'][^>]*>(.*?)\s*'
+ groups = re.findall(pattern, content, flags=re.I | re.S)
+ if not groups:
+ return {}
+
+ for group in groups:
+ payload = group.strip()
+ if not payload:
+ continue
+
+ payload = re.sub(r"<[^>]+>", "", payload, flags=re.I | re.S)
+ payload = html.unescape(payload)
+
+ try:
+ data = json.loads(payload)
+ if isinstance(data, dict):
+ return data
+ except Exception:
+ continue
+
+ return {}
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, _: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ url = "https://www.ip2location.com/demo"
+ headers = {"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}
+ data, error = await self._make_request(
+ session=session,
+ url=url,
+ retries=retries,
+ timeout=timeout,
+ headers=headers,
+ deserialize=False,
+ parser=self._extract_data,
+ )
+ if not data:
+ return None, "Invalid HTML response" if error == "Invalid response payload" else error
+
+ return data, None
+
+
+class IPLarkLibrary(IPLibrary):
+ name = "iplark"
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ node_type = (data.get("type") or "").strip().lower()
+ if node_type == "isp":
+ label = "家宽"
+ elif node_type == "business":
+ label = "商宽"
+ elif node_type == "education":
+ label = "教育"
+ else:
+ label = ""
+
+ country_code = (data.get("country_code") or "").upper()
+ country = await self._resolve_country(
+ session=session,
+ ip=ip,
+ country_code=country_code,
+ country=country_name_zh(country_code) or (data.get("country_zh") or data.get("country") or "未知"),
+ retries=retries,
+ timeout=timeout,
+ data=data,
+ )
+
+ # native if registered country code equals country code else broadcast
+ categroy = "N" if (data.get("registered_country_code") or "").upper() == country_code else "B"
+
+ asn = str(data.get("asn") or "").strip()
+ detail = f"{'AS'+asn if asn else 'NUL'}::{categroy}"
+
+ company_name = short_company_name(data.get("organization") or "")
+
+ return self._format_remark(
+ country_code=country_code,
+ country=country,
+ label=label,
+ include_asn_name=include_asn_name,
+ company_name=company_name,
+ detail=detail,
+ )
+
+ def _extract_province(self, data: Optional[Dict]) -> str:
+ return ""
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, _: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ url = "https://iplark.com/ipapi/public/ipinfo"
+ return await self._make_request(session, url, retries, timeout)
+
+
+class IPNetCoffeeLibrary(IPLibrary):
+ name = "ipnetcoffee"
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ label = "家宽" if data.get("isResidential") is True and data.get("company_type", "") != "business" else ""
+
+ country_code = (data.get("countryCode") or "").upper()
+ country = await self._resolve_country(
+ session=session,
+ ip=ip,
+ country_code=country_code,
+ country=country_name_zh(country_code) or (data.get("country") or "未知"),
+ retries=retries,
+ timeout=timeout,
+ data=data,
+ )
+
+ company_name = short_company_name(
+ data.get("asOrganization") or data.get("isp") or data.get("company_name") or ""
+ )
+ score = str(data.get("trust_score")).zfill(3) if "trust_score" in data else "NUL"
+
+ # native if registered country code equals country code else broadcast
+ category = "N" if (data.get("registered_country_code") or "").upper() == country_code else "B"
+
+ return self._format_remark(
+ country_code=country_code,
+ country=country,
+ label=label,
+ include_asn_name=include_asn_name,
+ company_name=company_name,
+ detail=f"{score}::{category}",
+ )
+
+ def _extract_province(self, data: Optional[Dict]) -> str:
+ province = super()._extract_province(data)
+ if province:
+ return province
+
+ if not isinstance(data, dict):
+ return ""
+
+ for source in data.get("geo_sources") or []:
+ if not isinstance(source, dict):
+ continue
+
+ province = self._normalize_province(source.get("region"))
+ if province:
+ return province
+
+ return ""
+
+ async def _resolve_ip(self, session: aiohttp.ClientSession, retries: int, timeout: int) -> Optional[str]:
+ url = "https://ipinfo.io/ip"
+ text, _ = await self._make_request(session, url, retries, timeout, deserialize=False)
+ if not isinstance(text, str):
+ return None
+
+ address = text.strip()
+ try:
+ ipaddress.ip_address(address)
+ return address
+ except ValueError:
+ return None
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, proxy_info: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ address = await self._resolve_ip(session, retries, timeout)
+ if not address:
+ host = "" if not proxy_info else proxy_info.host
+ return None, f"Failed to get egress IP, host: {host}"
+
+ url = f"https://ip.net.coffee/api/ip/lookup/{quote(address, safe='')}"
+ data, error = await self._make_request(session, url, retries, timeout)
+ if not data:
+ return None, error or f"Failed to get IP info from ip.net.coffee, ip: {address}"
+
+ return data, None
+
+
+class MeowVPSLibrary(IPLibrary):
+ name = "meowvps"
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ core = data.get("core_data") if isinstance(data.get("core_data"), dict) else {}
+ minfraud = data.get("minfraud") if isinstance(data.get("minfraud"), dict) else {}
+ traits = minfraud.get("traits") if isinstance(minfraud.get("traits"), dict) else {}
+
+ country_code = (core.get("country_code") or "").upper()
+ country = await self._resolve_country(
+ session=session,
+ ip=ip,
+ country_code=country_code,
+ country=country_name_zh(country_code) or minfraud.get("country") or core.get("country") or "未知",
+ retries=retries,
+ timeout=timeout,
+ data=data,
+ )
+
+ company_name = short_company_name(core.get("as_name") or traits.get("isp") or core.get("as_domain") or "")
+ scores = self._nested(data, "risk_assessment", "ipdata", "scores")
+ score = str(scores.get("trust_score")).zfill(3) if "trust_score" in scores else "NUL"
+
+ registered = (minfraud.get("registered_country") or "").strip()
+ current = (minfraud.get("country") or "").strip()
+ category = "N" if registered and current and registered == current else "B"
+
+ return self._format_remark(
+ country_code=country_code,
+ country=country,
+ label=self._build_label(data),
+ include_asn_name=include_asn_name,
+ company_name=company_name,
+ detail=f"{score}::{category}",
+ )
+
+ @staticmethod
+ def _nested(data: Optional[Dict], *keys: str) -> Dict:
+ current: Any = data
+ for key in keys:
+ if not isinstance(current, dict):
+ return {}
+ current = current.get(key)
+ return current if isinstance(current, dict) else {}
+
+ @classmethod
+ def _build_label(cls, data: Dict) -> str:
+ digital = cls._nested(data, "api4", "digital")
+ traits = cls._nested(data, "minfraud", "traits")
+ digital_type = "" if digital.get("type") is None else str(digital.get("type")).strip().lower()
+ user_type = str(traits.get("user_type") or "").strip().lower()
+
+ if digital_type == "edu" or user_type in {"college", "education", "edu"}:
+ return "教育"
+
+ if cls._is_residential(digital_type, user_type, data):
+ return "家宽"
+
+ return ""
+
+ @classmethod
+ def _is_residential(cls, digital_type: str, user_type: str, data: Dict) -> bool:
+ # Verified against representative IPs: empty api4.digital.type usually means ISP/residential, but 114.114.114.114 also has empty type while user_type/hosting/datacenter say DC
+ if digital_type in {"hosting", "edu"}:
+ return False
+ if user_type in {"hosting", "content_delivery_network", "college"}:
+ return False
+ if user_type in {"residential", "traveler", "cellular"}:
+ return True
+ if digital_type:
+ return False
+
+ ipapi = cls._nested(data, "risk_assessment", "ipapi")
+ threat = cls._nested(data, "risk_assessment", "ipdata", "threat")
+ if ipapi.get("hosting") is True or threat.get("is_datacenter") is True:
+ return False
+
+ return True
+
+ def _extract_province(self, data: Optional[Dict]) -> str:
+ province = super()._extract_province(data)
+ if province:
+ return province
+
+ minfraud = data.get("minfraud") if isinstance(data, dict) else None
+ if not isinstance(minfraud, dict):
+ return ""
+
+ for item in minfraud.get("subdivisions") or []:
+ province = self._normalize_province(item)
+ if province:
+ return province
+
+ return ""
+
+ async def _resolve_ip(self, session: aiohttp.ClientSession, retries: int, timeout: int) -> Optional[str]:
+ url = "https://ipinfo.io/ip"
+ text, _ = await self._make_request(session, url, retries, timeout, deserialize=False)
+ if not isinstance(text, str):
+ return None
+
+ address = text.strip()
+ try:
+ ipaddress.ip_address(address)
+ return address
+ except ValueError:
+ return None
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, proxy_info: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ address = await self._resolve_ip(session, retries, timeout)
+ if not address:
+ host = "" if not proxy_info else proxy_info.host
+ return None, f"Failed to get egress IP, host: {host}"
+
+ url = f"https://meowvps.com/api/ip-aggregator/{quote(address, safe='')}"
+ headers = {
+ "Accept": "*/*",
+ "Accept-Language": "zh-CN,zh;q=0.8",
+ "Origin": "https://meowvps.com",
+ "Referer": "https://meowvps.com/tools/ip-check/",
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/152.0.0.0 Safari/537.36"
+ ),
+ }
+ data, error = await self._make_request(session, url, retries, timeout, headers=headers)
+ if not data:
+ return None, error or f"Failed to get IP info from meowvps, ip: {address}"
+ if data.get("success") is False:
+ return None, f"MeowVPS lookup failed, ip: {address}"
+
+ return data, None
+
+
+IP_LIBRARIES = {
+ "ip2location": IP2LocationLibrary,
+ "iplark": IPLarkLibrary,
+ "ipinfo": IPInfoLibrary,
+ "ipnetcoffee": IPNetCoffeeLibrary,
+ "ippure": IPPureLibrary,
+ "meowvps": MeowVPSLibrary,
+}
+
+
+def get_ip_library(name: str) -> IPLibrary:
+ key = (name or "ip2location").strip().lower()
+ library = IP_LIBRARIES.get(key)
+ if not library:
+ supported = ", ".join(sorted(IP_LIBRARIES.keys()))
+ raise ValueError(f"Unsupported ip library: {name}. Supported: {supported}")
+
+ return library()
+
+
class ProxyChecker:
def __init__(
self,
@@ -316,6 +1223,7 @@ def __init__(
format_pattern: Optional[str] = None,
default_port: int = 1080,
include_asn_name: bool = False,
+ ip_library: str = "ip2location",
):
"""
初始化代理检测器
@@ -332,6 +1240,7 @@ def __init__(
self.format_pattern = format_pattern
self.default_port = default_port
self.include_asn_name = include_asn_name
+ self.ip_library = get_ip_library(ip_library)
self.results: List[TestResult] = []
self.summary: Optional[Dict[str, float]] = None
@@ -340,7 +1249,7 @@ def parse_proxy(self, text: str, format_pattern: Optional[str] = None) -> Option
解析代理字符串,支持自定义格式
支持的格式占位符:
- - {protocol}: 协议类型 (socks5/socks4/http等)
+ - {protocol}: 协议类型 (socks5/socks4/http/https等)
- {username}: 用户名
- {password}: 密码
- {host}: 主机地址
@@ -382,10 +1291,7 @@ def parse_proxy(self, text: str, format_pattern: Optional[str] = None) -> Option
prefix = f"socks5://{prefix}"
result = urlparse(prefix)
-
protocol = result.scheme or "socks5"
- if protocol == "https":
- protocol = "http"
return ProxyInfo(
protocol=protocol,
@@ -472,9 +1378,6 @@ def _parse_custom_format(self, text: str, format_pattern: str) -> Optional[Proxy
elif placeholder == "host":
host = value
- if protocol == "https":
- protocol = "http"
-
return ProxyInfo(
protocol=protocol,
username=username,
@@ -490,33 +1393,35 @@ async def test_proxy(self, proxy_info: ProxyInfo, retries: int = 3) -> TestResul
Test a single proxy with retries.
"""
result = TestResult.from_proxy(proxy_info)
-
- # Build proxy URL
- if proxy_info.username and proxy_info.password:
- proxy_url = (
- f"{proxy_info.protocol}://{proxy_info.username}:{proxy_info.password}"
- f"@{proxy_info.host}:{proxy_info.port}"
- )
- else:
- proxy_url = f"{proxy_info.protocol}://{proxy_info.host}:{proxy_info.port}"
-
start_time = datetime.now()
try:
- connector = ProxyConnector.from_url(proxy_url)
- async with aiohttp.ClientSession(connector=connector) as session:
- ip_address = await self._resolve_ip_with_proxy(session, proxy_info.host, retries)
- if not ip_address:
- result.error = "Failed to get IP from ipinfo.io/ip"
- return result
-
- ip_data, ip_error = await self._fetch_ipinfo(session, ip_address, retries)
- if not ip_data:
- result.error = ip_error or "Failed to get IP info from ipinfo.io"
+ protocol = (proxy_info.protocol or "").lower()
+ if protocol in ("http", "https"):
+ proxy_url = self._build_proxy_url(proxy_info, include_auth=False)
+ proxy_auth = self._build_proxy_auth(proxy_info)
+ connector = aiohttp.TCPConnector(ssl=False)
+ session = aiohttp.ClientSession(connector=connector, proxy=proxy_url, proxy_auth=proxy_auth)
+ else:
+ proxy_url = self._build_proxy_url(proxy_info, include_auth=True)
+ connector = ProxyConnector.from_url(proxy_url)
+ session = aiohttp.ClientSession(connector=connector)
+
+ async with session:
+ lookup = await self.ip_library.lookup(session, proxy_info, retries, self.timeout)
+ if not lookup.ip or not lookup.data:
+ result.error = lookup.error or f"Failed to get IP info from {self.ip_library.name}"
return result
- remark = self._build_remark_from_ipinfo(ip_data)
+ remark = await self.ip_library.build_remark(
+ session=session,
+ ip=lookup.ip,
+ data=lookup.data,
+ include_asn_name=self.include_asn_name,
+ retries=retries,
+ timeout=self.timeout,
+ )
result.remark = remark
- result.ip = ip_address
+ result.ip = lookup.ip
result.status = "success"
result.response_time = round((datetime.now() - start_time).total_seconds(), 2)
result.error = None
@@ -531,77 +1436,6 @@ async def test_proxy(self, proxy_info: ProxyInfo, retries: int = 3) -> TestResul
result.error = str(e)[:100]
return result
- def _is_ipv4(self, host: str) -> bool:
- if not host:
- return False
- try:
- return isinstance(ipaddress.ip_address(host), ipaddress.IPv4Address)
- except ValueError:
- return False
-
- async def _resolve_ip_with_proxy(self, session: aiohttp.ClientSession, host: str, retries: int) -> Optional[str]:
- if self._is_ipv4(host):
- return host
-
- url = "https://ipinfo.io/ip"
- for attempt in range(1, retries + 1):
- try:
- async with session.get(url, timeout=aiohttp.ClientTimeout(total=self.timeout)) as response:
- if response.status == 200:
- text = (await response.text()).strip()
- try:
- ipaddress.ip_address(text)
- return text
- except ValueError:
- pass
- except asyncio.TimeoutError:
- pass
- except Exception:
- pass
-
- if attempt < retries:
- await asyncio.sleep(attempt)
-
- return None
-
- async def _fetch_ipinfo(
- self, session: aiohttp.ClientSession, ip_address: str, retries: int
- ) -> Tuple[Optional[Dict], Optional[str]]:
- url = f"https://ipinfo.io/widget/demo/{ip_address}"
- last_error = None
- for attempt in range(1, retries + 1):
- try:
- async with session.get(url, timeout=aiohttp.ClientTimeout(total=self.timeout)) as response:
- if response.status == 200:
- data = await response.json()
- if isinstance(data, dict):
- return data.get("data", data), None
- last_error = "Invalid JSON response"
- else:
- last_error = f"HTTP {response.status}"
- except asyncio.TimeoutError:
- last_error = "Timeout"
- except Exception as e:
- last_error = str(e)[:100]
-
- if attempt < retries:
- await asyncio.sleep(attempt)
-
- return None, last_error
-
- def _country_flag_emoji(self, country_code: str) -> str:
- if not country_code or len(country_code) != 2:
- return ""
- code = country_code.upper()
- if not code.isalpha():
- return ""
- return chr(0x1F1E6 + ord(code[0]) - ord("A")) + chr(0x1F1E6 + ord(code[1]) - ord("A"))
-
- def _country_name_zh(self, country_code: str) -> str:
- if not country_code:
- return ""
- return COUNTRY_NAME_ZH.get(country_code.upper(), "")
-
def _format_standard(self, proxy_info: ProxyInfo, remark: str) -> str:
auth = ""
if proxy_info.username or proxy_info.password:
@@ -611,58 +1445,43 @@ def _format_standard(self, proxy_info: ProxyInfo, remark: str) -> str:
return f"{base}#{remark}"
return base
+ def _build_proxy_url(self, proxy_info: ProxyInfo, include_auth: bool) -> str:
+ auth = ""
+ if include_auth and (proxy_info.username or proxy_info.password):
+ username = quote(proxy_info.username or "", safe="")
+ password = quote(proxy_info.password or "", safe="")
+ auth = f"{username}:{password}@"
+
+ return f"{proxy_info.protocol}://{auth}{proxy_info.host}:{proxy_info.port}"
+
+ def _build_proxy_auth(self, proxy_info: ProxyInfo) -> Optional[aiohttp.BasicAuth]:
+ if not (proxy_info.username or proxy_info.password):
+ return None
+
+ return aiohttp.BasicAuth(proxy_info.username or "", proxy_info.password or "")
+
def _yaml_quote(self, value: str) -> str:
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def _format_yaml_line(self, result: TestResult) -> str:
name = result.remark or result.host
+ protocol = (result.protocol or "").lower()
+ clash_type = "http" if protocol == "https" else protocol
parts = [
f"name: {self._yaml_quote(name)}",
f"server: {self._yaml_quote(result.host)}",
f"port: {result.port}",
- f"type: {self._yaml_quote(result.protocol)}",
+ f"type: {self._yaml_quote(clash_type)}",
]
+ if protocol == "https":
+ parts.append("tls: true")
if result.username:
parts.append(f"username: {self._yaml_quote(result.username)}")
if result.password:
parts.append(f"password: {self._yaml_quote(result.password)}")
return " - {" + ", ".join(parts) + "}"
- def _build_remark_from_ipinfo(self, ip_data: Dict) -> str:
- country_code = (ip_data.get("country") or "").upper()
- flag = self._country_flag_emoji(country_code)
- country_display = self._country_name_zh(country_code) or "未知"
-
- asn_info = ip_data.get("asn", {}) or {}
- company_info = ip_data.get("company", {}) or {}
- asn_type = (asn_info.get("type") or "").lower()
- company_type = (company_info.get("type") or "").lower()
-
- asn_name = (asn_info.get("domain") or "").strip()
- if not asn_name:
- asn_name = (asn_info.get("name") or "").strip()
-
- if asn_name:
- parts = [p for p in re.split(r"[\s,\.]+", asn_name) if p]
- company_name = parts[0].upper() if parts else "UNKNOWN"
- else:
- company_name = "UNKNOWN"
-
- if asn_type == "isp" and company_type == "isp":
- label = "家宽"
- elif asn_type == "isp" or company_type == "isp":
- label = "商宽"
- elif asn_type == "edu" or company_type == "edu":
- label = "教育"
- else:
- label = ""
-
- base = f"{flag} {country_display}{label}".strip()
- if self.include_asn_name and company_name:
- return f"{base} [{company_name}]".strip()
- return base
-
def _convert(self, input_file: str, output_file: str, output_format: str, digits: int = 2) -> None:
proxies = read_proxies(input_file)
if not proxies:
@@ -765,7 +1584,7 @@ async def check_proxies(
output_handle = None
if not output_file:
- output_file = f'{output_format}.{"txt" if output_format == "v2ray" else "yaml"}'
+ output_file = f'{output_format}-{self.ip_library}.{"txt" if output_format == "v2ray" else "yaml"}'
output_handle = open(output_file, "w", encoding="utf-8")
if output_format == "clash":
@@ -797,12 +1616,22 @@ async def test_with_semaphore(proxy_info):
# 实时输出结果
status_icon = "✓" if result.status == "success" else "✗"
if result.status == "success":
- print(f"{status_icon} {result.original[:60]}... | {result.response_time}s | IP: {result.ip}")
+ print(
+ f"{status_icon} {result.original[:60]}... | {result.response_time}s | Export IP: {result.ip}".encode(
+ "utf-8", errors="ignore"
+ ).decode(
+ "utf-8"
+ )
+ )
if write_queue:
line = self._format_yaml_line(result) if output_format == "clash" else result.proxy
await write_queue.put(line + "\n")
else:
- print(f"{status_icon} {result.original[:60]}... | {result.error}")
+ print(
+ f"{status_icon} {result.original[:60]}... | {result.error}".encode(
+ "utf-8", errors="ignore"
+ ).decode("utf-8")
+ )
async with stats_lock:
if result.status == "success":
@@ -930,8 +1759,8 @@ def _build_proxy(entry: Dict) -> Optional[str]:
return None
protocol = str(entry.get("type") or "socks5").strip().lower()
- if protocol == "https":
- protocol = "http"
+ if protocol == "http" and entry.get("tls") is True:
+ protocol = "https"
elif protocol == "socks":
protocol = "socks5"
@@ -982,7 +1811,8 @@ def _load_proxies(data) -> List[str]:
def _parse_yaml(text: str) -> Tuple[Optional[List[str]], Optional[object]]:
try:
- data = yaml.safe_load(text)
+ content = text.lstrip("\ufeff").replace("\r\n", "\n").replace("\r", "\n").replace("\t", " ")
+ data = yaml.safe_load(content)
except yaml.YAMLError:
return None, None
@@ -1055,7 +1885,7 @@ async def main():
%(prog)s -f proxies.txt --input-format "socks5://{host}:{port}:{username}:{password}"
支持的格式占位符:
- {protocol} - 协议类型 (socks5/socks4/http等)
+ {protocol} - 协议类型 (socks5/socks4/http/https等)
{username} - 用户名
{password} - 密码
{host} - 主机地址/IP
@@ -1089,6 +1919,14 @@ async def main():
help="在备注中追加 ASN 名称 (默认不追加)",
)
+ parser.add_argument(
+ "--ip-library",
+ dest="ip_library",
+ choices=sorted(IP_LIBRARIES.keys()),
+ default="ip2location",
+ help="IP地址数据库服务商: ip2location、iplark、ipinfo、ipnetcoffee、ippure 或 meowvps (默认: ip2location)",
+ )
+
args = parser.parse_args()
# 获取代理列表
@@ -1116,6 +1954,7 @@ async def main():
format_pattern=args.format_pattern,
default_port=args.default_port,
include_asn_name=args.include_asn_name,
+ ip_library=args.ip_library,
)
await checker.check_proxies(