Release 3.9.0+2: tolerant subscription import, full Remnawave server list, subscription info card (expiry / traffic / devices).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+216
-45
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -25,13 +26,45 @@ type Item struct {
|
||||
URI string
|
||||
}
|
||||
|
||||
// Info is subscription usage metadata from the subscription-userinfo header
|
||||
// (upload/download/total in bytes, expire as unix seconds; 0 = unknown).
|
||||
type Info struct {
|
||||
Upload int64 `json:"upload,omitempty"`
|
||||
Download int64 `json:"download,omitempty"`
|
||||
Total int64 `json:"total,omitempty"`
|
||||
Expire int64 `json:"expire,omitempty"`
|
||||
}
|
||||
|
||||
// Result is a parsed subscription: imported items plus per-line skip diagnostics.
|
||||
type Result struct {
|
||||
Items []Item
|
||||
Skipped int
|
||||
Warnings []string
|
||||
Info *Info
|
||||
}
|
||||
|
||||
const maxWarnings = 8
|
||||
|
||||
var (
|
||||
reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|wireguard|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"']+)`)
|
||||
// reURILine matches a standalone "scheme://..." line — only such lines are
|
||||
// counted as skipped entries (YAML/HTML noise is ignored silently).
|
||||
reURILine = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*://\S`)
|
||||
)
|
||||
|
||||
// userAgents to try in order. Remnawave and most panels return raw share links
|
||||
// for a generic UA, but return a Clash YAML config for Clash-like UAs — that
|
||||
// YAML loses vless/trojan entries for us, so try the plain UA first.
|
||||
var userAgents = []string{
|
||||
"Navis/3.9 (+https://evilfox.win)",
|
||||
"ClashMeta/1.18.0",
|
||||
}
|
||||
|
||||
// Fetch downloads a subscription body and parses proxy share links.
|
||||
func Fetch(ctx context.Context, rawURL string) ([]Item, error) {
|
||||
// Parsing is tolerant: unsupported or broken entries are skipped (with
|
||||
// warnings), valid ones are imported.
|
||||
func Fetch(ctx context.Context, rawURL string) (*Result, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("пустой URL подписки")
|
||||
@@ -41,37 +74,116 @@ func Fetch(ctx context.Context, rawURL string) ([]Item, error) {
|
||||
return nil, fmt.Errorf("нужен http(s) URL подписки")
|
||||
}
|
||||
|
||||
var (
|
||||
best *Result
|
||||
info *Info
|
||||
lastErr error
|
||||
)
|
||||
for _, ua := range userAgents {
|
||||
body, header, err := download(ctx, rawURL, ua)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if info == nil {
|
||||
info = ParseUserInfoHeader(header.Get("Subscription-Userinfo"))
|
||||
}
|
||||
res, err := ParseBodyDetailed(body)
|
||||
if res != nil && (best == nil || len(res.Items) > 0 || res.Skipped > best.Skipped) {
|
||||
best = res
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if res != nil && len(res.Items) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if best == nil || len(best.Items) == 0 {
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок")
|
||||
}
|
||||
best.Info = info
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func download(ctx context.Context, rawURL, userAgent string) (string, http.Header, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ClashMeta/1.18.0")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось скачать подписку: %w", err)
|
||||
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)
|
||||
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 "", nil, err
|
||||
}
|
||||
items, err := ParseBody(string(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return string(body), resp.Header, nil
|
||||
}
|
||||
|
||||
// ParseUserInfoHeader parses "upload=..; download=..; total=..; expire=<unix>".
|
||||
// Returns nil when the header is absent or has no recognized fields.
|
||||
func ParseUserInfoHeader(v string) *Info {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return items, nil
|
||||
info := &Info{}
|
||||
found := false
|
||||
for _, part := range strings.Split(v, ";") {
|
||||
kv := strings.SplitN(part, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(kv[1]), 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(kv[0])) {
|
||||
case "upload":
|
||||
info.Upload, found = n, true
|
||||
case "download":
|
||||
info.Download, found = n, true
|
||||
case "total":
|
||||
info.Total, found = n, true
|
||||
case "expire":
|
||||
info.Expire, found = n, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// ParseBody accepts plain text lines, base64, or Clash-like blobs with share links.
|
||||
func ParseBody(body string) ([]Item, error) {
|
||||
res, err := ParseBodyDetailed(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.Items, nil
|
||||
}
|
||||
|
||||
// ParseBodyDetailed parses a subscription body and reports skipped entries.
|
||||
// The returned Result is non-nil even on error (zero valid entries) so callers
|
||||
// can surface the collected warnings.
|
||||
func ParseBodyDetailed(body string) (*Result, error) {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("пустая подписка")
|
||||
return &Result{}, fmt.Errorf("пустая подписка")
|
||||
}
|
||||
|
||||
candidates := []string{body}
|
||||
@@ -83,56 +195,68 @@ func ParseBody(body string) ([]Item, error) {
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var best *Result
|
||||
for _, text := range candidates {
|
||||
items, err := parseText(text)
|
||||
if err == nil && len(items) > 0 {
|
||||
return items, nil
|
||||
res := parseText(text)
|
||||
if len(res.Items) > 0 {
|
||||
return res, nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if best == nil || res.Skipped > best.Skipped {
|
||||
best = res
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
if best == nil {
|
||||
best = &Result{}
|
||||
}
|
||||
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
|
||||
if best.Skipped > 0 {
|
||||
return best, fmt.Errorf("нет поддерживаемых ссылок: %d записей пропущено (%s)",
|
||||
best.Skipped, strings.Join(best.Warnings, "; "))
|
||||
}
|
||||
return best, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
|
||||
}
|
||||
|
||||
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))
|
||||
func parseText(text string) *Result {
|
||||
res := &Result{}
|
||||
seen := map[string]int{}
|
||||
seenURI := map[string]struct{}{}
|
||||
for _, line := range lines {
|
||||
|
||||
// countSkips is true only for raw subscription lines; URIs extracted from
|
||||
// YAML/HTML/JSON noise are best-effort and never counted as "skipped".
|
||||
process := func(line string, countSkips bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
return
|
||||
}
|
||||
line = strings.Trim(line, `"'`)
|
||||
looksURI := reURILine.MatchString(line)
|
||||
skip := func(reason string) {
|
||||
if !countSkips || !looksURI {
|
||||
return
|
||||
}
|
||||
res.Skipped++
|
||||
if len(res.Warnings) < maxWarnings {
|
||||
res.Warnings = append(res.Warnings, shortenLink(line)+" — "+reason)
|
||||
}
|
||||
}
|
||||
|
||||
proto := config.DetectProtocol(line)
|
||||
if proto == "" {
|
||||
continue
|
||||
skip("неподдерживаемая схема")
|
||||
return
|
||||
}
|
||||
// Skip bare https:// without credentials (common noise in HTML/YAML).
|
||||
// Bare https:// without credentials (common noise in HTML/YAML,
|
||||
// or a subscription URL pasted among the links) is not a proxy link.
|
||||
if proto == config.ProtocolNaive && !naiveLinkHasAuth(line) && !strings.HasPrefix(strings.ToLower(line), "naive+") {
|
||||
continue
|
||||
skip("нет логина и пароля в ссылке")
|
||||
return
|
||||
}
|
||||
normalized, detected, remark, err := linknorm.Normalize(proto, line)
|
||||
if err != nil {
|
||||
continue
|
||||
skip(err.Error())
|
||||
return
|
||||
}
|
||||
if _, dup := seenURI[normalized]; dup {
|
||||
continue
|
||||
return
|
||||
}
|
||||
seenURI[normalized] = struct{}{}
|
||||
name := remark
|
||||
@@ -147,12 +271,33 @@ func parseText(text string) ([]Item, error) {
|
||||
name = fmt.Sprintf("%s-%d", base, n)
|
||||
}
|
||||
seen[name] = 1
|
||||
out = append(out, Item{Name: name, Protocol: detected, URI: normalized})
|
||||
res.Items = append(res.Items, Item{Name: name, Protocol: detected, URI: normalized})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
|
||||
|
||||
for _, line := range splitLines(text) {
|
||||
process(line, true)
|
||||
}
|
||||
return out, nil
|
||||
// Also extract share URIs embedded in YAML/HTML/JSON.
|
||||
for _, m := range reShareLine.FindAllString(text, -1) {
|
||||
process(strings.TrimRight(m, ".,;)]}\"'"), false)
|
||||
}
|
||||
for _, block := range extractClashHY2(text) {
|
||||
process(block, false)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// shortenLink trims a link for warning messages, hiding query/credentials noise.
|
||||
func shortenLink(line string) string {
|
||||
if i := strings.IndexAny(line, "?#"); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
const max = 48
|
||||
runes := []rune(line)
|
||||
if len(runes) > max {
|
||||
return string(runes[:max]) + "…"
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func naiveLinkHasAuth(line string) bool {
|
||||
@@ -175,7 +320,7 @@ func extractClashHY2(text string) []string {
|
||||
}
|
||||
var out []string
|
||||
// Split roughly by list items.
|
||||
chunks := strings.Split(text, "\n- ")
|
||||
chunks := splitClashItems(text)
|
||||
for _, chunk := range chunks {
|
||||
cl := strings.ToLower(chunk)
|
||||
if !strings.Contains(cl, "type:") || !strings.Contains(cl, "hysteria2") {
|
||||
@@ -223,8 +368,34 @@ func extractClashHY2(text string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// reClashItemStart matches a mapping list item ("- name: X", " - type: y"),
|
||||
// but not nested scalar list entries like "- h3" under alpn.
|
||||
var reClashItemStart = regexp.MustCompile(`^\s*-\s+[^\s:]+\s*:`)
|
||||
|
||||
// splitClashItems splits a Clash YAML proxies list into per-proxy chunks,
|
||||
// tolerating any list-item indentation ("- name:" at column 0 or nested).
|
||||
func splitClashItems(text string) []string {
|
||||
lines := splitLines(text)
|
||||
var out []string
|
||||
var cur []string
|
||||
flush := func() {
|
||||
if len(cur) > 0 {
|
||||
out = append(out, strings.Join(cur, "\n"))
|
||||
cur = nil
|
||||
}
|
||||
}
|
||||
for _, line := range lines {
|
||||
if reClashItemStart.MatchString(line) {
|
||||
flush()
|
||||
}
|
||||
cur = append(cur, line)
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
func yamlField(block, key string) string {
|
||||
re := regexp.MustCompile(`(?im)^\s*` + regexp.QuoteMeta(key) + `\s*:\s*["']?([^"'#\n\r]+)["']?`)
|
||||
re := regexp.MustCompile(`(?im)^\s*(?:-\s+)?` + regexp.QuoteMeta(key) + `\s*:\s*["']?([^"'#\n\r]+)["']?`)
|
||||
m := re.FindStringSubmatch(block)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user