Files
djk/internal/app/provider_discovery.go

147 lines
3.9 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
const legacyFrontendURL = "https://d2pypzndaqisk.cloudfront.net"
var apiFallbackHosts = []string{
defaultAPIBase,
"https://d18ka9rqpfd3lo.cloudfront.net",
"https://d37n0wjehmw08f.cloudfront.net",
}
func (d *Downloader) apiEndpoint(ctx context.Context) (string, error) {
d.apiMu.Lock()
defer d.apiMu.Unlock()
if d.apiBase != "" {
return d.apiBase, nil
}
configured := strings.TrimRight(firstNonEmpty(d.cfg.APIBase, defaultAPIBase), "/")
parsed, err := url.Parse(configured)
if err != nil || !isProviderHTTPMediaURL(configured) {
return "", errors.New("API 地址无效")
}
if !strings.HasSuffix(strings.ToLower(parsed.Hostname()), ".cloudfront.net") {
d.apiBase = configured
return configured, nil
}
candidates := append([]string{configured}, apiFallbackHosts...)
seen := map[string]bool{}
var lastErr error
for pass := 0; pass < 2; pass++ {
if pass == 1 {
candidates, err = d.discoverAPIHosts(ctx)
if err != nil {
lastErr = err
break
}
}
for _, candidate := range candidates {
if seen[candidate] {
continue
}
seen[candidate] = true
if err := ctx.Err(); err != nil {
return "", err
}
if err := d.probeAPIHost(ctx, candidate); err != nil {
lastErr = err
continue
}
d.apiBase = candidate
return candidate, nil
}
}
return "", fmt.Errorf("黄果原 API 的候选域名均不可用: %w", publicError(lastErr))
}
func (d *Downloader) resetAPIEndpoint(failed string) {
d.apiMu.Lock()
if d.apiBase == failed {
d.apiBase = ""
}
d.apiMu.Unlock()
}
func (d *Downloader) probeAPIHost(ctx context.Context, host string) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, host+"/api/app/ping/check", nil)
if err != nil {
return err
}
request.Header.Set("User-Agent", userAgent)
response, err := d.doCatalogRequestWithTimeout(request, 4*time.Second)
if err != nil {
return publicError(err)
}
defer response.Body.Close()
var envelope struct {
Code int `json:"code"`
}
if response.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&envelope) != nil || envelope.Code != 200 {
return fmt.Errorf("%s API 健康检查失败 (HTTP %d)", request.URL.Hostname(), response.StatusCode)
}
return nil
}
func (d *Downloader) discoverAPIHosts(ctx context.Context) ([]string, error) {
body, err := d.fetchLegacyFrontendScript(ctx)
if err != nil {
return nil, err
}
if hosts := parseAPIHosts(body); len(hosts) > 0 {
return hosts, nil
}
return nil, errors.New("官网前端未提供可识别的 API 地址,请更新 apiBase 配置")
}
func (d *Downloader) fetchLegacyFrontendScript(ctx context.Context) (string, error) {
page, err := d.fetchProviderText(ctx, legacyFrontendURL+"/", legacyFrontendURL+"/")
if err != nil {
return "", err
}
scripts := regexp.MustCompile(`(?i)<script\b[^>]*\bsrc=["']([^"']+)["']`).FindAllStringSubmatch(page, -1)
for _, script := range scripts {
if !strings.Contains(script[1], "/main-") || !strings.HasSuffix(script[1], ".js") {
continue
}
assetURL := resolveProviderURL(legacyFrontendURL+"/", script[1])
asset, err := url.Parse(assetURL)
if err != nil || asset.Hostname() != mustHost(legacyFrontendURL) {
continue
}
body, err := d.fetchProviderText(ctx, assetURL, legacyFrontendURL+"/")
if err != nil {
return "", err
}
return body, nil
}
return "", errors.New("黄果官网未提供可识别的接口脚本")
}
func parseAPIHosts(script string) []string {
matches := regexp.MustCompile(`https://[a-z0-9]+\.cloudfront\.net`).FindAllString(script, -1)
seen := map[string]bool{}
var hosts []string
for _, host := range matches {
if host != legacyFrontendURL && !seen[host] {
seen[host] = true
hosts = append(hosts, host)
if len(hosts) == 8 {
break
}
}
}
return hosts
}