Initial commit

This commit is contained in:
theshy
2025-08-14 23:00:16 +08:00
commit 088c3d2044
11 changed files with 1253 additions and 0 deletions

3
.cursorindexingignore Normal file
View File

@ -0,0 +1,3 @@
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references
.specstory/**

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
node_modules
npm-debug.log*
pnpm-lock.yaml
yarn.lock
.git
.gitignore
.DS_Store
File/
.specstory/
.cursorindexingignore

46
.gitignore vendored Normal file
View File

@ -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/

35
Dockerfile Normal file
View File

@ -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"]

53
index.js Normal file
View File

@ -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}`);
});

18
package.json Normal file
View File

@ -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"
}
}

595
pnpm-lock.yaml generated Normal file
View File

@ -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: {}

150
public/index.html Normal file
View File

@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>订阅格式转换 · Xray → Clash Mihomo</title>
<style>
:root {
color-scheme: light dark;
}
html,
body {
height: 100%;
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
Helvetica, Arial;
}
.container {
max-width: 880px;
margin: 40px auto;
padding: 0 16px;
}
h1 {
font-size: 20px;
margin: 0 0 16px;
}
textarea {
width: 100%;
min-height: 160px;
padding: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
border-radius: 8px;
border: 1px solid #8883;
background: transparent;
}
.row {
display: flex;
gap: 8px;
margin: 12px 0;
}
.row > * {
flex: 1;
}
button {
padding: 10px 14px;
border-radius: 8px;
border: 1px solid #8883;
background: #09f;
color: #fff;
cursor: pointer;
}
button.secondary {
background: transparent;
color: inherit;
}
.out {
white-space: pre;
padding: 12px;
border: 1px solid #8883;
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
min-height: 120px;
}
.hint {
color: #888;
font-size: 12px;
}
.grid {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 8px;
}
.right {
text-align: right;
}
a {
color: inherit;
}
</style>
</head>
<body>
<div class="container">
<h1>订阅格式转换Xray 链接/订阅 → Clash Mihomo</h1>
<p class="hint">
支持 vless/vmess/trojan 链接,或整段订阅文本/订阅地址(自动识别
base64
</p>
<textarea
id="input"
placeholder="在此粘贴 vless://、vmess:// 或 trojan:// 链接,或订阅文本"
></textarea>
<div class="grid">
<div class="hint">
输出为 YAML 的 proxies 段,可作 provider 或粘贴入主配置。
</div>
<div class="right">
<button id="btnConvert">转换为 Clash (YAML)</button>
<button id="btnDownload" class="secondary">下载 proxies.yaml</button>
</div>
</div>
<div id="out" class="out"></div>
</div>
<script>
const $ = (s) => document.querySelector(s);
const inputEl = $("#input");
const outEl = $("#out");
const btnConvert = $("#btnConvert");
const btnDownload = $("#btnDownload");
async function convert() {
const val = inputEl.value.trim();
if (!val) {
outEl.textContent = "请输入内容";
return;
}
try {
const resp = await fetch("/convert/clash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: val }),
});
const text = await resp.text();
if (!resp.ok) throw new Error(text);
outEl.textContent = text;
return text;
} catch (e) {
outEl.textContent =
"错误: " + (e && e.message ? e.message : String(e));
}
}
btnConvert.addEventListener("click", convert);
btnDownload.addEventListener("click", async () => {
const text = outEl.textContent || (await convert());
if (!text) return;
const blob = new Blob([text], {
type: "application/yaml;charset=utf-8",
});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "proxies.yaml";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
});
</script>
</body>
</html>

20
src/convert.js Normal file
View File

@ -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<string>} YAML text
*/
async function convertTextToClashMihomoYAML(input) {
const nodes = await parseXrayLikeInput(input);
const yaml = toClashProxiesYAML(nodes);
return yaml;
}
module.exports = { convertTextToClashMihomoYAML };

121
src/emitters.js Normal file
View File

@ -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 };

200
src/parsers.js Normal file
View File

@ -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<Node[]>}
*/
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 };