├── 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 |
7 |
8 |
9 |
12 |
13 |
14 |
15 |
16 |
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 | [](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 |
46 | ${generateSubscribeLinks(xrayUrl, singboxUrl, clashUrl, baseUrl)}
47 |
48 |
49 |
50 |
51 | ${generateScripts()}
52 |
53 |
54 |
55 |
56 | `;
57 |
58 | const generateDarkModeToggle = () => `
59 |
60 |
61 |
62 | `;
63 |
64 | const generateGithubLink = () => `
65 |
66 |
67 |
68 | `;
69 |
70 | const generateCardHeader = () => `
71 |
74 | `;
75 |
76 | const generateForm = () => `
77 |
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 |
Custom Path (optional):
140 |
141 |
142 | ${baseUrl}/s/
143 |
144 |
145 |
146 | Saved paths
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 | Shorten Links
156 |
157 |
158 |
159 | `;
160 |
161 | const generateLinkInput = (label, id, value) => `
162 |
163 |
${label}
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
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 = 'Saved paths ';
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 |
373 |
374 |
375 |
376 | Custom
377 | Minimal
378 | Balanced
379 | Comprehensive
380 |
381 |
382 |
383 | ${UNIFIED_RULES.map(rule => `
384 |
385 |
386 |
387 | ${rule.outbound}
388 |
389 |
390 | `).join('')}
391 |
392 |
393 |
Custom Rules
394 |
395 |
396 | Pin Custom Rules
397 |
398 |
399 |
400 |
401 |
Add Custom Rule
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 | Outbound Name*
629 |
630 |
631 |
632 | Geo-Site Rule Sets
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 | Geo-IP Rule Sets
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 | Domain Suffix
653 |
654 |
655 |
656 | Domain Keyword
657 |
658 |
659 |
660 | IP CIDR
661 |
662 |
663 |
664 | Protocol
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 | Remove
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 |
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 |
--------------------------------------------------------------------------------