├── .gitattributes ├── favicon.ico ├── requirements.txt ├── LICENSE ├── Dockerfile ├── .gitignore ├── RELEASES.md ├── README.md ├── .github └── workflows │ └── docker-publish.yml ├── frontend.html ├── script.js └── chain-subconverter.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slackworker/chain-subconverter/HEAD/favicon.ico -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slackworker/chain-subconverter/HEAD/requirements.txt -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 slackworker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.11-slim 3 | 4 | # Set environment variables 5 | ENV PYTHONDONTWRITEBYTECODE=1 6 | ENV PYTHONUNBUFFERED=1 7 | # Default port, can be overridden by PORT env var at runtime 8 | ENV PORT=11200 9 | # Default log level, can be overridden by LOG_LEVEL env var at runtime 10 | ENV LOG_LEVEL="INFO" 11 | # Default SSL verification for outgoing requests, can be overridden by REQUESTS_SSL_VERIFY env var at runtime 12 | # Valid values: "true", "false", or a path to a CA bundle. 13 | ENV REQUESTS_SSL_VERIFY="true" 14 | # Default to false, set to "true" to show the service address config section 15 | ENV SHOW_SERVICE_ADDRESS_CONFIG="false" 16 | 17 | # Set the working directory in the container 18 | WORKDIR /app 19 | 20 | # Copy the requirements file 21 | COPY requirements.txt . 22 | 23 | # Install build dependencies, then Python packages, then remove build dependencies. 24 | # This is to compile any C extensions (like ruamel.yaml.clib) if wheels are not available 25 | # and to keep the final image size smaller. 26 | RUN apt-get update && \ 27 | apt-get install -y --no-install-recommends gcc libc-dev && \ 28 | pip install --no-cache-dir -r requirements.txt && \ 29 | apt-get purge -y --auto-remove gcc libc-dev && \ 30 | rm -rf /var/lib/apt/lists/* 31 | 32 | # Copy the application code into the container 33 | COPY chain-subconverter.py . 34 | COPY frontend.html . 35 | COPY script.js . 36 | # The application serves a favicon.ico. Ensure this file is present in the same directory 37 | # as the Dockerfile during the build process. 38 | COPY favicon.ico . 39 | 40 | # Add a non-root user for security and switch to it 41 | # The application will create a 'logs' directory within /app. 42 | # Since /app will be owned by appuser, this will succeed. 43 | RUN useradd -m -s /bin/bash appuser && \ 44 | chown -R appuser:appuser /app 45 | USER appuser 46 | 47 | # Expose the port the application runs on (defined by the PORT environment variable) 48 | EXPOSE ${PORT} 49 | 50 | # Define the command to run the application 51 | CMD ["python", "chain-subconverter.py"] 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib60/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a CI script in a temporary folder. 32 | # Still, if you do run PyInstaller manually, you probably don't want to 33 | # commit the results. 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # PEP 582; __pypackages__ directory 89 | __pypackages__/ 90 | 91 | # Celery stuff 92 | celerybeat-schedule 93 | celerybeat.pid 94 | 95 | # SageMath files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ # <--- 忽略名为 venv 的文件夹 103 | .idea/ # <--- 忽略 JetBrains IDEs (PyCharm, IntelliJ IDEA) 的配置文件夹 104 | .vscode/ # <--- 忽略 VS Code 的配置文件夹 105 | *.DS_Store # <--- 忽略 macOS 的 .DS_Store 文件 106 | *.suo 107 | *.ntvs* 108 | *.njsproj 109 | *.sln 110 | *.sw? 111 | 112 | # Logs 113 | logs/ # <--- 忽略名为 logs 的文件夹 114 | *.log 115 | npm-debug.log* 116 | yarn-debug.log* 117 | yarn-error.log* 118 | pnpm-debug.log* 119 | lerna-debug.log* 120 | 121 | # Editor directories and files 122 | .idea 123 | .vscode 124 | *.sublime-project 125 | *.sublime-workspace 126 | *.komodoproject 127 | venv/Scripts/pythonw.exe 128 | venv/Scripts/python.exe 129 | venv/Scripts/pip3.exe 130 | venv/Scripts/pip3.12.exe 131 | venv/Scripts/pip.exe 132 | venv/Scripts/normalizer.exe 133 | venv/Scripts/httpx.exe 134 | venv/Scripts/deactivate.bat 135 | venv/Scripts/Activate.ps1 136 | venv/Scripts/activate.bat 137 | venv/Scripts/activate 138 | venv/pyvenv.cfg 139 | logs/server.log.1 140 | Dockerfile.txt 141 | -------------------------------------------------------------------------------- /RELEASES.md: -------------------------------------------------------------------------------- 1 | # Release v2.3.6: 全新前端交互与无状态后端架构 2 | 3 | **Tag:** `v2.3.6` 4 | **Date:** 2025-06-05 5 | 6 | ## 概述 (Overview) 7 | 8 | `chain-subconverter` v2.0+ 是一个重要的里程碑版本。本次更新的核心在于引入了一个全新的图形化前端配置界面,并对后端服务进行了无状态化重构。这些变更旨在提升用户体验的便捷性、部署的灵活性以及服务整体的维护性。相较于 [v1.0.0 版本](https://github.com/slackworker/chain-subconverter/releases/tag/v1.0.0),新版本在交互方式和功能实现上均有显著改进。 9 | 10 | 您可以通过以下链接在线预览和体验本项目: 11 | [➡️ 点击这里进行在线预览](https://chain-subconverter-latest.onrender.com/) 12 | 13 | **⚠️ 重要提醒:** 14 | * 以上仅供项目预览 / 调试,服务器具有超时休眠➡冷启动机制,无法用于生产环境。 15 | * 本项目完全开源,设计上不记录任何用户敏感信息。 16 | * **信别人不如信自己**,永远建议使用自行部署的订阅转换服务。[《部署指南》](https://github.com/slackworker/chain-subconverter/wiki/Deployment-Guide) 17 | 18 | ## 主要变更 (Key Changes) 19 | 20 | * **引入前端用户界面 (Introduction of Frontend User Interface)**: 21 | * 用户现在可以通过 Web 浏览器访问直观的图形界面来完成所有配置操作,包括输入原始订阅链接、定义落地节点与前置节点/组的对应关系。 22 | * 支持节点自动识别功能,并允许用户对识别结果进行手动编辑。 23 | * 操作结果(如成功、失败、警告)及相关的详细日志均可在前端界面清晰展示,便于问题定位。 24 | * 配置完成后,前端将生成包含所有必要参数的新订阅链接,并提供复制、浏览器打开预览及下载YAML文件等辅助功能。 25 | * **后端服务无状态化 (Stateless Backend Architecture)**: 26 | * v1.0.0 版本中通过环境变量(如 `REMOTE_URL`, `MANUAL_DIALER_ENABLED`)传递核心配置的方式已被移除。 27 | * 所有转换所需的配置信息(原始订阅地址、节点配对规则等)均由前端收集,通过URL 参数传递给转换后端。 28 | * 此架构变更使得后端服务不持有特定用户的会话状态,从而简化了部署和横向扩展(如果需要)。 29 | * **自动配置逻辑重构 (Refinement of Auto-Configuration Logic)**: 30 | * 对节点名称中区域信息的识别算法进行了优化,提升了其准确性。 31 | * 改进了在代理组中移除作为落地节点的成员的逻辑,防止潜在的代理循环。 32 | * 关于**自动识别**要求的节点命名规范,请参阅项目 Wiki 中的相关文档。 33 | 34 | ## 其他改进 (Additional Enhancements) 35 | 36 | * **Docker 部署简化**: 由于后端配置方式的转变,启动 Docker 容器所需的 `docker run` 命令参数有所减少,不再需要通过环境变量指定转换规则。 37 | * **API 端点更新**: 38 | * 新增 `/api/validate_configuration` (POST),用于前端提交用户配置以供后端验证其有效性。 39 | * 新增 `/api/auto_detect_pairs` (GET),供前端调用以执行自动节点对识别。 40 | * `/subscription.yaml` (GET) 端点现通过 URL 查询参数接收所有必要的转换配置。 41 | * **日志系统**: 后端服务继续提供详细的转换处理日志 。前端界面也针对当前用户的操作展示相关的日志信息。 42 | * **Dockerfile 调整**: 对 Dockerfile 进行了相应更新,以适应新的服务架构。 43 | 44 | ## Docker 部署 (Docker Deployment) 45 | 46 | 新版本的 Docker 部署命令如下: 47 | 48 | ```bash 49 | docker run -d \ 50 | --name chain-subconverter \ 51 | -p 11200:11200 \ 52 | --restart unless-stopped \ 53 | ghcr.io/slackworker/chain-subconverter:latest 54 | ``` 55 | 56 | * 某些终端可能不支持使用 `\` (反斜杠) 作为多行命令的连接符!如果遇到问题,请将命令中的 `\` 删除,并将所有内容合并为一行再执行: 57 | 58 | ```bash 59 | docker run -d --name chain-subconverter -p 11200:11200 --restart unless-stopped ghcr.io/slackworker/chain-subconverter:latest 60 | ``` 61 | 62 | * 注意:v1.0.0 版本中用于配置转换规则的环境变量(如 -e REMOTE_URL=...)已不再需要。 63 | * 服务启动后,请通过浏览器访问 http://<服务器IP_或_域名>:11200 以使用前端配置界面。 64 | 65 | ## 从 v1.0.0 升级 (Upgrading from v1.0.0) 66 | 67 | 由于配置机制的根本性变化,从 v1.0.0 升级到 v2.x 主要涉及替换 Docker 镜像并采用新的配置流程: 68 | 69 | 1. 停止并移除正在运行的 v1.0.0 版本容器。 70 | 2. 使用上一节中提供的 `docker run` 命令部署 v2.x 版本容器。 71 | 3. 通过新引入的前端界面重新配置您的订阅转换规则。 72 | 73 | ## 已知问题与未来展望 (Known Issues & Future Outlook) 74 | 75 | * 对于在公网环境中部署本服务,建议用户自行配置反向代理、HTTPS 加密或必要的身份验证机制以增强安全性。 76 | * 将持续关注用户反馈,以进一步优化用户体验及稳定性。 77 | * 关于 v1.0.0 版本提及的镜像体积问题,可能在新版本中将继续进行评估和优化。 78 | * 在**自动识别**中增加对前置节点相关关键字识别的功能。 79 | 80 | ## 致谢与反馈 (Acknowledgements & Feedback) 81 | 82 | 感谢用户对 `chain-subconverter` 项目的关注与支持。v2.3.6 版本的发布旨在提供一个更为便捷和用户友好的链式代理配置解决方案。 83 | 84 | 若您在升级或使用过程中遇到任何问题,或有任何功能建议,欢迎通过项目的 GitHub Issues 页面提交。 ([Issues](https://github.com/slackworker/chain-subconverter/issues)) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chain-subconverter 2 | 3 | [![GHCR](https://img.shields.io/badge/GHCR-chain--subconverter-blue?logo=github)](https://github.com/slackworker/chain-subconverter/pkgs/container/chain-subconverter) 4 | [![GitHub Stars](https://img.shields.io/github/stars/slackworker/chain-subconverter.svg?style=social&label=Star&maxAge=2592000)](https://github.com/slackworker/chain-subconverter/stargazers/) 5 | 6 | **链式代理 · 订阅转换器** for Mihomo 7 | 8 | 一个为 [Mihomo(Clash Meta) 内核](https://github.com/MetaCubeX/mihomo/tree/Meta) 设计的、用于链式代理 (`dialer-proxy`) 配置的订阅转换工具。它包含一个Python后端服务和直观的前端配置界面。 9 | 10 | --- 11 | 12 | ## 🤔 项目解决了什么问题? 13 | 14 | Mihomo 内核拥有强大的分流功能和完善的规则生态,通过订阅第三方维护的规则模板,我们能以低维护实现高精度分流。但市面上常用的订阅转换服务不支持 `dialer-proxy`(链式代理)字段的配置,并且会将其过滤。 这使得需要使用链式代理并希望保持订阅自动更新的用户,不得不频繁手动编辑 YAML 配置文件,过程繁琐且易出错。 15 | 16 | `chain-subconverter` 为解决这一痛点而生,让链式代理的配置和订阅更新变得简单高效。 17 | 18 | ## ✨ 项目特性 19 | 20 | * **🐳 一键 Docker 部署**:提供 Docker 镜像,快速启动后端服务及内建前端。 21 | * **🖥️ 易用 Web 前端**:通过图形化界面轻松配置链式代理规则。 22 | * **🤖 智能节点识别**:支持自动识别满足[《命名规范》](https://github.com/slackworker/chain-subconverter/wiki/Node-Naming-Convention)的落地节点和前置节点/组。 23 | * **🔄 动态应用配置**:生成的订阅链接在每次被客户端请求时,都会将链式配置动态应用于最新的原始订阅内容。 24 | 25 | ## ✨ 尝鲜预览 26 | 27 | 您可以通过以下链接在线预览和体验本项目: 28 | [➡️ 点击这里进行在线预览](https://fantastic-loise-slackers-134ea8cc.koyeb.app/) 29 | 30 | **⚠️ 重要提醒:** 31 | * 以上仅供项目预览 / 调试,服务器具有超时休眠➡冷启动机制,无法用于生产环境。 32 | * 本项目完全开源,设计上不记录任何用户敏感信息。 33 | * **信别人不如信自己**,永远建议使用自行部署的订阅转换服务。[《部署指南》](https://github.com/slackworker/chain-subconverter/wiki/Deployment-Guide) 34 | 35 | 36 | ## 🚀 快速开始 37 | 38 | ### 1. 部署 Docker 后端服务 39 | 40 | 在您的服务器或支持 Docker 的设备上执行以下命令: 41 | 42 | ```bash 43 | docker run -d \ 44 | --name chain-subconverter \ 45 | -p 11200:11200 \ 46 | --restart unless-stopped \ 47 | ghcr.io/slackworker/chain-subconverter:latest 48 | ``` 49 | * **重要提示**:某些终端可能不支持使用 `\` (反斜杠) 作为多行命令的连接符!如果遇到问题,请将命令中的 `\` 删除,并将所有内容合并为一行再执行: 50 | 51 | ```bash 52 | docker run -d --name chain-subconverter -p 11200:11200 --restart unless-stopped ghcr.io/slackworker/chain-subconverter:latest 53 | ``` 54 | 55 | 上述命令将在后台启动服务,并将您服务器的 11200 端口映射到容器。您可以修改 -p 参数的第一个 11200 来更改宿主机端口。默认情况下,日志级别为 INFO,SSL 验证为 true,自定义服务根地址为 false。 56 | 57 | ➡️ **详细部署步骤、参数说明及更新指南,请参阅:[GitHub Wiki - 部署指南](https://github.com/slackworker/chain-subconverter/wiki/Deployment-Guide)** 58 | 59 | ### 2. 使用前端配置订阅 60 | 61 | 1. **访问前端**:部署成功后,在浏览器中打开 `http://<运行Docker设备的IP或域名>:<映射的宿主机端口>/`。 62 | 2. **原订阅链接**:粘贴您的有效 Mihomo/Clash Meta 订阅链接。 63 | 3. **链式配置**:通过“自动识别”功能或“手动添加”,指定落地节点及其对应的前置节点/组。 64 | 4. **生成**:点击“生成”按钮,验证并生成增加链式代理配置的新订阅链接,并应用于您的客户端。 65 | 66 | ➡️ **完整使用教程、界面说明及节点命名建议,请参阅:[GitHub Wiki - 快速上手与使用教程](https://github.com/slackworker/chain-subconverter/wiki)** 67 | 68 | ## 🔗 相关链接 69 | 70 | * **[📖 项目 Wiki 文档](https://github.com/slackworker/chain-subconverter/wiki)** 71 | * **[📜 版本发布历史](https://github.com/slackworker/chain-subconverter/releases)** 72 | * **[🐛 问题反馈 / ✨ 功能建议](https://github.com/slackworker/chain-subconverter/issues)** 73 | * **[🐱Mihomo `dialer-proxy` 特性 官方文档](https://wiki.metacubex.one/config/proxies/dialer-proxy/)** 74 | 75 | ## 🚧 未来计划 (Todo List) 76 | 77 | * 补充优化自动识别节点规则。 78 | * (可能) 使用 JavaScript 重构整个项目以实现更灵活的部署方式。 79 | * 探索支持更多内核的链式配置功能。 80 | * UI/UX 持续改进。 81 | * 在**自动识别**中增加对前置节点相关关键字识别的功能。 82 | * 持续评估并优化镜像体积与资源占用,力求进一步降低(目前约 150MB 磁盘 / 25MB 内存)。 83 | 84 | 85 | 86 | ## 🤝 贡献 87 | 88 | 欢迎各种形式的贡献,包括但不限于: 89 | 90 | * 提交 Bug 报告和功能建议。 91 | * 完善文档。 92 | * 提交代码 (Pull Requests)。 93 | 94 | 95 | 96 | ## 📜 许可证 97 | 98 | 本项目基于 **MIT 许可证** 发布。 99 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker Image & Deploy to Render 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" # 当代码推送到 main 分支时触发 7 | tags: 8 | - 'v*.*.*' # 当推送形如 v1.0.0, v1.2.3 等版本标签时触发 9 | workflow_dispatch: # 允许手动触发 10 | 11 | jobs: 12 | build-and-push-main-latest: # 处理 main 分支的推送,打上 latest 标签 13 | # 仅当推送到 main 分支时运行此 job 14 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 15 | name: Build and Push Main Latest Image 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write # 写入 GitHub Packages (ghcr.io) 的权限 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Log in to GitHub Container Registry 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ghcr.io 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Set up QEMU 33 | uses: docker/setup-qemu-action@v3 34 | with: 35 | platforms: all 36 | 37 | - name: Set up Docker Buildx 38 | uses: docker/setup-buildx-action@v3 39 | 40 | - name: Extract metadata (tags, labels) for Main Latest Docker Image 41 | id: meta_main_latest 42 | uses: docker/metadata-action@v5 43 | with: 44 | images: ghcr.io/${{ github.repository_owner }}/chain-subconverter 45 | tags: | 46 | type=raw,value=latest # main 分支的推送打上 'latest' 标签 47 | # type=sha,prefix=main-,format=short # 也为每个 main 构建打上 'main-' 48 | 49 | - name: Build and push Main Latest Docker image 50 | uses: docker/build-push-action@v5 51 | with: 52 | context: . 53 | push: true 54 | tags: ${{ steps.meta_main_latest.outputs.tags }} 55 | labels: ${{ steps.meta_main_latest.outputs.labels }} 56 | platforms: linux/amd64,linux/arm64 57 | cache-from: type=gha 58 | cache-to: type=gha,mode=max 59 | 60 | build-versioned-and-dev-latest-for-render: # 处理版本标签的推送 61 | # 仅当推送版本标签 (v*.*.*) 时运行此 job 62 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 63 | name: Build Versioned Image (and dev-latest) & Deploy to Render 64 | runs-on: ubuntu-latest 65 | permissions: 66 | contents: read 67 | packages: write # 写入 GitHub Packages (ghcr.io) 的权限 68 | 69 | steps: 70 | - name: Checkout repository 71 | uses: actions/checkout@v4 72 | 73 | - name: Log in to GitHub Container Registry 74 | uses: docker/login-action@v3 75 | with: 76 | registry: ghcr.io 77 | username: ${{ github.actor }} 78 | password: ${{ secrets.GITHUB_TOKEN }} 79 | 80 | - name: Set up QEMU 81 | uses: docker/setup-qemu-action@v3 82 | with: 83 | platforms: all 84 | 85 | - name: Set up Docker Buildx 86 | uses: docker/setup-buildx-action@v3 87 | 88 | - name: Extract metadata (tags, labels) for Versioned & Dev-Latest Docker Image 89 | id: meta_versioned_dev_latest 90 | uses: docker/metadata-action@v5 91 | with: 92 | images: ghcr.io/${{ github.repository_owner }}/chain-subconverter 93 | tags: | 94 | type=semver,pattern={{version}} # 例如 Git 标签 v1.0.0 -> Docker 标签 1.0.0 95 | type=raw,value=dev-latest # 将这个版本标签也同时标记为 'dev-latest',用于Render部署 96 | flavor: | # 添加或修改 flavor 配置 97 | latest=false # 明确禁用自动添加 latest 标签 98 | 99 | - name: Build and push Versioned & Dev-Latest Docker image 100 | id: build_image_for_render 101 | uses: docker/build-push-action@v5 102 | with: 103 | context: . 104 | push: true 105 | tags: ${{ steps.meta_versioned_dev_latest.outputs.tags }} 106 | labels: ${{ steps.meta_versioned_dev_latest.outputs.labels }} 107 | platforms: linux/amd64,linux/arm64 108 | cache-from: type=gha 109 | cache-to: type=gha,mode=max 110 | 111 | - name: Trigger Render Deploy 112 | if: success() && steps.build_image_for_render.outputs.digest # 仅在镜像构建并推送成功时运行 113 | run: curl -X POST ${{ secrets.RENDER_DEPLOY_HOOK_URL }} 114 | # 确保 Render 服务配置为拉取 ghcr.io/${{ github.repository_owner }}/chain-subconverter:dev-latest -------------------------------------------------------------------------------- /frontend.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 链式代理 · 订阅转换器 for Mihomo 12 | 13 | 444 | 445 | 446 |

447 | 链式代理 · 订阅转换器 448 | for Mihomo 449 |

450 | 451 |
452 |
453 | 454 | 455 | 建议使用订阅转换服务合并生成;如使用OpenClash,可在更新订阅后的插件日志中找到合并后的URL。 456 |
457 | 458 |
459 | 460 |
461 |
462 |
463 | 469 |
470 |
471 | 472 | 488 | 489 | 490 |
491 | 492 |
493 | 494 |
495 | 496 | 497 | 498 |
499 | 500 | 504 | 505 | 507 |
508 | 509 | 512 | 513 | 514 | 515 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | // script.js (V3 - 与新API和HTML结构对齐) 2 | 3 | const MAX_MANUAL_PAIRS = 10; 4 | let actionButtons; 5 | let feedbackHistory = []; 6 | const MAX_LOG_ENTRIES = 100; 7 | let logContainer; 8 | let toggleLogButton; 9 | 10 | function getServiceUrl() { 11 | const serviceUrlInput = document.getElementById('serviceUrl'); 12 | const customizeServiceUrlCheckbox = document.getElementById('customizeServiceUrlSwitchInput'); 13 | let finalServiceUrl = serviceUrlInput.value.trim().replace(/\/$/, ''); 14 | 15 | if (customizeServiceUrlCheckbox && customizeServiceUrlCheckbox.checked && !finalServiceUrl) { 16 | showFeedback('错误:请输入自定义的服务根地址。', 'error', 5000); 17 | if(serviceUrlInput) serviceUrlInput.focus(); 18 | return null; 19 | } 20 | if(customizeServiceUrlCheckbox && !customizeServiceUrlCheckbox.checked){ 21 | try { 22 | const currentOrigin = window.location.origin; 23 | if (window.location.protocol.startsWith('http') && currentOrigin && 24 | !currentOrigin.includes('localhost') && !currentOrigin.includes('127.0.0.1')) { 25 | finalServiceUrl = currentOrigin; 26 | } else { finalServiceUrl = 'http://localhost:11200'; } 27 | } catch(e){ finalServiceUrl = 'http://localhost:11200'; } 28 | if(serviceUrlInput) serviceUrlInput.value = finalServiceUrl; 29 | } 30 | return finalServiceUrl; 31 | } 32 | 33 | document.addEventListener('DOMContentLoaded', function() { 34 | const serviceUrlInput = document.getElementById('serviceUrl'); 35 | const customizeServiceUrlCheckbox = document.getElementById('customizeServiceUrlSwitchInput'); 36 | const generateLinkButton = document.getElementById('generateLinkButton'); 37 | const copyUrlButton = document.getElementById('copyUrlButton'); 38 | const openUrlButton = document.getElementById('openUrlButton'); 39 | const downloadConfigButton = document.getElementById('downloadConfigButton'); 40 | const autoDetectButton = document.getElementById('autoDetectButton'); 41 | 42 | const feedbackAreaContainer = document.getElementById('feedbackAreaContainer'); 43 | logContainer = document.getElementById('logContainer'); 44 | toggleLogButton = document.getElementById('toggleLogButton'); 45 | 46 | const serviceAddressGroup = document.getElementById('serviceAddressGroup'); 47 | 48 | try { 49 | const currentOrigin = window.location.origin; 50 | if (window.location.protocol.startsWith('http') && currentOrigin && 51 | !currentOrigin.includes('localhost') && !currentOrigin.includes('127.0.0.1')) { 52 | serviceUrlInput.value = currentOrigin; 53 | } else { 54 | serviceUrlInput.value = 'http://localhost:11200'; 55 | } 56 | } catch (e) { 57 | console.warn("无法自动填充服务URL:", e); 58 | serviceUrlInput.value = 'http://localhost:11200'; 59 | } 60 | if (serviceUrlInput) serviceUrlInput.disabled = true; 61 | if (customizeServiceUrlCheckbox) customizeServiceUrlCheckbox.checked = false; 62 | 63 | actionButtons = [copyUrlButton, openUrlButton, downloadConfigButton]; 64 | actionButtons.forEach(btn => { if(btn) btn.disabled = true; }); 65 | if(document.getElementById('generatedUrl')) document.getElementById('generatedUrl').value = ''; 66 | 67 | if (customizeServiceUrlCheckbox) { 68 | customizeServiceUrlCheckbox.addEventListener('change', toggleServiceUrlInput); 69 | } 70 | 71 | // Check for the injected config and show the service address group if true 72 | if (window.SHOW_SERVICE_ADDRESS_CONFIG === true && serviceAddressGroup) { 73 | serviceAddressGroup.classList.remove('hidden'); 74 | console.log("Service address configuration section is enabled by environment variable."); 75 | } 76 | 77 | if (generateLinkButton) { 78 | generateLinkButton.addEventListener('click', validateConfigurationAndGenerateUrl); 79 | } 80 | if (autoDetectButton) { 81 | autoDetectButton.addEventListener('click', handleAutoDetectPairs); 82 | } 83 | if (copyUrlButton) copyUrlButton.addEventListener('click', copyUrl); 84 | if (openUrlButton) openUrlButton.addEventListener('click', precheckAndOpenUrl); 85 | if (downloadConfigButton) downloadConfigButton.addEventListener('click', downloadConfig); 86 | 87 | if (feedbackAreaContainer && logContainer && toggleLogButton) { 88 | const performLogToggle = function() { 89 | const isCurrentlyHidden = logContainer.classList.contains('hidden'); 90 | logContainer.classList.toggle('hidden', !isCurrentlyHidden); 91 | toggleLogButton.textContent = isCurrentlyHidden ? '˅' : '>'; 92 | toggleLogButton.title = isCurrentlyHidden ? '隐藏详细日志' : '显示详细日志'; 93 | if (isCurrentlyHidden && logContainer.children.length > 0) { 94 | logContainer.scrollTop = logContainer.scrollHeight; 95 | } 96 | }; 97 | feedbackAreaContainer.addEventListener('click', performLogToggle); 98 | feedbackAreaContainer.style.cursor = 'pointer'; 99 | feedbackAreaContainer.title = '点击显示/隐藏详细日志'; 100 | } 101 | renderManualPairRows(); 102 | updateManualPairControlsState(); 103 | }); 104 | 105 | function createManualPairRowElement(index, landingValue = '', frontValue = '') { 106 | const newRow = document.createElement('div'); 107 | newRow.className = 'manual-pair-dynamic-row'; 108 | const landingInput = document.createElement('input'); 109 | landingInput.type = 'text'; 110 | landingInput.className = 'landing-proxy-input'; 111 | landingInput.placeholder = '落地节点名称'; 112 | landingInput.value = landingValue; 113 | const frontInput = document.createElement('input'); 114 | frontInput.type = 'text'; 115 | frontInput.className = 'front-proxy-input'; 116 | frontInput.placeholder = '前置节点/组名称'; 117 | frontInput.value = frontValue; 118 | const addIconSvg = ''; 119 | const removeIconSvg = ''; 120 | newRow.innerHTML = ` 121 | ${index + 1}. 122 |
123 |
124 | dialer-proxy 125 | : 126 |
127 |
128 |
129 | 130 | 131 |
`; 132 | 133 | newRow.querySelectorAll('.input-cell')[0].appendChild(landingInput); 134 | newRow.querySelectorAll('.input-cell')[1].appendChild(frontInput); 135 | newRow.querySelector('.action-button-inline.add').addEventListener('click', function() { addManualPairRow(newRow); }); 136 | newRow.querySelector('.action-button-inline.remove').addEventListener('click', function() { removeManualPairRow(newRow); }); 137 | return newRow; 138 | } 139 | 140 | function renderManualPairRows(initialPairsData = null) { 141 | const container = document.getElementById('manualPairsInputsContainer'); 142 | if (!container) return; 143 | container.innerHTML = ''; 144 | let rowsData = initialPairsData; 145 | if (!rowsData) { 146 | rowsData = getManualPairDataFromDOM(); 147 | if (rowsData.length === 0) rowsData = [{ landing: '', front: '' }]; 148 | } else if (rowsData.length === 0) { 149 | rowsData = [{ landing: '', front: '' }]; 150 | } 151 | rowsData.forEach((data, index) => { 152 | container.appendChild(createManualPairRowElement(index, data.landing || '', data.front || '')); 153 | }); 154 | updateManualPairControlsState(); 155 | } 156 | 157 | function getManualPairDataFromDOM() { 158 | const rows = document.querySelectorAll('#manualPairsInputsContainer .manual-pair-dynamic-row'); 159 | const data = []; 160 | rows.forEach(row => { 161 | const landingInput = row.querySelector('.landing-proxy-input'); 162 | const frontInput = row.querySelector('.front-proxy-input'); 163 | data.push({ 164 | landing: landingInput ? landingInput.value.trim() : '', 165 | front: frontInput ? frontInput.value.trim() : '' 166 | }); 167 | }); 168 | return data; 169 | } 170 | 171 | function addManualPairRow(callingRowElement) { 172 | const container = document.getElementById('manualPairsInputsContainer'); 173 | if (!container) return; 174 | const currentRows = container.querySelectorAll('.manual-pair-dynamic-row'); 175 | if (currentRows.length >= MAX_MANUAL_PAIRS) { 176 | showFeedback(`最多只能添加 ${MAX_MANUAL_PAIRS} 对手动节点。`, 'info', 3000); 177 | return; 178 | } 179 | const newRow = createManualPairRowElement(currentRows.length); 180 | if (callingRowElement && callingRowElement.parentNode === container) { 181 | callingRowElement.after(newRow); 182 | } else { 183 | container.appendChild(newRow); 184 | } 185 | renumberRowsInDOM(); 186 | updateManualPairControlsState(); 187 | } 188 | 189 | function removeManualPairRow(rowElementToRemove) { 190 | const container = document.getElementById('manualPairsInputsContainer'); 191 | if (!container || !rowElementToRemove) return; 192 | const currentRows = container.querySelectorAll('.manual-pair-dynamic-row'); 193 | if (currentRows.length <= 1) { 194 | showFeedback('至少需要保留一行配置。请直接清空内容。', 'info', 3000); 195 | const landingInput = rowElementToRemove.querySelector('.landing-proxy-input'); 196 | const frontInput = rowElementToRemove.querySelector('.front-proxy-input'); 197 | if(landingInput) landingInput.value = ''; 198 | if(frontInput) frontInput.value = ''; 199 | return; 200 | } 201 | rowElementToRemove.remove(); 202 | renumberRowsInDOM(); 203 | updateManualPairControlsState(); 204 | } 205 | 206 | function renumberRowsInDOM() { 207 | const rows = document.querySelectorAll('#manualPairsInputsContainer .manual-pair-dynamic-row'); 208 | rows.forEach((row, index) => { 209 | const rowNumberSpan = row.querySelector('.row-number-cell'); 210 | if (rowNumberSpan) rowNumberSpan.textContent = `${index + 1}.`; 211 | }); 212 | } 213 | 214 | function updateManualPairControlsState() { 215 | const rows = document.querySelectorAll('#manualPairsInputsContainer .manual-pair-dynamic-row'); 216 | rows.forEach((row) => { 217 | const inputs = row.querySelectorAll('input[type="text"]'); 218 | const addButton = row.querySelector('.action-button-inline.add'); 219 | const removeButton = row.querySelector('.action-button-inline.remove'); 220 | inputs.forEach(input => input.disabled = false); 221 | if (addButton) addButton.disabled = (rows.length >= MAX_MANUAL_PAIRS); 222 | if (removeButton) removeButton.disabled = (rows.length <= 1); 223 | }); 224 | const container = document.getElementById('manualPairsInputsContainer'); 225 | if (container && rows.length === 0 && container.children.length === 0) { 226 | renderManualPairRows(); 227 | } 228 | } 229 | 230 | function toggleServiceUrlInput() { 231 | const customizeCheckbox = document.getElementById('customizeServiceUrlSwitchInput'); 232 | const serviceUrlInput = document.getElementById('serviceUrl'); 233 | if (!customizeCheckbox || !serviceUrlInput) return; 234 | serviceUrlInput.disabled = !customizeCheckbox.checked; 235 | if (customizeCheckbox.checked) { 236 | serviceUrlInput.focus(); 237 | } else { 238 | try { 239 | const currentOrigin = window.location.origin; 240 | if (window.location.protocol.startsWith('http') && currentOrigin && 241 | !currentOrigin.includes('localhost') && !currentOrigin.includes('127.0.0.1')) { 242 | serviceUrlInput.value = currentOrigin; 243 | } else { serviceUrlInput.value = 'http://localhost:11200'; } 244 | } catch(e){ serviceUrlInput.value = 'http://localhost:11200'; } 245 | } 246 | } 247 | 248 | function validateInputs() { 249 | const remoteUrlInput = document.getElementById('remoteUrl'); 250 | const remoteUrl = remoteUrlInput.value.trim(); 251 | const urlPattern = /^https?:\/\/[^\s/$.?#].[^\s]*$/; 252 | if (!remoteUrl) { 253 | showFeedback('请输入原始订阅链接。', 'error', 5000); 254 | if (remoteUrlInput) remoteUrlInput.focus(); 255 | return false; 256 | } 257 | if (!urlPattern.test(remoteUrl)) { 258 | showFeedback('请输入有效的 URL(以 http:// 或 https:// 开头)。', 'error', 5000); 259 | if (remoteUrlInput) remoteUrlInput.focus(); 260 | return false; 261 | } 262 | return true; 263 | } 264 | 265 | // --- 日志和反馈 --- 266 | function showFeedback(message, type = 'info', duration = 0) { 267 | const feedbackTextElement = document.getElementById('feedbackMessage'); 268 | const feedbackContainerElement = document.getElementById('feedbackAreaContainer'); 269 | if (!feedbackTextElement || !feedbackContainerElement) return; 270 | 271 | let displayMessage = message; 272 | // Adjusted shortening logic for better error display 273 | if (type === 'error') { 274 | if (message.toLowerCase().includes('http://') || message.toLowerCase().includes('https://')) { 275 | displayMessage = '发生错误。详情请查看日志。'; 276 | } 277 | // For other errors, 'displayMessage' remains the original 'message' from backend 278 | } else if (type !== 'info' && (message.length > 70 || message.toLowerCase().includes('http://') || message.toLowerCase().includes('https://'))) { 279 | if (type === 'success' && !(message.startsWith("链接已复制") || message.startsWith("配置文件下载成功"))) { 280 | displayMessage = '操作成功。详情请查看日志。'; 281 | } else if (type === 'warn') { 282 | displayMessage = '出现警告。详情请查看日志。'; 283 | } 284 | } else if (type === 'info' && message !== '等待操作...' && (message.length > 70 || message.toLowerCase().includes('http://') || message.toLowerCase().includes('https://'))) { 285 | displayMessage = '操作已记录。详情请查看日志。'; 286 | } 287 | 288 | feedbackTextElement.textContent = displayMessage; 289 | const typeToClass = { 290 | 'success': 'feedback-success', 'error': 'feedback-error', 291 | 'info': 'feedback-info', 'warn': 'feedback-warn', 292 | 'debug': 'feedback-info', 'action_start': 'feedback-action-start' // For styling separators 293 | }; 294 | Object.values(typeToClass).forEach(cls => feedbackContainerElement.classList.remove(cls)); 295 | feedbackContainerElement.classList.add(typeToClass[type] || 'feedback-info'); 296 | 297 | if (feedbackContainerElement.timeoutId) clearTimeout(feedbackContainerElement.timeoutId); 298 | const isDefaultMessage = type === 'info' && message === '等待操作...'; 299 | 300 | if (!isDefaultMessage && type !== 'action_start') { // action_start already pushed to history by caller 301 | const timestamp = new Date(); 302 | const formattedTimestamp = `${timestamp.getHours().toString().padStart(2, '0')}:${timestamp.getMinutes().toString().padStart(2, '0')}:${timestamp.getSeconds().toString().padStart(2, '0')}`; 303 | let logEntryType = type.toLowerCase(); 304 | if (!typeToClass[logEntryType]) { 305 | logEntryType = (logEntryType === 'debug' || logEntryType === 'trace') ? 'debug' : 'info'; 306 | } 307 | feedbackHistory.push({ timestamp: formattedTimestamp, type: logEntryType, message: message }); 308 | if (feedbackHistory.length > MAX_LOG_ENTRIES) feedbackHistory.shift(); 309 | renderLogs(); 310 | } 311 | 312 | if (duration > 0 && !isDefaultMessage) { 313 | feedbackContainerElement.timeoutId = setTimeout(() => { 314 | if (feedbackTextElement.textContent === displayMessage && feedbackContainerElement.classList.contains(typeToClass[type])) { 315 | feedbackTextElement.textContent = '等待操作...'; 316 | Object.values(typeToClass).forEach(cls => feedbackContainerElement.classList.remove(cls)); 317 | feedbackContainerElement.classList.add('feedback-info'); 318 | } 319 | }, duration); 320 | } 321 | } 322 | 323 | function renderLogs() { 324 | if (!logContainer) return; 325 | logContainer.innerHTML = ''; 326 | const typeToColor = { 327 | 'error': '#c53030', 'success': '#2f855a', 'warn': '#856404', 328 | 'info': '#0288d1', 'debug': '#4a5568', 'action_start': '#0056b3' // Color for action separator text 329 | }; 330 | 331 | if (feedbackHistory.length === 0) { 332 | const noLogsEntry = document.createElement('p'); 333 | noLogsEntry.textContent = '暂无详细日志。'; 334 | noLogsEntry.style.color = '#718096'; 335 | logContainer.appendChild(noLogsEntry); 336 | return; 337 | } 338 | 339 | feedbackHistory.forEach(logEntry => { 340 | const logElement = document.createElement('div'); 341 | const timestampSpan = document.createElement('span'); 342 | const messageSpan = document.createElement('span'); 343 | messageSpan.classList.add('log-message-content'); 344 | 345 | if (logEntry.type === 'action_start') { 346 | logElement.style.fontWeight = 'bold'; 347 | logElement.style.backgroundColor = '#f0f8ff'; // Light blue background for separator 348 | logElement.style.padding = '5px'; 349 | logElement.style.marginTop = '10px'; 350 | logElement.style.marginBottom = '5px'; 351 | logElement.style.borderTop = '1px dashed #0056b3'; 352 | logElement.style.borderBottom = '1px dashed #0056b3'; 353 | messageSpan.style.color = typeToColor[logEntry.type] || typeToColor['debug']; 354 | messageSpan.textContent = logEntry.message; // Full message for separator 355 | } else { 356 | logElement.style.marginBottom = '5px'; 357 | logElement.style.paddingBottom = '5px'; 358 | logElement.style.borderBottom = '1px dashed #e2e8f0'; 359 | timestampSpan.textContent = `[${logEntry.timestamp}] `; 360 | timestampSpan.style.fontWeight = 'bold'; 361 | const effectiveType = logEntry.type.toLowerCase(); 362 | timestampSpan.style.color = typeToColor[effectiveType] || typeToColor['debug']; 363 | messageSpan.textContent = logEntry.message; 364 | if (effectiveType === 'error' || effectiveType === 'warn') { 365 | messageSpan.style.color = typeToColor[effectiveType]; 366 | } else { 367 | messageSpan.style.color = '#2d3748'; 368 | } 369 | logElement.appendChild(timestampSpan); 370 | } 371 | logElement.appendChild(messageSpan); 372 | logContainer.appendChild(logElement); 373 | }); 374 | if (!logContainer.classList.contains('hidden')) logContainer.scrollTop = logContainer.scrollHeight; 375 | } 376 | 377 | // Helper to add action start log 378 | function addActionStartLog(actionName) { 379 | const timestamp = new Date().toLocaleTimeString([], { hour12: false }); 380 | feedbackHistory.push({ 381 | timestamp: timestamp, // Still provide timestamp for consistency if needed elsewhere 382 | type: 'action_start', 383 | message: `--- 操作开始: ${actionName} (${timestamp}) ---` 384 | }); 385 | if (feedbackHistory.length > MAX_LOG_ENTRIES) feedbackHistory.shift(); 386 | renderLogs(); // Render immediately 387 | } 388 | 389 | async function handleAutoDetectPairs() { 390 | if (!validateInputs()) return; 391 | addActionStartLog("自动识别节点对"); // Add action separator 392 | 393 | const remoteUrlInput = document.getElementById('remoteUrl'); 394 | const remoteUrl = remoteUrlInput.value.trim(); 395 | const serviceUrl = getServiceUrl(); 396 | if (!serviceUrl) return; 397 | 398 | showFeedback('正在自动识别节点对...', 'info', 0); 399 | actionButtons.forEach(btn => { if(btn) btn.disabled = true; }); 400 | if(document.getElementById('generateLinkButton')) document.getElementById('generateLinkButton').disabled = true; 401 | if(document.getElementById('autoDetectButton')) document.getElementById('autoDetectButton').disabled = true; 402 | 403 | try { 404 | const apiEndpoint = `${serviceUrl}/api/auto_detect_pairs?remote_url=${encodeURIComponent(remoteUrl)}`; 405 | const response = await fetch(apiEndpoint); 406 | const responseData = await response.json(); 407 | 408 | if (responseData.logs && Array.isArray(responseData.logs)) { 409 | responseData.logs.forEach(log => { 410 | const level = log.level ? log.level.toLowerCase() : 'debug'; 411 | const message = log.message || ""; 412 | if (level === 'debug') return; 413 | const fTimestamp = log.timestamp ? new Date(log.timestamp).toLocaleTimeString([], { hour12: false }) : new Date().toLocaleTimeString([], { hour12: false }); 414 | feedbackHistory.push({ timestamp: fTimestamp, type: level, message: message }); 415 | }); 416 | if (feedbackHistory.length > MAX_LOG_ENTRIES) { 417 | feedbackHistory.splice(0, feedbackHistory.length - MAX_LOG_ENTRIES); 418 | } 419 | } 420 | showFeedback(responseData.message || '自动识别完成。', responseData.success ? 'success' : 'error', 5000); 421 | if (responseData.success && responseData.suggested_pairs) { 422 | populatePairRows(responseData.suggested_pairs); 423 | } else if (!responseData.success && (!responseData.suggested_pairs || responseData.suggested_pairs.length === 0)) { 424 | populatePairRows([]); 425 | } 426 | } catch (error) { 427 | showFeedback(`自动识别请求失败: ${error.message}`, 'error', 7000); 428 | console.error('自动识别请求失败:', error); 429 | } finally { 430 | if(document.getElementById('generateLinkButton')) document.getElementById('generateLinkButton').disabled = false; 431 | if(document.getElementById('autoDetectButton')) document.getElementById('autoDetectButton').disabled = false; 432 | renderLogs(); 433 | } 434 | } 435 | 436 | function populatePairRows(pairsData) { 437 | const container = document.getElementById('manualPairsInputsContainer'); 438 | if (!container) return; 439 | container.innerHTML = ''; 440 | if (!pairsData || pairsData.length === 0) { 441 | renderManualPairRows(); 442 | return; 443 | } 444 | pairsData.forEach((pair, index) => { 445 | if (container.children.length < MAX_MANUAL_PAIRS) { 446 | container.appendChild(createManualPairRowElement(index, pair.landing, pair.front)); 447 | } else if (index === MAX_MANUAL_PAIRS) { 448 | showFeedback(`自动识别到超过 ${MAX_MANUAL_PAIRS} 对节点,仅显示前 ${MAX_MANUAL_PAIRS} 对。`, 'warn', 5000); 449 | } 450 | }); 451 | if(container.children.length === 0) renderManualPairRows(); 452 | renumberRowsInDOM(); 453 | updateManualPairControlsState(); 454 | } 455 | 456 | function convertPairsToQueryString(pairsList) { 457 | if (!pairsList || pairsList.length === 0) return ""; 458 | return pairsList 459 | .filter(p => p.landing && p.landing.trim() && p.front && p.front.trim()) 460 | .map(p => `${p.landing.trim()}:${p.front.trim()}`) 461 | .join(','); 462 | } 463 | 464 | async function validateConfigurationAndGenerateUrl() { 465 | if (!validateInputs()) return; 466 | addActionStartLog("验证配置并生成链接"); // Add action separator 467 | 468 | showFeedback('正在验证配置并生成链接...', 'info', 0); 469 | const generateBtn = document.getElementById('generateLinkButton'); 470 | const autoDetectBtn = document.getElementById('autoDetectButton'); 471 | if(generateBtn) generateBtn.disabled = true; 472 | if(autoDetectBtn) autoDetectBtn.disabled = true; 473 | 474 | const generatedUrlInput = document.getElementById('generatedUrl'); 475 | actionButtons.forEach(btn => { if(btn) btn.disabled = true; }); 476 | if(generatedUrlInput) generatedUrlInput.value = ''; 477 | 478 | const remoteUrlInput = document.getElementById('remoteUrl'); 479 | const remoteUrl = remoteUrlInput.value.trim(); 480 | const serviceUrl = getServiceUrl(); 481 | if (!serviceUrl) { 482 | if(generateBtn) generateBtn.disabled = false; 483 | if(autoDetectBtn) autoDetectBtn.disabled = false; 484 | return; 485 | } 486 | 487 | const nodePairsFromDOM = getManualPairDataFromDOM(); 488 | const validNodePairsForCheck = nodePairsFromDOM.filter(p => p.landing.trim() || p.front.trim()); 489 | let hasIncompletePair = false; 490 | if (validNodePairsForCheck.length > 0) { 491 | hasIncompletePair = validNodePairsForCheck.some(p => (p.landing.trim() && !p.front.trim()) || (!p.landing.trim() && p.front.trim())); 492 | } 493 | if (hasIncompletePair) { 494 | showFeedback('节点对配置中存在未完整填写的行,请检查。', 'error', 5000); 495 | if(generateBtn) generateBtn.disabled = false; 496 | if(autoDetectBtn) autoDetectBtn.disabled = false; 497 | return; 498 | } 499 | const nodePairsToSend = nodePairsFromDOM.filter(p => p.landing.trim() && p.front.trim()); 500 | if (nodePairsToSend.length === 0) { 501 | showFeedback('错误:请至少配置并提交一对完整的节点对。', 'error', 6000); 502 | if(generateBtn) generateBtn.disabled = false; 503 | if(autoDetectBtn) autoDetectBtn.disabled = false; 504 | // --- MODIFICATION START --- 505 | const firstLandingInput = document.querySelector('#manualPairsInputsContainer .manual-pair-dynamic-row .landing-proxy-input'); 506 | if (firstLandingInput) { 507 | firstLandingInput.focus(); 508 | } 509 | // --- MODIFICATION END --- 510 | return; 511 | } 512 | 513 | try { 514 | const apiEndpoint = `${serviceUrl}/api/validate_configuration`; 515 | const response = await fetch(apiEndpoint, { 516 | method: 'POST', 517 | headers: { 'Content-Type': 'application/json' }, 518 | body: JSON.stringify({ remote_url: remoteUrl, node_pairs: nodePairsToSend }) 519 | }); 520 | const responseData = await response.json(); 521 | 522 | if (responseData.logs && Array.isArray(responseData.logs)) { 523 | responseData.logs.forEach(log => { 524 | const level = log.level ? log.level.toLowerCase() : 'debug'; 525 | const message = log.message || ""; 526 | if (level === 'debug') return; 527 | const fTimestamp = log.timestamp ? new Date(log.timestamp).toLocaleTimeString([], { hour12: false }) : new Date().toLocaleTimeString([], { hour12: false }); 528 | feedbackHistory.push({ timestamp: fTimestamp, type: level, message: message }); 529 | }); 530 | if (feedbackHistory.length > MAX_LOG_ENTRIES) { 531 | feedbackHistory.splice(0, feedbackHistory.length - MAX_LOG_ENTRIES); 532 | } 533 | } 534 | showFeedback(responseData.message || (responseData.success ? '验证成功。' : '验证失败,请查看日志。'), responseData.success ? 'success' : 'error', 7000); 535 | 536 | if (responseData.success) { 537 | let subscriptionUrl = `${serviceUrl}/subscription.yaml?remote_url=${encodeURIComponent(remoteUrl)}`; 538 | if (nodePairsToSend.length > 0) { 539 | const pairsQueryString = convertPairsToQueryString(nodePairsToSend); 540 | if (pairsQueryString) subscriptionUrl += `&manual_pairs=${encodeURIComponent(pairsQueryString)}`; 541 | } 542 | if(generatedUrlInput) generatedUrlInput.value = subscriptionUrl; 543 | actionButtons.forEach(btn => { if(btn) btn.disabled = false; }); 544 | } else { 545 | actionButtons.forEach(btn => { if(btn) btn.disabled = true; }); 546 | if(generatedUrlInput) generatedUrlInput.value = ''; 547 | } 548 | } catch (error) { 549 | actionButtons.forEach(btn => { if(btn) btn.disabled = true; }); 550 | if(generatedUrlInput) generatedUrlInput.value = ''; 551 | showFeedback(`验证配置请求失败: ${error.message}`, 'error', 7000); 552 | console.error('验证配置请求失败:', error); 553 | } finally { 554 | if(generateBtn) generateBtn.disabled = false; 555 | if(autoDetectBtn) autoDetectBtn.disabled = false; 556 | renderLogs(); 557 | } 558 | } 559 | 560 | function copyUrl() { 561 | const generatedUrlInput = document.getElementById('generatedUrl'); 562 | if (!generatedUrlInput || !generatedUrlInput.value) { 563 | showFeedback('没有可复制的链接。', 'info', 3000); return; 564 | } 565 | const textToCopy = generatedUrlInput.value; 566 | if (navigator.clipboard && navigator.clipboard.writeText) { 567 | navigator.clipboard.writeText(textToCopy).then(() => { 568 | showFeedback('链接已复制到剪贴板!', 'success', 3000); 569 | }).catch(err => { 570 | console.warn('navigator.clipboard.writeText failed, trying legacy:', err); 571 | attemptLegacyCopy(textToCopy); 572 | }); 573 | } else { 574 | attemptLegacyCopy(textToCopy); 575 | } 576 | } 577 | 578 | function attemptLegacyCopy(textToCopy) { 579 | const textArea = document.createElement("textarea"); 580 | textArea.value = textToCopy; 581 | textArea.style.position = "fixed"; textArea.style.top = "-9999px"; textArea.style.left = "-9999px"; 582 | document.body.appendChild(textArea); 583 | textArea.focus(); textArea.select(); 584 | try { 585 | if (document.execCommand('copy')) { 586 | showFeedback('链接已复制到剪贴板! (备用模式)', 'success', 3000); 587 | } else { 588 | showFeedback('复制失败。请手动复制。', 'error', 5000); 589 | } 590 | } catch (err) { 591 | showFeedback('复制出错,请手动复制。', 'error', 5000); 592 | } 593 | document.body.removeChild(textArea); 594 | } 595 | 596 | async function precheckAndOpenUrl() { 597 | const generatedUrlInput = document.getElementById('generatedUrl'); 598 | if (!generatedUrlInput || !generatedUrlInput.value) { 599 | showFeedback('没有可打开的链接。', 'info', 3000); return; 600 | } 601 | window.open(generatedUrlInput.value, '_blank'); 602 | showFeedback('正在尝试在新标签页打开链接...', 'info', 3000); 603 | } 604 | 605 | async function downloadConfig() { 606 | const generatedUrlInput = document.getElementById('generatedUrl'); 607 | if (!generatedUrlInput || !generatedUrlInput.value) { 608 | showFeedback('没有可下载的链接。', 'error', 3000); return; 609 | } 610 | const urlToFetch = generatedUrlInput.value; 611 | addActionStartLog("下载配置文件"); // Add action separator 612 | showFeedback('正在准备下载配置文件...', 'info', 0); 613 | try { 614 | const response = await fetch(urlToFetch); 615 | if (!response.ok) { 616 | let errorText = `HTTP ${response.status} ${response.statusText}`; 617 | try { 618 | const serverErrorText = await response.text(); 619 | errorText = serverErrorText.substring(0, 200); 620 | } catch (e) { /* Ignore */ } 621 | showFeedback(`下载失败: ${errorText}`, 'error', 7000); 622 | console.error(`下载失败: ${response.status} ${response.statusText}`, errorText); 623 | return; 624 | } 625 | const blob = await response.blob(); 626 | const disposition = response.headers.get('content-disposition'); 627 | let fileName = `chain_subscription_${new Date().toISOString().slice(0,10)}.yaml`; 628 | if (disposition && disposition.includes('filename=')) { 629 | const filenameMatch = disposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)['"]?/i); 630 | if (filenameMatch && filenameMatch[1]) { 631 | try { fileName = decodeURIComponent(filenameMatch[1]); } catch (e) { /* Use default */ } 632 | } 633 | } else { 634 | try { 635 | const pathName = new URL(urlToFetch).pathname; 636 | const lastSegment = pathName.substring(pathName.lastIndexOf('/') + 1); 637 | if (lastSegment && (lastSegment.endsWith('.yaml') || lastSegment.endsWith('.yml'))) { 638 | fileName = lastSegment; 639 | } 640 | } catch (e) { /* Ignore */ } 641 | } 642 | const link = document.createElement('a'); 643 | link.href = URL.createObjectURL(blob); 644 | link.download = fileName; 645 | document.body.appendChild(link); 646 | link.click(); 647 | document.body.removeChild(link); 648 | URL.revokeObjectURL(link.href); 649 | showFeedback('配置文件下载成功!', 'success', 5000); 650 | } catch (error) { 651 | console.error('下载配置文件时出错:', error); 652 | showFeedback(`下载配置文件出错: ${error.message}`, 'error', 7000); 653 | } finally { 654 | renderLogs(); // Ensure all logs, including potential final errors, are rendered 655 | } 656 | } -------------------------------------------------------------------------------- /chain-subconverter.py: -------------------------------------------------------------------------------- 1 | import http.server 2 | import requests 3 | import logging 4 | import logging.handlers 5 | import os 6 | import re 7 | from ruamel.yaml import YAML 8 | from ruamel.yaml.compat import StringIO 9 | from http.server import ThreadingHTTPServer # 使用 ThreadingHTTPServer 处理并发请求 10 | from urllib.parse import urlparse, parse_qs, unquote, urlencode # 增加了 urlencode 11 | import mimetypes 12 | import datetime 13 | from datetime import timezone # Add this near your other datetime import 14 | import json 15 | 16 | # --- 配置日志开始 --- 17 | LOG_FILE = "logs/server.log" 18 | LOG_DIR = os.path.dirname(LOG_FILE) 19 | if not os.path.exists(LOG_DIR): 20 | os.makedirs(LOG_DIR) 21 | 22 | logger = logging.getLogger(__name__) 23 | LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() 24 | logger.setLevel(LOG_LEVEL) 25 | 26 | file_handler = logging.handlers.RotatingFileHandler( 27 | LOG_FILE, maxBytes=1024*1024, backupCount=2, encoding='utf-8' 28 | ) 29 | file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) 30 | logger.addHandler(file_handler) 31 | 32 | console_handler = logging.StreamHandler() 33 | console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) 34 | logger.addHandler(console_handler) 35 | # --- 配置日志结束 --- 36 | 37 | # --- 全局配置 --- 38 | PORT = int(os.getenv("PORT", 11200)) 39 | # 新增:读取SSL验证配置的环境变量 40 | REQUESTS_SSL_VERIFY_CONFIG = os.getenv("REQUESTS_SSL_VERIFY", "true").lower() 41 | # 新增:读取是否显示服务地址配置区块的环境变量 42 | env_value = os.getenv("SHOW_SERVICE_ADDRESS_CONFIG", "false").lower() 43 | SHOW_SERVICE_ADDRESS_CONFIG_ENV = env_value == "true" or env_value == "1" 44 | 45 | 46 | REGION_KEYWORD_CONFIG = [ 47 | {"id": "HK", "name": "Hong Kong", "keywords": ["HK", "HongKong", "Hong Kong", "香港", "🇭🇰"]}, 48 | {"id": "US", "name": "United States", "keywords": ["US", "USA", "UnitedStates", "United States", "美国", "🇺🇸"]}, 49 | {"id": "JP", "name": "Japan", "keywords": ["JP", "Japan", "日本", "🇯🇵"]}, 50 | {"id": "SG", "name": "Singapore", "keywords": ["SG", "Singapore", "新加坡", "🇸🇬"]}, 51 | {"id": "TW", "name": "Taiwan", "keywords": ["TW", "Taiwan", "台湾", "🇼🇸"]}, 52 | {"id": "KR", "name": "Korea", "keywords": ["KR", "Korea", "韩国", "🇰🇷"]}, 53 | ] 54 | LANDING_NODE_KEYWORDS = ["Landing", "落地"] 55 | 56 | yaml = YAML() 57 | yaml.preserve_quotes = True 58 | yaml.indent(mapping=2, sequence=4, offset=2) 59 | yaml.width = float('inf') 60 | yaml.explicit_start = True 61 | # --- 全局配置结束 --- 62 | 63 | # --- 日志辅助函数 --- 64 | def _add_log_entry(logs_list, level, message, an_exception=None): 65 | timestamp = datetime.datetime.now(timezone.utc).isoformat() 66 | log_entry = {"timestamp": timestamp, "level": level.upper(), "message": str(message)} 67 | logs_list.append(log_entry) 68 | 69 | if level.upper() == "ERROR": 70 | logger.error(message, exc_info=an_exception if an_exception else False) 71 | elif level.upper() == "WARN": 72 | logger.warning(message) 73 | elif level.upper() == "DEBUG": 74 | logger.debug(message) 75 | else: 76 | logger.info(message) 77 | 78 | # --- 核心逻辑函数 --- 79 | def apply_node_pairs_to_config(config_object, node_pairs_list): 80 | logs = [] # Logs specific to this function's execution 81 | _add_log_entry(logs, "info", f"开始应用 {len(node_pairs_list)} 个节点对到配置中。") 82 | 83 | if not isinstance(config_object, dict): 84 | _add_log_entry(logs, "error", "无效的配置对象:不是一个字典。") 85 | return False, config_object, logs 86 | 87 | proxies = config_object.get("proxies") 88 | proxy_groups = config_object.get("proxy-groups") 89 | 90 | if not isinstance(proxies, list): 91 | _add_log_entry(logs, "error", "配置对象中缺少有效的 'proxies' 部分。") 92 | return False, config_object, logs 93 | if "proxy-groups" in config_object and not isinstance(proxy_groups, list): 94 | _add_log_entry(logs, "warn", "配置对象中的 'proxy-groups' 部分无效(不是列表),可能会影响组操作。") 95 | proxy_groups = [] 96 | 97 | applied_count = 0 98 | for landing_name, front_name in node_pairs_list: 99 | _add_log_entry(logs, "debug", f"尝试应用节点对: 落地='{landing_name}', 前置='{front_name}'.") 100 | 101 | landing_node_found = False 102 | for proxy_node in proxies: 103 | if isinstance(proxy_node, dict) and proxy_node.get("name") == landing_name: 104 | landing_node_found = True 105 | proxy_node["dialer-proxy"] = front_name 106 | _add_log_entry(logs, "info", f"成功为落地节点 '{landing_name}' 设置 'dialer-proxy' 为 '{front_name}'.") 107 | applied_count += 1 108 | if isinstance(proxy_groups, list): 109 | for grp in proxy_groups: 110 | if isinstance(grp, dict) and grp.get("name") == front_name: 111 | group_proxies_list = grp.get("proxies") 112 | if isinstance(group_proxies_list, list) and landing_name in group_proxies_list: 113 | try: 114 | group_proxies_list.remove(landing_name) 115 | _add_log_entry(logs, "info", f"已从前置组 '{front_name}' 的节点列表中移除落地节点 '{landing_name}'。") 116 | except ValueError: 117 | _add_log_entry(logs, "warn", f"尝试从前置组 '{front_name}' 移除落地节点 '{landing_name}' 时失败 (ValueError)。") 118 | break 119 | break 120 | 121 | if not landing_node_found: 122 | _add_log_entry(logs, "warn", f"节点对中的落地节点 '{landing_name}' 未在 'proxies' 列表中找到,已跳过此对。") 123 | 124 | if len(node_pairs_list) > 0: 125 | if applied_count == 0: 126 | _add_log_entry(logs, "error", "未能应用任何提供的节点对。请检查节点名称是否与订阅中的节点匹配,或查看日志了解详情。") 127 | return False, config_object, logs 128 | elif applied_count < len(node_pairs_list): 129 | failed_count = len(node_pairs_list) - applied_count 130 | _add_log_entry(logs, "warn", f"节点对应用部分成功:成功 {applied_count} 个,失败 {failed_count} 个 (共 {len(node_pairs_list)} 个)。失败的节点对因无法匹配而被跳过。请核对节点名称或查看日志。") 131 | return False, config_object, logs 132 | else: 133 | _add_log_entry(logs, "info", f"成功应用所有 {applied_count} 个节点对。") 134 | return True, config_object, logs 135 | else: 136 | _add_log_entry(logs, "info", "没有提供节点对进行应用,配置未修改。") 137 | return True, config_object, logs 138 | 139 | def _keyword_match(text_to_search, keyword_to_find): 140 | if not text_to_search or not keyword_to_find: 141 | return False 142 | text_lower = text_to_search.lower() 143 | keyword_lower = keyword_to_find.lower() 144 | if re.search(r'[a-zA-Z]', keyword_to_find): 145 | pattern_str = r'(? 1: 198 | _add_log_entry(logs, "error", f"落地节点 '{proxy_name}': 识别出多个区域 {list(matched_region_ids)},区域不明确。跳过此节点。") 199 | continue 200 | target_region_id = matched_region_ids.pop() 201 | _add_log_entry(logs, "info", f"落地节点 '{proxy_name}': 成功识别区域ID为 '{target_region_id}'.") 202 | target_region_keywords_for_dialer_search = [] 203 | for region_def in region_keyword_config: 204 | if region_def.get("id") == target_region_id: 205 | target_region_keywords_for_dialer_search = region_def.get("keywords", []) 206 | break 207 | if not target_region_keywords_for_dialer_search: 208 | _add_log_entry(logs, "error", f"内部错误:区域ID '{target_region_id}' 未找到对应的关键字列表。跳过落地节点 '{proxy_name}'.") 209 | continue 210 | found_dialer_name = None 211 | if isinstance(proxy_groups, list): 212 | matching_groups = [] 213 | for group in proxy_groups: 214 | if not isinstance(group, dict): continue 215 | group_name = group.get("name") 216 | if not group_name: continue 217 | for r_kw in target_region_keywords_for_dialer_search: 218 | if _keyword_match(group_name, r_kw): 219 | matching_groups.append(group_name) 220 | break 221 | if len(matching_groups) == 1: 222 | found_dialer_name = matching_groups[0] 223 | _add_log_entry(logs, "info", f"落地节点 '{proxy_name}': 在区域 '{target_region_id}' 找到唯一匹配的前置组: '{found_dialer_name}'.") 224 | elif len(matching_groups) > 1: 225 | _add_log_entry(logs, "error", f"落地节点 '{proxy_name}': 在区域 '{target_region_id}' 找到多个匹配的前置组 {matching_groups},无法自动选择。跳过此节点。") 226 | continue 227 | else: 228 | _add_log_entry(logs, "info", f"落地节点 '{proxy_name}': 在区域 '{target_region_id}' 未找到匹配的前置组。将尝试查找节点。") 229 | else: 230 | _add_log_entry(logs, "debug", "跳过查找前置组,因为 'proxy-groups' 缺失或无效。") 231 | if not found_dialer_name: 232 | matching_nodes = [] 233 | for candidate_proxy in proxies: 234 | if not isinstance(candidate_proxy, dict): continue 235 | candidate_name = candidate_proxy.get("name") 236 | if not candidate_name or candidate_name == proxy_name: 237 | continue 238 | for r_kw in target_region_keywords_for_dialer_search: 239 | if _keyword_match(candidate_name, r_kw): 240 | matching_nodes.append(candidate_name) 241 | break 242 | if len(matching_nodes) == 1: 243 | found_dialer_name = matching_nodes[0] 244 | _add_log_entry(logs, "info", f"落地节点 '{proxy_name}': 在区域 '{target_region_id}' 找到唯一匹配的前置节点: '{found_dialer_name}'.") 245 | elif len(matching_nodes) > 1: 246 | _add_log_entry(logs, "error", f"落地节点 '{proxy_name}': 在区域 '{target_region_id}' 找到多个匹配的前置节点 {matching_nodes},无法自动选择。跳过此节点。") 247 | continue 248 | else: 249 | _add_log_entry(logs, "warn", f"落地节点 '{proxy_name}': 在区域 '{target_region_id}' 也未能找到匹配的前置节点。") 250 | if found_dialer_name: 251 | suggested_pairs.append({"landing": proxy_name, "front": found_dialer_name}) 252 | _add_log_entry(logs, "info", f"成功为落地节点 '{proxy_name}' 自动配置前置为 '{found_dialer_name}'.") 253 | _add_log_entry(logs, "info", f"自动节点对检测完成,共找到 {len(suggested_pairs)} 对建议。") 254 | if not suggested_pairs and len(proxies) > 0: 255 | _add_log_entry(logs, "warn", "未自动检测到任何可用的节点对。请检查节点命名是否符合预设的关键字规则,或调整关键字配置。") 256 | return suggested_pairs, logs 257 | 258 | class CustomHandler(http.server.SimpleHTTPRequestHandler): 259 | ALLOWED_EXTENSIONS = {'.html', '.js', '.css', '.ico'} 260 | 261 | def send_json_response(self, data_dict, http_status_code): 262 | try: 263 | response_body = json.dumps(data_dict, ensure_ascii=False).encode('utf-8') 264 | self.send_response(http_status_code) 265 | self.send_header("Content-Type", "application/json; charset=utf-8") 266 | self.send_header("Content-Length", str(len(response_body))) 267 | self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") 268 | self.end_headers() 269 | self.wfile.write(response_body) 270 | except Exception as e: 271 | _error_logs_internal = [] # Use a different name to avoid conflict if this function is nested 272 | _add_log_entry(_error_logs_internal, "error", f"发送JSON响应时发生严重内部错误: {e}", e) 273 | try: 274 | fallback_error = {"success": False, "message": "服务器在格式化响应时发生严重错误。", "logs": _error_logs_internal} 275 | response_body = json.dumps(fallback_error, ensure_ascii=False).encode('utf-8') 276 | self.send_response(500) 277 | self.send_header("Content-Type", "application/json; charset=utf-8") 278 | self.send_header("Content-Length", str(len(response_body))) 279 | self.end_headers() 280 | self.wfile.write(response_body) 281 | except: 282 | self.send_response(500) 283 | self.send_header("Content-Type", "text/plain; charset=utf-8") 284 | self.end_headers() 285 | self.wfile.write(b"Critical server error during response generation.") 286 | 287 | def _get_config_from_remote(self, remote_url, logs_list_ref): 288 | if not remote_url: 289 | _add_log_entry(logs_list_ref, "error", "必须提供 'remote_url'。") # 290 | return None 291 | try: 292 | parsed = urlparse(remote_url) # 293 | if parsed.scheme not in ('http', 'https'): # 294 | _add_log_entry(logs_list_ref, "error", f"仅支持 http 或 https 协议的远程 URL。") # 修改:移除 remote_url 变量 295 | return None 296 | _add_log_entry(logs_list_ref, "warn", f"服务配置为允许从任意 http/https 域名获取订阅。请务必注意相关的安全风险 (如 SSRF)。") # 297 | except Exception as e: 298 | _add_log_entry(logs_list_ref, "error", f"解析提供的远程 URL 时发生基本错误: {e}", e) # 修改:移除 remote_url 变量 299 | return None 300 | 301 | # 根据环境变量确定 verify 参数的值 302 | ssl_verify_value = True # 默认值 303 | if REQUESTS_SSL_VERIFY_CONFIG == "false": 304 | ssl_verify_value = False 305 | _add_log_entry(logs_list_ref, "warn", "警告:SSL证书验证已禁用 (REQUESTS_SSL_VERIFY=false)。这可能存在安全风险。") 306 | elif REQUESTS_SSL_VERIFY_CONFIG != "true": 307 | # 如果不是 "true" 或 "false",则假定它是一个 CA bundle 文件的路径 308 | if os.path.exists(REQUESTS_SSL_VERIFY_CONFIG): 309 | ssl_verify_value = REQUESTS_SSL_VERIFY_CONFIG 310 | _add_log_entry(logs_list_ref, "info", f"SSL证书验证将使用自定义CA证书包: {REQUESTS_SSL_VERIFY_CONFIG}") 311 | else: 312 | _add_log_entry(logs_list_ref, "error", f"自定义CA证书包路径无效: {REQUESTS_SSL_VERIFY_CONFIG}。将回退到默认验证。") 313 | # ssl_verify_value 保持 True 314 | 315 | try: 316 | _add_log_entry(logs_list_ref, "info", f"正在请求远程订阅 (URL provided).") # 317 | headers = {'User-Agent': 'chain-subconverter/1.0'} # 318 | response = requests.get(remote_url, timeout=15, headers=headers, verify=ssl_verify_value) # 使用 ssl_verify_value 319 | response.raise_for_status() # 320 | _add_log_entry(logs_list_ref, "info", f"远程订阅获取成功,状态码: {response.status_code}") # 321 | config_content = response.content # 322 | if config_content.startswith(b'\xef\xbb\xbf'): # 323 | config_content = config_content[3:] # 324 | _add_log_entry(logs_list_ref, "debug", "已移除UTF-8 BOM。") # 325 | config_object = yaml.load(config_content) # 326 | if not isinstance(config_object, dict) or \ 327 | not isinstance(config_object.get("proxies"), list): # 328 | _add_log_entry(logs_list_ref, "error", "远程YAML格式无效或缺少 'proxies' 列表。") # 329 | return None 330 | _add_log_entry(logs_list_ref, "debug", "远程配置解析成功。") # 331 | return config_object 332 | except requests.Timeout: 333 | _add_log_entry(logs_list_ref, "error", f"请求远程订阅超时 (URL provided).") # 334 | return None 335 | except requests.RequestException as e: 336 | _add_log_entry(logs_list_ref, "error", f"请求远程订阅发生错误 (URL provided): {e}", e) # 337 | return None 338 | except Exception as e: 339 | _add_log_entry(logs_list_ref, "error", f"处理远程订阅内容时出错 (URL provided): {e}", e) # 340 | return None 341 | 342 | 343 | def do_POST(self): 344 | parsed_url = urlparse(self.path) 345 | request_logs = [] # Renamed to avoid confusion with 'logs' parameter in other functions 346 | 347 | if parsed_url.path == "/api/validate_configuration": 348 | try: 349 | content_length = int(self.headers.get('Content-Length', 0)) 350 | if content_length == 0: 351 | _add_log_entry(request_logs, "error", "请求体为空。") 352 | self.send_json_response({"success": False, "message": "请求体为空。", "logs": request_logs}, 400) 353 | return 354 | 355 | post_body = self.rfile.read(content_length) 356 | _add_log_entry(request_logs, "debug", f"收到的原始POST数据: {post_body[:200]}") 357 | data = json.loads(post_body.decode('utf-8')) 358 | 359 | remote_url = data.get("remote_url") 360 | node_pairs_from_request = data.get("node_pairs", []) 361 | if not isinstance(node_pairs_from_request, list): 362 | _add_log_entry(request_logs, "error", "请求中的 'node_pairs' 格式无效,应为列表。") 363 | self.send_json_response({"success": False, "message": "请求中的 'node_pairs' 格式无效,应为列表。", "logs": request_logs}, 400) 364 | return 365 | 366 | 367 | node_pairs_tuples = [] 368 | for pair_dict in node_pairs_from_request: 369 | if isinstance(pair_dict, dict) and "landing" in pair_dict and "front" in pair_dict: 370 | node_pairs_tuples.append((str(pair_dict["landing"]), str(pair_dict["front"]))) 371 | else: 372 | _add_log_entry(request_logs, "warn", f"提供的节点对 '{pair_dict}' 格式不正确,已跳过。") 373 | 374 | _add_log_entry(request_logs, "info", f"开始验证配置 (URL provided), 节点对数量={len(node_pairs_tuples)}") 375 | 376 | config_object = self._get_config_from_remote(remote_url, request_logs) 377 | if config_object is None: 378 | # _get_config_from_remote already added specific error to request_logs 379 | client_message = "无法获取或解析远程配置以进行验证。" 380 | if request_logs: 381 | # Try to get the last error/warn from _get_config_from_remote 382 | reason = next((log_entry['message'] for log_entry in reversed(request_logs) if log_entry['level'] in ['ERROR', 'WARN']), None) 383 | if reason: 384 | client_message = reason # Use the specific reason as the main message 385 | _add_log_entry(request_logs, "error", "远程配置获取/解析失败,终止验证。") # Server-side overall status 386 | self.send_json_response({"success": False, "message": client_message, "logs": request_logs}, 400) 387 | return 388 | 389 | # config_object is valid, now try to apply pairs 390 | success, _, apply_logs_from_func = apply_node_pairs_to_config(config_object, node_pairs_tuples) 391 | 392 | if success: 393 | request_logs.extend(apply_logs_from_func) # Add apply logs for successful case 394 | _add_log_entry(request_logs, "info", "配置验证成功。") 395 | self.send_json_response({"success": True, "message": "配置验证成功。", "logs": request_logs}, 200) 396 | else: 397 | # Apply failed, determine the message from apply_logs_from_func 398 | client_message = "节点对应用配置失败,详情请查看日志。" # Default 399 | if apply_logs_from_func: 400 | reason_from_apply = next((log_entry['message'] for log_entry in reversed(apply_logs_from_func) if log_entry['level'] in ['ERROR', 'WARN']), None) 401 | if reason_from_apply: 402 | client_message = reason_from_apply # This will be like "节点对应用部分成功..." 403 | 404 | request_logs.extend(apply_logs_from_func) # Add logs from the apply function 405 | _add_log_entry(request_logs, "error", "配置验证因节点对应用问题判定为失败。") # Overall server-side status log 406 | 407 | self.send_json_response({"success": False, "message": client_message, "logs": request_logs}, 400) 408 | 409 | except json.JSONDecodeError as e: 410 | _add_log_entry(request_logs, "error", f"解析请求体JSON时出错: {e}", e) 411 | self.send_json_response({"success": False, "message": "请求体JSON格式错误。", "logs": request_logs}, 400) 412 | except ValueError as e: # For errors like invalid node_pairs format before _get_config_from_remote 413 | _add_log_entry(request_logs, "error", f"请求数据处理错误: {e}", e) 414 | self.send_json_response({"success": False, "message": f"请求数据错误: {e}", "logs": request_logs}, 400) 415 | except Exception as e: 416 | _add_log_entry(request_logs, "error", f"处理 /api/validate_configuration 时发生意外错误: {e}", e) 417 | self.send_json_response({"success": False, "message": "服务器内部错误。", "logs": request_logs}, 500) 418 | else: 419 | self.send_error_response("此路径不支持POST请求。", 405) 420 | 421 | 422 | def do_GET(self): 423 | parsed_url = urlparse(self.path) 424 | query_params = parse_qs(parsed_url.query) 425 | request_logs = [] 426 | 427 | if parsed_url.path == "/api/auto_detect_pairs": 428 | remote_url = query_params.get('remote_url', [None])[0] 429 | _add_log_entry(request_logs, "info", f"收到 /api/auto_detect_pairs 请求 (URL provided).") 430 | 431 | config_object = self._get_config_from_remote(remote_url, request_logs) 432 | client_message_auto_detect = "无法获取或解析远程配置。" 433 | if config_object is None: 434 | if request_logs: 435 | reason = next((log_entry['message'] for log_entry in reversed(request_logs) if log_entry['level'] in ['ERROR', 'WARN']), None) 436 | if reason: 437 | client_message_auto_detect = reason 438 | self.send_json_response({ 439 | "success": False, 440 | "message": client_message_auto_detect, 441 | "suggested_pairs": [], 442 | "logs": request_logs 443 | }, 400) 444 | return 445 | 446 | suggested_pairs, detect_logs = perform_auto_detection(config_object, REGION_KEYWORD_CONFIG, LANDING_NODE_KEYWORDS) 447 | request_logs.extend(detect_logs) 448 | 449 | success_flag = True if suggested_pairs else False 450 | final_message = f"自动检测完成,找到 {len(suggested_pairs)} 对。" if success_flag else "自动检测未找到可用节点对。" 451 | if not success_flag and request_logs: 452 | relevant_log_msg = next((log_item['message'] for log_item in reversed(detect_logs) if log_item['level'] == 'WARN'), None) 453 | if relevant_log_msg: # Append warning if detection failed and there's a relevant warning 454 | final_message += f" {relevant_log_msg}" 455 | self.send_json_response({ 456 | "success": success_flag, 457 | "message": final_message, 458 | "suggested_pairs": suggested_pairs, 459 | "logs": request_logs 460 | }, 200) 461 | 462 | elif parsed_url.path == "/subscription.yaml": 463 | remote_url = query_params.get('remote_url', [None])[0] 464 | manual_pairs_str = unquote(query_params.get('manual_pairs', [''])[0]) 465 | 466 | node_pairs_list = [] 467 | if manual_pairs_str: 468 | pairs = manual_pairs_str.split(',') 469 | for pair_str in pairs: 470 | if not pair_str.strip(): continue 471 | parts = pair_str.split(':', 1) 472 | if len(parts) == 2 and parts[0].strip() and parts[1].strip(): 473 | node_pairs_list.append((parts[0].strip(), parts[1].strip())) 474 | else: 475 | _add_log_entry(request_logs, "warn", f"解析 'manual_pairs' 中的 '{pair_str}' 格式不正确,已跳过。") 476 | 477 | _add_log_entry(request_logs, "info", f"收到 /subscription.yaml 请求 (URL provided), manual_pairs='{manual_pairs_str}' (解析后 {len(node_pairs_list)} 对)") 478 | 479 | config_object = self._get_config_from_remote(remote_url, request_logs) 480 | if config_object is None: 481 | error_detail = request_logs[-1]['message'] if request_logs and request_logs[-1]['message'] else '未知错误' 482 | self.send_error_response(f"错误: 无法获取或解析远程配置。详情: {error_detail}", 502) 483 | return 484 | 485 | success, modified_config, apply_logs_from_func = apply_node_pairs_to_config(config_object, node_pairs_list) 486 | request_logs.extend(apply_logs_from_func) 487 | 488 | if success: 489 | try: 490 | output = StringIO() 491 | yaml.dump(modified_config, output) 492 | final_yaml_string = output.getvalue() 493 | _add_log_entry(request_logs, "info", "成功生成YAML配置。") 494 | self.send_response(200) 495 | self.send_header("Content-Type", "text/yaml; charset=utf-8") 496 | self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") 497 | self.send_header("Content-Disposition", f"inline; filename=\"chain_subscription_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}.yaml\"") 498 | self.end_headers() 499 | self.wfile.write(final_yaml_string.encode("utf-8")) 500 | except Exception as e: 501 | _add_log_entry(request_logs, "error", f"生成最终YAML时出错: {e}", e) 502 | self.send_error_response(f"服务器内部错误:无法生成YAML。详情: {e}", 500) 503 | else: # success is False from apply_node_pairs_to_config 504 | client_error_detail = "应用节点对失败。" # Default 505 | if apply_logs_from_func: # Get specific reason from apply_node_pairs_to_config's logs 506 | reason = next((log_entry['message'] for log_entry in reversed(apply_logs_from_func) if log_entry['level'] in ['ERROR', 'WARN']), None) 507 | if reason: 508 | client_error_detail = reason 509 | _add_log_entry(request_logs, "error", "应用节点对到配置时失败(/subscription.yaml)。") # Server-side log 510 | self.send_error_response(f"错误: {client_error_detail}", 400) 511 | 512 | elif parsed_url.path == "/" or parsed_url.path == "/frontend.html": 513 | self.serve_static_file("frontend.html", "text/html; charset=utf-8") 514 | elif parsed_url.path == "/script.js": 515 | self.serve_static_file("script.js", "application/javascript; charset=utf-8") 516 | elif parsed_url.path == "/favicon.ico": 517 | self.serve_static_file("favicon.ico", "image/x-icon") 518 | else: 519 | self.send_error_response(f"资源未找到: {self.path}", 404) 520 | 521 | def serve_static_file(self, file_name, content_type): 522 | script_dir = os.path.dirname(os.path.abspath(__file__)) 523 | file_path = os.path.join(script_dir, file_name) 524 | normalized_script_dir = os.path.normcase(os.path.normpath(script_dir)) 525 | normalized_file_path = os.path.normcase(os.path.normpath(os.path.realpath(file_path))) 526 | if not normalized_script_dir.endswith(os.sep): 527 | normalized_script_dir += os.sep 528 | if not normalized_file_path.startswith(normalized_script_dir): 529 | logger.warning(f"禁止访问:尝试访问脚本目录之外的文件: {file_path}") 530 | self.send_error_response(f"禁止访问: {self.path}", 403) 531 | return 532 | ext = os.path.splitext(file_path)[1].lower() 533 | if ext not in self.ALLOWED_EXTENSIONS: 534 | logger.warning(f"禁止访问:不允许的文件类型 {ext} 对于路径 {file_path}") 535 | self.send_error_response(f"文件类型 {ext} 不允许访问", 403) 536 | return 537 | if not os.path.exists(file_path) or not os.path.isfile(file_path): 538 | logger.warning(f"静态文件未找到或不是一个文件: {file_path}") 539 | self.send_error_response(f"资源未找到: {self.path}", 404) 540 | return 541 | try: 542 | with open(file_path, "rb") as f: 543 | content_to_serve = f.read() 544 | 545 | if file_name == "frontend.html": 546 | logger.debug(f"Modifying frontend.html to inject SHOW_SERVICE_ADDRESS_CONFIG: {SHOW_SERVICE_ADDRESS_CONFIG_ENV}") 547 | html_content_str = content_to_serve.decode('utf-8') 548 | js_config_script = f"" 549 | # Insert before closing tag 550 | insertion_point = html_content_str.find("") 551 | if insertion_point != -1: 552 | html_content_str = html_content_str[:insertion_point] + js_config_script + html_content_str[insertion_point:] 553 | else: 554 | logger.warning(" tag not found in frontend.html, config script not injected near head. Trying before body.") 555 | insertion_point_body = html_content_str.find("",insertion_point_body) 559 | if end_of_body_tag != -1: 560 | html_content_str = html_content_str[:end_of_body_tag+1] + js_config_script + html_content_str[end_of_body_tag+1:] 561 | else: # fallback if body tag is weirdly formatted 562 | html_content_str = js_config_script + html_content_str # prepend 563 | else: # ultimate fallback 564 | html_content_str = js_config_script + html_content_str # prepend 565 | content_to_serve = html_content_str.encode('utf-8') 566 | 567 | 568 | logger.info(f"正在提供静态文件: {file_path} 类型: {content_type}") 569 | self.send_response(200) 570 | self.send_header("Content-Type", content_type) 571 | self.send_header("Content-Length", str(len(content_to_serve))) 572 | if content_type.startswith("text/html") or content_type.startswith("application/javascript"): 573 | self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") 574 | self.end_headers() 575 | self.wfile.write(content_to_serve) 576 | except Exception as e: 577 | logger.error(f"读取或提供静态文件 {file_path} 时发生错误: {e}", exc_info=True) 578 | self.send_error_response(f"提供文件时出错: {e}", 500) 579 | 580 | def send_error_response(self, message, code=500): 581 | logger.info(f"发送错误响应: code={code}, message='{message}'") 582 | self.send_response(code) 583 | self.send_header("Content-Type", "text/plain; charset=utf-8") 584 | self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") 585 | self.send_header("Content-Length", str(len(message.encode('utf-8')))) 586 | self.end_headers() 587 | self.wfile.write(message.encode("utf-8")) 588 | 589 | def log_message(self, format, *args): 590 | logger.debug(f"HTTP Request: {self.address_string()} {self.requestline} -> Status: {args[0] if args else 'N/A'}") 591 | return 592 | 593 | # --- 主执行 --- 594 | if __name__ == "__main__": 595 | if not os.path.exists(LOG_DIR): 596 | try: 597 | os.makedirs(LOG_DIR) 598 | logger.info(f"已创建日志目录: {LOG_DIR}") 599 | except OSError as e: 600 | logger.error(f"无法创建日志目录 {LOG_DIR}: {e}", exc_info=True) 601 | 602 | logger.info(f"正在启动服务,端口号: {PORT}...") 603 | logger.info(f"服务地址配置区块显示状态: {'启用' if SHOW_SERVICE_ADDRESS_CONFIG_ENV else '禁用'}") 604 | script_dir = os.path.dirname(os.path.abspath(__file__)) 605 | logger.info(f"脚本所在目录: {script_dir}") 606 | logger.info(f"前端文件 frontend.html 预期路径: {os.path.join(script_dir, 'frontend.html')}") 607 | logger.info(f"前端脚本 script.js 预期路径: {os.path.join(script_dir, 'script.js')}") 608 | 609 | mimetypes.init() 610 | 611 | httpd = ThreadingHTTPServer(("", PORT), CustomHandler) 612 | logger.info(f"服务已启动于 http://0.0.0.0:{PORT}") 613 | logger.info("--- Mihomo 链式订阅转换服务已就绪 ---") 614 | logger.info(f"请通过 http://<您的服务器IP>:{PORT}/ 访问前端配置页面") 615 | try: 616 | httpd.serve_forever() 617 | except KeyboardInterrupt: 618 | logger.info("服务正在关闭...") 619 | finally: 620 | httpd.server_close() 621 | logger.info("服务已成功关闭。") --------------------------------------------------------------------------------