Files
yml2213 bc366fd6b9 feat(proxy): 支持多实例独立代理与短效API动态提取
- 实例配置独立:支持为每个实例单独配置 http/https/socks5 代理,避免单机多开挤兑触发斗鱼出口 IP 限流
- 动态 API 提取:原生支持星空代理等平台的 API 提取(兼容 JSON/文本格式),提供前端一键提取填入与连通性/延迟测速
- 定时前自动拉取:针对 3 分钟短效代理,支持在开抢前 15 秒自动提取并绑定最新 IP,并在预热前完成热连接与时间复校
- 修复复校竞态:将 resyncBeforeTrigger 从 3s 提升至 8s,确保三轮采样在开抢前顺利落盘,消除时钟漂移
- 验证方式:通过 go test ./... 全量测试与前端 vue-tsc/vite 编译,实测通过星空代理 API 解析测试用例
2026-10-24 19:26:16 +08:00

332 lines
9.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package client
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"douyu_go/internal/types"
)
var ipPortRegex = regexp.MustCompile(`(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{2,5})`)
// ParseProxyURL 解析并规范化代理字符串为 *url.URL。
// 支持 http://, https://, socks5://;若未带协议头,默认补齐 http://。
func ParseProxyURL(proxyStr string) (*url.URL, error) {
s := strings.TrimSpace(proxyStr)
if s == "" {
return nil, nil
}
if !strings.Contains(s, "://") {
s = "http://" + s
}
u, err := url.Parse(s)
if err != nil {
return nil, fmt.Errorf("代理地址格式无效: %w", err)
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" && scheme != "socks5" {
return nil, fmt.Errorf("不支持的代理协议 %q,仅支持 http、https、socks5", u.Scheme)
}
if u.Host == "" {
return nil, errors.New("代理地址缺少主机或端口")
}
return u, nil
}
// FetchProxyFromAPI 从指定的代理提供商 API 拉取一个代理 IP。
// 兼容星空代理等常见 JSON 格式,以及纯文本 IP:Port 格式。
func FetchProxyFromAPI(apiURL string) (*types.ProxyFetchResult, error) {
trimmed := strings.TrimSpace(apiURL)
if trimmed == "" {
return nil, errors.New("代理提取 API 地址为空")
}
if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") {
return nil, errors.New("代理提取 API 地址必须以 http:// 或 https:// 开头")
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, trimmed, nil)
if err != nil {
return nil, fmt.Errorf("创建提取请求失败: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
client := &http.Client{
Timeout: 8 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求代理 API 失败: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
if err != nil {
return nil, fmt.Errorf("读取代理 API 响应失败: %w", err)
}
bodyStr := strings.TrimSpace(string(bodyBytes))
if bodyStr == "" {
return nil, errors.New("代理 API 返回内容为空")
}
// 尝试优先按照 JSON 结构体解析
var jsonObj struct {
Status int `json:"status"`
Code *int `json:"code"`
Info string `json:"info"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
if jsonErr := json.Unmarshal([]byte(bodyStr), &jsonObj); jsonErr == nil {
// 判断是否有错误提示
errInfo := jsonObj.Info
if errInfo == "" {
errInfo = jsonObj.Msg
}
// 星空代理成功状态为 100,其它平台通用成功状态为 0
hasSuccessStatus := jsonObj.Status == 100 || (jsonObj.Code != nil && *jsonObj.Code == 0)
hasErrorStatus := (jsonObj.Status != 0 && jsonObj.Status != 100) || (jsonObj.Code != nil && *jsonObj.Code != 0)
if hasErrorStatus && errInfo != "" {
return nil, fmt.Errorf("代理平台返回: %s (状态码=%d)", errInfo, jsonObj.Status)
}
if len(jsonObj.Data) > 0 {
// 1. 尝试解析为对象数组: [{"ip":"...", "port":"...", "prov":"...", "city":"..."}]
type nodeItem struct {
IP string `json:"ip"`
Port any `json:"port"`
Prov string `json:"prov"`
City string `json:"city"`
}
var items []nodeItem
if err := json.Unmarshal(jsonObj.Data, &items); err == nil && len(items) > 0 {
ip := strings.TrimSpace(items[0].IP)
portStr := fmt.Sprintf("%v", items[0].Port)
region := strings.TrimSpace(items[0].Prov + " " + items[0].City)
if ip != "" && portStr != "" {
return &types.ProxyFetchResult{
Success: true,
ProxyURL: fmt.Sprintf("http://%s:%s", ip, portStr),
IP: ip,
Port: portStr,
Region: region,
}, nil
}
}
// 2. 尝试解析为字符串数组: ["ip:port"]
var strArr []string
if err := json.Unmarshal(jsonObj.Data, &strArr); err == nil && len(strArr) > 0 {
if m := ipPortRegex.FindStringSubmatch(strArr[0]); len(m) == 3 {
return &types.ProxyFetchResult{
Success: true,
ProxyURL: fmt.Sprintf("http://%s:%s", m[1], m[2]),
IP: m[1],
Port: m[2],
}, nil
}
}
// 3. 尝试解析为单字符串
var singleStr string
if err := json.Unmarshal(jsonObj.Data, &singleStr); err == nil {
if m := ipPortRegex.FindStringSubmatch(singleStr); len(m) == 3 {
return &types.ProxyFetchResult{
Success: true,
ProxyURL: fmt.Sprintf("http://%s:%s", m[1], m[2]),
IP: m[1],
Port: m[2],
}, nil
}
}
}
if !hasSuccessStatus && errInfo != "" {
return nil, fmt.Errorf("代理平台提示: %s", errInfo)
}
}
// 兜底正则提取 IP:Port 文本格式
matches := ipPortRegex.FindStringSubmatch(bodyStr)
if len(matches) == 3 {
return &types.ProxyFetchResult{
Success: true,
ProxyURL: fmt.Sprintf("http://%s:%s", matches[1], matches[2]),
IP: matches[1],
Port: matches[2],
}, nil
}
return nil, fmt.Errorf("未能从 API 返回中识别出有效 IP:端口,原始返回: %s", truncateString(bodyStr, 120))
}
// TestProxyConnection 测试指定代理的连通性、出口公网 IP 与往返延迟。
func TestProxyConnection(proxyStr string) types.ProxyTestResult {
start := time.Now()
trsp := &http.Transport{
MaxConnsPerHost: 10,
MaxIdleConns: 10,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 10 * time.Second,
}
trimmed := strings.TrimSpace(proxyStr)
if trimmed != "" {
u, err := ParseProxyURL(trimmed)
if err != nil {
return types.ProxyTestResult{
Success: false,
Error: err.Error(),
}
}
trsp.Proxy = http.ProxyURL(u)
}
client := &http.Client{
Transport: trsp,
Timeout: 6 * time.Second,
}
// 优先选用 myip.ipip.net
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://myip.ipip.net", nil)
if err != nil {
return types.ProxyTestResult{
Success: false,
Error: fmt.Sprintf("创建请求失败: %v", err),
}
}
req.Header.Set("User-Agent", "curl/8.0")
resp, err := client.Do(req)
latency := time.Since(start).Milliseconds()
if err != nil {
// 备用请求 cip.cc
backupStart := time.Now()
backupReq, bErr := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://cip.cc", nil)
if bErr == nil {
backupReq.Header.Set("User-Agent", "curl/8.0")
if bResp, bDoErr := client.Do(backupReq); bDoErr == nil {
defer bResp.Body.Close()
latency = time.Since(backupStart).Milliseconds()
bBytes, _ := io.ReadAll(io.LimitReader(bResp.Body, 8192))
bText := string(bBytes)
ip, region := parseCipResponse(bText)
if ip != "" {
return types.ProxyTestResult{
Success: true,
IP: ip,
Region: region,
Latency: latency,
}
}
}
}
return types.ProxyTestResult{
Success: false,
Latency: latency,
Error: formatProxyError(err),
}
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 8192))
if err != nil {
return types.ProxyTestResult{
Success: false,
Latency: latency,
Error: fmt.Sprintf("读取响应失败: %v", err),
}
}
text := string(bodyBytes)
ip, region := parseIpipResponse(text)
if ip == "" {
// 尝试正则抽取 IP
m := regexp.MustCompile(`\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}`).FindString(text)
ip = m
}
if ip == "" {
return types.ProxyTestResult{
Success: false,
Latency: latency,
Error: fmt.Sprintf("无法识别出口 IP,返回内容: %s", truncateString(text, 80)),
}
}
return types.ProxyTestResult{
Success: true,
IP: ip,
Region: region,
Latency: latency,
}
}
func parseIpipResponse(text string) (string, string) {
// 示例: 当前 IP:115.230.134.174 来自于:中国 浙江 嘉兴 电信
text = strings.TrimSpace(text)
ip := ""
region := ""
if idx := strings.Index(text, "当前 IP:"); idx != -1 {
rest := text[idx+len("当前 IP:"):]
parts := strings.SplitN(rest, "来自于:", 2)
ip = strings.TrimSpace(parts[0])
if len(parts) > 1 {
region = strings.TrimSpace(parts[1])
}
}
return ip, region
}
func parseCipResponse(text string) (string, string) {
// IP : 115.230.134.174
// 地址 : 中国 浙江 嘉兴
lines := strings.Split(text, "\n")
ip := ""
region := ""
for _, l := range lines {
l = strings.TrimSpace(l)
if strings.HasPrefix(l, "IP") && strings.Contains(l, ":") {
parts := strings.SplitN(l, ":", 2)
ip = strings.TrimSpace(parts[1])
}
if strings.HasPrefix(l, "地址") && strings.Contains(l, ":") {
parts := strings.SplitN(l, ":", 2)
region = strings.TrimSpace(parts[1])
}
}
return ip, region
}
func formatProxyError(err error) string {
msg := err.Error()
if strings.Contains(msg, "407") {
return "代理服务器要求身份认证(407):请检查是否已在代理后台将本机公网IP添加进白名单,或代理账密是否有误"
}
if strings.Contains(msg, "context deadline exceeded") || strings.Contains(msg, "timeout") {
return "连接代理超时(timeout):代理节点可能已失效或网络不通"
}
if strings.Contains(msg, "connection refused") {
return "代理连接被拒绝(connection refused):请核对代理地址与端口"
}
return msg
}
func truncateString(s string, maxLen int) string {
r := []rune(s)
if len(r) <= maxLen {
return s
}
return string(r[:maxLen]) + "..."
}