commit 088c3d2044afae85940ca60d71ffcad8dd624ba5 Author: theshy Date: Thu Aug 14 23:00:16 2025 +0800 Initial commit diff --git a/.cursorindexingignore b/.cursorindexingignore new file mode 100644 index 0000000..953908e --- /dev/null +++ b/.cursorindexingignore @@ -0,0 +1,3 @@ + +# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references +.specstory/** diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b8d0d90 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +npm-debug.log* +pnpm-lock.yaml +yarn.lock +.git +.gitignore +.DS_Store +File/ +.specstory/ +.cursorindexingignore + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee5e9a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Node +node_modules/ + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Runtime +pids/ +*.pid +*.seed +*.pid.lock + +# Coverage & cache +coverage/ +.nyc_output/ +.cache/ + +# Build outputs +build/ +dist/ +.tmp/ + +# Env files +.env +.env.local +.env.*.local + +# Editors +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Project specific +File/ +.specstory/ +.cursor/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..637c6bd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# 1) 构建阶段:安装仅生产依赖 +FROM node:22-bookworm-slim AS builder +WORKDIR /app +ENV NODE_ENV=production + +# 仅复制依赖清单,提升缓存命中率 +COPY package.json package-lock.json* ./ + +# 无锁文件时回退 npm install;并关闭审计/基金提示 +RUN if [ -f package-lock.json ]; then \ + npm ci --omit=dev --no-audit --no-fund; \ + else \ + npm install --omit=dev --no-audit --no-fund; \ + fi + +# 复制应用源码 +COPY index.js ./index.js +COPY src ./src +COPY public ./public + +# 2) 运行阶段:Debian 12 distroless(极简、无 Shell) +FROM gcr.io/distroless/nodejs22-debian12 +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app /app + +EXPOSE 8080 +USER nonroot:nonroot + +# distroless nodejs 以 node 为入口,传入脚本路径即可 +CMD ["/app/index.js"] + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..367bd63 --- /dev/null +++ b/index.js @@ -0,0 +1,53 @@ +"use strict"; + +const path = require("path"); +const express = require("express"); +const { convertTextToClashMihomoYAML } = require("./src/convert"); + +const app = express(); +app.use(express.json({ limit: "2mb" })); +app.use( + express.text({ type: ["text/*", "application/octet-stream"], limit: "2mb" }) +); +app.use(express.static(path.join(__dirname, "public"))); + +app.get("/", (req, res) => { + res.sendFile(path.join(__dirname, "public", "index.html")); +}); + +app.get("/convert/clash", async (req, res) => { + try { + const input = (req.query.uri || "").toString(); + if (!input) return res.status(400).json({ error: "missing uri" }); + const yamlText = await convertTextToClashMihomoYAML(input); + res.setHeader("Content-Type", "application/yaml; charset=utf-8"); + res.send(yamlText); + } catch (err) { + res + .status(400) + .json({ error: err && err.message ? err.message : String(err) }); + } +}); + +app.post("/convert/clash", async (req, res) => { + try { + const input = + typeof req.body === "string" + ? req.body + : (req.body && req.body.input) || ""; + if (!input) return res.status(400).json({ error: "missing input" }); + const yamlText = await convertTextToClashMihomoYAML(input); + res.setHeader("Content-Type", "application/yaml; charset=utf-8"); + res.send(yamlText); + } catch (err) { + res + .status(400) + .json({ error: err && err.message ? err.message : String(err) }); + } +}); + +const port = process.env.PORT || 8080; +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`listening on http://127.0.0.1:${port}`); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..bf91dd6 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "xray-to-mihomo-converter", + "version": "0.1.0", + "private": true, + "description": "Convert Xray (vless/vmess/trojan links or subscription text) to Clash Mihomo YAML", + "license": "MIT", + "type": "commonjs", + "scripts": { + "start": "node index.js", + "dev": "node index.js" + }, + "dependencies": { + "express": "^4.19.2", + "yaml": "^2.5.1" + } +} + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d441e74 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,595 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + express: + specifier: ^4.19.2 + version: 4.21.2 + yaml: + specifier: ^2.5.1 + version: 2.8.1 + +packages: + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + array-flatten@1.1.1: {} + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.6: {} + + cookie@0.7.1: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + negotiator@0.6.3: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + parseurl@1.3.3: {} + + path-to-regexp@0.1.12: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.1: {} + + toidentifier@1.0.1: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + unpipe@1.0.0: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} + + yaml@2.8.1: {} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..3271be3 --- /dev/null +++ b/public/index.html @@ -0,0 +1,150 @@ + + + + + + 订阅格式转换 · Xray → Clash Mihomo + + + +
+

订阅格式转换(Xray 链接/订阅 → Clash Mihomo)

+

+ 支持 vless/vmess/trojan 链接,或整段订阅文本/订阅地址(自动识别 + base64)。 +

+ +
+
+ 输出为 YAML 的 proxies 段,可作 provider 或粘贴入主配置。 +
+
+ + +
+
+
+
+ + + diff --git a/src/convert.js b/src/convert.js new file mode 100644 index 0000000..b701d3b --- /dev/null +++ b/src/convert.js @@ -0,0 +1,20 @@ +'use strict'; + +const { parseXrayLikeInput } = require('./parsers'); +const { toClashProxiesYAML } = require('./emitters'); + +/** + * Accepts a single URL (vless://, vmess://, trojan://) or a subscription text (possibly base64), + * parses into nodes, then emits Clash Mihomo YAML (only proxies section for MVP). + * @param {string} input + * @returns {Promise} YAML text + */ +async function convertTextToClashMihomoYAML(input) { + const nodes = await parseXrayLikeInput(input); + const yaml = toClashProxiesYAML(nodes); + return yaml; +} + +module.exports = { convertTextToClashMihomoYAML }; + + diff --git a/src/emitters.js b/src/emitters.js new file mode 100644 index 0000000..f80a64e --- /dev/null +++ b/src/emitters.js @@ -0,0 +1,121 @@ +"use strict"; + +/** + * @param {import('./parsers').Node[]} nodes + */ +function toClashProxiesYAML(nodes) { + const lines = []; + lines.push("proxies:"); + for (const n of nodes) { + switch (n.type) { + case "vless": + emitVless(lines, n); + break; + case "vmess": + emitVmess(lines, n); + break; + case "trojan": + emitTrojan(lines, n); + break; + default: + // skip unsupported + break; + } + } + return lines.join("\n") + "\n"; +} + +function emitVless(lines, n) { + lines.push(` - name: ${yamlStr(n.name || "vless")}`); + lines.push(" type: vless"); + lines.push(` server: ${yamlStr(n.server)}`); + lines.push(` port: ${Number(n.port)}`); + lines.push(` uuid: ${yamlStr(n.uuid)}`); + if (n.flow) { + lines.push(` flow: ${yamlStr(n.flow)}`); + } + lines.push(` udp: true`); + if (n.tls && n.tls.enabled) { + lines.push(" tls: true"); + if (n.tls.serverName) + lines.push(` servername: ${yamlStr(n.tls.serverName)}`); + if (n.reality) { + lines.push(" reality-opts:"); + if (n.reality.publicKey) + lines.push(` public-key: ${yamlStr(n.reality.publicKey)}`); + if (n.reality.shortId) + lines.push(` short-id: ${yamlStr(n.reality.shortId)}`); + } + if (n.tls.utls && n.tls.utls.fingerprint) { + lines.push(` client-fingerprint: ${yamlStr(n.tls.utls.fingerprint)}`); + } + if (Array.isArray(n.alpn) && n.alpn.length > 0) { + lines.push(` alpn: [${n.alpn.map(yamlStr).join(", ")}]`); + } + } + const net = (n.network || "tcp").toLowerCase(); + lines.push(` network: ${yamlStr(net)}`); + if (net === "grpc" && n.grpc) { + lines.push(" grpc-opts:"); + if (n.grpc.serviceName != null) + lines.push(` grpc-service-name: ${yamlStr(n.grpc.serviceName)}`); + if (n.grpc.authority) + lines.push(` authority: ${yamlStr(n.grpc.authority)}`); + } +} + +function emitVmess(lines, n) { + lines.push(` - name: ${yamlStr(n.name || "vmess")}`); + lines.push(" type: vmess"); + lines.push(` server: ${yamlStr(n.server)}`); + lines.push(` port: ${Number(n.port)}`); + lines.push(` uuid: ${yamlStr(n.uuid)}`); + // vmess specific + lines.push(` alterId: ${Number(n.alterId || 0)}`); + lines.push(` cipher: ${yamlStr(n.cipher || "auto")}`); + lines.push(` udp: true`); + if (n.tls && (n.tls.enabled || n.tls.serverName)) { + lines.push(" tls: true"); + if (n.tls.serverName) + lines.push(` servername: ${yamlStr(n.tls.serverName)}`); + if (Array.isArray(n.alpn) && n.alpn.length > 0) { + lines.push(` alpn: [${n.alpn.map(yamlStr).join(", ")}]`); + } + if (n.tls.utls && n.tls.utls.fingerprint) { + lines.push(` client-fingerprint: ${yamlStr(n.tls.utls.fingerprint)}`); + } + } + const net = (n.network || "tcp").toLowerCase(); + lines.push(` network: ${yamlStr(net)}`); + if (net === "ws" && n.ws) { + lines.push(" ws-opts:"); + if (n.ws.path != null) lines.push(` path: ${yamlStr(n.ws.path)}`); + if (n.ws.headers && n.ws.headers.Host) { + lines.push(" headers:"); + lines.push(` Host: ${yamlStr(n.ws.headers.Host)}`); + } + } +} + +function emitTrojan(lines, n) { + lines.push(` - name: ${yamlStr(n.name || "trojan")}`); + lines.push(" type: trojan"); + lines.push(` server: ${yamlStr(n.server)}`); + lines.push(` port: ${Number(n.port)}`); + lines.push(` password: ${yamlStr(n.password)}`); + lines.push(" sni: null"); + if (n.tls && (n.tls.enabled || n.tls.serverName)) { + lines.push(" tls: true"); + if (n.tls.serverName) lines.push(` sni: ${yamlStr(n.tls.serverName)}`); + } +} + +function yamlStr(v) { + if (v == null) return '""'; + if (typeof v === "number") return String(v); + const s = String(v); + if (/^[A-Za-z0-9._-]+$/.test(s)) return s; + return JSON.stringify(s); +} + +module.exports = { toClashProxiesYAML }; diff --git a/src/parsers.js b/src/parsers.js new file mode 100644 index 0000000..6438286 --- /dev/null +++ b/src/parsers.js @@ -0,0 +1,200 @@ +"use strict"; + +const { Buffer } = require("buffer"); + +/** + * @typedef {Object} Node + * @property {string} type vless|vmess|trojan + * @property {string} name tag/remark + * @property {string} server + * @property {number} port + * @property {string} uuid for vless/vmess + * @property {string} password for trojan or vmess alterId? (not used here) + * @property {Object} tls + * @property {boolean} tls.enabled + * @property {string} [tls.serverName] + * @property {Object} [tls.utls] + * @property {string} [tls.fingerprint] + * @property {string[]} [alpn] + * @property {Object} [reality] + * @property {string} [reality.publicKey] + * @property {string} [reality.shortId] + * @property {string} [network] grpc|ws|tcp + * @property {number} [alterId] + * @property {string} [cipher] + * @property {string} [flow] + * @property {Object} [grpc] + * @property {string} [grpc.serviceName] + * @property {string} [grpc.authority] + * @property {Object} [ws] + * @property {string} [ws.path] + * @property {Object} [ws.headers] + */ + +/** + * Parse input: single URL or subscription content (raw or base64) possibly containing multiple lines. + * Returns list of Node objects. + * @param {string} input + * @returns {Promise} + */ +async function parseXrayLikeInput(input) { + if (!input || typeof input !== "string") throw new Error("empty input"); + + // If looks like a URL schema, parse single + if (/^(vless|vmess|trojan):\/\//i.test(input.trim())) { + return [parseSingleURL(input.trim())]; + } + + // Try base64 subscription (vmess often is base64 lines, but vless/trojan lists can be raw) + let text = input; + try { + const maybe = Buffer.from(input.trim(), "base64").toString("utf8"); + // heuristic: if decode yields many control chars, keep original + if (/\w+:\/\//.test(maybe) || /\n/.test(maybe)) { + text = maybe; + } + } catch (_) { + // ignore + } + + const lines = text + .split(/\r?\n/) + .map((s) => s.trim()) + .filter((s) => s && /^(vless|vmess|trojan):\/\//i.test(s)); + + return lines.map(parseSingleURL); +} + +function parseSingleURL(url) { + if (/^vless:\/\//i.test(url)) return parseVless(url); + if (/^vmess:\/\//i.test(url)) return parseVmess(url); + if (/^trojan:\/\//i.test(url)) return parseTrojan(url); + throw new Error("unsupported schema"); +} + +function parseVless(uri) { + const raw = uri.replace(/^vless:\/\//i, ""); + const [credsHost, hashTag] = raw.split("#"); + const name = decodeURIComponent(hashTag || "").trim() || "vless-node"; + const atIdx = credsHost.indexOf("@"); + if (atIdx < 0) throw new Error("invalid vless"); + const uuid = credsHost.slice(0, atIdx); + const rest = credsHost.slice(atIdx + 1); + const qIdx = rest.indexOf("?"); + const hostPort = qIdx >= 0 ? rest.slice(0, qIdx) : rest; + const queryStr = qIdx >= 0 ? rest.slice(qIdx + 1) : ""; + const [server, portStr] = hostPort.split(":"); + const params = new URLSearchParams(queryStr); + const get = (k) => { + const v = params.get(k); + return v ? decodeURIComponent(v) : undefined; + }; + const type = (get("type") || "tcp").toLowerCase(); + const security = (get("security") || "").toLowerCase(); + const tlsEnabled = security === "tls" || security === "reality"; + const node = { + type: "vless", + name, + server, + port: Number(portStr || 0), + uuid, + network: type, + flow: get("flow") || undefined, + tls: { + enabled: tlsEnabled, + serverName: tlsEnabled ? get("sni") || undefined : undefined, + utls: tlsEnabled && get("fp") ? { fingerprint: get("fp") } : undefined, + }, + }; + // optional alpn + const alpnStr = get("alpn"); + if (tlsEnabled && alpnStr && alpnStr.trim()) { + node.alpn = alpnStr + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + if (security === "reality") { + node.reality = { publicKey: get("pbk") || "", shortId: get("sid") || "" }; + node.tls.enabled = true; + } + if (node.network === "grpc") { + node.grpc = { + serviceName: get("serviceName") || "", + authority: get("authority") || undefined, + }; + } + if (!node.port) throw new Error("invalid port"); + return node; +} + +function parseVmess(uri) { + // vmess:// base64(json) + const payload = uri.replace(/^vmess:\/\//i, "").trim(); + const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")); + /** @type {Node} */ + const node = { + type: "vmess", + name: json.ps || "vmess-node", + server: json.add, + port: Number(json.port), + uuid: json.id, + network: (json.net || "tcp").toLowerCase(), + alterId: Number(json.aid || 0), + cipher: json.scy || json.scypher || undefined, + tls: { + enabled: json.tls === "tls" || Boolean(json.sni), + serverName: json.sni || undefined, + }, + }; + // alpn + if (typeof json.alpn === "string" && json.alpn.trim()) { + node.alpn = json.alpn + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + // fingerprint + if (typeof json.fp === "string" && json.fp.trim()) { + node.tls.utls = { fingerprint: json.fp }; + } + // ws + if (node.network === "ws") { + node.ws = { path: json.path || undefined, headers: {} }; + if (json.host && String(json.host).trim()) { + node.ws.headers = { Host: String(json.host).trim() }; + } + } + if (!node.port) throw new Error("invalid vmess port"); + return node; +} + +function parseTrojan(uri) { + const raw = uri.replace(/^trojan:\/\//i, ""); + const [credsHost, hashTag] = raw.split("#"); + const name = decodeURIComponent(hashTag || "").trim() || "trojan-node"; + const atIdx = credsHost.indexOf("@"); + if (atIdx < 0) throw new Error("invalid trojan"); + const password = credsHost.slice(0, atIdx); + const rest = credsHost.slice(atIdx + 1); + const qIdx = rest.indexOf("?"); + const hostPort = qIdx >= 0 ? rest.slice(0, qIdx) : rest; + const queryStr = qIdx >= 0 ? rest.slice(qIdx + 1) : ""; + const [server, portStr] = hostPort.split(":"); + const params = new URLSearchParams(queryStr); + const sni = params.get("sni") || params.get("peer") || undefined; + /** @type {Node} */ + const node = { + type: "trojan", + name, + server, + port: Number(portStr || 0), + password, + network: "tcp", + tls: { enabled: true, serverName: sni }, + }; + if (!node.port) throw new Error("invalid trojan port"); + return node; +} + +module.exports = { parseXrayLikeInput };