Release 1.6.1: fix subscription button stuck disabled and improve import parsing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-07-28 09:00:59 +03:00
co-authored by Cursor
parent 68a48b05ef
commit 740517dbfb
16 changed files with 258 additions and 68 deletions
+140 -17
View File
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"unicode"
@@ -24,6 +25,11 @@ type Item struct {
URI string
}
var (
reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`)
reClashHY2 = regexp.MustCompile(`(?is)type:\s*hysteria2\b.*?server:\s*(\S+).*?port:\s*(\d+).*?(?:password|auth):\s*["']?([^\s"']+)`)
)
// Fetch downloads a subscription body and parses proxy share links.
func Fetch(ctx context.Context, rawURL string) ([]Item, error) {
rawURL = strings.TrimSpace(rawURL)
@@ -39,53 +45,96 @@ func Fetch(ctx context.Context, rawURL string) ([]Item, error) {
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Navis/1.6.0")
req.Header.Set("User-Agent", "ClashMeta/1.18.0")
req.Header.Set("Accept", "*/*")
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
return nil, fmt.Errorf("не удалось скачать подписку: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("подписка HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, err
}
return ParseBody(string(body))
items, err := ParseBody(string(body))
if err != nil {
return nil, err
}
return items, nil
}
// ParseBody accepts plain text lines or base64 (Clash/V2Ray style subscription).
// ParseBody accepts plain text lines, base64, or Clash-like blobs with share links.
func ParseBody(body string) ([]Item, error) {
body = strings.TrimSpace(body)
if body == "" {
return nil, fmt.Errorf("пустая подписка")
}
text := body
if !looksLikeLinks(body) {
if decoded, err := decodeMaybeBase64(body); err == nil && looksLikeLinks(decoded) {
text = decoded
candidates := []string{body}
if decoded, err := decodeMaybeBase64(body); err == nil && decoded != "" && decoded != body {
candidates = append(candidates, decoded)
// some panels double-encode
if decoded2, err2 := decodeMaybeBase64(decoded); err2 == nil && decoded2 != "" && decoded2 != decoded {
candidates = append(candidates, decoded2)
}
}
var lastErr error
for _, text := range candidates {
items, err := parseText(text)
if err == nil && len(items) > 0 {
return items, nil
}
if err != nil {
lastErr = err
}
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
}
func parseText(text string) ([]Item, error) {
lines := splitLines(text)
// Also extract share URIs embedded in YAML/HTML/JSON.
extracted := reShareLine.FindAllString(text, -1)
for _, m := range extracted {
lines = append(lines, strings.TrimRight(m, ".,;)]}\"'"))
}
for _, block := range extractClashHY2(text) {
lines = append(lines, block)
}
out := make([]Item, 0, len(lines))
seen := map[string]int{}
seenURI := map[string]struct{}{}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Some panels wrap links in quotes
line = strings.Trim(line, `"'`)
proto := config.DetectProtocol(line)
if proto == "" {
continue
}
// Skip bare https:// without credentials (common noise in HTML/YAML).
if proto == config.ProtocolNaive && !naiveLinkHasAuth(line) && !strings.HasPrefix(strings.ToLower(line), "naive+") {
continue
}
normalized, detected, remark, err := linknorm.Normalize(proto, line)
if err != nil {
continue
}
if _, dup := seenURI[normalized]; dup {
continue
}
seenURI[normalized] = struct{}{}
name := remark
if name == "" {
name = defaultName(detected, normalized)
@@ -106,13 +155,81 @@ func ParseBody(body string) ([]Item, error) {
return out, nil
}
func looksLikeLinks(s string) bool {
lower := strings.ToLower(s)
return strings.Contains(lower, "hysteria2://") ||
strings.Contains(lower, "hy2://") ||
strings.Contains(lower, "naive+") ||
strings.Contains(lower, "https://") ||
strings.Contains(lower, "quic://")
func naiveLinkHasAuth(line string) bool {
lower := strings.ToLower(strings.TrimSpace(line))
for _, p := range []string{"naive+https://", "naive+quic://", "naive://", "https://", "http://", "quic://"} {
if strings.HasPrefix(lower, p) {
rest := line[len(p):]
at := strings.IndexByte(rest, '@')
return at > 0
}
}
return false
}
func extractClashHY2(text string) []string {
// Very small helper for Clash YAML hysteria2 blocks when share URI is absent.
lower := strings.ToLower(text)
if !strings.Contains(lower, "type:") || !strings.Contains(lower, "hysteria2") {
return nil
}
var out []string
// Split roughly by list items.
chunks := strings.Split(text, "\n- ")
for _, chunk := range chunks {
cl := strings.ToLower(chunk)
if !strings.Contains(cl, "type:") || !strings.Contains(cl, "hysteria2") {
continue
}
server := yamlField(chunk, "server")
port := yamlField(chunk, "port")
pass := yamlField(chunk, "password")
if pass == "" {
pass = yamlField(chunk, "auth")
}
if server == "" || port == "" || pass == "" {
continue
}
name := yamlField(chunk, "name")
sni := yamlField(chunk, "sni")
obfs := yamlField(chunk, "obfs")
obfsPass := yamlField(chunk, "obfs-password")
if obfsPass == "" {
obfsPass = yamlField(chunk, "obfs_password")
}
u := url.URL{
Scheme: "hysteria2",
User: url.User(pass),
Host: server + ":" + port,
}
q := url.Values{}
if sni != "" {
q.Set("sni", sni)
}
if obfs != "" {
q.Set("obfs", obfs)
}
if obfsPass != "" {
q.Set("obfs-password", obfsPass)
}
u.RawQuery = q.Encode()
link := u.String()
if name != "" {
link += "#" + url.PathEscape(name)
}
out = append(out, link)
}
_ = reClashHY2 // kept for possible future tightening
return out
}
func yamlField(block, key string) string {
re := regexp.MustCompile(`(?im)^\s*` + regexp.QuoteMeta(key) + `\s*:\s*["']?([^"'#\n\r]+)["']?`)
m := re.FindStringSubmatch(block)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}
func decodeMaybeBase64(s string) (string, error) {
@@ -122,6 +239,9 @@ func decodeMaybeBase64(s string) (string, error) {
}
return r
}, s)
if s == "" {
return "", fmt.Errorf("empty")
}
raw, err := base64.StdEncoding.DecodeString(s)
if err != nil {
raw, err = base64.RawStdEncoding.DecodeString(s)
@@ -129,6 +249,9 @@ func decodeMaybeBase64(s string) (string, error) {
if err != nil {
raw, err = base64.URLEncoding.DecodeString(s)
}
if err != nil {
raw, err = base64.RawURLEncoding.DecodeString(s)
}
if err != nil {
return "", err
}
+44 -1
View File
@@ -1,6 +1,10 @@
package subscription
import "testing"
import (
"encoding/base64"
"strings"
"testing"
)
func TestParseBodyPlain(t *testing.T) {
body := `# comment
@@ -15,3 +19,42 @@ naive+https://u:p@n.example.com#naive1
t.Fatalf("got %d items", len(items))
}
}
func TestParseBodyBase64(t *testing.T) {
plain := "hysteria2://secret@host.example:443/?sni=host.example#HY\nhttps://user:pass@naive.example.com#NV\n"
body := base64.StdEncoding.EncodeToString([]byte(plain))
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("got %d items: %+v", len(items), items)
}
}
func TestParseBodyClashYAML(t *testing.T) {
body := `
proxies:
- name: "DE HY2"
type: hysteria2
server: de.example.com
port: 443
password: secret
sni: de.example.com
obfs: salamander
obfs-password: obfspass
`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("got %d", len(items))
}
if items[0].Protocol != "hysteria2" {
t.Fatalf("proto %s", items[0].Protocol)
}
if !strings.Contains(items[0].URI, "de.example.com:443") {
t.Fatalf("uri %s", items[0].URI)
}
}