Spaces:
Paused
Paused
Upload 21 files
Browse files- Dockerfile +31 -25
- app/main.py +144 -236
- app/sub_manager.py +218 -116
Dockerfile
CHANGED
|
@@ -34,24 +34,21 @@ RUN mkdir -p ./clash_core ./subconverter ./data && \
|
|
| 34 |
chmod -R 777 ./clash_core && \
|
| 35 |
chmod -R 777 ./subconverter
|
| 36 |
|
| 37 |
-
#
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
echo "
|
| 51 |
-
|
| 52 |
-
gzip -d clash.meta.gz && \
|
| 53 |
-
mv clash.meta /app/clash_core/clash.meta-linux-${TARGETARCH} && \
|
| 54 |
-
chmod +x /app/clash_core/clash.meta-linux-${TARGETARCH} # Add execute permission here
|
| 55 |
|
| 56 |
# 下载并完整解压subconverter
|
| 57 |
RUN echo "Downloading subconverter..." && \
|
|
@@ -86,12 +83,21 @@ RUN echo "Installing Python dependencies..." && \
|
|
| 86 |
# 可选:删除构建依赖以减小镜像体积
|
| 87 |
RUN apk del python3-dev musl-dev libffi-dev yaml-dev
|
| 88 |
|
| 89 |
-
#
|
| 90 |
-
RUN echo "Downloading Yacd UI
|
| 91 |
-
|
| 92 |
-
mkdir -p
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
# 设置环境变量
|
| 97 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
@@ -111,7 +117,7 @@ COPY entrypoint.sh ./
|
|
| 111 |
RUN chmod +x ./entrypoint.sh
|
| 112 |
|
| 113 |
# 给脚本和二进制文件执行权限 (重复的chmod可能不需要,但在构建阶段设置更安全)
|
| 114 |
-
RUN chmod +x ./clash_core/clash.meta-linux
|
| 115 |
RUN chmod +x ./subconverter/subconverter || true
|
| 116 |
|
| 117 |
# 暴露端口
|
|
|
|
| 34 |
chmod -R 777 ./clash_core && \
|
| 35 |
chmod -R 777 ./subconverter
|
| 36 |
|
| 37 |
+
# 下载并安装Clash Meta,保留原始文件名
|
| 38 |
+
RUN echo "Downloading Clash Meta..." && \
|
| 39 |
+
curl -L -f -o /tmp/clash-meta.gz "https://github.com/MetaCubeX/Clash.Meta/releases/download/v1.16.0/clash.meta-linux-amd64-v1.16.0.gz" && \
|
| 40 |
+
echo "Extracting Clash Meta..." && \
|
| 41 |
+
gunzip -c /tmp/clash-meta.gz > ./clash_core/clash.meta-linux-amd64 && \
|
| 42 |
+
echo "Setting Clash Meta permissions..." && \
|
| 43 |
+
chmod +x ./clash_core/clash.meta-linux-amd64 && \
|
| 44 |
+
# 确保Linux可执行属性已设置
|
| 45 |
+
ls -la ./clash_core/clash.meta-linux-amd64 && \
|
| 46 |
+
# 显示文件类型
|
| 47 |
+
file ./clash_core/clash.meta-linux-amd64 && \
|
| 48 |
+
echo "Verifying Clash Meta exists..." && \
|
| 49 |
+
test -f ./clash_core/clash.meta-linux-amd64 && \
|
| 50 |
+
echo "Cleaning up Clash Meta download..." && \
|
| 51 |
+
rm /tmp/clash-meta.gz
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
# 下载并完整解压subconverter
|
| 54 |
RUN echo "Downloading subconverter..." && \
|
|
|
|
| 83 |
# 可选:删除构建依赖以减小镜像体积
|
| 84 |
RUN apk del python3-dev musl-dev libffi-dev yaml-dev
|
| 85 |
|
| 86 |
+
# 下载并准备 Yacd UI 文件 (从 Yacd-meta 的 gh-pages 分支)
|
| 87 |
+
RUN echo "Downloading Yacd-meta UI (gh-pages branch)..." && \
|
| 88 |
+
YACD_DIR=/app/app/static/yacd && \
|
| 89 |
+
mkdir -p ${YACD_DIR} && \
|
| 90 |
+
# 下载 gh-pages 分支的 zip 压缩包
|
| 91 |
+
curl -L -f -o /tmp/yacd-gh-pages.zip "https://github.com/MetaCubeX/Yacd-meta/archive/refs/heads/gh-pages.zip" && \
|
| 92 |
+
echo "Extracting Yacd-meta UI (gh-pages)..." && \
|
| 93 |
+
# 解压到临时目录
|
| 94 |
+
unzip -q /tmp/yacd-gh-pages.zip -d /tmp && \
|
| 95 |
+
# 将解压后的 gh-pages 目录下的 *所有内容* 移动到目标位置
|
| 96 |
+
# 注意:解压后的文件夹名通常是 {repo_name}-{branch_name},即 Yacd-meta-gh-pages
|
| 97 |
+
mv /tmp/Yacd-meta-gh-pages/* ${YACD_DIR}/ && \
|
| 98 |
+
echo "Cleaning up Yacd-meta download..." && \
|
| 99 |
+
rm /tmp/yacd-gh-pages.zip && \
|
| 100 |
+
rm -rf /tmp/Yacd-meta-gh-pages
|
| 101 |
|
| 102 |
# 设置环境变量
|
| 103 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
|
|
| 117 |
RUN chmod +x ./entrypoint.sh
|
| 118 |
|
| 119 |
# 给脚本和二进制文件执行权限 (重复的chmod可能不需要,但在构建阶段设置更安全)
|
| 120 |
+
RUN chmod +x ./clash_core/clash.meta-linux-amd64 || true
|
| 121 |
RUN chmod +x ./subconverter/subconverter || true
|
| 122 |
|
| 123 |
# 暴露端口
|
app/main.py
CHANGED
|
@@ -7,13 +7,14 @@ Simple Clash Relay - Flask 应用入口
|
|
| 7 |
|
| 8 |
import os
|
| 9 |
import logging
|
| 10 |
-
from flask import Flask, request, jsonify, Response, redirect, send_from_directory
|
| 11 |
from .clash_manager import ClashManager
|
| 12 |
from .sub_manager import SubscriptionManager
|
| 13 |
from .auth import authenticate
|
| 14 |
import requests
|
| 15 |
from functools import wraps
|
| 16 |
-
from
|
|
|
|
| 17 |
|
| 18 |
# 配置日志
|
| 19 |
logging.basicConfig(
|
|
@@ -22,108 +23,24 @@ logging.basicConfig(
|
|
| 22 |
)
|
| 23 |
logger = logging.getLogger(__name__)
|
| 24 |
|
| 25 |
-
#
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
initialization_error = "尚未初始化" # 初始状态
|
| 29 |
-
API_KEY = os.environ.get("API_KEY", "changeme") # 从环境变量获取API密钥,默认changeme
|
| 30 |
-
SUB_URL = os.environ.get("SUB_URL") # 从环境变量获取订阅链接
|
| 31 |
FLASK_PORT = int(os.environ.get("FLASK_PORT", 7860)) # 默认端口改为7860
|
| 32 |
CLASH_PROXY_PORT = int(os.environ.get("CLASH_PROXY_PORT", 7890))
|
| 33 |
CLASH_API_PORT = int(os.environ.get("CLASH_API_PORT", 9090))
|
| 34 |
|
| 35 |
-
#
|
| 36 |
-
|
| 37 |
-
DATA_DIR = os.path.join(BASE_DIR, "data")
|
| 38 |
-
CLASH_CORE_DIR = os.path.join(BASE_DIR, "clash_core")
|
| 39 |
-
CONFIG_PATH = os.path.join(DATA_DIR, "config.yaml")
|
| 40 |
-
RAW_CONFIG_PATH = f"{CONFIG_PATH}.raw"
|
| 41 |
-
CLASH_BINARY_PATH = os.path.join(CLASH_CORE_DIR, "clash.meta-linux-amd64") # 根据实际文件名调整
|
| 42 |
-
|
| 43 |
-
# 确保目录存在
|
| 44 |
-
os.makedirs(DATA_DIR, exist_ok=True)
|
| 45 |
-
os.makedirs(CLASH_CORE_DIR, exist_ok=True)
|
| 46 |
-
|
| 47 |
-
class InitializationManager:
|
| 48 |
-
def __init__(self):
|
| 49 |
-
self._initialized = False
|
| 50 |
-
self._lock = Lock()
|
| 51 |
-
|
| 52 |
-
def __call__(self):
|
| 53 |
-
global clash_manager, sub_manager, initialization_error
|
| 54 |
-
|
| 55 |
-
# 使用锁确保只初始化一次
|
| 56 |
-
with self._lock:
|
| 57 |
-
if self._initialized:
|
| 58 |
-
return # 如果已经初始化,直接返回
|
| 59 |
-
|
| 60 |
-
logger.info("正在初始化应用 (首次请求或重新初始化)...")
|
| 61 |
-
initialization_error = "正在初始化..." # 设置状态
|
| 62 |
-
|
| 63 |
-
try:
|
| 64 |
-
# 优先检查本地配置文件是否存在
|
| 65 |
-
if os.path.exists(CONFIG_PATH):
|
| 66 |
-
logger.info(f"找到本地配置文件: {CONFIG_PATH},优先使用此配置")
|
| 67 |
-
try:
|
| 68 |
-
# 验证并可能需要修补现有配置
|
| 69 |
-
temp_sub_manager = SubscriptionManager(config_path=CONFIG_PATH)
|
| 70 |
-
temp_sub_manager._patch_config() # 确保端口等设置正确
|
| 71 |
-
logger.info("已检查并修补本地配置文件")
|
| 72 |
-
|
| 73 |
-
# 使用本地配置启动Clash
|
| 74 |
-
clash_manager = ClashManager(
|
| 75 |
-
config_path=CONFIG_PATH,
|
| 76 |
-
clash_path=CLASH_BINARY_PATH,
|
| 77 |
-
api_port=CLASH_API_PORT,
|
| 78 |
-
proxy_port=CLASH_PROXY_PORT
|
| 79 |
-
)
|
| 80 |
-
clash_manager.start_clash()
|
| 81 |
-
initialization_error = None # 初始化成功
|
| 82 |
-
self._initialized = True
|
| 83 |
-
logger.info("使用本地配置成功初始化并启动Clash")
|
| 84 |
-
return # 初始化成功,退出
|
| 85 |
-
except Exception as e:
|
| 86 |
-
error_msg = f"使用本地配置文件启动Clash失败: {str(e)}"
|
| 87 |
-
logger.error(error_msg)
|
| 88 |
-
initialization_error = error_msg
|
| 89 |
-
# 不要在这里返回,继续尝试下载订阅(如果提供了URL)
|
| 90 |
-
|
| 91 |
-
# 如果本地配置不存在或启动失败,并且提供了订阅URL,则尝试下载
|
| 92 |
-
if SUB_URL:
|
| 93 |
-
logger.info("未找到本地配置或启动失败,尝试从订阅URL加载...")
|
| 94 |
-
sub_manager = SubscriptionManager(sub_url=SUB_URL, config_path=CONFIG_PATH)
|
| 95 |
-
sub_manager.load_and_convert_sub() # 下载并转换
|
| 96 |
-
|
| 97 |
-
clash_manager = ClashManager(
|
| 98 |
-
config_path=CONFIG_PATH,
|
| 99 |
-
clash_path=CLASH_BINARY_PATH,
|
| 100 |
-
api_port=CLASH_API_PORT,
|
| 101 |
-
proxy_port=CLASH_PROXY_PORT
|
| 102 |
-
)
|
| 103 |
-
clash_manager.start_clash()
|
| 104 |
-
initialization_error = None # 初始化成功
|
| 105 |
-
logger.info("成功从订阅URL初始化并启动Clash")
|
| 106 |
-
|
| 107 |
-
# 如果没有本地配置,也没有提供订阅URL
|
| 108 |
-
elif not os.path.exists(CONFIG_PATH):
|
| 109 |
-
error_msg = "初始化失败:未找到本地 config.yaml 且未设置 SUB_URL 环境变量"
|
| 110 |
-
logger.error(error_msg)
|
| 111 |
-
initialization_error = error_msg
|
| 112 |
-
|
| 113 |
-
# 如果走到这里,表示初始化完成(可能成功也可能失败)
|
| 114 |
-
self._initialized = True
|
| 115 |
-
|
| 116 |
-
except Exception as e:
|
| 117 |
-
error_msg = f"应用初始化过程中发生严重错误: {str(e)}"
|
| 118 |
-
logger.exception(error_msg) # 使用exception记录堆栈信息
|
| 119 |
-
initialization_error = error_msg
|
| 120 |
-
# 即使失败,也标记为已尝试初始化,避免无限重试
|
| 121 |
-
self._initialized = True
|
| 122 |
-
|
| 123 |
-
initialize_once = InitializationManager()
|
| 124 |
|
| 125 |
# 初始化Flask应用
|
| 126 |
app = Flask(__name__, static_folder='static')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
@app.before_request
|
| 129 |
def initialize_once():
|
|
@@ -235,9 +152,14 @@ def get_current_node():
|
|
| 235 |
@app.route("/api/refresh", methods=["POST"])
|
| 236 |
@authenticate
|
| 237 |
def refresh_subscription():
|
| 238 |
-
"""刷新订阅并重新加载Clash
|
| 239 |
global clash_manager, sub_manager, initialization_error
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
try:
|
| 242 |
# 尝试重新加载订阅
|
| 243 |
if sub_manager is None:
|
|
@@ -268,84 +190,6 @@ def refresh_subscription():
|
|
| 268 |
initialization_error = error_msg
|
| 269 |
return jsonify({"success": False, "error": error_msg}), 500
|
| 270 |
|
| 271 |
-
@app.route("/api/upload_config", methods=["POST"])
|
| 272 |
-
@authenticate
|
| 273 |
-
def upload_config():
|
| 274 |
-
"""上传并使用自定义配置文件"""
|
| 275 |
-
global clash_manager, initialization_error
|
| 276 |
-
|
| 277 |
-
if 'config_file' not in request.files:
|
| 278 |
-
return jsonify({"success": False, "error": "未找到配置文件"}), 400
|
| 279 |
-
|
| 280 |
-
file = request.files['config_file']
|
| 281 |
-
if file.filename == '':
|
| 282 |
-
return jsonify({"success": False, "error": "未选择文件"}), 400
|
| 283 |
-
|
| 284 |
-
if not file.filename.endswith(('.yaml', '.yml')):
|
| 285 |
-
return jsonify({"success": False, "error": "文件必须是YAML格式"}), 400
|
| 286 |
-
|
| 287 |
-
# 配置文件路径
|
| 288 |
-
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
|
| 289 |
-
raw_config_path = f"{config_path}.raw"
|
| 290 |
-
|
| 291 |
-
try:
|
| 292 |
-
# 保存上传的文件作为原始配置
|
| 293 |
-
file.save(raw_config_path)
|
| 294 |
-
logger.info(f"配置文件已上传到: {raw_config_path}")
|
| 295 |
-
|
| 296 |
-
# 创建一个临时的SubscriptionManager来处理配置
|
| 297 |
-
temp_sub_manager = SubscriptionManager(
|
| 298 |
-
sub_url=None, # 不需要URL
|
| 299 |
-
config_path=config_path
|
| 300 |
-
)
|
| 301 |
-
|
| 302 |
-
# 转换并修补配置
|
| 303 |
-
try:
|
| 304 |
-
# 将原始配置处理为Clash配置
|
| 305 |
-
temp_sub_manager._convert_raw_to_config()
|
| 306 |
-
logger.info("成功处理上传的配置文件")
|
| 307 |
-
except Exception as e:
|
| 308 |
-
logger.error(f"处理配置文件时出错: {str(e)}")
|
| 309 |
-
return jsonify({"success": False, "error": f"配置文件处理失败: {str(e)}"}), 500
|
| 310 |
-
|
| 311 |
-
# 重启Clash
|
| 312 |
-
if clash_manager is not None:
|
| 313 |
-
try:
|
| 314 |
-
clash_manager.restart_clash()
|
| 315 |
-
logger.info("已重启Clash以应用新配置")
|
| 316 |
-
return jsonify({
|
| 317 |
-
"success": True,
|
| 318 |
-
"message": "配置文件已上传并应用,Clash已重启"
|
| 319 |
-
})
|
| 320 |
-
except Exception as e:
|
| 321 |
-
error_msg = f"重启Clash失败: {str(e)}"
|
| 322 |
-
logger.error(error_msg)
|
| 323 |
-
initialization_error = error_msg
|
| 324 |
-
return jsonify({"success": False, "error": error_msg}), 500
|
| 325 |
-
else:
|
| 326 |
-
# 如果Clash未运行,尝试启动它
|
| 327 |
-
try:
|
| 328 |
-
clash_manager = ClashManager(
|
| 329 |
-
config_path=config_path,
|
| 330 |
-
clash_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "clash_core", "clash.meta-linux-amd64"),
|
| 331 |
-
api_port=CLASH_API_PORT,
|
| 332 |
-
proxy_port=CLASH_PROXY_PORT
|
| 333 |
-
)
|
| 334 |
-
clash_manager.start_clash()
|
| 335 |
-
initialization_error = None
|
| 336 |
-
return jsonify({
|
| 337 |
-
"success": True,
|
| 338 |
-
"message": "配置文件已上传并应用,Clash已启动"
|
| 339 |
-
})
|
| 340 |
-
except Exception as e:
|
| 341 |
-
error_msg = f"启动Clash失败: {str(e)}"
|
| 342 |
-
logger.error(error_msg)
|
| 343 |
-
initialization_error = error_msg
|
| 344 |
-
return jsonify({"success": False, "error": error_msg}), 500
|
| 345 |
-
except Exception as e:
|
| 346 |
-
logger.error(f"上传配置文件时发生错误: {str(e)}")
|
| 347 |
-
return jsonify({"success": False, "error": f"上传失败: {str(e)}"}), 500
|
| 348 |
-
|
| 349 |
@app.route("/health", methods=["GET"])
|
| 350 |
def health_check():
|
| 351 |
"""健康检查接口"""
|
|
@@ -361,18 +205,22 @@ def health_check():
|
|
| 361 |
@app.route("/debug/clean", methods=["POST"])
|
| 362 |
@authenticate
|
| 363 |
def debug_clean():
|
| 364 |
-
"""
|
| 365 |
global clash_manager, sub_manager, initialization_error
|
| 366 |
|
| 367 |
try:
|
|
|
|
| 368 |
if clash_manager is not None:
|
| 369 |
clash_manager.stop_clash()
|
| 370 |
clash_manager = None
|
| 371 |
|
| 372 |
-
|
|
|
|
|
|
|
| 373 |
raw_config_path = f"{config_path}.raw"
|
|
|
|
| 374 |
|
| 375 |
-
files_to_delete = [config_path, raw_config_path]
|
| 376 |
deleted_files = []
|
| 377 |
|
| 378 |
for file_path in files_to_delete:
|
|
@@ -380,18 +228,19 @@ def debug_clean():
|
|
| 380 |
try:
|
| 381 |
os.remove(file_path)
|
| 382 |
deleted_files.append(os.path.basename(file_path))
|
|
|
|
| 383 |
except OSError as e:
|
| 384 |
logger.warning(f"删除文件失败: {file_path}, 错误: {e}")
|
| 385 |
|
| 386 |
-
#
|
| 387 |
-
initialize_once._initialized = False
|
| 388 |
initialize_once()
|
| 389 |
|
| 390 |
-
status_msg = "
|
| 391 |
return jsonify({"success": True, "message": status_msg, "deleted_files": deleted_files})
|
| 392 |
|
| 393 |
except Exception as e:
|
| 394 |
-
logger.
|
| 395 |
return jsonify({"success": False, "error": str(e)}), 500
|
| 396 |
|
| 397 |
@app.route("/debug/config", methods=["GET"])
|
|
@@ -517,17 +366,88 @@ def clash_api_proxy(subpath):
|
|
| 517 |
logger.error(f"代理Clash API请求时发生意外错误: {str(e)}")
|
| 518 |
return jsonify({"error": f"代理请求时发生意外错误: {str(e)}"}), 500
|
| 519 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
# --- 基础路由 ---
|
| 521 |
|
| 522 |
@app.route('/', methods=['GET'])
|
| 523 |
def index():
|
| 524 |
-
"""首页 -
|
| 525 |
global initialization_error
|
| 526 |
|
|
|
|
|
|
|
| 527 |
status = "运行中" if initialization_error is None else "初始化失败"
|
| 528 |
error_msg = "" if initialization_error is None else f"<p style='color:red'>错误: {initialization_error}</p>"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
|
| 530 |
-
# 检查Yacd UI是否可用
|
| 531 |
yacd_link = "<a href='/ui/' class='button'>访问高级控制面板 (Yacd)</a>"
|
| 532 |
|
| 533 |
return f"""
|
|
@@ -567,22 +487,17 @@ def index():
|
|
| 567 |
color: #8a6d3b;
|
| 568 |
font-size: 14px;
|
| 569 |
}}
|
| 570 |
-
.upload-section {{
|
| 571 |
-
margin-top: 20px;
|
| 572 |
-
padding: 15px;
|
| 573 |
-
border: 1px solid #ddd;
|
| 574 |
-
border-radius: 4px;
|
| 575 |
-
background-color: #f8f9fa;
|
| 576 |
-
}}
|
| 577 |
-
.upload-form {{
|
| 578 |
-
margin-top: 10px;
|
| 579 |
-
}}
|
| 580 |
pre {{ max-height: 300px; overflow: auto; background-color: #eee; padding: 10px; border-radius: 4px; }}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
</style>
|
| 582 |
<script>
|
|
|
|
| 583 |
function requestWithApiKey(url, method = 'POST') {{
|
| 584 |
const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
|
| 585 |
-
if (apiKey === null) return;
|
| 586 |
|
| 587 |
fetch(url, {{
|
| 588 |
method: method,
|
|
@@ -590,12 +505,13 @@ def index():
|
|
| 590 |
}})
|
| 591 |
.then(response => response.json())
|
| 592 |
.then(data => {{
|
| 593 |
-
alert(data.success ? data.message : '失败: ' + data.error);
|
| 594 |
if(data.success) location.reload();
|
| 595 |
}})
|
| 596 |
.catch(error => alert('请求失败: ' + error));
|
| 597 |
}}
|
| 598 |
|
|
|
|
| 599 |
function viewConfig() {{
|
| 600 |
const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
|
| 601 |
if (apiKey === null) return;
|
|
@@ -624,30 +540,31 @@ def index():
|
|
| 624 |
}});
|
| 625 |
}}
|
| 626 |
|
|
|
|
| 627 |
function uploadConfig() {{
|
| 628 |
-
const fileInput = document.getElementById('config-file');
|
| 629 |
-
if (!fileInput.files || fileInput.files.length === 0) {{
|
| 630 |
-
alert('请先选择配置文件');
|
| 631 |
-
return;
|
| 632 |
-
}}
|
| 633 |
-
|
| 634 |
const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
|
| 635 |
if (apiKey === null) return;
|
| 636 |
|
|
|
|
| 637 |
const file = fileInput.files[0];
|
| 638 |
-
|
| 639 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 640 |
return;
|
| 641 |
}}
|
| 642 |
|
| 643 |
const formData = new FormData();
|
| 644 |
formData.append('config_file', file);
|
| 645 |
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
fetch('/api/upload_config', {{
|
| 652 |
method: 'POST',
|
| 653 |
headers: {{ 'X-API-Key': apiKey }},
|
|
@@ -655,21 +572,15 @@ def index():
|
|
| 655 |
}})
|
| 656 |
.then(response => response.json())
|
| 657 |
.then(data => {{
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
uploadStatus.style.color = 'green';
|
| 661 |
-
// 清除文件选择
|
| 662 |
-
fileInput.value = '';
|
| 663 |
-
// 3秒后刷新页面
|
| 664 |
-
setTimeout(() => location.reload(), 3000);
|
| 665 |
-
}} else {{
|
| 666 |
-
uploadStatus.textContent = '❌ 失败: ' + data.error;
|
| 667 |
-
uploadStatus.style.color = 'red';
|
| 668 |
-
}}
|
| 669 |
}})
|
| 670 |
.catch(error => {{
|
| 671 |
-
|
| 672 |
-
|
|
|
|
|
|
|
|
|
|
| 673 |
}});
|
| 674 |
}}
|
| 675 |
</script>
|
|
@@ -679,6 +590,7 @@ def index():
|
|
| 679 |
<h1>Simple Clash Relay</h1>
|
| 680 |
<p>状态: <span class='status {"running" if initialization_error is None else "error"}'>{status}</span></p>
|
| 681 |
{error_msg}
|
|
|
|
| 682 |
|
| 683 |
<h2>控制面板</h2>
|
| 684 |
{yacd_link}
|
|
@@ -702,25 +614,21 @@ def index():
|
|
| 702 |
</div>
|
| 703 |
|
| 704 |
<h2>基本操作</h2>
|
| 705 |
-
<button class="button warning" onclick="requestWithApiKey('/api/refresh')">刷新订阅并重启Clash</button>
|
| 706 |
|
| 707 |
<div class="upload-section">
|
| 708 |
-
<h3
|
| 709 |
-
<
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
<div class="note">
|
| 715 |
-
<p>上传的配置文件将替代订阅生成的配置。适合有自定义需求的用户。</p>
|
| 716 |
-
<p>推荐使用完整的Clash配置,包含必要的端口和API设置。</p>
|
| 717 |
-
</div>
|
| 718 |
</div>
|
| 719 |
|
| 720 |
<div class="debug-section">
|
| 721 |
-
<h3
|
| 722 |
<button class="button" onclick="viewConfig()">查看当前配置文件</button>
|
| 723 |
-
<button class="button danger" onclick="if(confirm('
|
| 724 |
<div id="config-content" style="margin-top: 15px;"></div>
|
| 725 |
</div>
|
| 726 |
|
|
|
|
| 7 |
|
| 8 |
import os
|
| 9 |
import logging
|
| 10 |
+
from flask import Flask, request, jsonify, Response, redirect, send_from_directory, flash
|
| 11 |
from .clash_manager import ClashManager
|
| 12 |
from .sub_manager import SubscriptionManager
|
| 13 |
from .auth import authenticate
|
| 14 |
import requests
|
| 15 |
from functools import wraps
|
| 16 |
+
from werkzeug.utils import secure_filename
|
| 17 |
+
import time
|
| 18 |
|
| 19 |
# 配置日志
|
| 20 |
logging.basicConfig(
|
|
|
|
| 23 |
)
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
|
| 26 |
+
# 从环境变量加载配置
|
| 27 |
+
SUB_URL = os.environ.get("SUB_URL")
|
| 28 |
+
API_KEY = os.environ.get("API_KEY", "changeme")
|
|
|
|
|
|
|
|
|
|
| 29 |
FLASK_PORT = int(os.environ.get("FLASK_PORT", 7860)) # 默认端口改为7860
|
| 30 |
CLASH_PROXY_PORT = int(os.environ.get("CLASH_PROXY_PORT", 7890))
|
| 31 |
CLASH_API_PORT = int(os.environ.get("CLASH_API_PORT", 9090))
|
| 32 |
|
| 33 |
+
# 添加标记文件路径
|
| 34 |
+
MANUAL_CONFIG_MARKER = os.path.join(os.path.dirname(__file__), "data", ".use_manual_config")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
# 初始化Flask应用
|
| 37 |
app = Flask(__name__, static_folder='static')
|
| 38 |
+
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "supersecretkey") # 用于flash消息
|
| 39 |
+
|
| 40 |
+
# 初始化管理器
|
| 41 |
+
clash_manager = None
|
| 42 |
+
sub_manager = None
|
| 43 |
+
initialization_error = None
|
| 44 |
|
| 45 |
@app.before_request
|
| 46 |
def initialize_once():
|
|
|
|
| 152 |
@app.route("/api/refresh", methods=["POST"])
|
| 153 |
@authenticate
|
| 154 |
def refresh_subscription():
|
| 155 |
+
"""刷新订阅并重新加载Clash配置(如果未使用手动配置)"""
|
| 156 |
global clash_manager, sub_manager, initialization_error
|
| 157 |
|
| 158 |
+
# 检查是否正在使用手动配置
|
| 159 |
+
if os.path.exists(MANUAL_CONFIG_MARKER):
|
| 160 |
+
logger.info("正在使用手动配置文件,跳过订阅刷新。")
|
| 161 |
+
return jsonify({"success": True, "message": "当前使用手动配置文件,无需刷新订阅。"})
|
| 162 |
+
|
| 163 |
try:
|
| 164 |
# 尝试重新加载订阅
|
| 165 |
if sub_manager is None:
|
|
|
|
| 190 |
initialization_error = error_msg
|
| 191 |
return jsonify({"success": False, "error": error_msg}), 500
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
@app.route("/health", methods=["GET"])
|
| 194 |
def health_check():
|
| 195 |
"""健康检查接口"""
|
|
|
|
| 205 |
@app.route("/debug/clean", methods=["POST"])
|
| 206 |
@authenticate
|
| 207 |
def debug_clean():
|
| 208 |
+
"""清理配置、标记文件并重新初始化(恢复使用订阅)"""
|
| 209 |
global clash_manager, sub_manager, initialization_error
|
| 210 |
|
| 211 |
try:
|
| 212 |
+
# 停止Clash
|
| 213 |
if clash_manager is not None:
|
| 214 |
clash_manager.stop_clash()
|
| 215 |
clash_manager = None
|
| 216 |
|
| 217 |
+
# 删除配置文件和标记文件
|
| 218 |
+
config_dir = os.path.join(os.path.dirname(__file__), "data")
|
| 219 |
+
config_path = os.path.join(config_dir, "config.yaml")
|
| 220 |
raw_config_path = f"{config_path}.raw"
|
| 221 |
+
marker_path = MANUAL_CONFIG_MARKER
|
| 222 |
|
| 223 |
+
files_to_delete = [config_path, raw_config_path, marker_path]
|
| 224 |
deleted_files = []
|
| 225 |
|
| 226 |
for file_path in files_to_delete:
|
|
|
|
| 228 |
try:
|
| 229 |
os.remove(file_path)
|
| 230 |
deleted_files.append(os.path.basename(file_path))
|
| 231 |
+
logger.info(f"已删除文件: {file_path}")
|
| 232 |
except OSError as e:
|
| 233 |
logger.warning(f"删除文件失败: {file_path}, 错误: {e}")
|
| 234 |
|
| 235 |
+
# 强制重新初始化 (将恢复使用订阅,因为标记文件已删除)
|
| 236 |
+
initialize_once._initialized = False
|
| 237 |
initialize_once()
|
| 238 |
|
| 239 |
+
status_msg = "配置已清理,恢复使用订阅链接。" if initialization_error is None else f"配置已清理,但重新初始化失败: {initialization_error}"
|
| 240 |
return jsonify({"success": True, "message": status_msg, "deleted_files": deleted_files})
|
| 241 |
|
| 242 |
except Exception as e:
|
| 243 |
+
logger.exception(f"清理配置时出错: {str(e)}")
|
| 244 |
return jsonify({"success": False, "error": str(e)}), 500
|
| 245 |
|
| 246 |
@app.route("/debug/config", methods=["GET"])
|
|
|
|
| 366 |
logger.error(f"代理Clash API请求时发生意外错误: {str(e)}")
|
| 367 |
return jsonify({"error": f"代理请求时发生意外错误: {str(e)}"}), 500
|
| 368 |
|
| 369 |
+
# --- 新增:上传配置 API ---
|
| 370 |
+
@app.route("/api/upload_config", methods=["POST"])
|
| 371 |
+
@authenticate
|
| 372 |
+
def upload_config():
|
| 373 |
+
"""上传并使用自定义配置文件"""
|
| 374 |
+
global clash_manager, initialization_error
|
| 375 |
+
|
| 376 |
+
if 'config_file' not in request.files:
|
| 377 |
+
logger.error("上传配置请求中未找到名为 'config_file' 的文件")
|
| 378 |
+
return jsonify({"success": False, "error": "未找到配置文件部分"}), 400
|
| 379 |
+
|
| 380 |
+
file = request.files['config_file']
|
| 381 |
+
if file.filename == '' or not file:
|
| 382 |
+
logger.error("上传的文件无效或未选择")
|
| 383 |
+
return jsonify({"success": False, "error": "未选择文件或文件无效"}), 400
|
| 384 |
+
|
| 385 |
+
# 限制文件名,防止路径遍历
|
| 386 |
+
filename = secure_filename(file.filename)
|
| 387 |
+
if not filename.lower().endswith(('.yaml', '.yml')):
|
| 388 |
+
logger.error(f"上传的文件类型不支持: {filename}")
|
| 389 |
+
return jsonify({"success": False, "error": "只允许上传 .yaml 或 .yml 文件"}), 400
|
| 390 |
+
|
| 391 |
+
config_dir = os.path.join(os.path.dirname(__file__), "data")
|
| 392 |
+
config_path = os.path.join(config_dir, "config.yaml")
|
| 393 |
+
marker_path = MANUAL_CONFIG_MARKER
|
| 394 |
+
|
| 395 |
+
try:
|
| 396 |
+
# 确保数据目录存在
|
| 397 |
+
os.makedirs(config_dir, exist_ok=True)
|
| 398 |
+
|
| 399 |
+
# 保存上传的文件,覆盖旧的 config.yaml
|
| 400 |
+
file.save(config_path)
|
| 401 |
+
logger.info(f"成功保存上传的配置文件到: {config_path}")
|
| 402 |
+
|
| 403 |
+
# 创建标记文件,表示正在使用手动配置
|
| 404 |
+
with open(marker_path, 'w') as f:
|
| 405 |
+
f.write(f"Uploaded at {time.time()}")
|
| 406 |
+
logger.info(f"已创建手动配置标记文件: {marker_path}")
|
| 407 |
+
|
| 408 |
+
# 重启Clash以加载新配置
|
| 409 |
+
if clash_manager is not None:
|
| 410 |
+
logger.info("正在重启Clash以加载手动配置...")
|
| 411 |
+
clash_manager.restart_clash(config_path=config_path) # 明确指定新配置路径
|
| 412 |
+
initialization_error = None # 清除之前的初始化错误
|
| 413 |
+
logger.info("Clash已使用手动配置重启")
|
| 414 |
+
else:
|
| 415 |
+
# 如果Clash未运行,��试初始化并启动
|
| 416 |
+
logger.info("Clash未运行,尝试使用手动配置进行初始化和启动...")
|
| 417 |
+
initialize_once._initialized = False
|
| 418 |
+
initialize_once()
|
| 419 |
+
if initialization_error:
|
| 420 |
+
logger.error(f"使用手动配置启动Clash失败: {initialization_error}")
|
| 421 |
+
# 即使启动失败,也保留手动配置和标记
|
| 422 |
+
else:
|
| 423 |
+
logger.info("Clash已使用手动配置成功启动")
|
| 424 |
+
|
| 425 |
+
return jsonify({"success": True, "message": "配置文件已上传并应用,Clash已重启。"})
|
| 426 |
+
|
| 427 |
+
except Exception as e:
|
| 428 |
+
logger.exception(f"处理上传的配置文件时出错: {str(e)}")
|
| 429 |
+
# 清理可能不完整的状态
|
| 430 |
+
if os.path.exists(marker_path):
|
| 431 |
+
os.remove(marker_path)
|
| 432 |
+
return jsonify({"success": False, "error": f"处理上传文件时出错: {str(e)}"}), 500
|
| 433 |
+
|
| 434 |
# --- 基础路由 ---
|
| 435 |
|
| 436 |
@app.route('/', methods=['GET'])
|
| 437 |
def index():
|
| 438 |
+
"""首页 - 提供说明、状态和操作"""
|
| 439 |
global initialization_error
|
| 440 |
|
| 441 |
+
using_manual_config = os.path.exists(MANUAL_CONFIG_MARKER)
|
| 442 |
+
|
| 443 |
status = "运行中" if initialization_error is None else "初始化失败"
|
| 444 |
error_msg = "" if initialization_error is None else f"<p style='color:red'>错误: {initialization_error}</p>"
|
| 445 |
+
config_mode_msg = "<p class='note'>当前模式:<b>手动配置文件</b></p>" if using_manual_config else "<p class='note'>当前模式:<b>订阅链接</b></p>"
|
| 446 |
+
|
| 447 |
+
# 刷新按钮状态
|
| 448 |
+
refresh_button_disabled = "disabled" if using_manual_config else ""
|
| 449 |
+
refresh_button_title = "title='当前使用手动配置,无需刷新订阅'" if using_manual_config else ""
|
| 450 |
|
|
|
|
| 451 |
yacd_link = "<a href='/ui/' class='button'>访问高级控制面板 (Yacd)</a>"
|
| 452 |
|
| 453 |
return f"""
|
|
|
|
| 487 |
color: #8a6d3b;
|
| 488 |
font-size: 14px;
|
| 489 |
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
pre {{ max-height: 300px; overflow: auto; background-color: #eee; padding: 10px; border-radius: 4px; }}
|
| 491 |
+
.note b {{ font-weight: bold; color: #555; }}
|
| 492 |
+
.upload-section {{ margin-top: 20px; padding: 15px; border: 1px dashed #46b8da; background-color: #d9edf7; }}
|
| 493 |
+
.upload-section label {{ display: block; margin-bottom: 5px; font-weight: bold; }}
|
| 494 |
+
.upload-section input[type='file'] {{ margin-bottom: 10px; }}
|
| 495 |
</style>
|
| 496 |
<script>
|
| 497 |
+
// 请求API (带密钥)
|
| 498 |
function requestWithApiKey(url, method = 'POST') {{
|
| 499 |
const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
|
| 500 |
+
if (apiKey === null) return;
|
| 501 |
|
| 502 |
fetch(url, {{
|
| 503 |
method: method,
|
|
|
|
| 505 |
}})
|
| 506 |
.then(response => response.json())
|
| 507 |
.then(data => {{
|
| 508 |
+
alert(data.success ? data.message : ('失败: ' + (data.error || '未知错误')));
|
| 509 |
if(data.success) location.reload();
|
| 510 |
}})
|
| 511 |
.catch(error => alert('请求失败: ' + error));
|
| 512 |
}}
|
| 513 |
|
| 514 |
+
// 查看配置
|
| 515 |
function viewConfig() {{
|
| 516 |
const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
|
| 517 |
if (apiKey === null) return;
|
|
|
|
| 540 |
}});
|
| 541 |
}}
|
| 542 |
|
| 543 |
+
// 上传配置
|
| 544 |
function uploadConfig() {{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
|
| 546 |
if (apiKey === null) return;
|
| 547 |
|
| 548 |
+
const fileInput = document.getElementById('configFileInput');
|
| 549 |
const file = fileInput.files[0];
|
| 550 |
+
|
| 551 |
+
if (!file) {{
|
| 552 |
+
alert('请先选择一个 .yaml 或 .yml 文件。');
|
| 553 |
+
return;
|
| 554 |
+
}}
|
| 555 |
+
|
| 556 |
+
if (!file.name.toLowerCase().endsWith('.yaml') && !file.name.toLowerCase().endsWith('.yml')) {{
|
| 557 |
+
alert('只允许上传 .yaml 或 .yml 文件。');
|
| 558 |
return;
|
| 559 |
}}
|
| 560 |
|
| 561 |
const formData = new FormData();
|
| 562 |
formData.append('config_file', file);
|
| 563 |
|
| 564 |
+
const uploadButton = document.getElementById('uploadButton');
|
| 565 |
+
uploadButton.disabled = true;
|
| 566 |
+
uploadButton.textContent = '上传中...';
|
| 567 |
+
|
|
|
|
| 568 |
fetch('/api/upload_config', {{
|
| 569 |
method: 'POST',
|
| 570 |
headers: {{ 'X-API-Key': apiKey }},
|
|
|
|
| 572 |
}})
|
| 573 |
.then(response => response.json())
|
| 574 |
.then(data => {{
|
| 575 |
+
alert(data.success ? data.message : ('上传失败: ' + (data.error || '未知错误')));
|
| 576 |
+
if(data.success) location.reload();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
}})
|
| 578 |
.catch(error => {{
|
| 579 |
+
alert('上传请求失败: ' + error);
|
| 580 |
+
}})
|
| 581 |
+
.finally(() => {{
|
| 582 |
+
uploadButton.disabled = false;
|
| 583 |
+
uploadButton.textContent = '上传并应用配置';
|
| 584 |
}});
|
| 585 |
}}
|
| 586 |
</script>
|
|
|
|
| 590 |
<h1>Simple Clash Relay</h1>
|
| 591 |
<p>状态: <span class='status {"running" if initialization_error is None else "error"}'>{status}</span></p>
|
| 592 |
{error_msg}
|
| 593 |
+
{config_mode_msg}
|
| 594 |
|
| 595 |
<h2>控制面板</h2>
|
| 596 |
{yacd_link}
|
|
|
|
| 614 |
</div>
|
| 615 |
|
| 616 |
<h2>基本操作</h2>
|
| 617 |
+
<button class="button warning" onclick="requestWithApiKey('/api/refresh')" {refresh_button_disabled} {refresh_button_title}>刷新订阅并重启Clash</button>
|
| 618 |
|
| 619 |
<div class="upload-section">
|
| 620 |
+
<h3>上传手动配置</h3>
|
| 621 |
+
<p>您可以上传自己的 <code>config.yaml</code> 文件来覆盖订阅链接。上传后,系统将只使用此文件,不再进行订阅更新。</p>
|
| 622 |
+
<label for="configFileInput">选择配置文件 (.yaml 或 .yml):</label>
|
| 623 |
+
<input type="file" id="configFileInput" name="config_file" accept=".yaml,.yml">
|
| 624 |
+
<button class="button" id="uploadButton" onclick="uploadConfig()">上传并应用配置</button>
|
| 625 |
+
<p><small>要恢复使用订阅链接,请使用下面的"清理配置并重启"按钮。</small></p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
</div>
|
| 627 |
|
| 628 |
<div class="debug-section">
|
| 629 |
+
<h3>调试与恢复</h3>
|
| 630 |
<button class="button" onclick="viewConfig()">查看当前配置文件</button>
|
| 631 |
+
<button class="button danger" onclick="if(confirm('确定要清理配置并重启服务吗?此操作将删除手动配置(如果存在)并恢复使用订阅链接!')) requestWithApiKey('/debug/clean')">清理配置并重启 (恢复订阅)</button>
|
| 632 |
<div id="config-content" style="margin-top: 15px;"></div>
|
| 633 |
</div>
|
| 634 |
|
app/sub_manager.py
CHANGED
|
@@ -15,172 +15,274 @@ from urllib.parse import urlparse
|
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
class SubscriptionManager:
|
| 18 |
-
"""
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
| 21 |
"""
|
| 22 |
初始化订阅管理器
|
| 23 |
|
| 24 |
Args:
|
| 25 |
sub_url: 订阅链接URL
|
| 26 |
-
config_path:
|
| 27 |
"""
|
| 28 |
self.sub_url = sub_url
|
| 29 |
-
self.config_path = config_path
|
| 30 |
-
|
| 31 |
-
# 如果未指定配置路径,使用默认路径
|
| 32 |
-
if not self.config_path:
|
| 33 |
-
self.config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
|
| 34 |
-
|
| 35 |
self.subconverter_path = os.path.join(
|
| 36 |
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
| 37 |
"subconverter", "subconverter"
|
| 38 |
)
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
# 确保配置目录存在
|
| 41 |
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
| 42 |
|
| 43 |
-
# 只在需要订阅链接但未提供时发出警告
|
| 44 |
-
if not sub_url:
|
| 45 |
-
logger.warning("未设置订阅链接,将尝试处理已有的配置文件")
|
| 46 |
-
|
| 47 |
# 检查subconverter可执行文件是否存在
|
| 48 |
if not os.path.exists(self.subconverter_path):
|
| 49 |
-
|
| 50 |
|
| 51 |
def load_and_convert_sub(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
"""
|
| 53 |
-
|
| 54 |
|
|
|
|
|
|
|
|
|
|
| 55 |
Raises:
|
| 56 |
-
RuntimeError:
|
| 57 |
"""
|
| 58 |
-
if not self.sub_url:
|
| 59 |
-
logger.warning("未提供订阅URL,跳过下载步骤")
|
| 60 |
-
# 尝试处理已有的raw文件
|
| 61 |
-
self._convert_raw_to_config()
|
| 62 |
-
return
|
| 63 |
-
|
| 64 |
-
# 下载订阅内容
|
| 65 |
-
raw_config_path = f"{self.config_path}.raw"
|
| 66 |
try:
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
response = requests.get(self.sub_url, timeout=30)
|
| 72 |
-
response.raise_for_status()
|
|
|
|
| 73 |
|
| 74 |
-
content
|
| 75 |
-
|
| 76 |
-
# 将原始内容保存为raw文件以备转换
|
| 77 |
-
with open(raw_config_path, "wb") as f:
|
| 78 |
-
f.write(content)
|
| 79 |
|
| 80 |
logger.info(f"成功下载订阅,大小: {len(content)} 字节")
|
| 81 |
-
|
| 82 |
-
except requests.RequestException as e:
|
| 83 |
-
error_msg = f"下载订阅失败: {str(e)}"
|
| 84 |
-
logger.error(error_msg)
|
| 85 |
-
raise RuntimeError(error_msg)
|
| 86 |
-
|
| 87 |
-
# 转换为Clash配置
|
| 88 |
-
self._convert_raw_to_config()
|
| 89 |
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
"""
|
| 92 |
-
|
| 93 |
|
|
|
|
|
|
|
|
|
|
| 94 |
Raises:
|
| 95 |
-
RuntimeError:
|
| 96 |
"""
|
| 97 |
-
|
|
|
|
| 98 |
|
| 99 |
-
#
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
logger.
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
| 104 |
|
|
|
|
| 105 |
try:
|
| 106 |
-
|
| 107 |
-
logger.info(f"输入文件: {raw_config_path}, 配置路径: {self.config_path}")
|
| 108 |
-
|
| 109 |
-
# 先检查原始文件是否已经是Clash格式
|
| 110 |
-
with open(raw_config_path, "r", encoding="utf-8", errors="ignore") as f:
|
| 111 |
content = f.read()
|
| 112 |
-
|
| 113 |
-
# 简单检查是否已经是Clash配置文件
|
| 114 |
-
if "proxies:" in content and ("rules:" in content or "proxy-groups:" in content):
|
| 115 |
-
logger.info("检测到输入文件已是Clash配置格式,直接使用")
|
| 116 |
-
|
| 117 |
-
# 写入到目标配置文件
|
| 118 |
-
with open(self.config_path, "w", encoding="utf-8") as f:
|
| 119 |
-
f.write(content)
|
| 120 |
-
|
| 121 |
-
# 修补配置文件
|
| 122 |
-
self._patch_config()
|
| 123 |
-
return
|
| 124 |
|
| 125 |
-
#
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
f.write(f" - {line}\n")
|
| 150 |
-
|
| 151 |
-
logger.info("已生成Clash配置")
|
| 152 |
-
|
| 153 |
-
# 修补配置文件
|
| 154 |
-
self._patch_config()
|
| 155 |
-
return
|
| 156 |
-
except Exception as e:
|
| 157 |
-
logger.error(f"Base64解码或处理失败: {str(e)}")
|
| 158 |
-
# 继续尝试其他方法
|
| 159 |
-
except Exception as e:
|
| 160 |
-
logger.error(f"读取订阅内容失败: {str(e)}")
|
| 161 |
-
|
| 162 |
-
# 如果上述方法都失败,可能是其他格式,直接尝试作为配置使用
|
| 163 |
try:
|
| 164 |
-
with open(
|
| 165 |
content = f.read()
|
| 166 |
-
|
| 167 |
with open(self.config_path, "w", encoding="utf-8") as f:
|
| 168 |
f.write(content)
|
| 169 |
-
logger.
|
| 170 |
# 验证文件是否成功写入
|
| 171 |
if os.path.exists(self.config_path):
|
| 172 |
logger.info(f"文件已成功写入,大小: {os.path.getsize(self.config_path)} 字节")
|
| 173 |
else:
|
| 174 |
logger.error(f"文件写入失败,{self.config_path} 不存在")
|
| 175 |
except Exception as e:
|
| 176 |
-
logger.error(f"
|
| 177 |
raise RuntimeError(f"写入配置文件失败: {str(e)}")
|
| 178 |
-
|
| 179 |
-
# 修补配置文件
|
| 180 |
-
self._patch_config()
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
raise RuntimeError(f"配置转换失败: {str(e)}")
|
| 185 |
|
| 186 |
def _add_clash_headers(self):
|
|
|
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
class SubscriptionManager:
|
| 18 |
+
"""管理订阅链接的下载和配置转换"""
|
| 19 |
|
| 20 |
+
MANUAL_CONFIG_MARKER = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", ".use_manual_config")
|
| 21 |
+
|
| 22 |
+
def __init__(self, sub_url, config_path):
|
| 23 |
"""
|
| 24 |
初始化订阅管理器
|
| 25 |
|
| 26 |
Args:
|
| 27 |
sub_url: 订阅链接URL
|
| 28 |
+
config_path: 生成的Clash配置文件保存路径
|
| 29 |
"""
|
| 30 |
self.sub_url = sub_url
|
| 31 |
+
self.config_path = os.path.abspath(config_path)
|
| 32 |
+
self.raw_config_path = f"{self.config_path}.raw"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
self.subconverter_path = os.path.join(
|
| 34 |
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
| 35 |
"subconverter", "subconverter"
|
| 36 |
)
|
| 37 |
|
| 38 |
+
# 检查是否设置了订阅链接
|
| 39 |
+
if not sub_url:
|
| 40 |
+
raise ValueError("未设置订阅链接 (SUB_URL)")
|
| 41 |
+
|
| 42 |
# 确保配置目录存在
|
| 43 |
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
# 检查subconverter可执行文件是否存在
|
| 46 |
if not os.path.exists(self.subconverter_path):
|
| 47 |
+
raise FileNotFoundError(f"subconverter可执行文件未找到: {self.subconverter_path}")
|
| 48 |
|
| 49 |
def load_and_convert_sub(self):
|
| 50 |
+
"""下载订阅并转换为Clash配置,或使用现有配置"""
|
| 51 |
+
# 检查是否使用了手动上传的配置
|
| 52 |
+
if os.path.exists(self.MANUAL_CONFIG_MARKER):
|
| 53 |
+
logger.info("检测到手动配置文件标记,跳过订阅下载和转换。")
|
| 54 |
+
if not os.path.exists(self.config_path):
|
| 55 |
+
logger.warning("手动配置模式,但配置文件 config.yaml 不存在!")
|
| 56 |
+
raise FileNotFoundError("手动配置模式,但配置文件 config.yaml 不存在!")
|
| 57 |
+
# 确保手动配置也被修补 (如果需要)
|
| 58 |
+
self._patch_config()
|
| 59 |
+
return
|
| 60 |
+
|
| 61 |
+
# 如果没有设置订阅URL,且配置文件已存在,则直接使用
|
| 62 |
+
if not self.sub_url:
|
| 63 |
+
if os.path.exists(self.config_path):
|
| 64 |
+
logger.info("未设置订阅URL,使用现有的配置文件")
|
| 65 |
+
self._patch_config()
|
| 66 |
+
return
|
| 67 |
+
else:
|
| 68 |
+
logger.error("未设置订阅URL,且配置文件不存在!")
|
| 69 |
+
raise ValueError("必须提供订阅URL或已存在的配置文件")
|
| 70 |
+
|
| 71 |
+
# 下载订阅
|
| 72 |
+
try:
|
| 73 |
+
logger.info(f"正在下载订阅: {self._mask_url(self.sub_url)}")
|
| 74 |
+
content = self._download_subscription()
|
| 75 |
+
# 将原始订阅内容保存到 .raw 文件
|
| 76 |
+
with open(self.raw_config_path, "wb") as f:
|
| 77 |
+
f.write(content)
|
| 78 |
+
logger.info(f"成功下载订阅,大小: {len(content)} 字节")
|
| 79 |
+
except Exception as e:
|
| 80 |
+
logger.error(f"下载订阅失败: {str(e)}")
|
| 81 |
+
# 如果下载失败,但旧配置文件存在,则继续使用旧的
|
| 82 |
+
if os.path.exists(self.config_path):
|
| 83 |
+
logger.warning("下载失败,继续使用旧的配置文件")
|
| 84 |
+
self._patch_config()
|
| 85 |
+
return
|
| 86 |
+
else:
|
| 87 |
+
raise RuntimeError(f"下载订阅失败: {str(e)}")
|
| 88 |
+
|
| 89 |
+
# 转换配置
|
| 90 |
+
try:
|
| 91 |
+
logger.info("正在将订阅转换为Clash配置")
|
| 92 |
+
self._convert_to_clash(self.raw_config_path)
|
| 93 |
+
except Exception as e:
|
| 94 |
+
logger.error(f"转换配置失败: {str(e)}")
|
| 95 |
+
# 如果转换失败,但旧配置文件存在,尝试使用旧的
|
| 96 |
+
if os.path.exists(self.config_path):
|
| 97 |
+
logger.warning("转换失败,尝试使用旧的配置文件")
|
| 98 |
+
# 如果转换失败,且没有旧配置,尝试直接使用原始下载内容
|
| 99 |
+
elif os.path.exists(self.raw_config_path):
|
| 100 |
+
logger.warning("转换失败,尝试直接使用原始订阅内容作为配置文件")
|
| 101 |
+
try:
|
| 102 |
+
with open(self.raw_config_path, "r", encoding="utf-8") as infile, \
|
| 103 |
+
open(self.config_path, "w", encoding="utf-8") as outfile:
|
| 104 |
+
outfile.write(infile.read())
|
| 105 |
+
logger.info("已将原始订阅内容复制为配置文件")
|
| 106 |
+
except Exception as copy_err:
|
| 107 |
+
logger.error(f"复制原始订阅内容失败: {copy_err}")
|
| 108 |
+
raise RuntimeError(f"转换配置失败,且无法使用原始订阅: {str(e)}")
|
| 109 |
+
else:
|
| 110 |
+
raise RuntimeError(f"转换配置失败: {str(e)}")
|
| 111 |
+
|
| 112 |
+
# 修补配置文件(添加端口、API等)
|
| 113 |
+
self._patch_config()
|
| 114 |
+
|
| 115 |
+
return self.config_path
|
| 116 |
+
|
| 117 |
+
def _download_subscription(self):
|
| 118 |
"""
|
| 119 |
+
下载订阅内容
|
| 120 |
|
| 121 |
+
Returns:
|
| 122 |
+
str: 订阅内容文本
|
| 123 |
+
|
| 124 |
Raises:
|
| 125 |
+
RuntimeError: 如果下载失败
|
| 126 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
try:
|
| 128 |
+
headers = {
|
| 129 |
+
"User-Agent": "ClashforWindows/0.19.0",
|
| 130 |
+
"Accept": "*/*",
|
| 131 |
+
}
|
| 132 |
+
response = requests.get(self.sub_url, headers=headers, timeout=30)
|
| 133 |
+
response.raise_for_status()
|
| 134 |
+
content = response.text
|
| 135 |
|
| 136 |
+
if not content or len(content) < 10:
|
| 137 |
+
raise RuntimeError("下载的订阅内容为空或过短")
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
logger.info(f"成功下载订阅,大小: {len(content)} 字节")
|
| 140 |
+
return content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
+
except requests.RequestException as e:
|
| 143 |
+
logger.error(f"下载订阅失败: {str(e)}")
|
| 144 |
+
raise RuntimeError(f"下载订阅失败: {str(e)}")
|
| 145 |
+
|
| 146 |
+
def _convert_to_clash(self, input_file):
|
| 147 |
"""
|
| 148 |
+
使用subconverter将订阅内容转换为Clash配置
|
| 149 |
|
| 150 |
+
Args:
|
| 151 |
+
input_file: 包含订阅内容的文件路径
|
| 152 |
+
|
| 153 |
Raises:
|
| 154 |
+
RuntimeError: 如果转换失败
|
| 155 |
"""
|
| 156 |
+
logger.info(f"正在将订阅转换为Clash配置")
|
| 157 |
+
logger.info(f"输入文件: {input_file}, 配置路径: {self.config_path}")
|
| 158 |
|
| 159 |
+
# 确保数据目录存在
|
| 160 |
+
data_dir = os.path.dirname(self.config_path)
|
| 161 |
+
if not os.path.exists(data_dir):
|
| 162 |
+
logger.info(f"创建数据目录: {data_dir}")
|
| 163 |
+
try:
|
| 164 |
+
os.makedirs(data_dir, exist_ok=True)
|
| 165 |
+
except Exception as e:
|
| 166 |
+
logger.error(f"创建数据目录失败: {str(e)}")
|
| 167 |
|
| 168 |
+
# 尝试直接读取订阅内容,确认它是否已经是Clash配置
|
| 169 |
try:
|
| 170 |
+
with open(input_file, "r", encoding="utf-8") as f:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
content = f.read()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
+
# 简单检查是否已经是Clash配置
|
| 174 |
+
if "proxies:" in content and ("port:" in content or "mixed-port:" in content):
|
| 175 |
+
logger.info("检测到输入文件已是Clash配置格式,直接使用")
|
| 176 |
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
| 177 |
+
f.write(content)
|
| 178 |
+
return
|
| 179 |
+
except Exception as e:
|
| 180 |
+
logger.warning(f"读取输入文件时出错: {str(e)},将尝试转换")
|
| 181 |
+
|
| 182 |
+
# 准备subconverter命令
|
| 183 |
+
cmd = [
|
| 184 |
+
self.subconverter_path,
|
| 185 |
+
"-g", # 生成配置文件
|
| 186 |
+
"--target", "clash", # 输出格式为Clash (修正为 --target)
|
| 187 |
+
"--url", input_file, # 修正为 --url 参数
|
| 188 |
+
"--output", self.config_path, # 输出文件
|
| 189 |
+
"--include-remarks", ".*" # 包含所有节点
|
| 190 |
+
]
|
| 191 |
+
|
| 192 |
+
logger.info(f"执行命令: {' '.join(cmd)}")
|
| 193 |
+
|
| 194 |
+
# 如果subconverter不存在或执行出错,我们就尝试直接使用订阅内容
|
| 195 |
+
if not os.path.exists(self.subconverter_path):
|
| 196 |
+
logger.warning("subconverter不存在,尝试直接使用订阅内容")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
try:
|
| 198 |
+
with open(input_file, "r", encoding="utf-8") as f:
|
| 199 |
content = f.read()
|
|
|
|
| 200 |
with open(self.config_path, "w", encoding="utf-8") as f:
|
| 201 |
f.write(content)
|
| 202 |
+
logger.info(f"已将订阅内容直接写入到: {self.config_path}")
|
| 203 |
# 验证文件是否成功写入
|
| 204 |
if os.path.exists(self.config_path):
|
| 205 |
logger.info(f"文件已成功写入,大小: {os.path.getsize(self.config_path)} 字节")
|
| 206 |
else:
|
| 207 |
logger.error(f"文件写入失败,{self.config_path} 不存在")
|
| 208 |
except Exception as e:
|
| 209 |
+
logger.error(f"直接使用订阅内容时出错: {str(e)}")
|
| 210 |
raise RuntimeError(f"写入配置文件失败: {str(e)}")
|
| 211 |
+
return
|
|
|
|
|
|
|
| 212 |
|
| 213 |
+
try:
|
| 214 |
+
# 执行subconverter
|
| 215 |
+
process = subprocess.Popen(
|
| 216 |
+
cmd,
|
| 217 |
+
stdout=subprocess.PIPE,
|
| 218 |
+
stderr=subprocess.PIPE,
|
| 219 |
+
universal_newlines=True
|
| 220 |
+
)
|
| 221 |
+
stdout, stderr = process.communicate(timeout=30)
|
| 222 |
+
|
| 223 |
+
logger.info(f"subconverter输出: {stdout[:200]}...") # 限制日志长度
|
| 224 |
+
|
| 225 |
+
if process.returncode != 0:
|
| 226 |
+
logger.error(f"subconverter执��失败: {stderr}")
|
| 227 |
+
# 错误处理:尝试直接使用订阅内容
|
| 228 |
+
try:
|
| 229 |
+
with open(input_file, "r", encoding="utf-8") as f:
|
| 230 |
+
content = f.read()
|
| 231 |
+
|
| 232 |
+
# 确保它是有效的配置,如果是普通订阅格式,添加基本的Clash头
|
| 233 |
+
if "proxies:" not in content:
|
| 234 |
+
content = self._add_clash_headers() + content
|
| 235 |
+
|
| 236 |
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
| 237 |
+
f.write(content)
|
| 238 |
+
logger.warning("尝试直接使用订阅内容作为配置文件")
|
| 239 |
+
# 验证文件是否成功写入
|
| 240 |
+
if os.path.exists(self.config_path):
|
| 241 |
+
logger.info(f"文件已成功写入,大小: {os.path.getsize(self.config_path)} 字节")
|
| 242 |
+
else:
|
| 243 |
+
logger.error(f"文件写入失败,{self.config_path} 不存在")
|
| 244 |
+
except Exception as e:
|
| 245 |
+
logger.error(f"使用订阅内容作为配置文件时出错: {str(e)}")
|
| 246 |
+
raise RuntimeError(f"写入配置文件失败: {str(e)}")
|
| 247 |
+
else:
|
| 248 |
+
logger.info("成功转换配置")
|
| 249 |
+
# 验证输出文件是否存在
|
| 250 |
+
if os.path.exists(self.config_path):
|
| 251 |
+
logger.info(f"配置文件已生成,路径: {self.config_path},大小: {os.path.getsize(self.config_path)} 字节")
|
| 252 |
+
else:
|
| 253 |
+
# 如果文件不存在但subconverter返回成功,尝试查找配置文件
|
| 254 |
+
logger.warning(f"subconverter声称成功但配置文件不存在: {self.config_path}")
|
| 255 |
+
# 查找当前目录下可能生成的配置文件
|
| 256 |
+
possible_files = [f for f in os.listdir('.') if f.endswith('.yaml') or f.endswith('.yml')]
|
| 257 |
+
if possible_files:
|
| 258 |
+
logger.info(f"找到可能的配置文件: {possible_files}")
|
| 259 |
+
# 尝试复制找到的第一个文件
|
| 260 |
+
try:
|
| 261 |
+
import shutil
|
| 262 |
+
shutil.copy(possible_files[0], self.config_path)
|
| 263 |
+
logger.info(f"已复制 {possible_files[0]} 到 {self.config_path}")
|
| 264 |
+
except Exception as e:
|
| 265 |
+
logger.error(f"复制文件失败: {str(e)}")
|
| 266 |
+
else:
|
| 267 |
+
logger.error("未找到任何可能的配置文件")
|
| 268 |
+
# 尝试使用原始订阅内容作为配置
|
| 269 |
+
try:
|
| 270 |
+
with open(input_file, "r", encoding="utf-8") as f:
|
| 271 |
+
content = f.read()
|
| 272 |
+
|
| 273 |
+
# 确保它是有效的配置,如果是普通订阅格式,添加基本的Clash头
|
| 274 |
+
if "proxies:" not in content:
|
| 275 |
+
content = self._add_clash_headers() + content
|
| 276 |
+
|
| 277 |
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
| 278 |
+
f.write(content)
|
| 279 |
+
logger.warning("使用订阅内容作为配置文件")
|
| 280 |
+
except Exception as e:
|
| 281 |
+
logger.error(f"使用订阅内容时出错: {str(e)}")
|
| 282 |
+
raise RuntimeError(f"写入配置文件失败: {str(e)}")
|
| 283 |
+
|
| 284 |
+
except (subprocess.SubprocessError, OSError) as e:
|
| 285 |
+
logger.error(f"执行subconverter时出错: {str(e)}")
|
| 286 |
raise RuntimeError(f"配置转换失败: {str(e)}")
|
| 287 |
|
| 288 |
def _add_clash_headers(self):
|