├── docs ├── img │ ├── main-1.png │ ├── main.png │ └── custom-1.png ├── FAQ.md ├── update-log.md ├── base-config.md └── API-doc.md ├── wrangler.toml ├── .github ├── ISSUE_TEMPLATE │ ├── 功能请求.md │ ├── 使用问题.md │ └── bug-反馈.md └── workflows │ └── deploy.yml ├── package.json ├── LICENSE ├── src ├── BaseConfigBuilder.js ├── SingboxConfigBuilder.js ├── utils.js ├── ProxyParsers.js ├── ClashConfigBuilder.js ├── index.js ├── config.js ├── style.js └── htmlBuilder.js ├── README.md └── .gitignore /docs/img/main-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuilenren/sublink-worker/HEAD/docs/img/main-1.png -------------------------------------------------------------------------------- /docs/img/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuilenren/sublink-worker/HEAD/docs/img/main.png -------------------------------------------------------------------------------- /docs/img/custom-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuilenren/sublink-worker/HEAD/docs/img/custom-1.png -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "sublink-worker" 3 | main = "src/index.js" 4 | compatibility_date = "2024-07-24" 5 | compatibility_flags = ["nodejs_compat"] 6 | 7 | kv_namespaces = [ 8 | { binding = "SUBLINK_KV", id = "1234" } 9 | ] 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/功能请求.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 功能请求 3 | about: 为项目提供建议 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 功能描述 11 | 12 | 描述你想要的功能 13 | 14 | ## 使用场景 15 | 16 | 描述这个功能会在什么场景下使用 17 | 18 | ## 实现方案(可选) 19 | 20 | 如果你有实现思路,请描述 21 | 22 | ## 其他信息 23 | 24 | - [x] 我已通过搜索确认没有类似issue 25 | 26 | - [ ] 我愿意提交PR来实现这个功能 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/使用问题.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 使用问题 3 | about: 寻求使用帮助 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ## 问题描述 10 | 描述你遇到的使用问题 11 | 12 | ## 环境信息 13 | - 客户端类型(SingBox, Clash等) 14 | - 客户端版本 15 | 16 | ## 原始节点信息: 17 | 使用适当的方式展示未进行订阅转换前的配置,注意脱敏。例如: 18 | - 分享url 19 | - Xray/SingBox/Clash 的配置文件 20 | 21 | ## 操作步骤 22 | 1. 23 | 2. 24 | 3. 25 | 26 | ## 预期行为 27 | 描述你期望发生的事情 28 | 29 | ## 尝试过的解决方案 30 | 描述你已经尝试过的解决方法 31 | 32 | - [x] 我确认没有找到解决相同问题的 issue -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sublink-plus", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "wrangler deploy", 7 | "dev": "wrangler dev", 8 | "start": "wrangler dev", 9 | "test": "vitest" 10 | }, 11 | "devDependencies": { 12 | "@cloudflare/vitest-pool-workers": "^0.4.5", 13 | "vitest": "1.5.0", 14 | "wrangler": "^3.60.3" 15 | }, 16 | "dependencies": { 17 | "js-yaml": "^4.1.0", 18 | "sublink-plus": "file:" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-反馈.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 反馈 3 | about: 报告 bug 以帮助我们改进 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 问题描述 11 | 清晰简洁地描述 bug 12 | 13 | ## 环境信息 14 | - 客户端类型(SingBox, Clash) 15 | - 客户端版本: 16 | 17 | ## 原始节点信息: 18 | 使用适当的方式展示未进行订阅转换前的配置,注意脱敏。例如: 19 | - 分享url 20 | - Xray/SingBox/Clash 的配置文件 21 | 22 | ## 错误日志 23 | 可选,技术条件允许的话建议粘贴完整日志信息 24 | 25 | ## 复现步骤 26 | 1. 27 | 2. 28 | 3. 29 | 30 | ## 预期行为 31 | 描述你期望发生的事情 32 | 33 | ## 补充信息 34 | 添加任何其他有关问题的信息 35 | 36 | - [ ] 我愿意提交PR来修复这个问题 37 | 38 | - [ ] 我提供的信息已经经过脱敏 39 | 40 | - [x] 我确认没有找到解决相同问题的 issue 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2024 7Sageer 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/FAQ.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | 如果你发现文档中没有你遇到的问题,请务必提交 Issue。 4 | 5 | ## 部署 6 | 7 | --- 8 | 9 | ### 我不清楚如何部署 10 | 11 | 如果你没有 Cloudflare 账号或 Github 账号,请先注册。 12 | 13 | 点击[Readme.md](../Readme.md)中的 **Deploy to Cloudflare** 按钮,按照提示操作即可。 14 | 15 | 根据提示点点点即可,你仅需手动填写 **Account ID** 和 **API token**,详见 [如何获取 Worker Account ID 和 API token?](#如何获取-worker-account-id-和-api-token) 16 | 17 | --- 18 | 19 | ### 如何获取 Worker Account ID 和 API token? 20 | 21 | **获取 Worker Account ID** 22 | 23 | 1. 打开 [Cloudflare Dashboard](https://dash.cloudflare.com/) 24 | 2. 点击左侧的 **Workers 和 Pages** 25 | 3. 在右栏即可看到你的账户 ID 26 | 27 | --- 28 | 29 | **获取 API token** 30 | 31 | 1. 打开 [Cloudflare API](https://dash.cloudflare.com/profile/api-tokens) 32 | 2. 点击 **创建令牌** 33 | 3. 在 **API 令牌模板** 中选择 **编辑 Cloudflare Workers** 栏,点击 **使用模板** 34 | 4. 配置 **名称** 和 **资源**,点击 **创建令牌** 35 | 36 | > 如果要使用自定义权限的令牌,注意在 Cloudflare API 中,**读取** 与 **编辑** 权限是分开的,**编辑** 权限不包含 **读取** 权限。因此请确保勾选 **编辑** 权限时,也将 **读取** 权限勾选。 37 | 38 | 39 | --- 40 | 41 | ### 为什么部署失败了? 42 | 43 | 1. 参考 [如何获取 Worker Account ID 和 API token?](#如何获取-worker-account-id-和-api-token) 确保获取的 Account ID 和 API token 正确,对于 API token,请确保拥有对应的权限。 44 | 45 | 2. 检查 Github Action 中的环境变量是否符合预期,特别是 `ACCOUNT_ID` 和 `API_TOKEN`。 46 | 47 | 3. 如果你认为上面两点都没有问题,那么请提交 Issue,并附上你的 Worker 的完整部署日志。 48 | 49 | --- 50 | 51 | ## 使用 52 | 53 | --- 54 | 55 | ### 我看到你的仓库更新了,我应该如何同步? 56 | 57 | 在**你自己的**仓库主页面,点击右上角的 **Sync fork** 按钮,即可同步。 58 | 59 | --- 60 | 61 | ### 为什么生成的链接无法在国内访问? 62 | 63 | Cloudflare Worker 的`workers.dev`域名默认无法在国内访问。 64 | 65 | 要解决这个问题,你可以: 66 | 67 | - 绑定自己的域名 68 | - 使用代理获取/更新订阅 69 | -------------------------------------------------------------------------------- /src/BaseConfigBuilder.js: -------------------------------------------------------------------------------- 1 | import { ProxyParser } from './ProxyParsers.js'; 2 | import { DeepCopy } from './utils.js'; 3 | 4 | export class BaseConfigBuilder { 5 | constructor(inputString, baseConfig) { 6 | this.inputString = inputString; 7 | this.config = DeepCopy(baseConfig); 8 | this.customRules = []; 9 | } 10 | 11 | async build() { 12 | const customItems = await this.parseCustomItems(); 13 | this.addCustomItems(customItems); 14 | this.addSelectors(); 15 | return this.formatConfig(); 16 | } 17 | 18 | async parseCustomItems() { 19 | const urls = this.inputString.split('\n').filter(url => url.trim() !== ''); 20 | const parsedItems = []; 21 | 22 | for (const url of urls) { 23 | const result = await ProxyParser.parse(url); 24 | if (Array.isArray(result)) { 25 | for (const subUrl of result) { 26 | const subResult = await ProxyParser.parse(subUrl); 27 | if (subResult) { 28 | parsedItems.push(subResult); 29 | } 30 | } 31 | } else if (result) { 32 | parsedItems.push(result); 33 | } 34 | } 35 | 36 | return parsedItems; 37 | } 38 | 39 | addCustomItems(customItems) { 40 | // This method should be implemented in child classes 41 | throw new Error('addCustomItems must be implemented in child class'); 42 | } 43 | 44 | addSelectors() { 45 | // This method should be implemented in child classes 46 | throw new Error('addSelectors must be implemented in child class'); 47 | } 48 | 49 | formatConfig() { 50 | // This method should be implemented in child classes 51 | throw new Error('formatConfig must be implemented in child class'); 52 | } 53 | } -------------------------------------------------------------------------------- /docs/update-log.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | ## 2024-12-27 4 | 5 | - 更新了 sing-box tun中废弃的 inet4_address 为 address 6 | 7 | ## 2024-12-07 8 | 9 | - 确保在手动选择规则时,预定义规则集的选择框也会自动更新为 "custom" 10 | 11 | ## 2024-11-30 12 | 13 | - 添加对 Shadowsocks 旧式 URL 的支持 14 | 15 | ## 2024-11-23 16 | 17 | - Bug修复: 18 | - 重复点击生成按钮时,可能导致无法访问短链 19 | 20 | ## 2024-11-20 21 | 22 | - 修复sing-box配置初次下载过慢的问题 23 | 24 | ## 2024-11-19 25 | 26 | - 改进了整体UI交互体验,提升了操作流畅度 27 | 28 | ## 2024-11-05 29 | 30 | - [新功能] 现在可以保存自定义基础配置 31 | - 优化了UI 32 | 33 | ## 2024-10-15 34 | 35 | - 添加了[FAQ文档](/doc/FAQ.md) 36 | 37 | ## 2024-10-03 38 | 39 | - 现在可以保存并管理自定义短链接 40 | 41 | ## 2024-09-28 42 | 43 | - ([#41](https://github.com/7Sageer/sublink-worker/pull/41)) (by [@Wikeolf](https://github.com/Wikeolf)) 44 | - 添加自定义域名关键词支持 45 | - 现在可以决定自定义规则的顺序 46 | 47 | ## 2024-09-23 48 | 49 | - ([#37](https://github.com/7Sageer/sublink-worker/issues/37)) 修复了VMess和Shadowsocks url中文可能出现乱码的问题 50 | 51 | ## 2024-09-20 52 | 53 | - 在公共站点启用新域名(https://sublink-worker.sageer.me) 54 | 55 | ## 2024-09-18 56 | 57 | - ([#35](https://github.com/7Sageer/sublink-worker/issues/35)) 确保Vmess转换时security选项存在 58 | - 修复了默认配置缺乏出站的问题 59 | 60 | ## 2024-09-15 61 | 62 | - ([#31](https://github.com/7Sageer/sublink-worker/issues/31),[#25](https://github.com/7Sageer/sublink-worker/issues/25)) 现在可以自定义短链接路径 63 | - 优化了前端显示,简化操作流程 64 | 65 | ## 2024-09-13 66 | 67 | - ([#27](https://github.com/7Sageer/sublink-worker/issues/27)) 优化了出站选择排布 68 | 69 | ## 2024-09-10 70 | 71 | - ([#25](https://github.com/7Sageer/sublink-worker/issues/25)) 修复了Base64无法转换多个HTTP的问题 72 | - 现在为生成的链接提供二维码 73 | 74 | ## 2024-09-09 75 | 76 | - ([#23](https://github.com/7Sageer/sublink-worker/issues/23)) 修复了Github规则无效的问题 77 | 78 | ## 2024-09-07 79 | 80 | - ([#16](https://github.com/7Sageer/sublink-worker/issues/16)) 修复了导入base64订阅时出现乱码的问题 81 | 82 | ## 2024-09-03 83 | 84 | - `📚 教育资源` 现在添加了 `geosite-category-scholar-!cn` 规则 85 | 86 | ## 2024-09-02 87 | 88 | - 现在使用 KV 存储短链接,不再依赖 R2 89 | 90 | ## 2024-09-01 91 | 92 | - 自定义规则现在支持以下规则: 93 | - domain_suffix 94 | - ip_cidr 95 | - geoip 96 | - geosite 97 | 98 | ## 2024-08-25 99 | 100 | - 修复 ClashMeta For Android 高于[v2.10.1]版本不显示规则集的问题 101 | 102 | ## 2024-08-20 103 | 104 | - 新增: 105 | - 自定义规则 106 | - 自定义规则的 API 支持,详见 [API-doc.md](/doc/API-doc.md) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Sublink Worker

3 |
Serverless 自部署订阅转换工具最佳实践
4 | 5 | 6 | 7Sageer%2Fsublink-worker | Trendshift 7 | 8 | 9 | 12 |
13 | 14 |

15 | 16 | Deploy to Cloudflare Workers 17 | 18 |

19 |
20 | 21 | ## 🚀 快速开始 22 | 23 | ### 一键部署 24 | 点击上方的 "Deploy to Workers" 按钮,即可快速部署您的专属订阅转换服务。 25 | 26 | ### 新手指南 27 | - [视频教程1](https://www.youtube.com/watch?v=ZTgDm4qReyA) 28 | - [视频教程2](https://www.youtube.com/watch?v=_1BfM2Chn7w) 29 | - [视频教程3](https://www.youtube.com/watch?v=7abmWqCXPR8) 30 | 31 | > 💡 这些是由Youtube社区成员制作的教程视频,详细的讲解可以让你快速上手。但是部分内容可能与我们的见解不同,也可能与最新版本存在差异,建议同时参考[官方文档](/docs) 32 | 33 | ## ✨ 功能特点 34 | 35 | ### 支持协议 36 | - ShadowSocks 37 | - VMess 38 | - VLESS 39 | - Hysteria2 40 | - Trojan 41 | - TUIC 42 | 43 | ### 核心功能 44 | - 支持导入 Base64 的 http/https 订阅链接以及多种协议的分享URL 45 | - 纯JavaScript + Cloudflare Worker实现,一键部署,开箱即用 46 | - 支持固定/随机短链接生成(基于 KV) 47 | - 浅色/深色主题切换 48 | - 灵活的 API,支持脚本化操作 49 | 50 | ### 客户端支持 51 | - Sing-Box 52 | - Clash 53 | - Xray/V2Ray 54 | 55 | ### Web 界面特性 56 | - 用户友好的操作界面 57 | - 提供多种预定义规则集 58 | - 可自建关于 geo-site、geo-ip、ip-cidr 和 domain-suffix 的自定义策略组 59 | 60 | ## 📖 API 文档 61 | 62 | 详细的 API 文档请参考 [API-doc.md](/docs/API-doc.md) 63 | 64 | ### 主要端点 65 | - `/singbox` - 生成 Sing-Box 配置 66 | - `/clash` - 生成 Clash 配置 67 | - `/xray` - 生成 Xray 配置 68 | - `/shorten` - 生成短链接 69 | 70 | ## 📝 最近更新 71 | 72 | ### 2024-12-27 73 | 74 | - 更新了 sing-box tun中废弃的 inet4_address 为 address 75 | 76 | ## 🔧 项目结构 77 | 78 | ``` 79 | . 80 | ├── index.js # 主要的服务器逻辑,处理请求路由 81 | ├── BaseConfigBuilder.js # 构建基础配置 82 | ├── SingboxConfigBuilder.js # 构建 Sing-Box 配置 83 | ├── ClashConfigBuilder.js # 构建 Clash 配置 84 | ├── ProxyParsers.js # 解析各种代理协议的 URL 85 | ├── utils.js # 提供各种实用函数 86 | ├── htmlBuilder.js # 生成 Web 界面 87 | ├── style.js # 生成 Web 界面的 CSS 88 | ├── config.js # 保存配置信息 89 | └── docs/ 90 | ├── API-doc.md # API 文档 91 | ├── update-log.md # 更新日志 92 | └── FAQ.md # 常见问题解答 93 | ``` 94 | 95 | ## 🤝 贡献 96 | 97 | 欢迎提交 Issues 和 Pull Requests 来改进这个项目。 98 | 99 | ## 📄 许可证 100 | 101 | 本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。 102 | 103 | ## ⚠️ 免责声明 104 | 105 | 本项目仅供学习交流使用,请勿用于非法用途。使用本项目所造成的一切后果由使用者自行承担,与开发者无关。 106 | 107 | ## ⭐ Star History 108 | 109 | 感谢所有为本项目点亮 Star 的朋友们!🌟 110 | 111 | [![Star History Chart](https://api.star-history.com/svg?repos=7Sageer/sublink-worker&type=Date)](https://star-history.com/#7Sageer/sublink-worker&Date) 112 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | 174 | test 175 | .editorconfig 176 | .prettierrc 177 | package-lock.json -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Worker 2 | on: 3 | workflow_dispatch: 4 | repository_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: '21' 19 | 20 | - name: Install dependencies 21 | run: npm install 22 | 23 | - name: Install wrangler 24 | run: npm install -g wrangler 25 | 26 | - name: Check and Create KV Namespace 27 | env: 28 | CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 29 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 30 | run: | 31 | KV_NAMESPACE="SUBLINK_KV" 32 | echo "Checking for KV namespace: $KV_NAMESPACE" 33 | if ! LIST_OUTPUT=$(wrangler kv namespace list 2>&1); then 34 | echo "Error getting KV namespace list: $LIST_OUTPUT" 35 | exit 1 36 | fi 37 | 38 | LIST_OUTPUT=$(echo "$LIST_OUTPUT" | grep -v "Cloudflare collects" | grep -v "telemetry") 39 | echo "Cleaned KV namespace list output: $LIST_OUTPUT" 40 | 41 | if ! echo "$LIST_OUTPUT" | jq empty; then 42 | echo "Invalid JSON output from wrangler" 43 | exit 1 44 | fi 45 | 46 | KV_ID=$(echo "$LIST_OUTPUT" | jq -r '.[] | select(.title == "sublink-worker-'$KV_NAMESPACE'") | .id') 47 | 48 | if [ -z "$KV_ID" ]; then 49 | echo "KV namespace $KV_NAMESPACE does not exist. Creating..." 50 | CREATE_OUTPUT=$(wrangler kv namespace create "$KV_NAMESPACE" 2>&1) 51 | echo "Create KV namespace output: $CREATE_OUTPUT" 52 | KV_ID=$(echo "$CREATE_OUTPUT" | grep -o '[0-9a-f]\{32\}') 53 | 54 | if [ -z "$KV_ID" ]; then 55 | echo "Failed to extract KV ID. Full output: $CREATE_OUTPUT" 56 | exit 1 57 | fi 58 | 59 | echo "KV namespace $KV_NAMESPACE created successfully with ID: $KV_ID" 60 | else 61 | echo "KV namespace $KV_NAMESPACE already exists with ID: $KV_ID" 62 | fi 63 | echo "KV_ID=$KV_ID" >> $GITHUB_ENV 64 | 65 | - name: Update wrangler.toml 66 | run: | 67 | # Read the entire content of wrangler.toml 68 | WRANGLER_CONTENT=$(cat wrangler.toml) 69 | 70 | # Update the KV namespace ID 71 | UPDATED_CONTENT=$(echo "$WRANGLER_CONTENT" | sed 's/id = "[^"]*"/id = "'$KV_ID'"/') 72 | 73 | # Write the updated content back to wrangler.toml 74 | echo "$UPDATED_CONTENT" > wrangler.toml 75 | 76 | echo "Updated wrangler.toml content:" 77 | cat wrangler.toml 78 | 79 | - name: Deploy to Cloudflare Workers 80 | uses: cloudflare/wrangler-action@2.0.0 81 | with: 82 | apiToken: ${{ secrets.CF_API_TOKEN }} 83 | command: deploy -------------------------------------------------------------------------------- /src/SingboxConfigBuilder.js: -------------------------------------------------------------------------------- 1 | import { SING_BOX_CONFIG, generateRuleSets, generateRules, getOutbounds, PREDEFINED_RULE_SETS} from './config.js'; 2 | import { BaseConfigBuilder } from './BaseConfigBuilder.js'; 3 | import { DeepCopy } from './utils.js'; 4 | 5 | export class ConfigBuilder extends BaseConfigBuilder { 6 | constructor(inputString, selectedRules, customRules, pin, baseConfig) { 7 | if (baseConfig === undefined) { 8 | baseConfig = SING_BOX_CONFIG 9 | } 10 | super(inputString, baseConfig); 11 | this.selectedRules = selectedRules; 12 | this.customRules = customRules; 13 | this.pin = pin; 14 | } 15 | 16 | addCustomItems(customItems) { 17 | const validItems = customItems.filter(item => item != null); 18 | this.config.outbounds.push(...validItems); 19 | } 20 | 21 | addSelectors() { 22 | let outbounds; 23 | if (typeof this.selectedRules === 'string' && PREDEFINED_RULE_SETS[this.selectedRules]) { 24 | outbounds = getOutbounds(PREDEFINED_RULE_SETS[this.selectedRules]); 25 | } else if(this.selectedRules && Object.keys(this.selectedRules).length > 0) { 26 | outbounds = getOutbounds(this.selectedRules); 27 | } else { 28 | outbounds = getOutbounds(PREDEFINED_RULE_SETS.minimal); 29 | } 30 | 31 | const proxyList = this.config.outbounds.filter(outbound => outbound?.server != undefined).map(outbound => outbound.tag); 32 | 33 | this.config.outbounds.unshift({ 34 | type: "urltest", 35 | tag: "⚡ 自动选择", 36 | outbounds: DeepCopy(proxyList), 37 | }); 38 | 39 | proxyList.unshift('DIRECT', 'REJECT', '⚡ 自动选择'); 40 | outbounds.unshift('🚀 节点选择','GLOBAL'); 41 | 42 | outbounds.forEach(outbound => { 43 | if (outbound !== '🚀 节点选择') { 44 | this.config.outbounds.push({ 45 | type: "selector", 46 | tag: outbound, 47 | outbounds: ['🚀 节点选择', ...proxyList] 48 | }); 49 | } else { 50 | this.config.outbounds.unshift({ 51 | type: "selector", 52 | tag: outbound, 53 | outbounds: proxyList 54 | }); 55 | } 56 | }); 57 | 58 | if (Array.isArray(this.customRules)) { 59 | this.customRules.forEach(rule => { 60 | this.config.outbounds.push({ 61 | type: "selector", 62 | tag: rule.name, 63 | outbounds: ['🚀 节点选择', ...proxyList] 64 | }); 65 | }); 66 | } 67 | 68 | this.config.outbounds.push({ 69 | type: "selector", 70 | tag: "🐟 漏网之鱼", 71 | outbounds: ['🚀 节点选择', ...proxyList] 72 | }); 73 | } 74 | 75 | formatConfig() { 76 | const rules = generateRules(this.selectedRules, this.customRules, this.pin); 77 | const { site_rule_sets, ip_rule_sets } = generateRuleSets(this.selectedRules,this.customRules); 78 | 79 | this.config.route.rule_set = [...site_rule_sets, ...ip_rule_sets]; 80 | 81 | this.config.route.rules = rules.map(rule => ({ 82 | rule_set: [ 83 | ...(rule.site_rules.length > 0 && rule.site_rules[0] !== '' ? rule.site_rules : []), 84 | ...(rule.ip_rules.filter(ip => ip.trim() !== '').map(ip => `${ip}-ip`)) 85 | ], 86 | domain_suffix: rule.domain_suffix, 87 | domain_keyword: rule.domain_keyword, 88 | ip_cidr: rule.ip_cidr, 89 | protocol: rule.protocol, 90 | outbound: rule.outbound 91 | })); 92 | // Add any default rules that should always be present 93 | this.config.route.rules.unshift( 94 | { protocol: 'dns', outbound: 'dns-out' }, 95 | { clash_mode: 'direct', outbound: 'DIRECT' }, 96 | { clash_mode: 'global', outbound: 'GLOBAL' } 97 | ); 98 | 99 | this.config.route.auto_detect_interface = true; 100 | this.config.route.final = '🐟 漏网之鱼'; 101 | 102 | return this.config; 103 | } 104 | } -------------------------------------------------------------------------------- /docs/base-config.md: -------------------------------------------------------------------------------- 1 | # 自定义基础配置指南 2 | 3 | ## 概述 4 | 5 | 如果你不知道如何配置 SingBox 和 Clash 的配置格式,请不要使用此功能。 6 | 7 | 我们正试图让用户能够根据自己的需求修改 SingBox 和 Clash 的基础配置。这是一个实验性功能,主要面向高级用户。 8 | 9 | 如果需要使用此功能,请确保你了解 SingBox 和 Clash 的配置格式,并能够正确配置。 10 | 11 | > 注意:此功能目前还在早期开发阶段,可能会出现各种预期之外的问题,请谨慎使用 12 | 13 | ## 默认配置 14 | 15 | ### SingBox 默认配置 16 | 17 | ```json 18 | { 19 | "dns": { 20 | "servers": [ 21 | { 22 | "tag": "dns_proxy", 23 | "address": "tls://1.1.1.1", 24 | "address_resolver": "dns_resolver" 25 | }, 26 | { 27 | "tag": "dns_direct", 28 | "address": "h3://dns.alidns.com/dns-query", 29 | "address_resolver": "dns_resolver", 30 | "detour": "DIRECT" 31 | }, 32 | { 33 | "tag": "dns_fakeip", 34 | "address": "fakeip" 35 | }, 36 | { 37 | "tag": "dns_resolver", 38 | "address": "223.5.5.5", 39 | "detour": "DIRECT" 40 | }, 41 | { 42 | "tag": "dns_block", 43 | "address": "rcode://success" 44 | } 45 | ], 46 | "rules": [ 47 | { 48 | "outbound": ["any"], 49 | "server": "dns_resolver" 50 | }, 51 | { 52 | "geosite": ["category-ads-all"], 53 | "server": "dns_block", 54 | "disable_cache": true 55 | }, 56 | { 57 | "geosite": ["geolocation-!cn"], 58 | "query_type": ["A", "AAAA"], 59 | "server": "dns_fakeip" 60 | }, 61 | { 62 | "geosite": ["geolocation-!cn"], 63 | "server": "dns_proxy" 64 | } 65 | ], 66 | "final": "dns_direct", 67 | "independent_cache": true, 68 | "fakeip": { 69 | "enabled": true, 70 | "inet4_range": "198.18.0.0/15" 71 | } 72 | }, 73 | "ntp": { 74 | "enabled": true, 75 | "server": "time.apple.com", 76 | "server_port": 123, 77 | "interval": "30m", 78 | "detour": "DIRECT" 79 | }, 80 | "inbounds": [ 81 | { 82 | "type": "mixed", 83 | "tag": "mixed-in", 84 | "listen": "0.0.0.0", 85 | "listen_port": 2080 86 | }, 87 | { 88 | "type": "tun", 89 | "tag": "tun-in", 90 | "inet4_address": "172.19.0.1/30", 91 | "auto_route": true, 92 | "strict_route": true, 93 | "stack": "mixed", 94 | "sniff": true 95 | } 96 | ], 97 | "outbounds": [ 98 | { 99 | "type": "direct", 100 | "tag": "DIRECT" 101 | }, 102 | { 103 | "type": "block", 104 | "tag": "REJECT" 105 | }, 106 | { 107 | "type": "dns", 108 | "tag": "dns-out" 109 | } 110 | ], 111 | "route": {}, 112 | "experimental": { 113 | "cache_file": { 114 | "enabled": true, 115 | "store_fakeip": true 116 | }, 117 | "clash_api": { 118 | "external_controller": "127.0.0.1:9090", 119 | "external_ui": "dashboard" 120 | } 121 | } 122 | } 123 | ``` 124 | 125 | ### Clash 默认配置 126 | 127 | ```yaml 128 | port: 7890 129 | socks-port: 7891 130 | allow-lan: false 131 | mode: Rule 132 | log-level: info 133 | dns: 134 | enable: true 135 | ipv6: true 136 | respect-rules: true 137 | enhanced-mode: fake-ip 138 | nameserver: 139 | - https://120.53.53.53/dns-query 140 | - https://223.5.5.5/dns-query 141 | proxy-server-nameserver: 142 | - https://120.53.53.53/dns-query 143 | - https://223.5.5.5/dns-query 144 | nameserver-policy: 145 | geosite:cn,private: 146 | "https://120.53.53.53/dns-query" 147 | "https://223.5.5.5/dns-query" 148 | geosite:geolocation-!cn: 149 | "https://dns.cloudflare.com/dns-query" 150 | "https://dns.google/dns-query" 151 | ``` 152 | 153 | ## 注意事项 154 | 155 | 1. **格式要求**: 156 | - SingBox 配置必须是有效的 JSON 格式 157 | - Clash 配置必须是有效的 YAML 格式 158 | - 目前不支持跨客户端的配置,例如:使用 Clash 的配置文件将无法在 SingBox 中使用 159 | - 配置中的必要字段不能缺失 160 | 161 | 2. **保留字段**: 162 | - 目前程序**不会**自动添加、覆盖任何字段。例如,如果在sing-box配置文件中缺失了`block`, `direct`等字段,将导致程序无法按照预期工作 163 | - 建议主要修改 DNS、NTP等基础配置,或者在提供的配置基础上修改 164 | 165 | 3. **配置验证**: 166 | - 保存前会进行基本的格式验证 167 | - 建议在本地测试配置是否可用后再使用 168 | 169 | 4. **持久化存储**: 170 | - 配置会通过 URL 参数保存 171 | - 可以通过分享链接分享你的自定义配置 172 | 173 | ## 配置保存 174 | 175 | ### 功能说明 176 | 177 | - 支持保存自定义的SingBox和Clash基础配置,会以唯一ID的形式存储在KV中,保存期限为30天。 178 | - 点击保存按钮后会生成一个配置ID,使用该ID可以访问到保存的配置。 179 | - 你也可以直接使用通过API进行保存,详见[API文档](./API-doc.md) 180 | 181 | ### 使用方法 182 | 183 | 1. 选择配置类型(SingBox或Clash) 184 | 2. 在配置编辑器中粘贴你的配置并保存 185 | 3. 系统会生成一个配置ID并更新URL,直接点击`Convert`按钮即可获取对应订阅链接 186 | 4. 可以直接使用带有configId参数的URL 187 | 188 | ### 贡献 189 | 190 | 如果你有任何建议或问题,请随时在[GitHub](https://github.com/7Sageer/sublink-worker)上提交issue。 191 | 192 | 也欢迎提交PR来完善此功能 193 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const PATH_LENGTH = 7; 2 | 3 | 4 | // Base64 编码函数 5 | export function encodeBase64(input) { 6 | const encoder = new TextEncoder(); 7 | const utf8Array = encoder.encode(input); 8 | let binaryString = ''; 9 | for (const byte of utf8Array) { 10 | binaryString += String.fromCharCode(byte); 11 | } 12 | return base64FromBinary(binaryString); 13 | } 14 | 15 | // Base64 解码函数 16 | export function decodeBase64(input) { 17 | const binaryString = base64ToBinary(input); 18 | const bytes = new Uint8Array(binaryString.length); 19 | for (let i = 0; i < binaryString.length; i++) { 20 | bytes[i] = binaryString.charCodeAt(i); 21 | } 22 | const decoder = new TextDecoder(); 23 | return decoder.decode(bytes); 24 | } 25 | 26 | // 将二进制字符串转换为 Base64(编码) 27 | export function base64FromBinary(binaryString) { 28 | const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; 29 | let base64String = ''; 30 | let padding = ''; 31 | 32 | const remainder = binaryString.length % 3; 33 | if (remainder > 0) { 34 | padding = '='.repeat(3 - remainder); 35 | binaryString += '\0'.repeat(3 - remainder); 36 | } 37 | 38 | for (let i = 0; i < binaryString.length; i += 3) { 39 | const bytes = [ 40 | binaryString.charCodeAt(i), 41 | binaryString.charCodeAt(i + 1), 42 | binaryString.charCodeAt(i + 2) 43 | ]; 44 | const base64Index1 = bytes[0] >> 2; 45 | const base64Index2 = ((bytes[0] & 3) << 4) | (bytes[1] >> 4); 46 | const base64Index3 = ((bytes[1] & 15) << 2) | (bytes[2] >> 6); 47 | const base64Index4 = bytes[2] & 63; 48 | 49 | base64String += base64Chars[base64Index1] + 50 | base64Chars[base64Index2] + 51 | base64Chars[base64Index3] + 52 | base64Chars[base64Index4]; 53 | } 54 | 55 | return base64String.slice(0, base64String.length - padding.length) + padding; 56 | } 57 | 58 | // 将 Base64 转换为二进制字符串(解码) 59 | export function base64ToBinary(base64String) { 60 | const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; 61 | let binaryString = ''; 62 | base64String = base64String.replace(/=+$/, ''); // 去掉末尾的 '=' 63 | 64 | for (let i = 0; i < base64String.length; i += 4) { 65 | const bytes = [ 66 | base64Chars.indexOf(base64String[i]), 67 | base64Chars.indexOf(base64String[i + 1]), 68 | base64Chars.indexOf(base64String[i + 2]), 69 | base64Chars.indexOf(base64String[i + 3]) 70 | ]; 71 | const byte1 = (bytes[0] << 2) | (bytes[1] >> 4); 72 | const byte2 = ((bytes[1] & 15) << 4) | (bytes[2] >> 2); 73 | const byte3 = ((bytes[2] & 3) << 6) | bytes[3]; 74 | 75 | if (bytes[1] !== -1) binaryString += String.fromCharCode(byte1); 76 | if (bytes[2] !== -1) binaryString += String.fromCharCode(byte2); 77 | if (bytes[3] !== -1) binaryString += String.fromCharCode(byte3); 78 | } 79 | 80 | return binaryString; 81 | } 82 | export function DeepCopy(obj) { 83 | if (obj === null || typeof obj !== 'object') { 84 | return obj; 85 | } 86 | if (Array.isArray(obj)) { 87 | return obj.map(item => DeepCopy(item)); 88 | } 89 | const newObj = {}; 90 | for (const key in obj) { 91 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 92 | newObj[key] = DeepCopy(obj[key]); 93 | } 94 | } 95 | return newObj; 96 | } 97 | 98 | export function GenerateWebPath(length = PATH_LENGTH) { 99 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 100 | let result = '' 101 | for (let i = 0; i < length; i++) { 102 | result += characters.charAt(Math.floor(Math.random() * characters.length)) 103 | } 104 | return result 105 | } 106 | 107 | export function parseServerInfo(serverInfo) { 108 | let host, port; 109 | if (serverInfo.startsWith('[')) { 110 | const closeBracketIndex = serverInfo.indexOf(']'); 111 | host = serverInfo.slice(1, closeBracketIndex); 112 | port = serverInfo.slice(closeBracketIndex + 2); // +2 to skip ']:' 113 | } else { 114 | const lastColonIndex = serverInfo.lastIndexOf(':'); 115 | host = serverInfo.slice(0, lastColonIndex); 116 | port = serverInfo.slice(lastColonIndex + 1); 117 | } 118 | return { host, port: parseInt(port) }; 119 | } 120 | 121 | export function parseUrlParams(url) { 122 | const [, rest] = url.split('://'); 123 | const [addressPart, ...remainingParts] = rest.split('?'); 124 | const paramsPart = remainingParts.join('?'); 125 | 126 | const [paramsOnly, ...fragmentParts] = paramsPart.split('#'); 127 | const searchParams = new URLSearchParams(paramsOnly); 128 | const params = Object.fromEntries(searchParams.entries()); 129 | 130 | const name = fragmentParts.length > 0 ? decodeURIComponent(fragmentParts.join('#')) : ''; 131 | 132 | return { addressPart, params, name }; 133 | } 134 | 135 | export function createTlsConfig(params) { 136 | let tls = { enabled: false }; 137 | if (params.security === 'xtls' || params.security === 'tls' || params.security === 'reality') { 138 | tls = { 139 | enabled: true, 140 | server_name: params.sni || params.host, 141 | insecure: false, 142 | utls: { 143 | enabled: true, 144 | fingerprint: "chrome" 145 | }, 146 | }; 147 | if (params.security === 'reality') { 148 | tls.reality = { 149 | enabled: true, 150 | public_key: params.pbk, 151 | short_id: params.sid, 152 | }; 153 | } 154 | } 155 | return tls; 156 | } 157 | 158 | export function createTransportConfig(params) { 159 | return { 160 | type: params.type, 161 | path: params.path ?? undefined, 162 | ...(params.host && { 'headers': { 'host': params.host } }), 163 | service_name: params.serviceName ?? undefined, 164 | }; 165 | } 166 | -------------------------------------------------------------------------------- /docs/API-doc.md: -------------------------------------------------------------------------------- 1 | # Sublink Worker API 文档 2 | 3 | ## 概述 4 | 5 | Sublink Worker 是一个部署在 Cloudflare Workers 上的轻量级订阅转换工具。它可以将各种代理协议的分享 URL 转换为不同客户端可用的订阅链接。本文档概述了 API 端点及其用法。 6 | 7 | ## 基础 URL 8 | 9 | 所有 API 请求应发送至: 10 | 11 | ``` 12 | https://your-worker-domain.workers.dev 13 | ``` 14 | 15 | 将 `your-worker-domain` 替换为您实际的 Cloudflare Workers 域名。 16 | 17 | ## 端点 18 | 19 | ### 1. 生成配置 20 | 21 | #### Sing-Box 配置 22 | 23 | - **URL**: `/singbox` 24 | - **方法**: GET 25 | - **参数**: 26 | - `config` (必需): URL 编码的字符串,包含一个或多个代理配置 27 | - `selectedRules` (可选): 预定义规则集名称或自定义规则的 JSON 数组 28 | - `customRules` (可选): 自定义规则的 JSON 数组 29 | - `pin` (可选): 布尔值,是否将自定义规则置于预定义规则之上 30 | - `configId` (可选): 字符串,使用保存的配置ID。详见[保存自定义配置](#4-保存自定义配置) 31 | 32 | **示例**: 33 | ``` 34 | /singbox?config=vmess%3A%2F%2Fexample&selectedRules=balanced&customRules=%5B%7B%22sites%22%3A%5B%22example.com%22%5D%2C%22ips%22%3A%5B%22192.168.1.1%22%5D%2C%22domain_suffix%22%3A%5B%22.com%22%5D%2C%22ip_cidr%22%3A%5B%2210.0.0.0%2F8%22%5D%2C%22outbound%22%3A%22MyCustomRule%22%7D%5D 35 | ``` 36 | 37 | #### Clash 配置 38 | 39 | - **URL**: `/clash` 40 | - **方法**: GET 41 | - **参数**: 与 Sing-Box 配置相同 42 | 43 | #### Xray 配置 44 | 45 | - **URL**: `/xray` 46 | - **方法**: GET 47 | - **参数**: 48 | - `config` (必需): URL 编码的字符串,包含一个或多个代理配置 49 | 50 | ### 2. 缩短 URL 51 | 52 | - **URL**: `/shorten` 53 | - **方法**: GET 54 | - **参数**: 55 | - `url` (必需): 需要缩短的原始 URL 56 | 57 | **示例**: 58 | ``` 59 | /shorten?url=https%3A%2F%2Fexample.com%2Fvery-long-url 60 | ``` 61 | 62 | **响应**: 63 | ```json 64 | { 65 | "shortUrl": "https://your-worker-domain.workers.dev/s/abcdefg" 66 | } 67 | ``` 68 | 69 | ### 3. 重定向短 URL 70 | 71 | - **URL**: `/s/{shortCode}` 72 | - **方法**: GET 73 | - **描述**: 重定向到与短代码关联的原始 URL 74 | 75 | ### 4. 保存自定义配置 76 | 77 | - **URL**: `/config` 78 | - **方法**: POST 79 | - **Content-Type**: application/json 80 | - **请求体**: 81 | 82 | ```json 83 | { 84 | "type": "clash" | "singbox", // 配置类型 85 | "content": "配置内容" // 字符串格式的配置内容 86 | } 87 | ``` 88 | 89 | - **响应**: 90 | - 成功: 返回配置ID (字符串) 91 | - 失败: 返回错误信息 (400 状态码) 92 | 93 | **说明**: 94 | - 配置内容会进行格式验证 95 | - Clash配置支持YAML和JSON格式 96 | - SingBox配置必须是有效的JSON格式 97 | - 配置将保存30天 98 | - 配置ID可以通过URL参数`configId`使用 99 | 100 | **示例**: 101 | 102 | ``` bash 103 | curl -X POST https://your-worker-domain.workers.dev/config \ 104 | -H "Content-Type: application/json" \ 105 | -d '{ 106 | "type": "clash", 107 | "content": "port: 7890\nallow-lan: false\nmode: Rule" 108 | }' 109 | ``` 110 | 111 | **使用保存的配置**: 112 | 将返回的配置ID添加到URL参数中即可使用保存的配置: 113 | ``` 114 | https://your-worker-domain.workers.dev/clash?config=vmess://xxx&configId=clash_abc123 115 | ``` 116 | 117 | 详情请参考[使用说明](#使用说明) 118 | 119 | ## 预定义规则集 120 | 121 | API 支持以下预定义规则集: 122 | 123 | - `minimal`: 基本规则集 124 | - `balanced`: 适中规则集 125 | - `comprehensive`: 完整规则集 126 | 127 | 这些可以在 Sing-Box 和 Clash 配置的 `selectedRules` 参数中使用。 128 | 129 | 下面是目前支持的预定义规则集: 130 | 131 | | Rule Name | Used Site Rules | Used IP Rules | 132 | |---|---|---| 133 | | Ad Block | category-ads-all | | 134 | | AI Services | openai,anthropic,jetbrains-ai,perplexity | | 135 | | Bilibili | bilibili | | 136 | | Youtube | youtube | | 137 | | Google | google | google | 138 | | Private | | private | 139 | | Location:CN | geolocation-cn | cn | 140 | | Telegram | | telegram | 141 | | Microsoft | microsoft | | 142 | | Apple | apple | | 143 | | Bahamut | bahamut | | 144 | | Social Media | facebook, instagram, twitter, tiktok, linkedin | | 145 | | Streaming | netflix, hulu, disney, hbo, amazon | | 146 | | Gaming | steam, epicgames, ea, ubisoft, blizzard | | 147 | | Github | github, gitlab | | 148 | | Education | coursera, edx, udemy, khanacademy, category-scholar-!cn | | 149 | | Financial | paypal, visa, mastercard, stripe, wise | | 150 | | Cloud Services | aws, azure, digitalocean, heroku, dropbox | | 151 | 152 | Singbox 的规则集来自 [https://github.com/lyc8503/sing-box-rules](https://github.com/lyc8503/sing-box-rules), 感谢 lyc8503 的贡献! 153 | 154 | ## 自定义规则 155 | 156 | 除了使用预定义规则集,您还可以在 `customRules` 参数中提供自定义规则列表作为 JSON 数组。每个自定义规则应包含以下字段: 157 | 158 | - `sites`: 域名规则数组 159 | - `ips`: IP 规则数组 160 | - `domain_suffix`: 域名后缀规则数组 161 | - `domain_keyword`: 域名关键词规则数组 162 | - `ip_cidr`: IP CIDR 规则数组 163 | - `protocol`: 协议规则数组 164 | - `outbound`: 出站名称 165 | 166 | 示例: 167 | 168 | ```json 169 | [ 170 | { 171 | "sites": ["google", "anthropic"], 172 | "ips": ["private", "cn"], 173 | "domain_suffix": [".com", ".org"], 174 | "domain_keyword": ["Mijia Cloud", "push.apple"], 175 | "ip_cidr": ["192.168.0.0/16", "10.0.0.0/8"], 176 | "protocol": ["http", "tls", "dns"], 177 | "outbound": "🤪 MyCustomRule" 178 | } 179 | ] 180 | ``` 181 | 您还可以使用 `pin` 参数将自定义规则置于预定义规则之上,以便自定义规则生效。 182 | 183 | ## 错误处理 184 | 185 | API 在出现问题时将返回适当的 HTTP 状态码和错误消息: 186 | 187 | - 400 Bad Request: 当缺少必需参数或参数无效时 188 | - 404 Not Found: 当请求的资源(如短 URL)不存在时 189 | - 500 Internal Server Error: 服务器端错误 190 | 191 | ## 使用说明 192 | 193 | 1. `config` 参数中的所有代理配置应进行 URL 编码。 194 | 2. 可以在单个请求中包含多个代理配置,方法是在 URL 编码的 `config` 参数中用换行符 (`%0A`) 分隔它们。 195 | 3. 使用自定义规则时,确保规则名称与自定义规则部分列出的名称完全匹配。 196 | 4. 缩短的 URL 旨在临时使用,可能在一定时间后过期。 197 | 198 | ## 示例 199 | 200 | 1. 生成带有平衡规则集的 Sing-Box 配置: 201 | ``` 202 | /singbox?config=vmess%3A%2F%2Fexample&selectedRules=balanced 203 | ``` 204 | 205 | 2. 生成带有置顶自定义规则的 Clash 配置: 206 | ``` 207 | /clash?config=vless%3A%2F%2Fexample&customRules=%5B%7B%22sites%22%3A%5B%22example.com%22%5D%2C%22ips%22%3A%5B%22192.168.1.1%22%5D%2C%22domain_suffix%22%3A%5B%22.com%22%5D%2C%22domain_keyword%22%3A%5B%22Mijia%20Cloud%22%5D%2C%22ip_cidr%22%3A%5B%2210.0.0.0%2F8%22%5D%2C%22outbound%22%3A%22MyCustomRule%22%7D%5D&pin=true 208 | ``` 209 | 210 | 3. 缩短 URL: 211 | ``` 212 | /shorten?url=https%3A%2F%2Fyour-worker-domain.workers.dev%2Fsingbox%3Fconfig%3Dvmess%253A%252F%252Fexample%26selectedRules%3Dbalanced 213 | ``` 214 | 215 | ## 结论 216 | 217 | Sublink Worker API 提供了一种灵活而强大的方式来生成和管理代理配置。它支持多种代理协议、各种客户端类型和可自定义的路由规则。URL 缩短功能允许轻松共享和管理复杂的配置。 218 | 219 | 如有任何问题或功能请求,欢迎联系[@7Sageer](https://github.com/7Sageer)。 220 | -------------------------------------------------------------------------------- /src/ProxyParsers.js: -------------------------------------------------------------------------------- 1 | import { parseServerInfo, parseUrlParams, createTlsConfig, createTransportConfig, decodeBase64 } from './utils.js'; 2 | 3 | 4 | export class ProxyParser { 5 | static parse(url) { 6 | url = url.trim(); 7 | const type = url.split('://')[0]; 8 | switch(type) { 9 | case 'ss': return new ShadowsocksParser().parse(url); 10 | case 'vmess': return new VmessParser().parse(url); 11 | case 'vless': return new VlessParser().parse(url); 12 | case 'hysteria2': 13 | case 'hy2': 14 | return new Hysteria2Parser().parse(url); 15 | case 'http': 16 | case 'https': 17 | return HttpParser.parse(url); 18 | case 'trojan': return new TrojanParser().parse(url); 19 | case 'tuic': return new TuicParser().parse(url); 20 | } 21 | } 22 | } 23 | 24 | class ShadowsocksParser { 25 | parse(url) { 26 | let parts = url.replace('ss://', '').split('#'); 27 | let mainPart = parts[0]; 28 | let tag = parts[1]; 29 | if (tag && tag.includes('%')) { 30 | tag = decodeURIComponent(tag); 31 | } 32 | 33 | // Try new format first 34 | try { 35 | let [base64, serverPart] = mainPart.split('@'); 36 | // If no @ symbol found, try legacy format 37 | if (!serverPart) { 38 | // Decode the entire mainPart for legacy format 39 | let decodedLegacy = decodeBase64(mainPart); 40 | // Legacy format: method:password@server:port 41 | let [methodAndPass, serverInfo] = decodedLegacy.split('@'); 42 | let [method, password] = methodAndPass.split(':'); 43 | let [server, server_port] = this.parseServer(serverInfo); 44 | 45 | return this.createConfig(tag, server, server_port, method, password); 46 | } 47 | 48 | // Continue with new format parsing 49 | let decodedParts = decodeBase64(base64).split(':'); 50 | let method = decodedParts[0]; 51 | let password = decodedParts.slice(1).join(':'); 52 | let [server, server_port] = this.parseServer(serverPart); 53 | 54 | return this.createConfig(tag, server, server_port, method, password); 55 | } catch (e) { 56 | console.error('Failed to parse shadowsocks URL:', e); 57 | return null; 58 | } 59 | } 60 | 61 | // Helper method to parse server info 62 | parseServer(serverPart) { 63 | // Match IPv6 address 64 | let match = serverPart.match(/\[([^\]]+)\]:(\d+)/); 65 | if (match) { 66 | return [match[1], match[2]]; 67 | } 68 | return serverPart.split(':'); 69 | } 70 | 71 | // Helper method to create config object 72 | createConfig(tag, server, server_port, method, password) { 73 | return { 74 | "tag": tag || "Shadowsocks", 75 | "type": 'shadowsocks', 76 | "server": server, 77 | "server_port": parseInt(server_port), 78 | "method": method, 79 | "password": password, 80 | "network": 'tcp', 81 | "tcp_fast_open": false 82 | }; 83 | } 84 | } 85 | 86 | class VmessParser { 87 | parse(url) { 88 | let base64 = url.replace('vmess://', '') 89 | let vmessConfig = JSON.parse(decodeBase64(base64)) 90 | let tls = { "enabled": false } 91 | let transport = {} 92 | if (vmessConfig.net === 'ws') { 93 | transport = { 94 | "type": "ws", 95 | "path": vmessConfig.path, 96 | "headers": { 'Host': vmessConfig.host? vmessConfig.host : vmessConfig.sni } 97 | } 98 | if (vmessConfig.tls !== '') { 99 | tls = { 100 | "enabled": true, 101 | "server_name": vmessConfig.sni, 102 | "insecure": false 103 | } 104 | } 105 | } 106 | return { 107 | "tag": vmessConfig.ps, 108 | "type": "vmess", 109 | "server": vmessConfig.add, 110 | "server_port": parseInt(vmessConfig.port), 111 | "uuid": vmessConfig.id, 112 | "alter_id": parseInt(vmessConfig.aid), 113 | "security": vmessConfig.scy || "auto", 114 | "network": "tcp", 115 | "tcp_fast_open": false, 116 | "transport": transport, 117 | "tls": tls.enabled ? tls : undefined 118 | } 119 | 120 | } 121 | } 122 | 123 | class VlessParser { 124 | parse(url) { 125 | const { addressPart, params, name } = parseUrlParams(url); 126 | const [uuid, serverInfo] = addressPart.split('@'); 127 | const { host, port } = parseServerInfo(serverInfo); 128 | 129 | const tls = createTlsConfig(params); 130 | const transport = params.type !== 'tcp' ? createTransportConfig(params) : undefined; 131 | 132 | return { 133 | type: "vless", 134 | tag: name, 135 | server: host, 136 | server_port: port, 137 | uuid: uuid, 138 | tcp_fast_open: false, 139 | tls: tls, 140 | transport: transport, 141 | network: "tcp", 142 | flow: params.flow ?? undefined 143 | }; 144 | } 145 | } 146 | 147 | class Hysteria2Parser { 148 | parse(url) { 149 | const { addressPart, params, name } = parseUrlParams(url); 150 | const [uuid, serverInfo] = addressPart.split('@'); 151 | const { host, port } = parseServerInfo(serverInfo); 152 | 153 | const tls = { 154 | enabled: true, 155 | server_name: params.sni, 156 | insecure: true, 157 | alpn: ["h3"], 158 | }; 159 | 160 | const obfs = {}; 161 | if (params['obfs-password']) { 162 | obfs.type = params.obfs; 163 | obfs.password = params['obfs-password']; 164 | }; 165 | 166 | return { 167 | tag: name, 168 | type: "hysteria2", 169 | server: host, 170 | server_port: port, 171 | password: uuid, 172 | tls: tls, 173 | obfs: obfs, 174 | up_mbps: 100, 175 | down_mbps: 100 176 | }; 177 | } 178 | } 179 | 180 | class TrojanParser { 181 | parse(url) { 182 | const { addressPart, params, name } = parseUrlParams(url); 183 | const [password, serverInfo] = addressPart.split('@'); 184 | const { host, port } = parseServerInfo(serverInfo); 185 | 186 | const parsedURL = parseServerInfo(addressPart); 187 | const tls = createTlsConfig(params); 188 | const transport = params.type !== 'tcp' ? createTransportConfig(params) : undefined; 189 | return { 190 | type: 'trojan', 191 | tag: name, 192 | server: host, 193 | server_port: port, 194 | password: password || parsedURL.username, 195 | network: "tcp", 196 | tcp_fast_open: false, 197 | tls: tls, 198 | transport: transport, 199 | flow: params.flow ?? undefined 200 | }; 201 | } 202 | } 203 | 204 | class TuicParser { 205 | 206 | parse(url) { 207 | const { addressPart, params, name } = parseUrlParams(url); 208 | const [userinfo, serverInfo] = addressPart.split('@'); 209 | const { host, port } = parseServerInfo(serverInfo); 210 | const tls = { 211 | enabled: true, 212 | server_name: params.sni, 213 | alpn: [params.alpn], 214 | insecure: true, 215 | }; 216 | 217 | return { 218 | tag: name, 219 | type: "tuic", 220 | server: host, 221 | server_port: port, 222 | uuid: userinfo.split(':')[0], 223 | password: userinfo.split(':')[1], 224 | congestion_control: params.congestion_control, 225 | tls: tls, 226 | flow: params.flow ?? undefined 227 | }; 228 | } 229 | } 230 | 231 | 232 | class HttpParser { 233 | static async parse(url) { 234 | try { 235 | const response = await fetch(url); 236 | if (!response.ok) { 237 | throw new Error(`HTTP error! status: ${response.status}`); 238 | } 239 | const text = await response.text(); 240 | let decodedText; 241 | try { 242 | decodedText = decodeBase64(text.trim()); 243 | // Check if the decoded text needs URL decoding 244 | if (decodedText.includes('%')) { 245 | decodedText = decodeURIComponent(decodedText); 246 | } 247 | } catch (e) { 248 | decodedText = text; 249 | // Check if the original text needs URL decoding 250 | if (decodedText.includes('%')) { 251 | try { 252 | decodedText = decodeURIComponent(decodedText); 253 | } catch (urlError) { 254 | console.warn('Failed to URL decode the text:', urlError); 255 | } 256 | } 257 | } 258 | return decodedText.split('\n').filter(line => line.trim() !== ''); 259 | } catch (error) { 260 | console.error('Error fetching or parsing HTTP(S) content:', error); 261 | return null; 262 | } 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/ClashConfigBuilder.js: -------------------------------------------------------------------------------- 1 | import yaml from 'js-yaml'; 2 | import { CLASH_CONFIG, generateRuleSets, generateRules, getOutbounds, PREDEFINED_RULE_SETS} from './config.js'; 3 | import { BaseConfigBuilder } from './BaseConfigBuilder.js'; 4 | import { DeepCopy } from './utils.js'; 5 | 6 | export class ClashConfigBuilder extends BaseConfigBuilder { 7 | constructor(inputString, selectedRules, customRules, pin, baseConfig) { 8 | if (!baseConfig) { 9 | baseConfig = CLASH_CONFIG 10 | } 11 | super(inputString, baseConfig); 12 | this.selectedRules = selectedRules; 13 | this.customRules = customRules; 14 | this.pin = pin; 15 | } 16 | 17 | addCustomItems(customItems) { 18 | customItems.forEach(item => { 19 | if (item?.tag && !this.config.proxies.some(p => p.name === item.tag)) { 20 | this.config.proxies.push(this.convertToClashProxy(item)); 21 | } 22 | }); 23 | } 24 | 25 | addSelectors() { 26 | let outbounds; 27 | if (typeof this.selectedRules === 'string' && PREDEFINED_RULE_SETS[this.selectedRules]) { 28 | outbounds = getOutbounds(PREDEFINED_RULE_SETS[this.selectedRules]); 29 | } else if(this.selectedRules && Object.keys(this.selectedRules).length > 0) { 30 | outbounds = getOutbounds(this.selectedRules); 31 | } else { 32 | outbounds = getOutbounds(PREDEFINED_RULE_SETS.minimal); 33 | } 34 | 35 | const proxyList = this.config.proxies.map(proxy => proxy.name); 36 | 37 | this.config['proxy-groups'].push({ 38 | name: '⚡ 自动选择', 39 | type: 'url-test', 40 | proxies: DeepCopy(proxyList), 41 | url: 'https://www.gstatic.com/generate_204', 42 | interval: 300, 43 | lazy: false 44 | }); 45 | 46 | proxyList.unshift('DIRECT', 'REJECT', '⚡ 自动选择'); 47 | outbounds.unshift('🚀 节点选择'); 48 | 49 | outbounds.forEach(outbound => { 50 | if (outbound !== '🚀 节点选择') { 51 | this.config['proxy-groups'].push({ 52 | type: "select", 53 | name: outbound, 54 | proxies: ['🚀 节点选择', ...proxyList] 55 | }); 56 | } else { 57 | this.config['proxy-groups'].unshift({ 58 | type: "select", 59 | name: outbound, 60 | proxies: proxyList 61 | }); 62 | } 63 | }); 64 | 65 | if (Array.isArray(this.customRules)) { 66 | this.customRules.forEach(rule => { 67 | this.config['proxy-groups'].push({ 68 | type: "select", 69 | name: rule.name, 70 | proxies: ['🚀 节点选择', ...proxyList] 71 | }); 72 | }); 73 | } 74 | 75 | this.config['proxy-groups'].push({ 76 | type: "select", 77 | name: "🐟 漏网之鱼", 78 | proxies: ['🚀 节点选择', ...proxyList] 79 | }); 80 | } 81 | formatConfig() { 82 | const rules = generateRules(this.selectedRules, this.customRules, this.pin); 83 | 84 | this.config.rules = rules.flatMap(rule => { 85 | const siteRules = rule.site_rules[0] !== '' ? rule.site_rules.map(site => `GEOSITE,${site},${rule.outbound}`) : []; 86 | const ipRules = rule.ip_rules[0] !== '' ? rule.ip_rules.map(ip => `GEOIP,${ip},${rule.outbound}`) : []; 87 | const domainSuffixRules = rule.domain_suffix ? rule.domain_suffix.map(suffix => `DOMAIN-SUFFIX,${suffix},${rule.outbound}`) : []; 88 | const domainKeywordRules = rule.domain_keyword ? rule.domain_keyword.map(keyword => `DOMAIN-KEYWORD,${keyword},${rule.outbound}`) : []; 89 | const ipCidrRules = rule.ip_cidr ? rule.ip_cidr.map(cidr => `IP-CIDR,${cidr},${rule.outbound}`) : []; 90 | return [...siteRules, ...ipRules, ...domainSuffixRules, ...domainKeywordRules, ...ipCidrRules]; 91 | }); 92 | 93 | // Add the final catch-all rule 94 | this.config.rules.push('MATCH,🐟 漏网之鱼'); 95 | 96 | return yaml.dump(this.config); 97 | } 98 | 99 | convertToClashProxy(proxy) { 100 | switch(proxy.type) { 101 | case 'shadowsocks': 102 | return { 103 | name: proxy.tag, 104 | type: 'ss', 105 | server: proxy.server, 106 | port: proxy.server_port, 107 | cipher: proxy.method, 108 | password: proxy.password 109 | }; 110 | case 'vmess': 111 | return { 112 | name: proxy.tag, 113 | type: proxy.type, 114 | server: proxy.server, 115 | port: proxy.server_port, 116 | uuid: proxy.uuid, 117 | alterId: proxy.alter_id, 118 | cipher: proxy.security, 119 | tls: proxy.tls?.enabled || false, 120 | servername: proxy.tls?.server_name || '', 121 | network: proxy.transport?.type || 'tcp', 122 | 'ws-opts': proxy.transport?.type === 'ws' ? { 123 | path: proxy.transport.path, 124 | headers: proxy.transport.headers 125 | } : undefined 126 | }; 127 | case 'vless': 128 | return { 129 | name: proxy.tag, 130 | type: proxy.type, 131 | server: proxy.server, 132 | port: proxy.server_port, 133 | uuid: proxy.uuid, 134 | cipher: proxy.security, 135 | tls: proxy.tls?.enabled || false, 136 | 'client-fingerprint': proxy.tls.utls?.fingerprint, 137 | servername: proxy.tls?.server_name || '', 138 | network: proxy.transport?.type || 'tcp', 139 | 'ws-opts': proxy.transport?.type === 'ws' ? { 140 | path: proxy.transport.path, 141 | headers: proxy.transport.headers 142 | }: undefined, 143 | 'reality-opts': proxy.tls.reality?.enabled ? { 144 | 'public-key': proxy.tls.reality.public_key, 145 | 'short-id': proxy.tls.reality.short_id, 146 | } : undefined, 147 | 'grpc-opts': proxy.transport?.type === 'grpc' ? { 148 | 'grpc-mode': 'gun', 149 | 'grpc-service-name': proxy.transport.service_name, 150 | } : undefined, 151 | tfo : proxy.tcp_fast_open, 152 | 'skip-cert-verify': proxy.tls.insecure, 153 | 'flow': proxy.flow ?? undefined, 154 | }; 155 | case 'hysteria2': 156 | return { 157 | name: proxy.tag, 158 | type: proxy.type, 159 | server: proxy.server, 160 | port: proxy.server_port, 161 | obfs: proxy.obfs.type, 162 | 'obfs-password': proxy.obfs.password, 163 | password: proxy.password, 164 | auth: proxy.password, 165 | 'skip-cert-verify': proxy.tls.insecure, 166 | }; 167 | case 'trojan': 168 | return { 169 | name: proxy.tag, 170 | type: proxy.type, 171 | server: proxy.server, 172 | port: proxy.server_port, 173 | password: proxy.password, 174 | cipher: proxy.security, 175 | tls: proxy.tls?.enabled || false, 176 | 'client-fingerprint': proxy.tls.utls?.fingerprint, 177 | sni: proxy.tls?.server_name || '', 178 | network: proxy.transport?.type || 'tcp', 179 | 'ws-opts': proxy.transport?.type === 'ws' ? { 180 | path: proxy.transport.path, 181 | headers: proxy.transport.headers 182 | }: undefined, 183 | 'reality-opts': proxy.tls.reality?.enabled ? { 184 | 'public-key': proxy.tls.reality.public_key, 185 | 'short-id': proxy.tls.reality.short_id, 186 | } : undefined, 187 | 'grpc-opts': proxy.transport?.type === 'grpc' ? { 188 | 'grpc-mode': 'gun', 189 | 'grpc-service-name': proxy.transport.service_name, 190 | } : undefined, 191 | tfo : proxy.tcp_fast_open, 192 | 'skip-cert-verify': proxy.tls.insecure, 193 | 'flow': proxy.flow ?? undefined, 194 | } 195 | case 'tuic': 196 | return { 197 | name: proxy.tag, 198 | type: proxy.type, 199 | server: proxy.server, 200 | port: proxy.server_port, 201 | uuid: proxy.uuid, 202 | password: proxy.password, 203 | 'congestion-controller': proxy.congestion, 204 | 'skip-cert-verify': proxy.tls.insecure, 205 | 'disable-sni': true, 206 | 'alpn': proxy.tls.alpn, 207 | 'sni': proxy.tls.server_name, 208 | 'udp-relay-mode': 'native', 209 | }; 210 | default: 211 | return proxy; // Return as-is if no specific conversion is defined 212 | } 213 | } 214 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { ConfigBuilder } from './SingboxConfigBuilder.js'; 2 | import { generateHtml } from './htmlBuilder.js'; 3 | import { ClashConfigBuilder } from './ClashConfigBuilder.js'; 4 | import { encodeBase64, decodeBase64, GenerateWebPath, DeepCopy } from './utils.js'; 5 | import { PREDEFINED_RULE_SETS, SING_BOX_CONFIG, CLASH_CONFIG } from './config.js'; 6 | import yaml from 'js-yaml'; 7 | 8 | addEventListener('fetch', event => { 9 | event.respondWith(handleRequest(event.request)) 10 | }) 11 | 12 | async function handleRequest(request) { 13 | try { 14 | const url = new URL(request.url); 15 | 16 | if (request.method === 'GET' && url.pathname === '/') { 17 | // Return the HTML form for GET requests 18 | return new Response(generateHtml('', '', '', url.origin), { 19 | headers: { 'Content-Type': 'text/html' } 20 | }); 21 | } else if (request.method === 'POST' && url.pathname === '/') { 22 | const formData = await request.formData(); 23 | const inputString = formData.get('input'); 24 | const selectedRules = formData.getAll('selectedRules'); 25 | const customRuleDomains = formData.getAll('customRuleSite[]'); 26 | const customRuleIPs = formData.getAll('customRuleIP[]'); 27 | const customRuleNames = formData.getAll('customRuleName[]'); 28 | const customRules = customRuleDomains.map((domains, index) => ({ 29 | sites: domains.split(',').map(site => site.trim()), 30 | ips: customRuleIPs[index].split(',').map(ip => ip.trim()), 31 | outbound: customRuleNames[index] 32 | })); 33 | const pin = formData.get('pin'); 34 | 35 | if (!inputString) { 36 | return new Response('Missing input parameter', { status: 400 }); 37 | } 38 | 39 | // If no rules are selected, use the default rules 40 | const rulesToUse = selectedRules.length > 0 ? selectedRules : ['广告拦截', '谷歌服务', '国外媒体', '电报消息']; 41 | 42 | const xrayUrl = `${url.origin}/xray?config=${encodeURIComponent(inputString)}`; 43 | const singboxUrl = `${url.origin}/singbox?config=${encodeURIComponent(inputString)}&selectedRules=${encodeURIComponent(JSON.stringify(rulesToUse))}&customRules=${encodeURIComponent(JSON.stringify(customRules))}pin=${pin}`; 44 | const clashUrl = `${url.origin}/clash?config=${encodeURIComponent(inputString)}&selectedRules=${encodeURIComponent(JSON.stringify(rulesToUse))}&customRules=${encodeURIComponent(JSON.stringify(customRules))}pin=${pin}`; 45 | 46 | return new Response(generateHtml(xrayUrl, singboxUrl, clashUrl), { 47 | headers: { 'Content-Type': 'text/html' } 48 | }); 49 | } else if (url.pathname.startsWith('/singbox') || url.pathname.startsWith('/clash')) { 50 | const inputString = url.searchParams.get('config'); 51 | let selectedRules = url.searchParams.get('selectedRules'); 52 | let customRules = url.searchParams.get('customRules'); 53 | let pin = url.searchParams.get('pin'); 54 | 55 | if (!inputString) { 56 | return new Response('Missing config parameter', { status: 400 }); 57 | } 58 | 59 | // Deal with predefined rules 60 | if (PREDEFINED_RULE_SETS[selectedRules]) { 61 | selectedRules = PREDEFINED_RULE_SETS[selectedRules]; 62 | } else { 63 | try { 64 | selectedRules = JSON.parse(decodeURIComponent(selectedRules)); 65 | } catch (error) { 66 | console.error('Error parsing selectedRules:', error); 67 | selectedRules = PREDEFINED_RULE_SETS.minimal; 68 | } 69 | } 70 | 71 | // Deal with custom rules 72 | try { 73 | customRules = JSON.parse(decodeURIComponent(customRules)); 74 | } catch (error) { 75 | console.error('Error parsing customRules:', error); 76 | customRules = []; 77 | } 78 | 79 | // Modify the existing conversion logic 80 | const configId = url.searchParams.get('configId'); 81 | let baseConfig; 82 | if (configId) { 83 | const customConfig = await SUBLINK_KV.get(configId); 84 | if (customConfig) { 85 | baseConfig = JSON.parse(customConfig); 86 | } 87 | } 88 | 89 | // Env pin is use to pin customRules to top 90 | let configBuilder; 91 | if (url.pathname.startsWith('/singbox')) { 92 | configBuilder = new ConfigBuilder(inputString, selectedRules, customRules, pin, baseConfig); 93 | } else { 94 | configBuilder = new ClashConfigBuilder(inputString, selectedRules, customRules, pin, baseConfig); 95 | } 96 | 97 | const config = await configBuilder.build(); 98 | 99 | return new Response( 100 | url.pathname.startsWith('/singbox') ? JSON.stringify(config, null, 2) : config, 101 | { 102 | headers: { 103 | 'content-type': url.pathname.startsWith('/singbox') 104 | ? 'application/json; charset=utf-8' 105 | : 'text/yaml; charset=utf-8' 106 | } 107 | } 108 | ); 109 | 110 | } else if (url.pathname === '/shorten') { 111 | const originalUrl = url.searchParams.get('url'); 112 | if (!originalUrl) { 113 | return new Response('Missing URL parameter', { status: 400 }); 114 | } 115 | 116 | const shortCode = GenerateWebPath(); 117 | await SUBLINK_KV.put(shortCode, originalUrl); 118 | 119 | const shortUrl = `${url.origin}/s/${shortCode}`; 120 | return new Response(JSON.stringify({ shortUrl }), { 121 | headers: { 'Content-Type': 'application/json' } 122 | }); 123 | 124 | } else if (url.pathname === '/shorten-v2'){ 125 | const originalUrl = url.searchParams.get('url'); 126 | let shortCode = url.searchParams.get('shortCode'); 127 | 128 | if (!originalUrl) { 129 | return new Response('Missing URL parameter', { status: 400 }); 130 | } 131 | 132 | // Create a URL object to correctly parse the original URL 133 | const parsedUrl = new URL(originalUrl); 134 | const queryString = parsedUrl.search; 135 | 136 | if (!shortCode) { 137 | shortCode = GenerateWebPath(); 138 | } 139 | 140 | await SUBLINK_KV.put(shortCode, queryString); 141 | 142 | return new Response(shortCode, { 143 | headers: { 'Content-Type': 'text/plain' } 144 | }); 145 | 146 | } else if (url.pathname.startsWith('/s/')) { 147 | const shortCode = url.pathname.split('/')[2]; 148 | const originalUrl = await SUBLINK_KV.get(shortCode); 149 | 150 | if (originalUrl === null) { 151 | return new Response('Short URL not found', { status: 404 }); 152 | } 153 | 154 | return Response.redirect(originalUrl, 302); 155 | } else if (url.pathname.startsWith('/b/') || url.pathname.startsWith('/c/') || url.pathname.startsWith('/x/')) { 156 | const shortCode = url.pathname.split('/')[2]; 157 | const originalParam = await SUBLINK_KV.get(shortCode); 158 | let originalUrl; 159 | 160 | if (url.pathname.startsWith('/b/')) { 161 | originalUrl = `${url.origin}/singbox${originalParam}`; 162 | } else if (url.pathname.startsWith('/c/')) { 163 | originalUrl = `${url.origin}/clash${originalParam}`; 164 | } else if (url.pathname.startsWith('/x/')) { 165 | originalUrl = `${url.origin}/xray${originalParam}`; 166 | } 167 | 168 | if (originalUrl === null) { 169 | return new Response('Short URL not found', { status: 404 }); 170 | } 171 | 172 | return Response.redirect(originalUrl, 302); 173 | } else if (url.pathname.startsWith('/xray')) { 174 | // Handle Xray config requests 175 | const inputString = url.searchParams.get('config'); 176 | const proxylist = inputString.split('\n'); 177 | 178 | const finalProxyList = []; 179 | 180 | for (const proxy of proxylist) { 181 | if (proxy.startsWith('http://') || proxy.startsWith('https://')) { 182 | try { 183 | const response = await fetch(proxy) 184 | const text = await response.text(); 185 | let decodedText; 186 | decodedText = decodeBase64(text.trim()); 187 | // Check if the decoded text needs URL decoding 188 | if (decodedText.includes('%')) { 189 | decodedText = decodeURIComponent(decodedText); 190 | } 191 | finalProxyList.push(...decodedText.split('\n')); 192 | } catch (e) { 193 | console.warn('Failed to fetch the proxy:', e); 194 | } 195 | } else { 196 | finalProxyList.push(proxy); 197 | } 198 | } 199 | 200 | const finalString = finalProxyList.join('\n'); 201 | 202 | if (!finalString) { 203 | return new Response('Missing config parameter', { status: 400 }); 204 | } 205 | 206 | return new Response(encodeBase64(finalString), { 207 | headers: { 'content-type': 'application/json; charset=utf-8' } 208 | }); 209 | } else if (url.pathname === '/favicon.ico') { 210 | return Response.redirect('https://cravatar.cn/avatar/9240d78bbea4cf05fb04f2b86f22b18d?s=160&d=retro&r=g', 301) 211 | } else if (url.pathname === '/config') { 212 | const { type, content } = await request.json(); 213 | const configId = `${type}_${GenerateWebPath(8)}`; 214 | 215 | try { 216 | let configString; 217 | if (type === 'clash') { 218 | // 如果是 YAML 格式,先转换为 JSON 219 | if (typeof content === 'string' && (content.trim().startsWith('-') || content.includes(':'))) { 220 | const yamlConfig = yaml.load(content); 221 | configString = JSON.stringify(yamlConfig); 222 | } else { 223 | configString = typeof content === 'object' 224 | ? JSON.stringify(content) 225 | : content; 226 | } 227 | } else { 228 | // singbox 配置处理 229 | configString = typeof content === 'object' 230 | ? JSON.stringify(content) 231 | : content; 232 | } 233 | 234 | // 验证 JSON 格式 235 | JSON.parse(configString); 236 | 237 | await SUBLINK_KV.put(configId, configString, { 238 | expirationTtl: 60 * 60 * 24 * 30 // 30 days 239 | }); 240 | 241 | return new Response(configId, { 242 | headers: { 'Content-Type': 'text/plain' } 243 | }); 244 | } catch (error) { 245 | console.error('Config validation error:', error); 246 | return new Response('Invalid format: ' + error.message, { 247 | status: 400, 248 | headers: { 'Content-Type': 'text/plain' } 249 | }); 250 | } 251 | } 252 | 253 | return new Response('Not Found', { status: 404 }); 254 | } catch (error) { 255 | console.error('Error processing request:', error); 256 | return new Response('Internal Server Error', { status: 500 }); 257 | } 258 | } -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | export const SITE_RULE_SET_BASE_URL = 'https://raw.githubusercontent.com/lyc8503/sing-box-rules/refs/heads/rule-set-geosite/'; 2 | export const IP_RULE_SET_BASE_URL = 'https://raw.githubusercontent.com/lyc8503/sing-box-rules/refs/heads/rule-set-geoip/'; 3 | // Custom rules 4 | export const CUSTOM_RULES = []; 5 | // Unified rule structure 6 | export const UNIFIED_RULES = [ 7 | { 8 | name: 'Ad Block', 9 | outbound: '🛑 广告拦截', 10 | site_rules: ['category-ads-all'], 11 | ip_rules: [] 12 | }, 13 | { 14 | name: 'AI Services', 15 | outbound: '💬 AI 服务', 16 | site_rules: ['openai', 'anthropic','jetbrains-ai','perplexity'], 17 | ip_rules: [] 18 | }, 19 | { 20 | name: 'Bilibili', 21 | outbound: '📺 哔哩哔哩', 22 | site_rules: ['bilibili'], 23 | ip_rules: [] 24 | }, 25 | { 26 | name: 'Youtube', 27 | outbound: '📹 油管视频', 28 | site_rules: ['youtube'], 29 | ip_rules: [] 30 | }, 31 | { 32 | name: 'Google', 33 | outbound: '🔍 谷歌服务', 34 | site_rules: ['google'], 35 | ip_rules: ['google'] 36 | }, 37 | 38 | { 39 | name: 'Private', 40 | outbound: '🏠 私有网络', 41 | site_rules: [], 42 | ip_rules: ['private'] 43 | }, 44 | { 45 | name: 'Location:CN', 46 | outbound: '🔒 国内服务', 47 | site_rules: ['geolocation-cn'], 48 | ip_rules: ['cn'] 49 | }, 50 | { 51 | name: 'Telegram', 52 | outbound: '📲 电报消息', 53 | site_rules: [], 54 | ip_rules: ['telegram'] 55 | }, 56 | { 57 | name: 'Github', 58 | outbound: '🐱 Github', 59 | site_rules: ['github', 'gitlab'], 60 | ip_rules: [] 61 | }, 62 | { 63 | name: 'Microsoft', 64 | outbound: 'Ⓜ️ 微软服务', 65 | site_rules: ['microsoft'], 66 | ip_rules: [] 67 | }, 68 | { 69 | name: 'Apple', 70 | outbound: '🍏 苹果服务', 71 | site_rules: ['apple'], 72 | ip_rules: [] 73 | }, 74 | { 75 | name: 'Social Media', 76 | outbound: '🌐 社交媒体', 77 | site_rules: ['facebook', 'instagram', 'twitter', 'tiktok', 'linkedin'], 78 | ip_rules: [] 79 | }, 80 | { 81 | name: 'Streaming', 82 | outbound: '🎬 流媒体', 83 | site_rules: ['netflix', 'hulu', 'disney', 'hbo', 'amazon','bahamut'], 84 | ip_rules: [] 85 | }, 86 | { 87 | name: 'Gaming', 88 | outbound: '🎮 游戏平台', 89 | site_rules: ['steam', 'epicgames', 'ea', 'ubisoft', 'blizzard'], 90 | ip_rules: [] 91 | }, 92 | { 93 | name: 'Education', 94 | outbound: '📚 教育资源', 95 | site_rules: ['coursera', 'edx', 'udemy', 'khanacademy', 'category-scholar-!cn'], 96 | ip_rules: [] 97 | }, 98 | { 99 | name: 'Financial', 100 | outbound: '💰 金融服务', 101 | site_rules: ['paypal', 'visa', 'mastercard','stripe','wise'], 102 | ip_rules: [] 103 | }, 104 | { 105 | name: 'Cloud Services', 106 | outbound: '☁️ 云服务', 107 | site_rules: ['aws', 'azure', 'digitalocean', 'heroku', 'dropbox'], 108 | ip_rules: [] 109 | }, 110 | { 111 | name: 'Non-China', 112 | outbound: '🌐 非中国', 113 | site_rules: ['geolocation-!cn'], 114 | ip_rules: [] 115 | } 116 | 117 | ]; 118 | 119 | export const PREDEFINED_RULE_SETS = { 120 | minimal: ['Location:CN', 'Private', 'Non-China'], 121 | balanced: ['Location:CN', 'Private', 'Non-China', 'Google', 'Youtube', 'AI Services', 'Telegram'], 122 | comprehensive: UNIFIED_RULES.map(rule => rule.name) 123 | }; 124 | 125 | 126 | 127 | // Generate SITE_RULE_SETS and IP_RULE_SETS from UNIFIED_RULES 128 | export const SITE_RULE_SETS = UNIFIED_RULES.reduce((acc, rule) => { 129 | rule.site_rules.forEach(site_rule => { 130 | acc[site_rule] = `geosite-${site_rule}.srs`; 131 | }); 132 | return acc; 133 | }, {}); 134 | 135 | export const IP_RULE_SETS = UNIFIED_RULES.reduce((acc, rule) => { 136 | rule.ip_rules.forEach(ip_rule => { 137 | acc[ip_rule] = `geoip-${ip_rule}.srs`; 138 | }); 139 | return acc; 140 | }, {}); 141 | 142 | // Helper function to get outbounds based on selected rule names 143 | export function getOutbounds(selectedRuleNames) { 144 | if (!selectedRuleNames || !Array.isArray(selectedRuleNames)) { 145 | return []; // or handle this case as appropriate for your use case 146 | } 147 | return UNIFIED_RULES 148 | .filter(rule => selectedRuleNames.includes(rule.name)) 149 | .map(rule => rule.outbound); 150 | } 151 | 152 | // Helper function to generate rules based on selected rule names 153 | export function generateRules(selectedRules = [], customRules = [], pin) { 154 | if (typeof selectedRules === 'string' && PREDEFINED_RULE_SETS[selectedRules]) { 155 | selectedRules = PREDEFINED_RULE_SETS[selectedRules]; 156 | } 157 | 158 | if (!selectedRules || selectedRules.length === 0) { 159 | selectedRules = PREDEFINED_RULE_SETS.minimal; 160 | } 161 | 162 | const rules = []; 163 | 164 | UNIFIED_RULES.forEach(rule => { 165 | if (selectedRules.includes(rule.name)) { 166 | rules.push({ 167 | site_rules: rule.site_rules, 168 | ip_rules: rule.ip_rules, 169 | domain_suffix: rule?.domain_suffix, 170 | ip_cidr: rule?.ip_cidr, 171 | outbound: rule.outbound 172 | }); 173 | } 174 | }); 175 | 176 | if (customRules && customRules.length > 0 && pin !== "true") { 177 | customRules.forEach((rule) => { 178 | rules.push({ 179 | site_rules: rule.site.split(','), 180 | ip_rules: rule.ip.split(','), 181 | domain_suffix: rule.domain_suffix ? rule.domain_suffix.split(',') : [], 182 | domain_keyword: rule.domain_keyword ? rule.domain_keyword.split(',') : [], 183 | ip_cidr: rule.ip_cidr ? rule.ip_cidr.split(',') : [], 184 | protocol: rule.protocol ? rule.protocol.split(',') : [], 185 | outbound: rule.name 186 | }); 187 | }); 188 | } 189 | else if (customRules && customRules.length > 0 && pin === "true") { 190 | customRules.reverse(); 191 | customRules.forEach((rule) => { 192 | rules.unshift({ 193 | site_rules: rule.site.split(','), 194 | ip_rules: rule.ip.split(','), 195 | domain_suffix: rule.domain_suffix ? rule.domain_suffix.split(',') : [], 196 | domain_keyword: rule.domain_keyword ? rule.domain_keyword.split(',') : [], 197 | ip_cidr: rule.ip_cidr ? rule.ip_cidr.split(',') : [], 198 | protocol: rule.protocol ? rule.protocol.split(',') : [], 199 | outbound: rule.name 200 | }); 201 | }); 202 | } 203 | 204 | return rules; 205 | } 206 | 207 | 208 | export function generateRuleSets(selectedRules = [], customRules = []) { 209 | if (typeof selectedRules === 'string' && PREDEFINED_RULE_SETS[selectedRules]) { 210 | selectedRules = PREDEFINED_RULE_SETS[selectedRules]; 211 | } 212 | 213 | if (!selectedRules || selectedRules.length === 0) { 214 | selectedRules = PREDEFINED_RULE_SETS.minimal; 215 | } 216 | 217 | const selectedRulesSet = new Set(selectedRules); 218 | 219 | const siteRuleSets = new Set(); 220 | const ipRuleSets = new Set(); 221 | 222 | const ruleSets = []; 223 | 224 | UNIFIED_RULES.forEach(rule => { 225 | if (selectedRulesSet.has(rule.name)) { 226 | rule.site_rules.forEach(siteRule => siteRuleSets.add(siteRule)); 227 | rule.ip_rules.forEach(ipRule => ipRuleSets.add(ipRule)); 228 | } 229 | }); 230 | 231 | 232 | 233 | const site_rule_sets = Array.from(siteRuleSets).map(rule => ({ 234 | tag: rule, 235 | type: 'remote', 236 | format: 'binary', 237 | url: `${SITE_RULE_SET_BASE_URL}${SITE_RULE_SETS[rule]}`, 238 | download_detour: '⚡ 自动选择' 239 | })); 240 | 241 | const ip_rule_sets = Array.from(ipRuleSets).map(rule => ({ 242 | tag: `${rule}-ip`, 243 | type: 'remote', 244 | format: 'binary', 245 | url: `${IP_RULE_SET_BASE_URL}${IP_RULE_SETS[rule]}`, 246 | download_detour: '⚡ 自动选择' 247 | })); 248 | 249 | if(!selectedRules.includes('Non-China')){ 250 | site_rule_sets.push({ 251 | tag: 'geolocation-!cn', 252 | type: 'remote', 253 | format: 'binary', 254 | url: `${SITE_RULE_SET_BASE_URL}geosite-geolocation-!cn.srs`, 255 | download_detour: '⚡ 自动选择' 256 | }); 257 | } 258 | 259 | if(customRules){ 260 | customRules.forEach(rule => { 261 | if(rule.site!=''){ 262 | rule.site.split(',').forEach(site => { 263 | site_rule_sets.push({ 264 | tag: site.trim(), 265 | type: 'remote', 266 | format: 'binary', 267 | url: `${SITE_RULE_SET_BASE_URL}geosite-${site.trim()}.srs`, 268 | download_detour: '⚡ 自动选择' 269 | }); 270 | }); 271 | } 272 | if(rule.ip!=''){ 273 | rule.ip.split(',').forEach(ip => { 274 | ip_rule_sets.push({ 275 | tag: `${ip.trim()}-ip`, 276 | type: 'remote', 277 | format: 'binary', 278 | url: `${IP_RULE_SET_BASE_URL}geoip-${ip.trim()}.srs`, 279 | download_detour: '⚡ 自动选择' 280 | }); 281 | }); 282 | } 283 | }); 284 | } 285 | 286 | ruleSets.push(...site_rule_sets, ...ip_rule_sets); 287 | 288 | return { site_rule_sets, ip_rule_sets }; 289 | } 290 | 291 | // Singbox configuration 292 | export const SING_BOX_CONFIG = { 293 | dns: { 294 | servers: [ 295 | { 296 | tag: "dns_proxy", 297 | address: "tcp://1.1.1.1", 298 | address_resolver: "dns_resolver", 299 | strategy: "ipv4_only", 300 | detour: "🚀 节点选择" 301 | }, 302 | { 303 | tag: "dns_direct", 304 | address: "https://dns.alidns.com/dns-query", 305 | address_resolver: "dns_resolver", 306 | strategy: "ipv4_only", 307 | detour: "DIRECT" 308 | }, 309 | { 310 | tag: "dns_resolver", 311 | address: "223.5.5.5", 312 | detour: "DIRECT" 313 | }, 314 | { 315 | tag: "dns_success", 316 | address: "rcode://success" 317 | }, 318 | { 319 | tag: "dns_refused", 320 | address: "rcode://refused" 321 | }, 322 | { 323 | tag: "dns_fakeip", 324 | address: "fakeip" 325 | } 326 | ], 327 | rules: [ 328 | { 329 | outbound: "any", 330 | server: "dns_resolver" 331 | }, 332 | { 333 | rule_set: "geolocation-!cn", 334 | query_type: [ 335 | "A", 336 | "AAAA" 337 | ], 338 | server: "dns_fakeip" 339 | }, 340 | { 341 | rule_set: "geolocation-!cn", 342 | query_type: [ 343 | "CNAME" 344 | ], 345 | server: "dns_proxy" 346 | }, 347 | { 348 | query_type: [ 349 | "A", 350 | "AAAA", 351 | "CNAME" 352 | ], 353 | invert: true, 354 | server: "dns_refused", 355 | disable_cache: true 356 | } 357 | ], 358 | final: "dns_direct", 359 | independent_cache: true, 360 | fakeip: { 361 | enabled: true, 362 | inet4_range: "198.18.0.0/15", 363 | inet6_range: "fc00::/18" 364 | } 365 | }, 366 | ntp: { 367 | enabled: true, 368 | server: 'time.apple.com', 369 | server_port: 123, 370 | interval: '30m', 371 | detour: 'DIRECT' 372 | }, 373 | inbounds: [ 374 | { type: 'mixed', tag: 'mixed-in', listen: '0.0.0.0', listen_port: 2080 }, 375 | { type: 'tun', tag: 'tun-in', address: '172.19.0.1/30', auto_route: true, strict_route: true, stack: 'mixed', sniff: true } 376 | ], 377 | outbounds: [ 378 | { type: 'direct', tag: 'DIRECT' }, 379 | { type: 'block', tag: 'REJECT' }, 380 | { type: 'dns', tag: 'dns-out' } 381 | ], 382 | route : { 383 | "rule_set": [ 384 | { 385 | "tag": "geosite-geolocation-!cn", 386 | "type": "local", 387 | "format": "binary", 388 | "path": "geosite-geolocation-!cn.srs" 389 | } 390 | ], 391 | rules: [ 392 | { 393 | "outbound": "any", 394 | "server": "dns_resolver" 395 | } 396 | ] 397 | }, 398 | experimental: { 399 | cache_file: { 400 | enabled: true, 401 | store_fakeip: true 402 | }, 403 | clash_api: { 404 | external_controller: '127.0.0.1:9090', 405 | external_ui: 'dashboard' 406 | } 407 | } 408 | }; 409 | 410 | export const CLASH_CONFIG = { 411 | port: 7890, 412 | 'socks-port': 7891, 413 | 'allow-lan': false, 414 | mode: 'Rule', 415 | 'log-level': 'info', 416 | dns: { 417 | enable: true, 418 | ipv6: true, 419 | 'respect-rules': true, 420 | 'enhanced-mode': 'fake-ip', 421 | nameserver: [ 422 | 'https://120.53.53.53/dns-query', 423 | 'https://223.5.5.5/dns-query' 424 | ], 425 | 'proxy-server-nameserver': [ 426 | 'https://120.53.53.53/dns-query', 427 | 'https://223.5.5.5/dns-query' 428 | ], 429 | 'nameserver-policy': { 430 | 'geosite:cn,private': [ 431 | 'https://120.53.53.53/dns-query', 432 | 'https://223.5.5.5/dns-query' 433 | ], 434 | 'geosite:geolocation-!cn': [ 435 | 'https://dns.cloudflare.com/dns-query', 436 | 'https://dns.google/dns-query' 437 | ] 438 | } 439 | }, 440 | proxies: [], 441 | 'proxy-groups': [], 442 | }; 443 | -------------------------------------------------------------------------------- /src/style.js: -------------------------------------------------------------------------------- 1 | export const generateStyles = () => ` 2 | :root { 3 | --bg-color: #f0f2f5; 4 | --text-color: #495057; 5 | --card-bg: #ffffff; 6 | --card-header-bg: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); 7 | --btn-primary-bg: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); 8 | --input-bg: #ffffff; 9 | --input-border: #ced4da; 10 | --input-text: #495057; 11 | --placeholder-color: #6c757d; 12 | --section-border: rgba(0, 0, 0, 0.1); 13 | --section-bg: rgba(0, 0, 0, 0.02); 14 | --select-bg: #ffffff; 15 | --select-text: #495057; 16 | --select-border: #ced4da; 17 | --dropdown-bg: #ffffff; 18 | --dropdown-text: #495057; 19 | --dropdown-hover-bg: #f8f9fa; 20 | --dropdown-hover-text: #495057; 21 | --switch-bg: #e9ecef; 22 | --switch-checked-bg: #6a11cb; 23 | --transition-speed: 0.3s; 24 | --transition-timing: cubic-bezier(0.4, 0, 0.2, 1); 25 | } 26 | 27 | [data-theme="dark"] { 28 | --bg-color: #1a1a1a; 29 | --text-color: #e0e0e0; 30 | --card-bg: #2c2c2c; 31 | --card-header-bg: linear-gradient(135deg, #4a0e8f 0%, #1a5ab8 100%); 32 | --btn-primary-bg: linear-gradient(135deg, #4a0e8f 0%, #1a5ab8 100%); 33 | --input-bg: #3c3c3c; 34 | --input-border: #555555; 35 | --input-text: #e0e0e0; 36 | --placeholder-color: #adb5bd; 37 | --section-border: rgba(255, 255, 255, 0.1); 38 | --section-bg: rgba(255, 255, 255, 0.02); 39 | --select-bg: #3c3c3c; 40 | --select-text: #e0e0e0; 41 | --select-border: #555555; 42 | --dropdown-bg: #2c2c2c; 43 | --dropdown-text: #e0e0e0; 44 | --dropdown-hover-bg: #3c3c3c; 45 | --dropdown-hover-text: #e0e0e0; 46 | --switch-bg: #555555; 47 | --switch-checked-bg: #4a0e8f; 48 | } 49 | 50 | .container { max-width: 800px; } 51 | 52 | body { 53 | background-color: var(--bg-color); 54 | color: var(--text-color); 55 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 56 | transition: background-color 0.3s var(--transition-timing), color 0.3s var(--transition-timing); 57 | } 58 | 59 | .card { 60 | background-color: var(--card-bg); 61 | border: none; 62 | border-radius: 15px; 63 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); 64 | margin-bottom: 2rem; 65 | } 66 | 67 | .card-header { 68 | background: var(--card-header-bg); 69 | color: white; 70 | border-radius: 15px 15px 0 0; 71 | padding: 2.5rem 2rem; 72 | border-bottom: 1px solid var(--section-border); 73 | } 74 | 75 | .card-body { 76 | padding: 2rem; 77 | } 78 | 79 | .form-section { 80 | padding: 1.5rem; 81 | margin-bottom: 1.5rem; 82 | border: 1px solid var(--section-border); 83 | border-radius: 10px; 84 | background: var(--section-bg); 85 | } 86 | 87 | .form-section-title { 88 | font-size: 1.1rem; 89 | font-weight: 600; 90 | margin-bottom: 1rem; 91 | color: var(--text-color); 92 | } 93 | 94 | .input-group { 95 | margin-bottom: 1rem; 96 | } 97 | 98 | .form-control, .form-select { 99 | padding: 0.75rem 1rem; 100 | border-radius: 8px; 101 | transition: all 0.3s ease; 102 | } 103 | 104 | .form-control:focus, .form-select:focus { 105 | border-color: #6a11cb; 106 | box-shadow: 0 0 0 0.2rem rgba(106, 17, 203, 0.25); 107 | } 108 | 109 | .btn { 110 | padding: 0.75rem 1.5rem; 111 | border-radius: 8px; 112 | font-weight: 500; 113 | transition: all 0.3s ease; 114 | } 115 | 116 | .btn-primary { 117 | background: var(--btn-primary-bg); 118 | border: none; 119 | } 120 | 121 | .btn-primary:hover { 122 | transform: translateY(-2px); 123 | box-shadow: 0 5px 15px rgba(106, 17, 203, 0.2); 124 | } 125 | 126 | .input-group-text, .form-control { 127 | background-color: var(--input-bg); 128 | border-color: var(--input-border); 129 | color: var(--input-text); 130 | } 131 | 132 | .form-control:focus { 133 | background-color: var(--input-bg); 134 | color: var(--input-text); 135 | box-shadow: 0 0 0 0.2rem rgba(106, 17, 203, 0.25); 136 | } 137 | 138 | .input-group { box-shadow: 0 4px 6px rgba(0, 0, 0, 0.04); } 139 | 140 | h2, h4 { 141 | color: var(--text-color); 142 | font-weight: 600; 143 | } 144 | 145 | h5 { 146 | color: var(--text-color); 147 | font-weight: 500; 148 | } 149 | 150 | .form-label { 151 | font-weight: 500; 152 | color: var(--text-color); 153 | } 154 | 155 | .btn-outline-secondary { 156 | color: var(--text-color); 157 | border-color: var(--input-border); 158 | } 159 | 160 | .btn-outline-secondary:hover { 161 | background-color: var(--input-bg); 162 | color: var(--text-color); 163 | } 164 | 165 | .btn-success { 166 | background-color: #28a745; 167 | border-color: #28a745; 168 | color: white; 169 | } 170 | 171 | .btn-success:hover { 172 | background-color: #218838; 173 | border-color: #1e7e34; 174 | } 175 | 176 | #darkModeToggle { 177 | position: fixed; 178 | top: 20px; 179 | right: 20px; 180 | z-index: 1000; 181 | } 182 | 183 | .github-link { 184 | position: fixed; 185 | bottom: 20px; 186 | right: 20px; 187 | z-index: 1000; 188 | font-size: 2rem; 189 | color: var(--text-color); 190 | transition: color 0.3s ease; 191 | } 192 | 193 | .github-link:hover { color: #6a11cb; } 194 | 195 | .tooltip-icon { 196 | cursor: pointer; 197 | margin-left: 5px; 198 | color: var(--text-color); 199 | position: relative; 200 | display: inline-block; 201 | vertical-align: super; 202 | font-size: 1em; 203 | } 204 | 205 | .question-mark { 206 | display: inline-block; 207 | width: 16px; 208 | height: 16px; 209 | line-height: 16px; 210 | text-align: center; 211 | border-radius: 50%; 212 | background-color: var(--text-color); 213 | color: var(--card-bg); 214 | } 215 | 216 | .tooltip-content { 217 | visibility: hidden; 218 | opacity: 0; 219 | background-color: var(--card-bg); 220 | position: fixed; // 改为固定定位 221 | background-color: var(--card-bg); 222 | color: var(--text-color); 223 | border: 1px solid var(--input-border); 224 | border-radius: 6px; 225 | padding: 10px; 226 | z-index: 1000; // 提高z-index值 227 | width: 300px; 228 | max-width: 90vw; // 限制最大宽度 229 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 230 | transition: opacity 0.3s, visibility 0.3s; 231 | } 232 | 233 | .tooltip-icon:hover .tooltip-content { 234 | visibility: visible; 235 | opacity: 1; 236 | } 237 | 238 | @media (max-width: 768px) { 239 | .tooltip-content { 240 | width: 250px; 241 | left: auto; 242 | right: 0; 243 | transform: none; 244 | } 245 | } 246 | 247 | .form-check-input { 248 | background-color: var(--checkbox-bg); 249 | border-color: var(--checkbox-border); 250 | } 251 | 252 | .form-check-input:checked { 253 | background-color: var(--checkbox-checked-bg); 254 | border-color: var(--checkbox-checked-border); 255 | } 256 | 257 | .form-check-label { 258 | color: var(--text-color); 259 | } 260 | .explanation-text { 261 | background-color: var(--explanation-bg); 262 | color: var(--explanation-text); 263 | padding: 10px; 264 | border-radius: 5px; 265 | margin-bottom: 15px; 266 | transition: background-color 0.3s ease, color 0.3s ease; 267 | } 268 | 269 | .form-select { 270 | background-color: var(--select-bg); 271 | color: var(--select-text); 272 | border-color: var(--select-border); 273 | transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; 274 | 275 | appearance: none; 276 | -webkit-appearance: none; 277 | -moz-appearance: none; 278 | 279 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23495057' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 280 | background-repeat: no-repeat; 281 | background-position: right 0.75rem center; 282 | background-size: 1em; 283 | padding-right: 2.5em; 284 | } 285 | 286 | [data-theme="dark"] .form-select { 287 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23e0e0e0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 288 | } 289 | 290 | .form-select:focus { 291 | background-color: var(--select-bg); 292 | color: var(--select-text); 293 | border-color: var(--checkbox-checked-border); 294 | box-shadow: 0 0 0 0.2rem rgba(106, 17, 203, 0.25); 295 | } 296 | 297 | .form-control::placeholder { 298 | color: var(--placeholder-color); 299 | opacity: 1; 300 | } 301 | 302 | .form-control::-webkit-input-placeholder { 303 | color: var(--placeholder-color); 304 | opacity: 1; 305 | } 306 | 307 | .form-control::-moz-placeholder { 308 | color: var(--placeholder-color); 309 | opacity: 1; 310 | } 311 | 312 | .form-control:-ms-input-placeholder { 313 | color: var(--placeholder-color); 314 | opacity: 1; 315 | } 316 | 317 | .form-control::-ms-input-placeholder { 318 | color: var(--placeholder-color); 319 | opacity: 1; 320 | } 321 | 322 | #advancedOptions { 323 | max-height: 0; 324 | opacity: 0; 325 | overflow: hidden; 326 | transform: translateY(-20px); 327 | transition: max-height 0.5s var(--transition-timing), 328 | opacity 0.3s var(--transition-timing), 329 | transform 0.3s var(--transition-timing); 330 | } 331 | 332 | #advancedOptions.show { 333 | max-height: 2000px; 334 | opacity: 1; 335 | transform: translateY(0); 336 | } 337 | 338 | .header-container { 339 | display: flex; 340 | align-items: center; 341 | margin-bottom: 10px; 342 | } 343 | .header-title { 344 | margin: 0; 345 | margin-right: 10px; 346 | } 347 | 348 | .qr-modal { 349 | position: fixed; 350 | top: 0; 351 | left: 0; 352 | width: 100%; 353 | height: 100%; 354 | background-color: rgba(0, 0, 0, 0.5); 355 | display: flex; 356 | justify-content: center; 357 | align-items: center; 358 | opacity: 0; 359 | visibility: hidden; 360 | transition: opacity 0.3s var(--transition-timing), 361 | visibility 0.3s var(--transition-timing); 362 | z-index: 1000; 363 | } 364 | 365 | .qr-modal.show { 366 | opacity: 1; 367 | visibility: visible; 368 | } 369 | 370 | .qr-card { 371 | background-color: white; 372 | padding: 20px; 373 | border-radius: 10px; 374 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 375 | text-align: center; 376 | transform: scale(0.9) translateY(20px); 377 | transition: transform 0.3s var(--transition-timing); 378 | } 379 | 380 | .qr-modal.show .qr-card { 381 | transform: scale(1) translateY(0); 382 | } 383 | 384 | .qr-card img { 385 | max-width: 100%; 386 | height: auto; 387 | } 388 | 389 | .qr-card p { 390 | margin-top: 10px; 391 | color: #333; 392 | font-size: 16px; 393 | } 394 | 395 | .base-url-label { 396 | background-color: var(--input-bg); 397 | color: var(--input-text); 398 | border: 1px solid var(--input-border); 399 | border-radius: 0.25rem; 400 | padding: 0.375rem 0.75rem; 401 | font-size: 1rem; 402 | line-height: 1.5; 403 | } 404 | 405 | #subscribeLinksContainer { 406 | max-height: 0; 407 | opacity: 0; 408 | transform: translateY(20px); 409 | transition: max-height 0.5s var(--transition-timing), 410 | opacity 0.3s var(--transition-timing), 411 | transform 0.3s var(--transition-timing); 412 | } 413 | 414 | #subscribeLinksContainer.show { 415 | max-height: 1000px; 416 | opacity: 1; 417 | transform: translateY(0); 418 | } 419 | 420 | #subscribeLinksContainer.hide { 421 | max-height: 0; 422 | opacity: 0; 423 | } 424 | 425 | .form-select option { 426 | background-color: var(--dropdown-bg); 427 | color: var(--dropdown-text); 428 | } 429 | 430 | .form-select option:hover { 431 | background-color: var(--dropdown-hover-bg); 432 | color: var(--dropdown-hover-text); 433 | } 434 | 435 | .form-check-input { 436 | background-color: var(--switch-bg); 437 | border-color: var(--switch-border); 438 | } 439 | 440 | .form-check-input:checked { 441 | background-color: var(--switch-checked-bg); 442 | border-color: var(--switch-checked-bg); 443 | } 444 | 445 | .dropdown-menu { 446 | background-color: var(--dropdown-bg); 447 | border-color: var(--select-border); 448 | } 449 | 450 | .dropdown-item { 451 | color: var(--dropdown-text); 452 | } 453 | 454 | .dropdown-item:hover, 455 | .dropdown-item:focus { 456 | background-color: var(--dropdown-hover-bg); 457 | color: var(--dropdown-hover-text); 458 | } 459 | 460 | /* 通用过渡效果 */ 461 | .card, 462 | .btn, 463 | .form-control, 464 | .form-select, 465 | .input-group, 466 | .tooltip-content, 467 | .github-link, 468 | .qr-modal, 469 | .qr-card { 470 | transition: all var(--transition-speed) var(--transition-timing); 471 | } 472 | 473 | /* 高级选项展开/收起动画 */ 474 | #advancedOptions { 475 | max-height: 0; 476 | opacity: 0; 477 | overflow: hidden; 478 | transform: translateY(-20px); 479 | transition: max-height 0.5s var(--transition-timing), 480 | opacity 0.3s var(--transition-timing), 481 | transform 0.3s var(--transition-timing); 482 | } 483 | 484 | #advancedOptions.show { 485 | max-height: 2000px; 486 | opacity: 1; 487 | transform: translateY(0); 488 | } 489 | 490 | /* 订阅链接容器动画 */ 491 | #subscribeLinksContainer { 492 | max-height: 0; 493 | opacity: 0; 494 | transform: translateY(20px); 495 | transition: max-height 0.5s var(--transition-timing), 496 | opacity 0.3s var(--transition-timing), 497 | transform 0.3s var(--transition-timing); 498 | } 499 | 500 | #subscribeLinksContainer.show { 501 | max-height: 1000px; 502 | opacity: 1; 503 | transform: translateY(0); 504 | } 505 | 506 | /* 按钮悬停动画 */ 507 | .btn { 508 | transform: translateY(0); 509 | } 510 | 511 | .btn:hover { 512 | transform: translateY(-2px); 513 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); 514 | } 515 | 516 | /* 复制按钮成功动画 */ 517 | @keyframes successPulse { 518 | 0% { transform: scale(1); } 519 | 50% { transform: scale(1.1); } 520 | 100% { transform: scale(1); } 521 | } 522 | 523 | .btn-success { 524 | animation: successPulse 0.3s var(--transition-timing); 525 | } 526 | 527 | /* QR码模态框动画 */ 528 | .qr-modal { 529 | opacity: 0; 530 | visibility: hidden; 531 | backdrop-filter: blur(5px); 532 | transition: opacity 0.3s var(--transition-timing), 533 | visibility 0.3s var(--transition-timing); 534 | } 535 | 536 | .qr-modal.show { 537 | opacity: 1; 538 | visibility: visible; 539 | } 540 | 541 | .qr-card { 542 | transform: scale(0.9) translateY(20px); 543 | transition: transform 0.3s var(--transition-timing); 544 | } 545 | 546 | .qr-modal.show .qr-card { 547 | transform: scale(1) translateY(0); 548 | } 549 | 550 | /* 自定义规则添加/删除动画 */ 551 | .custom-rule { 552 | opacity: 0; 553 | transform: translateY(20px); 554 | animation: slideIn 0.3s var(--transition-timing) forwards; 555 | } 556 | 557 | .custom-rule.removing { 558 | animation: slideOut 0.3s var(--transition-timing) forwards; 559 | } 560 | 561 | @keyframes slideIn { 562 | to { 563 | opacity: 1; 564 | transform: translateY(0); 565 | } 566 | } 567 | 568 | @keyframes slideOut { 569 | from { 570 | opacity: 1; 571 | transform: translateY(0); 572 | } 573 | to { 574 | opacity: 0; 575 | transform: translateY(-20px); 576 | } 577 | } 578 | 579 | /* 暗色模式切换动画 */ 580 | body { 581 | transition: background-color 0.3s var(--transition-timing), 582 | color 0.3s var(--transition-timing); 583 | } 584 | 585 | /* 工具提示动画 */ 586 | .tooltip-content { 587 | opacity: 0; 588 | visibility: hidden; 589 | transform: translateY(10px); 590 | transition: opacity 0.3s var(--transition-timing), 591 | visibility 0.3s var(--transition-timing), 592 | transform 0.3s var(--transition-timing); 593 | } 594 | 595 | .tooltip-icon:hover .tooltip-content { 596 | opacity: 1; 597 | visibility: visible; 598 | transform: translateY(0); 599 | } 600 | `; -------------------------------------------------------------------------------- /src/htmlBuilder.js: -------------------------------------------------------------------------------- 1 | import { UNIFIED_RULES, PREDEFINED_RULE_SETS } from './config.js'; 2 | import { generateStyles } from './style.js'; 3 | 4 | export function generateHtml(xrayUrl, singboxUrl, clashUrl, baseUrl) { 5 | return ` 6 | 7 | 8 | ${generateHead()} 9 | ${generateBody(xrayUrl, singboxUrl, clashUrl, baseUrl)} 10 | 11 | `; 12 | } 13 | 14 | const generateHead = () => ` 15 | 16 | 17 | 18 | 19 | 20 | Sublink Worker - 轻量高效的订阅转换工具 | 支持V2Ray/Xray、SingBox、Clash 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | 32 | `; 33 | 34 | 35 | 36 | const generateBody = (xrayUrl, singboxUrl, clashUrl, baseUrl) => ` 37 | 38 | ${generateDarkModeToggle()} 39 | ${generateGithubLink()} 40 |
41 |
42 | ${generateCardHeader()} 43 |
44 | ${generateForm()} 45 | 48 |
49 |
50 |
51 | ${generateScripts()} 52 | 53 | 54 | 55 | 56 | `; 57 | 58 | const generateDarkModeToggle = () => ` 59 | 62 | `; 63 | 64 | const generateGithubLink = () => ` 65 | 66 | 67 | 68 | `; 69 | 70 | const generateCardHeader = () => ` 71 |
72 |

Sublink Worker

73 |
74 | `; 75 | 76 | const generateForm = () => ` 77 |
78 |
79 |
Share URLs
80 | 81 |
82 | 83 |
84 | 85 | 86 |
87 | 88 |
89 |
90 | ${generateRuleSetSelection()} 91 |
92 | 93 |
94 |
95 | Base Config Settings(Optional) 96 | 97 | 98 | 99 | This feature is experimental and may not work as expected. You can paste your own base config here. Go to docs for more information. 100 | 101 | 102 |
103 |
104 | 108 |
109 |
110 | 111 |
112 |
113 | 114 | 117 |
118 |
119 |
120 | 121 |
122 | 125 | 128 |
129 |
130 | `; 131 | 132 | const generateSubscribeLinks = (xrayUrl, singboxUrl, clashUrl, baseUrl) => ` 133 |
134 |

Your subscribe links:

135 | ${generateLinkInput('Xray Link:', 'xrayLink', xrayUrl)} 136 | ${generateLinkInput('SingBox Link:', 'singboxLink', singboxUrl)} 137 | ${generateLinkInput('Clash Link:', 'clashLink', clashUrl)} 138 |
139 | 140 |
141 | 142 | ${baseUrl}/s/ 143 | 144 | 145 | 148 | 151 |
152 |
153 |
154 | 157 |
158 |
159 | `; 160 | 161 | const generateLinkInput = (label, id, value) => ` 162 |
163 | 164 |
165 | 166 | 167 | 170 | 173 |
174 |
175 | `; 176 | 177 | const generateScripts = () => ` 178 | 192 | `; 193 | 194 | const customPathFunctions = () => ` 195 | function saveCustomPath() { 196 | const customPath = document.getElementById('customShortCode').value; 197 | if (customPath) { 198 | let savedPaths = JSON.parse(localStorage.getItem('savedCustomPaths') || '[]'); 199 | if (!savedPaths.includes(customPath)) { 200 | savedPaths.push(customPath); 201 | localStorage.setItem('savedCustomPaths', JSON.stringify(savedPaths)); 202 | updateSavedPathsDropdown(); 203 | } 204 | } 205 | } 206 | 207 | function updateSavedPathsDropdown() { 208 | const savedPaths = JSON.parse(localStorage.getItem('savedCustomPaths') || '[]'); 209 | const dropdown = document.getElementById('savedCustomPaths'); 210 | dropdown.innerHTML = ''; 211 | savedPaths.forEach(path => { 212 | const option = document.createElement('option'); 213 | option.value = path; 214 | option.textContent = path; 215 | dropdown.appendChild(option); 216 | }); 217 | } 218 | 219 | function loadSavedCustomPath() { 220 | const dropdown = document.getElementById('savedCustomPaths'); 221 | const customShortCode = document.getElementById('customShortCode'); 222 | if (dropdown.value) { 223 | customShortCode.value = dropdown.value; 224 | } 225 | } 226 | 227 | function deleteSelectedPath() { 228 | const dropdown = document.getElementById('savedCustomPaths'); 229 | const selectedPath = dropdown.value; 230 | if (selectedPath) { 231 | let savedPaths = JSON.parse(localStorage.getItem('savedCustomPaths') || '[]'); 232 | savedPaths = savedPaths.filter(path => path !== selectedPath); 233 | localStorage.setItem('savedCustomPaths', JSON.stringify(savedPaths)); 234 | updateSavedPathsDropdown(); 235 | document.getElementById('customShortCode').value = ''; 236 | } 237 | } 238 | 239 | document.addEventListener('DOMContentLoaded', function() { 240 | updateSavedPathsDropdown(); 241 | document.getElementById('savedCustomPaths').addEventListener('change', loadSavedCustomPath); 242 | }); 243 | `; 244 | 245 | const advancedOptionsToggleFunction = () => ` 246 | document.getElementById('advancedToggle').addEventListener('change', function() { 247 | const advancedOptions = document.getElementById('advancedOptions'); 248 | if (this.checked) { 249 | advancedOptions.classList.add('show'); 250 | } else { 251 | advancedOptions.classList.remove('show'); 252 | } 253 | }); 254 | `; 255 | 256 | const copyToClipboardFunction = () => ` 257 | function copyToClipboard(elementId) { 258 | const element = document.getElementById(elementId); 259 | element.select(); 260 | document.execCommand('copy'); 261 | 262 | const button = element.nextElementSibling; 263 | const originalText = button.innerHTML; 264 | button.innerHTML = ' Copied!'; 265 | button.classList.remove('btn-outline-secondary'); 266 | button.classList.add('btn-success'); 267 | setTimeout(() => { 268 | button.innerHTML = originalText; 269 | button.classList.remove('btn-success'); 270 | button.classList.add('btn-outline-secondary'); 271 | }, 2000); 272 | } 273 | `; 274 | 275 | const shortenAllUrlsFunction = () => ` 276 | let isShortening = false; // Add flag to track shortening status 277 | 278 | async function shortenUrl(url, customShortCode) { 279 | saveCustomPath(); 280 | const response = await fetch(\`/shorten-v2?url=\${encodeURIComponent(url)}&shortCode=\${encodeURIComponent(customShortCode || '')}\`); 281 | if (response.ok) { 282 | const data = await response.text(); 283 | return data; 284 | } 285 | throw new Error('Failed to shorten URL'); 286 | } 287 | 288 | async function shortenAllUrls() { 289 | // Prevent multiple clicks 290 | if (isShortening) { 291 | return; 292 | } 293 | 294 | const shortenButton = document.querySelector('button[onclick="shortenAllUrls()"]'); 295 | 296 | try { 297 | isShortening = true; 298 | shortenButton.disabled = true; 299 | shortenButton.innerHTML = 'Shortening...'; 300 | 301 | const singboxLink = document.getElementById('singboxLink'); 302 | const customShortCode = document.getElementById('customShortCode').value; 303 | 304 | // Check if links are already shortened 305 | if (singboxLink.value.includes('/b/')) { 306 | alert('Links are already shortened!'); 307 | return; 308 | } 309 | 310 | const shortCode = await shortenUrl(singboxLink.value, customShortCode); 311 | 312 | const xrayLink = document.getElementById('xrayLink'); 313 | const clashLink = document.getElementById('clashLink'); 314 | 315 | xrayLink.value = window.location.origin + '/x/' + shortCode; 316 | singboxLink.value = window.location.origin + '/b/' + shortCode; 317 | clashLink.value = window.location.origin + '/c/' + shortCode; 318 | } catch (error) { 319 | console.error('Error:', error); 320 | alert('Failed to shorten URLs. Please try again.'); 321 | } finally { 322 | isShortening = false; 323 | shortenButton.disabled = false; 324 | shortenButton.innerHTML = 'Shorten Links'; 325 | } 326 | } 327 | `; 328 | 329 | const darkModeToggleFunction = () => ` 330 | const darkModeToggle = document.getElementById('darkModeToggle'); 331 | const body = document.body; 332 | 333 | darkModeToggle.addEventListener('click', () => { 334 | body.setAttribute('data-theme', body.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'); 335 | darkModeToggle.innerHTML = body.getAttribute('data-theme') === 'dark' ? '' : ''; 336 | }); 337 | 338 | // Check for saved theme preference or use system preference 339 | const savedTheme = localStorage.getItem('theme'); 340 | const systemDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 341 | 342 | if (savedTheme) { 343 | body.setAttribute('data-theme', savedTheme); 344 | darkModeToggle.innerHTML = savedTheme === 'dark' ? '' : ''; 345 | } else if (systemDarkMode) { 346 | body.setAttribute('data-theme', 'dark'); 347 | darkModeToggle.innerHTML = ''; 348 | } 349 | 350 | // Save theme preference when changed 351 | const observer = new MutationObserver((mutations) => { 352 | mutations.forEach((mutation) => { 353 | if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') { 354 | localStorage.setItem('theme', body.getAttribute('data-theme')); 355 | } 356 | }); 357 | }); 358 | 359 | observer.observe(body, { attributes: true }); 360 | `; 361 | 362 | const generateRuleSetSelection = () => ` 363 |
364 |
365 |

Rule Selection

366 | 367 | 368 | 369 | These rules determine how traffic is directed through different proxies or directly. If you're unsure, you can use a predefined rule set. 370 | 371 | 372 |
373 | 374 |
375 | 381 |
382 |
383 | ${UNIFIED_RULES.map(rule => ` 384 |
385 |
386 | 387 | 388 |
389 |
390 | `).join('')} 391 |
392 |
393 |
Custom Rules
394 |
395 | 396 | 397 |
398 |
399 | 400 |
401 | 402 |
403 |
404 | `; 405 | 406 | const applyPredefinedRulesFunction = () => ` 407 | function applyPredefinedRules() { 408 | const predefinedRules = document.getElementById('predefinedRules').value; 409 | const checkboxes = document.querySelectorAll('.rule-checkbox'); 410 | 411 | checkboxes.forEach(checkbox => { 412 | checkbox.checked = false; 413 | }); 414 | 415 | if (predefinedRules === 'custom') { 416 | return; 417 | } 418 | 419 | const rulesToApply = ${JSON.stringify(PREDEFINED_RULE_SETS)}; 420 | 421 | rulesToApply[predefinedRules].forEach(rule => { 422 | const checkbox = document.getElementById(rule); 423 | if (checkbox) { 424 | checkbox.checked = true; 425 | } 426 | }); 427 | } 428 | 429 | // Add event listeners to checkboxes 430 | document.addEventListener('DOMContentLoaded', function() { 431 | const checkboxes = document.querySelectorAll('.rule-checkbox'); 432 | checkboxes.forEach(checkbox => { 433 | checkbox.addEventListener('change', function() { 434 | const predefinedSelect = document.getElementById('predefinedRules'); 435 | if (predefinedSelect.value !== 'custom') { 436 | predefinedSelect.value = 'custom'; 437 | } 438 | }); 439 | }); 440 | }); 441 | `; 442 | 443 | const tooltipFunction = () => ` 444 | function initTooltips() { 445 | const tooltips = document.querySelectorAll('.tooltip-icon'); 446 | tooltips.forEach(tooltip => { 447 | tooltip.addEventListener('click', (e) => { 448 | e.stopPropagation(); 449 | const content = tooltip.querySelector('.tooltip-content'); 450 | content.style.display = content.style.display === 'block' ? 'none' : 'block'; 451 | }); 452 | }); 453 | 454 | document.addEventListener('click', () => { 455 | const openTooltips = document.querySelectorAll('.tooltip-content[style="display: block;"]'); 456 | openTooltips.forEach(tooltip => { 457 | tooltip.style.display = 'none'; 458 | }); 459 | }); 460 | } 461 | 462 | document.addEventListener('DOMContentLoaded', initTooltips); 463 | `; 464 | 465 | const submitFormFunction = () => ` 466 | function submitForm(event) { 467 | event.preventDefault(); 468 | const form = event.target; 469 | const formData = new FormData(form); 470 | const inputString = formData.get('input'); 471 | 472 | // Save form data to localStorage 473 | localStorage.setItem('inputTextarea', inputString); 474 | localStorage.setItem('advancedToggle', document.getElementById('advancedToggle').checked); 475 | localStorage.setItem('crpinToggle', document.getElementById('crpinToggle').checked); 476 | 477 | // 保存 configEditor 和 configType 到 localStorage 478 | localStorage.setItem('configEditor', document.getElementById('configEditor').value); 479 | localStorage.setItem('configType', document.getElementById('configType').value); 480 | 481 | let selectedRules; 482 | const predefinedRules = document.getElementById('predefinedRules').value; 483 | if (predefinedRules !== 'custom') { 484 | selectedRules = predefinedRules; 485 | } else { 486 | selectedRules = Array.from(document.querySelectorAll('input[name="selectedRules"]:checked')) 487 | .map(checkbox => checkbox.value); 488 | } 489 | 490 | let pin = document.getElementById('crpinToggle').checked; 491 | const configEditor = document.getElementById('configEditor'); 492 | const configId = new URLSearchParams(window.location.search).get('configId') || ''; 493 | 494 | const customRules = Array.from(document.querySelectorAll('.custom-rule')).map(rule => ({ 495 | site: rule.querySelector('input[name="customRuleSite[]"]').value, 496 | ip: rule.querySelector('input[name="customRuleIP[]"]').value, 497 | name: rule.querySelector('input[name="customRuleName[]"]').value, 498 | domain_suffix: rule.querySelector('input[name="customRuleDomainSuffix[]"]').value, 499 | domain_keyword: rule.querySelector('input[name="customRuleDomainKeyword[]"]').value, 500 | ip_cidr: rule.querySelector('input[name="customRuleIPCIDR[]"]').value, 501 | protocol: rule.querySelector('input[name="customRuleProtocol[]"]').value 502 | })); 503 | 504 | const configParam = configId ? \`&configId=\${configId}\` : ''; 505 | const xrayUrl = \`\${window.location.origin}/xray?config=\${encodeURIComponent(inputString)}\${configParam}\`; 506 | const singboxUrl = \`\${window.location.origin}/singbox?config=\${encodeURIComponent(inputString)}&selectedRules=\${encodeURIComponent(JSON.stringify(selectedRules))}&customRules=\${encodeURIComponent(JSON.stringify(customRules))}&pin=\${pin}\${configParam}\`; 507 | const clashUrl = \`\${window.location.origin}/clash?config=\${encodeURIComponent(inputString)}&selectedRules=\${encodeURIComponent(JSON.stringify(selectedRules))}&customRules=\${encodeURIComponent(JSON.stringify(customRules))}&pin=\${pin}\${configParam}\`; 508 | 509 | document.getElementById('xrayLink').value = xrayUrl; 510 | document.getElementById('singboxLink').value = singboxUrl; 511 | document.getElementById('clashLink').value = clashUrl; 512 | 513 | // Show the subscribe part 514 | const subscribeLinksContainer = document.getElementById('subscribeLinksContainer'); 515 | subscribeLinksContainer.classList.remove('hide'); 516 | subscribeLinksContainer.classList.add('show'); 517 | 518 | // Scroll to the subscribe part 519 | subscribeLinksContainer.scrollIntoView({ behavior: 'smooth' }); 520 | } 521 | 522 | function loadSavedFormData() { 523 | const savedInput = localStorage.getItem('inputTextarea'); 524 | if (savedInput) { 525 | document.getElementById('inputTextarea').value = savedInput; 526 | } 527 | 528 | const advancedToggle = localStorage.getItem('advancedToggle'); 529 | if (advancedToggle) { 530 | document.getElementById('advancedToggle').checked = advancedToggle === 'true'; 531 | if (advancedToggle === 'true') { 532 | document.getElementById('advancedOptions').classList.add('show'); 533 | } 534 | } 535 | 536 | // 加载 configEditor 和 configType 537 | const savedConfig = localStorage.getItem('configEditor'); 538 | const savedConfigType = localStorage.getItem('configType'); 539 | 540 | if (savedConfig) { 541 | document.getElementById('configEditor').value = savedConfig; 542 | } 543 | if (savedConfigType) { 544 | document.getElementById('configType').value = savedConfigType; 545 | } 546 | 547 | const savedCustomPath = localStorage.getItem('customPath'); 548 | if (savedCustomPath) { 549 | document.getElementById('customShortCode').value = savedCustomPath; 550 | } 551 | 552 | loadSelectedRules(); 553 | } 554 | 555 | function saveSelectedRules() { 556 | const selectedRules = Array.from(document.querySelectorAll('input[name="selectedRules"]:checked')) 557 | .map(checkbox => checkbox.value); 558 | localStorage.setItem('selectedRules', JSON.stringify(selectedRules)); 559 | localStorage.setItem('predefinedRules', document.getElementById('predefinedRules').value); 560 | } 561 | 562 | function loadSelectedRules() { 563 | const savedRules = localStorage.getItem('selectedRules'); 564 | if (savedRules) { 565 | const rules = JSON.parse(savedRules); 566 | rules.forEach(rule => { 567 | const checkbox = document.querySelector(\`input[name="selectedRules"][value="\${rule}"]\`); 568 | if (checkbox) { 569 | checkbox.checked = true; 570 | } 571 | }); 572 | } 573 | 574 | const savedPredefinedRules = localStorage.getItem('predefinedRules'); 575 | if (savedPredefinedRules) { 576 | document.getElementById('predefinedRules').value = savedPredefinedRules; 577 | } 578 | } 579 | 580 | function clearFormData() { 581 | localStorage.removeItem('inputTextarea'); 582 | localStorage.removeItem('advancedToggle'); 583 | localStorage.removeItem('selectedRules'); 584 | localStorage.removeItem('predefinedRules'); 585 | localStorage.removeItem('configEditor'); // 添加清除 configEditor 586 | localStorage.removeItem('configType'); // 添加清除 configType 587 | 588 | document.getElementById('inputTextarea').value = ''; 589 | document.getElementById('advancedToggle').checked = false; 590 | document.getElementById('advancedOptions').classList.remove('show'); 591 | document.getElementById('configEditor').value = ''; 592 | document.getElementById('configType').value = 'singbox'; // 重置为默认值 593 | 594 | localStorage.removeItem('customPath'); 595 | document.getElementById('customShortCode').value = ''; 596 | 597 | const subscribeLinksContainer = document.getElementById('subscribeLinksContainer'); 598 | subscribeLinksContainer.classList.remove('show'); 599 | subscribeLinksContainer.classList.add('hide'); 600 | 601 | document.getElementById('xrayLink').value = ''; 602 | document.getElementById('singboxLink').value = ''; 603 | document.getElementById('clashLink').value = ''; 604 | 605 | // wait to reset the container 606 | setTimeout(() => { 607 | subscribeLinksContainer.classList.remove('hide'); 608 | }, 500); 609 | } 610 | 611 | document.addEventListener('DOMContentLoaded', function() { 612 | loadSavedFormData(); 613 | document.getElementById('encodeForm').addEventListener('submit', submitForm); 614 | document.getElementById('clearFormBtn').addEventListener('click', clearFormData); 615 | }); 616 | `; 617 | 618 | const customRuleFunctions = ` 619 | let customRuleCount = 0; 620 | 621 | function addCustomRule() { 622 | const customRulesDiv = document.getElementById('customRules'); 623 | const newRuleDiv = document.createElement('div'); 624 | newRuleDiv.className = 'custom-rule mb-3 p-3 border rounded'; 625 | newRuleDiv.dataset.ruleId = customRuleCount++; 626 | newRuleDiv.innerHTML = \` 627 |
628 | 629 | 630 |
631 |
632 | 633 | 634 | 635 | 636 | Site Rules in SingBox comes from https://github.com/lyc8503/sing-box-rules, that means your custom rules must be in the repos 637 | 638 | 639 | 640 |
641 |
642 | 643 | 644 | 645 | 646 | IP Rules in SingBox comes from https://github.com/lyc8503/sing-box-rules, that means your custom rules must be in the repos 647 | 648 | 649 | 650 |
651 |
652 | 653 | 654 |
655 |
656 | 657 | 658 |
659 |
660 | 661 | 662 |
663 |
664 | 665 | 666 | 667 | 668 | Protocol rules for specific traffic types. More details: https://sing-box.sagernet.org/configuration/route/sniff/ 669 | 670 | 671 | 672 |
673 | 674 | \`; 675 | customRulesDiv.appendChild(newRuleDiv); 676 | } 677 | 678 | function removeCustomRule(button) { 679 | const ruleDiv = button.closest('.custom-rule'); 680 | if (ruleDiv) { 681 | ruleDiv.classList.add('removing'); 682 | ruleDiv.addEventListener('animationend', () => { 683 | ruleDiv.remove(); 684 | customRuleCount--; 685 | }, { once: true }); 686 | } 687 | } 688 | `; 689 | 690 | const generateQRCodeFunction = () => ` 691 | function generateQRCode(id) { 692 | const input = document.getElementById(id); 693 | const text = input.value; 694 | if (!text) { 695 | alert('No link provided!'); 696 | return; 697 | } 698 | try { 699 | const qr = qrcode(0, 'M'); 700 | qr.addData(text); 701 | qr.make(); 702 | 703 | const moduleCount = qr.getModuleCount(); 704 | const cellSize = Math.max(2, Math.min(8, Math.floor(300 / moduleCount))); 705 | const margin = Math.floor(cellSize * 0.5); 706 | 707 | const qrImage = qr.createDataURL(cellSize, margin); 708 | 709 | const modal = document.createElement('div'); 710 | modal.className = 'qr-modal'; 711 | modal.innerHTML = \` 712 |
713 | QR Code 714 |

Scan QR Code

715 |
716 | \`; 717 | 718 | document.body.appendChild(modal); 719 | 720 | modal.addEventListener('click', (e) => { 721 | if (e.target === modal) { 722 | closeQRModal(); 723 | } 724 | }); 725 | 726 | document.addEventListener('keydown', (e) => { 727 | if (e.key === 'Escape') { 728 | closeQRModal(); 729 | } 730 | }); 731 | 732 | requestAnimationFrame(() => { 733 | modal.classList.add('show'); 734 | }); 735 | } catch (error) { 736 | console.error('Error in generating:', error); 737 | alert('Try to use short links!'); 738 | } 739 | } 740 | 741 | function closeQRModal() { 742 | const modal = document.querySelector('.qr-modal'); 743 | if (modal) { 744 | modal.classList.remove('show'); 745 | modal.addEventListener('transitionend', () => { 746 | document.body.removeChild(modal); 747 | }, { once: true }); 748 | } 749 | } 750 | `; 751 | 752 | const saveConfig = () => ` 753 | function saveConfig() { 754 | const configEditor = document.getElementById('configEditor'); 755 | const configType = document.getElementById('configType').value; 756 | const config = configEditor.value; 757 | 758 | localStorage.setItem('configEditor', config); 759 | localStorage.setItem('configType', configType); 760 | 761 | fetch('/config?type=' + configType, { 762 | method: 'POST', 763 | headers: { 764 | 'Content-Type': 'application/json', 765 | }, 766 | body: JSON.stringify({ 767 | type: configType, 768 | content: config 769 | }) 770 | }) 771 | .then(response => { 772 | if (!response.ok) { 773 | throw new Error('Failed to save configuration'); 774 | } 775 | return response.text(); 776 | }) 777 | .then(configId => { 778 | const currentUrl = new URL(window.location.href); 779 | currentUrl.searchParams.set('configId', configId); 780 | window.history.pushState({}, '', currentUrl); 781 | alert('Configuration saved successfully!'); 782 | }) 783 | .catch(error => { 784 | alert('Error: ' + error.message); 785 | }); 786 | } 787 | `; 788 | 789 | const clearConfig = () => ` 790 | function clearConfig() { 791 | document.getElementById('configEditor').value = ''; 792 | const currentUrl = new URL(window.location.href); 793 | currentUrl.searchParams.delete('configId'); 794 | window.history.pushState({}, '', currentUrl); 795 | localStorage.removeItem('configEditor'); 796 | } 797 | `; 798 | --------------------------------------------------------------------------------