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