add groupRules

This commit is contained in:
nite 2025-04-20 13:03:06 +10:00
parent 87e06ffc67
commit ed3d2e9e64
9 changed files with 116 additions and 74 deletions

View File

@ -39,6 +39,13 @@ func Convert(c *gin.Context) {
}) })
return return
} }
groupRules := make(map[string][]string)
err = json.Unmarshal([]byte(data.GroupRules), &groupRules)
if err != nil {
c.JSON(400, gin.H{
"error": err.Error(),
})
}
result, err := common.Convert( result, err := common.Convert(
data.Subscriptions, data.Subscriptions,
data.Proxies, data.Proxies,
@ -49,6 +56,7 @@ func Convert(c *gin.Context) {
data.GroupType, data.GroupType,
data.SortKey, data.SortKey,
data.SortType, data.SortType,
groupRules,
) )
if err != nil { if err != nil {
c.JSON(400, gin.H{ c.JSON(400, gin.H{

View File

@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="mdui-theme-auto"> <html lang="en" class="mdui-theme-auto">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -16,9 +17,11 @@
body { body {
font-family: "Roboto", "Noto Sans SC", sans-serif; font-family: "Roboto", "Noto Sans SC", sans-serif;
} }
h3 { h3 {
margin-top: 0; margin-top: 0;
} }
.section { .section {
margin: 20px 0; margin: 20px 0;
padding: 15px; padding: 15px;
@ -26,19 +29,23 @@
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
background-color: rgb(var(--mdui-color-surface-container)); background-color: rgb(var(--mdui-color-surface-container));
} }
.section > * {
.section>* {
margin-bottom: 10px; margin-bottom: 10px;
} }
.mdui-container { .mdui-container {
border-radius: 10px; border-radius: 10px;
max-width: 1200px; max-width: 1200px;
margin: 30px auto; margin: 30px auto;
} }
.header-controls { .header-controls {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
} }
/* Rename fields: now stacked in a wide layout */ /* Rename fields: now stacked in a wide layout */
.rename-group { .rename-group {
display: flex; display: flex;
@ -47,6 +54,7 @@
} }
</style> </style>
</head> </head>
<body> <body>
<div class="mdui-container"> <div class="mdui-container">
<div style="display: flex; align-items: center; justify-content: space-between;"> <div style="display: flex; align-items: center; justify-content: space-between;">
@ -89,24 +97,20 @@
<div class="section"> <div class="section">
<h3 data-i18n="nodes">Nodes</h3> <h3 data-i18n="nodes">Nodes</h3>
<div> <div>
<mdui-text-field autosize min-rows="3" max-rows="5" data-i18n="subscription-link" <mdui-text-field autosize min-rows="3" max-rows="5" data-i18n="subscription-link"
data-i18n-placeholder="one-per-line" data-i18n-placeholder="one-per-line" label="Subscription Link" type="text" id="subscription"
label="Subscription Link" type="text" id="subscription" name="subscription" name="subscription" placeholder="One per line">
</mdui-text-field>
</div>
<div>
<mdui-text-field autosize min-rows="3" max-rows="5" data-i18n="proxy-link"
data-i18n-placeholder="one-per-line" label="Node Share Link" type="text" id="proxy" name="proxy"
placeholder="One per line"> placeholder="One per line">
</mdui-text-field> </mdui-text-field>
</div> </div>
<div> <div>
<mdui-text-field autosize min-rows="3" max-rows="5" data-i18n="proxy-link" <mdui-text-field data-i18n="delete-node" data-i18n-placeholder="supports-regex" label="Delete Node"
data-i18n-placeholder="one-per-line" type="text" id="delete" name="delete" placeholder="Supports regex">
label="Node Share Link" type="text" id="proxy" name="proxy"
placeholder="One per line">
</mdui-text-field>
</div>
<div>
<mdui-text-field data-i18n="delete-node"
data-i18n-placeholder="supports-regex"
label="Delete Node" type="text" id="delete" name="delete"
placeholder="Supports regex">
</mdui-text-field> </mdui-text-field>
</div> </div>
<div> <div>
@ -123,8 +127,11 @@
<div class="section"> <div class="section">
<h3 data-i18n="policy-group">Policy Group</h3> <h3 data-i18n="policy-group">Policy Group</h3>
<div> <div>
<mdui-text-field data-i18n="type" label="Type" type="text" id="group-type" name="group-type" <mdui-checkbox data-i18n="group" label="Group" name="group" id="group"
value="selector"> value="true">placeholder</mdui-checkbox>
</div>
<div>
<mdui-text-field data-i18n="type" label="Type" type="text" id="group-type" name="group-type" value="selector">
</mdui-text-field> </mdui-text-field>
</div> </div>
<div> <div>
@ -139,6 +146,11 @@
<mdui-menu-item value="desc" data-i18n="descending">Descending</mdui-menu-item> <mdui-menu-item value="desc" data-i18n="descending">Descending</mdui-menu-item>
</mdui-select> </mdui-select>
</div> </div>
<div>
<mdui-text-field autosize data-i18n="group-rules" label="Group Rules" type="text" id="group-rules"
name="group-rules" min-rows="3" max-rows="5" data-i18n-placeholder="group-rules-placeholder">
</mdui-text-field>
</div>
</div> </div>
<!-- Result Section --> <!-- Result Section -->
<div class="section"> <div class="section">
@ -176,7 +188,10 @@
"theme-auto": "跟随系统", "theme-auto": "跟随系统",
"import-profile": "导入配置", "import-profile": "导入配置",
"one-per-line": "一行一个", "one-per-line": "一行一个",
"supports-regex": "支持正则表达式" "supports-regex": "支持正则表达式",
"group-rules": "策略组规则",
"group-rules-placeholder": "Json格式: {\"US\": [\"US-CA\", \"US-TX\"], \"CN\": [\"CN-BJ\", \"CN-SH\"]}, 支持正则表达式",
"group": "启用策略组"
}, },
"en": { "en": {
"template": "Template", "template": "Template",
@ -199,7 +214,10 @@
"theme-auto": "Auto", "theme-auto": "Auto",
"import-profile": "Import Profile", "import-profile": "Import Profile",
"one-per-line": "One per line", "one-per-line": "One per line",
"supports-regex": "Supports regex" "supports-regex": "Supports regex",
"group-rules": "Group Rules",
"group-rules-placeholder": "Json format: {\"US\": [\"US-CA\", \"US-TX\"], \"CN\": [\"CN-BJ\", \"CN-SH\"]}, support regex",
"group": "Enable Policy Group"
}, },
"ru": { "ru": {
"template": "Шаблон", "template": "Шаблон",
@ -222,11 +240,14 @@
"theme-auto": "Системная", "theme-auto": "Системная",
"import-profile": "Импортировать профиль", "import-profile": "Импортировать профиль",
"one-per-line": "Одна на строку", "one-per-line": "Одна на строку",
"supports-regex": "Поддерживает рег. выражения" "supports-regex": "Поддерживает рег. выражения",
"group-rules": "Группа правил",
"group-rules-placeholder": "Формат JSON: {\"US\": [\"US-CA\", \"US-TX\"], \"CN\": [\"CN-BJ\", \"CN-SH\"]}, поддержка регулярных выражений",
"group": "Включить группу политик"
} }
}; };
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
setupLanguage(); setupLanguage();
setupTheme(); setupTheme();
listenInput(); listenInput();
@ -242,7 +263,7 @@
languageMenu.value = savedLang; languageMenu.value = savedLang;
changeLanguage(savedLang); changeLanguage(savedLang);
languageMenu.addEventListener('change', function() { languageMenu.addEventListener('change', function () {
changeLanguage(languageMenu.value); changeLanguage(languageMenu.value);
}); });
} }
@ -257,8 +278,10 @@
const translation = translations[lang][key]; const translation = translations[lang][key];
if (translation) { if (translation) {
if (element.tagName.toLowerCase() === 'mdui-text-field' || if (element.tagName.toLowerCase() === 'mdui-text-field' ||
element.tagName.toLowerCase() === 'mdui-select') { element.tagName.toLowerCase() === 'mdui-select') {
element.setAttribute('label', translation); element.setAttribute('label', translation);
} else if (element.tagName.toLowerCase() === 'mdui-checkbox') {
element.innerHTML = translation;
} else { } else {
element.textContent = translation; element.textContent = translation;
} }
@ -279,7 +302,7 @@
document.documentElement.className = "mdui-theme-" + savedTheme; document.documentElement.className = "mdui-theme-" + savedTheme;
themeMenu.value = savedTheme; themeMenu.value = savedTheme;
themeMenu.addEventListener("change", function() { themeMenu.addEventListener("change", function () {
const newTheme = themeMenu.value; const newTheme = themeMenu.value;
document.documentElement.className = "mdui-theme-" + newTheme; document.documentElement.className = "mdui-theme-" + newTheme;
localStorage.setItem("theme", newTheme); localStorage.setItem("theme", newTheme);
@ -344,14 +367,16 @@
const groupType = document.getElementById("group-type").value; const groupType = document.getElementById("group-type").value;
const sort = document.getElementById("sort").value; const sort = document.getElementById("sort").value;
const sortType = document.getElementById("sort-type").value; const sortType = document.getElementById("sort-type").value;
const groupRules = document.getElementById("group-rules").value;
const group = document.getElementById("group").value;
let rename = {}; let rename = {};
for (let i = 0; i < renameFrom.length; i++) { for (let i = 0; i < renameFrom.length; i++) {
if (renameFrom[i] && renameTo[i]) { if (renameFrom[i] && renameTo[i]) {
rename[renameFrom[i]] = renameTo[i]; rename[renameFrom[i]] = renameTo[i];
} }
} }
// Only generate link if there is some user input (subscription, proxy, delete, or rename) // Only generate link if there is some user input (subscription, proxy, delete, or rename)
if ( if (
subscription.length === 0 && subscription.length === 0 &&
@ -362,15 +387,15 @@
output.value = ""; output.value = "";
return; return;
} }
// Determine base URL using window.location.origin and window.location.pathname // Determine base URL using window.location.origin and window.location.pathname
let basePath = window.location.pathname; let basePath = window.location.pathname;
// Remove trailing slash if exists // Remove trailing slash if exists
if(basePath.endsWith('/')){ if (basePath.endsWith('/')) {
basePath = basePath.slice(0, -1); basePath = basePath.slice(0, -1);
} }
const baseURL = window.location.origin + basePath; const baseURL = window.location.origin + basePath;
const data = { const data = {
subscription, subscription,
proxy, proxy,
@ -379,17 +404,11 @@
rename, rename,
"group-type": groupType, "group-type": groupType,
sort, sort,
"sort-type": sortType "sort-type": sortType,
"group-rules": groupRules,
group: group === "true"
}; };
output.value = baseURL + "/convert?data=" + encodeBase64(JSON.stringify(data)); output.value = baseURL + "/convert?data=" + btoa(unescape(encodeURIComponent(JSON.stringify(data)))).replace(/\+/g, "-").replace(/\//g, "_");
}
function encodeBase64(str) {
return btoa(
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function(match, p1) {
return String.fromCharCode("0x" + p1);
})
).replace(/\+/g, "-").replace(/\//g, "_");
} }
function openProfileLink() { function openProfileLink() {
@ -418,4 +437,5 @@
} }
</script> </script>
</body> </body>
</html>
</html>

View File

@ -24,6 +24,7 @@ var (
sortKey string sortKey string
sortType string sortType string
config string config string
groupRules string
) )
func init() { func init() {
@ -38,6 +39,7 @@ func init() {
convertCmd.Flags().StringVarP(&sortKey, "sort", "S", "", "sort key, tag or num") convertCmd.Flags().StringVarP(&sortKey, "sort", "S", "", "sort key, tag or num")
convertCmd.Flags().StringVarP(&sortType, "sort-type", "T", "", "sort type, asc or desc") convertCmd.Flags().StringVarP(&sortType, "sort-type", "T", "", "sort type, asc or desc")
convertCmd.Flags().StringVarP(&config, "config", "c", "", "configuration file path") convertCmd.Flags().StringVarP(&config, "config", "c", "", "configuration file path")
convertCmd.Flags().StringVarP(&groupRules, "group-rules", "R", "", "group rules")
RootCmd.AddCommand(convertCmd) RootCmd.AddCommand(convertCmd)
} }
@ -50,6 +52,14 @@ var convertCmd = &cobra.Command{
func convertRun(cmd *cobra.Command, args []string) { func convertRun(cmd *cobra.Command, args []string) {
loadConfig() loadConfig()
groupRulesMap := make(map[string][]string)
if groupRules != "" {
err := json.Unmarshal([]byte(groupRules), &groupRulesMap)
if err != nil {
fmt.Println("Error parsing group rules:", err)
return
}
}
result, err := common.Convert( result, err := common.Convert(
subscriptions, subscriptions,
proxies, proxies,
@ -60,6 +70,7 @@ func convertRun(cmd *cobra.Command, args []string) {
groupType, groupType,
sortKey, sortKey,
sortType, sortType,
groupRulesMap,
) )
if err != nil { if err != nil {
fmt.Println("Conversion error:", err) fmt.Println("Conversion error:", err)

View File

@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices"
"sort" "sort"
"strings" "strings"
@ -34,6 +35,7 @@ func Convert(
groupType string, groupType string,
sortKey string, sortKey string,
sortType string, sortType string,
groupRules map[string][]string,
) (string, error) { ) (string, error) {
result := "" result := ""
var err error var err error
@ -99,7 +101,7 @@ func Convert(
} }
if enableGroup { if enableGroup {
outbounds = AddCountryGroup(outbounds, groupType, sortKey, sortType) outbounds = AddCountryGroup(outbounds, groupType, sortKey, sortType, groupRules)
} }
if templatePath != "" { if templatePath != "" {
templateData, err := ReadTemplate(templatePath) templateData, err := ReadTemplate(templatePath)
@ -114,7 +116,7 @@ func Convert(
} }
} }
if !enableGroup && (reg.MatchString(templateData) || strings.Contains(templateData, constant.AllCountryTags) || group) { if !enableGroup && (reg.MatchString(templateData) || strings.Contains(templateData, constant.AllCountryTags) || group) {
outbounds = AddCountryGroup(outbounds, groupType, sortKey, sortType) outbounds = AddCountryGroup(outbounds, groupType, sortKey, sortType, groupRules)
} }
var template model.Options var template model.Options
if template, err = J.UnmarshalExtendedContext[model.Options](globalCtx, []byte(templateData)); err != nil { if template, err = J.UnmarshalExtendedContext[model.Options](globalCtx, []byte(templateData)); err != nil {
@ -129,7 +131,7 @@ func Convert(
for _, v := range template.Options.Endpoints { for _, v := range template.Options.Endpoints {
template.Endpoints = append(template.Endpoints, (model.Endpoint)(v)) template.Endpoints = append(template.Endpoints, (model.Endpoint)(v))
} }
result, err = MergeTemplate(outbounds, &template) result, err = MergeTemplate(outbounds, &template, groupRules)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -151,11 +153,25 @@ func Convert(
return string(result), nil return string(result), nil
} }
func AddCountryGroup(proxies []model.Outbound, groupType string, sortKey string, sortType string) []model.Outbound { func AddCountryGroup(proxies []model.Outbound, groupType string, sortKey string, sortType string, groupRules map[string][]string) []model.Outbound {
newGroup := make(map[string]model.Outbound) newGroup := make(map[string]model.Outbound)
groupRulesRegexps := make(map[string][]*regexp.Regexp)
for k, v := range groupRules {
for _, rule := range v {
groupRulesRegexps[k] = append(groupRulesRegexps[k], regexp.MustCompile(rule))
}
}
for _, p := range proxies { for _, p := range proxies {
if p.Type != C.TypeSelector && p.Type != C.TypeURLTest { if p.Type != C.TypeSelector && p.Type != C.TypeURLTest {
country := model.GetContryName(p.Tag) country := model.GetContryName(p.Tag)
for k, rules := range groupRulesRegexps {
for _, rule := range rules {
if rule.MatchString(p.Tag) {
country = k
break
}
}
}
if group, ok := newGroup[country]; ok { if group, ok := newGroup[country]; ok {
AppendOutbound(&group, p.Tag) AppendOutbound(&group, p.Tag)
newGroup[country] = group newGroup[country] = group
@ -235,13 +251,17 @@ func ReadTemplate(template string) (string, error) {
} }
} }
func MergeTemplate(outbounds []model.Outbound, template *model.Options) (string, error) { func MergeTemplate(outbounds []model.Outbound, template *model.Options, groupRules map[string][]string) (string, error) {
var err error var err error
proxyTags := make([]string, 0) proxyTags := make([]string, 0)
groupTags := make([]string, 0) groupTags := make([]string, 0)
groups := make(map[string]model.Outbound) groups := make(map[string]model.Outbound)
rulesKeys := make([]string, 0)
for k := range groupRules {
rulesKeys = append(rulesKeys, k)
}
for _, p := range outbounds { for _, p := range outbounds {
if model.IsCountryGroup(p.Tag) { if slices.Contains(rulesKeys, p.Tag) || model.IsCountryGroup(p.Tag) {
groupTags = append(groupTags, p.Tag) groupTags = append(groupTags, p.Tag)
reg := regexp.MustCompile("[A-Za-z]{2}") reg := regexp.MustCompile("[A-Za-z]{2}")
country := reg.FindString(p.Tag) country := reg.FindString(p.Tag)

View File

@ -11,4 +11,5 @@ type ConvertRequest struct {
SortKey string `form:"sort" json:"sort"` SortKey string `form:"sort" json:"sort"`
SortType string `form:"sort-type" json:"sort-type"` SortType string `form:"sort-type" json:"sort-type"`
Output string `json:"output"` Output string `json:"output"`
GroupRules string `form:"group-rules" json:"group-rules"`
} }

View File

@ -1,7 +1,7 @@
package model package model
type VmessJson struct { type VmessJson struct {
V string `json:"v"` V any `json:"v"`
Ps string `json:"ps"` Ps string `json:"ps"`
Add string `json:"add"` Add string `json:"add"`
Port any `json:"port"` Port any `json:"port"`

View File

@ -68,10 +68,7 @@
"inbounds": [ "inbounds": [
{ {
"type": "tun", "type": "tun",
"address": [ "address": ["172.16.0.1/30", "fdfe:dcba:9876::1/126"],
"172.16.0.1/30",
"fdfe:dcba:9876::1/126"
],
"auto_route": true, "auto_route": true,
"strict_route": true, "strict_route": true,
"exclude_interface": "tailscale0", "exclude_interface": "tailscale0",
@ -84,6 +81,7 @@
"tag": "default", "tag": "default",
"outbounds": [ "outbounds": [
"\u003call-proxy-tags\u003e", "\u003call-proxy-tags\u003e",
"\u003c-all-country-tags\u003e",
"direct" "direct"
] ]
}, },
@ -107,11 +105,7 @@
"action": "hijack-dns" "action": "hijack-dns"
}, },
{ {
"port": [ "port": [3478, 5348, 5349],
3478,
5348,
5349
],
"outbound": "direct" "outbound": "direct"
}, },
{ {

View File

@ -69,10 +69,7 @@
{ {
"type": "tun", "type": "tun",
"tag": "tun-in", "tag": "tun-in",
"address": [ "address": ["192.168.135.1/30", "fdfe:dcba:9876::1/126"],
"192.168.135.1/30",
"fdfe:dcba:9876::1/126"
],
"auto_route": true, "auto_route": true,
"auto_redirect": true, "auto_redirect": true,
"strict_route": true, "strict_route": true,
@ -85,6 +82,7 @@
"tag": "default", "tag": "default",
"outbounds": [ "outbounds": [
"\u003call-proxy-tags\u003e", "\u003call-proxy-tags\u003e",
"\u003c-all-country-tags\u003e",
"direct" "direct"
] ]
}, },
@ -108,11 +106,7 @@
"action": "hijack-dns" "action": "hijack-dns"
}, },
{ {
"port": [ "port": [3478, 5348, 5349],
3478,
5348,
5349
],
"outbound": "direct" "outbound": "direct"
}, },
{ {

View File

@ -68,10 +68,7 @@
"inbounds": [ "inbounds": [
{ {
"type": "tun", "type": "tun",
"address": [ "address": ["172.16.0.1/30", "fdfe:dcba:9876::1/126"],
"172.16.0.1/30",
"fdfe:dcba:9876::1/126"
],
"auto_route": true, "auto_route": true,
"strict_route": true, "strict_route": true,
"exclude_interface": "tailscale0", "exclude_interface": "tailscale0",
@ -84,6 +81,7 @@
"tag": "default", "tag": "default",
"outbounds": [ "outbounds": [
"\u003call-proxy-tags\u003e", "\u003call-proxy-tags\u003e",
"\u003c-all-country-tags\u003e",
"direct" "direct"
] ]
}, },
@ -107,11 +105,7 @@
"action": "hijack-dns" "action": "hijack-dns"
}, },
{ {
"port": [ "port": [3478, 5348, 5349],
3478,
5348,
5349
],
"outbound": "direct" "outbound": "direct"
}, },
{ {