mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
channel: master version: 1.0.0 source-ref: master published-at-utc: 2026-03-13T15:19:28Z
249 lines
6.0 KiB
Go
249 lines
6.0 KiB
Go
package backend
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
const (
|
||
maxClashSubscriptionBytes = 8 * 1024 * 1024
|
||
clashSubscriptionTimeout = 25 * time.Second
|
||
)
|
||
|
||
// BrowserProxyFetchClashByURL 拉取 Clash 订阅 URL,并返回可直接导入的 YAML 文本与建议配置。
|
||
func (a *App) BrowserProxyFetchClashByURL(rawURL string) (map[string]interface{}, error) {
|
||
rawURL = strings.TrimSpace(rawURL)
|
||
if rawURL == "" {
|
||
return nil, fmt.Errorf("订阅 URL 不能为空")
|
||
}
|
||
|
||
parsedURL, err := url.Parse(rawURL)
|
||
if err != nil || parsedURL.Host == "" {
|
||
return nil, fmt.Errorf("URL 格式无效")
|
||
}
|
||
scheme := strings.ToLower(strings.TrimSpace(parsedURL.Scheme))
|
||
if scheme != "http" && scheme != "https" {
|
||
return nil, fmt.Errorf("仅支持 http/https URL")
|
||
}
|
||
|
||
req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||
}
|
||
req.Header.Set("User-Agent", "clash-verge/2.0 ant-chrome/1.0")
|
||
req.Header.Set("Accept", "application/yaml,text/yaml,text/plain,*/*")
|
||
req.Header.Set("Cache-Control", "no-cache")
|
||
|
||
client := &http.Client{
|
||
Timeout: clashSubscriptionTimeout,
|
||
}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("拉取订阅失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return nil, fmt.Errorf("拉取订阅失败: HTTP %d", resp.StatusCode)
|
||
}
|
||
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxClashSubscriptionBytes+1))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取订阅内容失败: %w", err)
|
||
}
|
||
if len(body) > maxClashSubscriptionBytes {
|
||
return nil, fmt.Errorf("订阅内容过大(超过 8MB)")
|
||
}
|
||
|
||
content, payload, err := normalizeClashSubscriptionContent(body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
proxyCount := clashProxyCount(payload)
|
||
if proxyCount <= 0 {
|
||
return nil, fmt.Errorf("未检测到可导入的 proxies 节点")
|
||
}
|
||
|
||
dnsYAML := extractClashDNSYAML(payload)
|
||
suggestedGroup := suggestClashGroupName(payload, parsedURL.Hostname())
|
||
|
||
return map[string]interface{}{
|
||
"url": parsedURL.String(),
|
||
"content": content,
|
||
"proxyCount": proxyCount,
|
||
"dnsServers": dnsYAML,
|
||
"suggestedGroup": suggestedGroup,
|
||
}, nil
|
||
}
|
||
|
||
func normalizeClashSubscriptionContent(body []byte) (string, interface{}, error) {
|
||
baseText := strings.TrimSpace(strings.ReplaceAll(string(body), "\r\n", "\n"))
|
||
if baseText == "" {
|
||
return "", nil, fmt.Errorf("订阅内容为空")
|
||
}
|
||
|
||
tryTexts := make([]string, 0, 4)
|
||
tryTexts = append(tryTexts, baseText)
|
||
|
||
if unescaped, err := url.QueryUnescape(baseText); err == nil {
|
||
unescaped = strings.TrimSpace(strings.ReplaceAll(unescaped, "\r\n", "\n"))
|
||
if unescaped != "" && unescaped != baseText {
|
||
tryTexts = append(tryTexts, unescaped)
|
||
}
|
||
}
|
||
|
||
if decoded, ok := decodeBase64Text(baseText); ok {
|
||
tryTexts = append(tryTexts, decoded)
|
||
}
|
||
|
||
for _, text := range tryTexts {
|
||
payload, ok := parseClashPayload(text)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if clashProxyCount(payload) > 0 {
|
||
return text, payload, nil
|
||
}
|
||
}
|
||
|
||
return "", nil, fmt.Errorf("URL 内容不是有效 Clash YAML(需包含 proxies)")
|
||
}
|
||
|
||
func decodeBase64Text(raw string) (string, bool) {
|
||
candidate := strings.TrimSpace(raw)
|
||
if candidate == "" {
|
||
return "", false
|
||
}
|
||
// 一些订阅会返回 URL-safe base64 或缺少 padding,这里都尝试一遍。
|
||
padded := candidate
|
||
if mod := len(padded) % 4; mod != 0 {
|
||
padded += strings.Repeat("=", 4-mod)
|
||
}
|
||
|
||
encoders := []*base64.Encoding{
|
||
base64.StdEncoding,
|
||
base64.RawStdEncoding,
|
||
base64.URLEncoding,
|
||
base64.RawURLEncoding,
|
||
}
|
||
for _, enc := range encoders {
|
||
if data, err := enc.DecodeString(candidate); err == nil {
|
||
decoded := strings.TrimSpace(strings.ReplaceAll(string(data), "\r\n", "\n"))
|
||
if decoded != "" {
|
||
return decoded, true
|
||
}
|
||
}
|
||
if data, err := enc.DecodeString(padded); err == nil {
|
||
decoded := strings.TrimSpace(strings.ReplaceAll(string(data), "\r\n", "\n"))
|
||
if decoded != "" {
|
||
return decoded, true
|
||
}
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func parseClashPayload(text string) (interface{}, bool) {
|
||
var payload interface{}
|
||
if err := yaml.Unmarshal([]byte(text), &payload); err != nil {
|
||
return nil, false
|
||
}
|
||
return payload, true
|
||
}
|
||
|
||
func clashProxyCount(payload interface{}) int {
|
||
if m := toStringMap(payload); m != nil {
|
||
if arr, ok := m["proxies"].([]interface{}); ok {
|
||
return len(arr)
|
||
}
|
||
if arr, ok := m["proxy"].([]interface{}); ok {
|
||
return len(arr)
|
||
}
|
||
if arr, ok := m["Proxy"].([]interface{}); ok {
|
||
return len(arr)
|
||
}
|
||
}
|
||
if arr, ok := payload.([]interface{}); ok {
|
||
return len(arr)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func extractClashDNSYAML(payload interface{}) string {
|
||
m := toStringMap(payload)
|
||
if m == nil {
|
||
return ""
|
||
}
|
||
dnsRaw, exists := m["dns"]
|
||
if !exists || dnsRaw == nil {
|
||
return ""
|
||
}
|
||
data, err := yaml.Marshal(map[string]interface{}{
|
||
"dns": dnsRaw,
|
||
})
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(string(data))
|
||
}
|
||
|
||
func suggestClashGroupName(payload interface{}, fallbackHost string) string {
|
||
fallbackHost = strings.TrimSpace(fallbackHost)
|
||
m := toStringMap(payload)
|
||
if m != nil {
|
||
if groups, ok := m["proxy-groups"].([]interface{}); ok {
|
||
for _, item := range groups {
|
||
if groupMap := toStringMap(item); groupMap != nil {
|
||
if name := strings.TrimSpace(getMapString(groupMap, "name")); name != "" {
|
||
return name
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if strings.HasPrefix(strings.ToLower(fallbackHost), "www.") {
|
||
fallbackHost = fallbackHost[4:]
|
||
}
|
||
return fallbackHost
|
||
}
|
||
|
||
func toStringMap(value interface{}) map[string]interface{} {
|
||
switch m := value.(type) {
|
||
case map[string]interface{}:
|
||
return m
|
||
case map[interface{}]interface{}:
|
||
out := make(map[string]interface{}, len(m))
|
||
for k, v := range m {
|
||
key := fmt.Sprint(k)
|
||
out[key] = v
|
||
}
|
||
return out
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func getMapString(m map[string]interface{}, key string) string {
|
||
if m == nil {
|
||
return ""
|
||
}
|
||
value, ok := m[key]
|
||
if !ok || value == nil {
|
||
return ""
|
||
}
|
||
switch v := value.(type) {
|
||
case string:
|
||
return v
|
||
default:
|
||
return fmt.Sprint(v)
|
||
}
|
||
}
|