309 lines
8.3 KiB
Go
309 lines
8.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// NewDouyuClient 创建一个专门请求斗鱼接口的客户端。
|
|
func NewDouyuClient(timeAPIURL string) *DouyuClient {
|
|
return &DouyuClient{
|
|
timeAPIURL: timeAPIURL,
|
|
httpClient: &http.Client{
|
|
Timeout: 8 * time.Second,
|
|
Transport: &http.Transport{
|
|
MaxConnsPerHost: 120,
|
|
MaxIdleConns: 120,
|
|
MaxIdleConnsPerHost: 80,
|
|
IdleConnTimeout: 30 * time.Second,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// ServerTime 从商品详情接口读取 data.preExchange.currentTime。
|
|
func (c *DouyuClient) ServerTime() (time.Time, error) {
|
|
var lastErr error
|
|
for i := 0; i < 3; i++ {
|
|
t, err := c.fetchServerTimeOnce()
|
|
if err == nil {
|
|
return t, nil
|
|
}
|
|
lastErr = err
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
return time.Now().UTC(), fmt.Errorf("服务端时间获取失败,已降级本地时间: %w", lastErr)
|
|
}
|
|
|
|
// fetchServerTimeOnce 执行一次服务端时间请求。
|
|
func (c *DouyuClient) fetchServerTimeOnce() (time.Time, error) {
|
|
url := appendCacheBuster(c.timeAPIURL)
|
|
var result struct {
|
|
APIResponse
|
|
Data struct {
|
|
PreExchange struct {
|
|
CurrentTime string `json:"currentTime"`
|
|
} `json:"preExchange"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.getJSON(url, "", &result); err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
ts, err := strconv.ParseInt(result.Data.PreExchange.CurrentTime, 10, 64)
|
|
if err != nil || ts <= 0 {
|
|
return time.Time{}, fmt.Errorf("currentTime 无效: %q", result.Data.PreExchange.CurrentTime)
|
|
}
|
|
return time.Unix(ts, 0).UTC(), nil
|
|
}
|
|
|
|
// Products 拉取商城商品列表。
|
|
func (c *DouyuClient) Products() ([]Product, error) {
|
|
var result struct {
|
|
APIResponse
|
|
Data struct {
|
|
List []Product `json:"list"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.getJSON(storeHomeURL, "", &result); err != nil {
|
|
return nil, err
|
|
}
|
|
if result.Error != 0 {
|
|
return nil, fmt.Errorf("商品接口失败: %s", result.Msg)
|
|
}
|
|
return result.Data.List, nil
|
|
}
|
|
|
|
// Score 查询账号积分余额。
|
|
func (c *DouyuClient) Score(cookie string) (int, error) {
|
|
req, err := http.NewRequest("POST", scoreURL, strings.NewReader(`{}`))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
c.setJSONHeaders(req, cookie)
|
|
req.Header.Set("Referer", baseURL+"/")
|
|
|
|
var result struct {
|
|
APIResponse
|
|
Data struct {
|
|
Balance int `json:"balance"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.doJSON(req, &result); err != nil {
|
|
return 0, err
|
|
}
|
|
if result.Error != 0 {
|
|
return 0, fmt.Errorf("积分接口失败: %s", result.Msg)
|
|
}
|
|
return result.Data.Balance, nil
|
|
}
|
|
|
|
// GenerateCSRF 调用 generateCsrf,并从 Set-Cookie 中取 cvl_csrf_token。
|
|
func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) {
|
|
req, err := http.NewRequest("POST", csrfURL, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
c.setBrowserHeaders(req, cookie)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("CSRF HTTP %d: %s", resp.StatusCode, trimForLog(string(body), 120))
|
|
}
|
|
|
|
var api APIResponse
|
|
if err := json.Unmarshal(body, &api); err != nil {
|
|
return "", fmt.Errorf("CSRF 响应解析失败: %w", err)
|
|
}
|
|
if api.Error != 0 {
|
|
return "", fmt.Errorf("CSRF 接口失败: %s", api.Msg)
|
|
}
|
|
for _, ck := range resp.Cookies() {
|
|
if ck.Name == "cvl_csrf_token" && ck.Value != "" {
|
|
return ck.Value, nil
|
|
}
|
|
}
|
|
return "", errors.New("CSRF 响应没有 cvl_csrf_token")
|
|
}
|
|
|
|
// BatchExchangeLimit 请求兑换限制接口,返回本次允许的批量上限。
|
|
func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID, csrf string) (int, error) {
|
|
var result struct {
|
|
APIResponse
|
|
Data struct {
|
|
Limit string `json:"limit"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.postMultipart(batchLimitURL, cookie, csrf, map[string]string{
|
|
"manualID": manualID,
|
|
"commodityID": commodityID,
|
|
"rid": roomID,
|
|
"csrfToken": csrf,
|
|
}, &result); err != nil {
|
|
return 0, err
|
|
}
|
|
if result.Error != 0 {
|
|
return 0, fmt.Errorf("批量限制接口失败: %s", result.Msg)
|
|
}
|
|
limit, err := strconv.Atoi(result.Data.Limit)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("批量限制值无效: %q", result.Data.Limit)
|
|
}
|
|
return limit, nil
|
|
}
|
|
|
|
// PreExchange 按抓包流程执行预兑换检查。
|
|
func (c *DouyuClient) PreExchange(cookie, commodityID, csrf string) error {
|
|
var result APIResponse
|
|
if err := c.postMultipart(preExchangeURL, cookie, csrf, map[string]string{
|
|
"manualID": manualID,
|
|
"rid": roomID,
|
|
"commodityID": commodityID,
|
|
"num": "1",
|
|
"csrfToken": csrf,
|
|
}, &result); err != nil {
|
|
return err
|
|
}
|
|
if result.Error != 0 {
|
|
return fmt.Errorf("预兑换失败: %s", result.Msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Exchange 正式兑换一个商品,num 固定为 1,和浏览器抓包一致。
|
|
func (c *DouyuClient) Exchange(cookie, commodityID, csrf string) (string, error) {
|
|
var result struct {
|
|
APIResponse
|
|
Data struct {
|
|
ExchangeID int `json:"exchangeId"`
|
|
ExchangeNum string `json:"exchangeNum"`
|
|
CommodityTyp int `json:"commodityType"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.postMultipart(exchangeURL, cookie, csrf, map[string]string{
|
|
"manualID": manualID,
|
|
"commodityID": commodityID,
|
|
"num": "1",
|
|
"rid": roomID,
|
|
"csrfToken": csrf,
|
|
}, &result); err != nil {
|
|
return "", err
|
|
}
|
|
if result.Error != 0 {
|
|
if strings.TrimSpace(result.Msg) == "" {
|
|
return "", errors.New("兑换失败,接口未返回原因")
|
|
}
|
|
return "", fmt.Errorf("兑换失败: %s", result.Msg)
|
|
}
|
|
return fmt.Sprintf("exchangeId=%d,num=%s", result.Data.ExchangeID, result.Data.ExchangeNum), nil
|
|
}
|
|
|
|
// RoleName 查询绑定角色名。
|
|
func (c *DouyuClient) RoleName(cookie string) (string, error) {
|
|
var result struct {
|
|
APIResponse
|
|
Data struct {
|
|
GameRole struct {
|
|
RoleName string `json:"roleName"`
|
|
} `json:"gameRole"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.getJSON(bindInfoURL, cookie, &result); err != nil {
|
|
return "", err
|
|
}
|
|
if result.Error != 0 {
|
|
return "", fmt.Errorf("角色接口失败: %s", result.Msg)
|
|
}
|
|
return result.Data.GameRole.RoleName, nil
|
|
}
|
|
|
|
// getJSON 发送 GET 请求并解析 JSON。
|
|
func (c *DouyuClient) getJSON(url, cookie string, out any) error {
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.setBrowserHeaders(req, cookie)
|
|
return c.doJSON(req, out)
|
|
}
|
|
|
|
// postMultipart 发送 multipart/form-data 请求并解析 JSON。
|
|
func (c *DouyuClient) postMultipart(url, cookie, csrf string, fields map[string]string, out any) error {
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
for key, value := range fields {
|
|
if err := writer.WriteField(key, value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", url, &body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.setBrowserHeaders(req, mergeCookie(cookie, "cvl_csrf_token", csrf))
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
return c.doJSON(req, out)
|
|
}
|
|
|
|
// doJSON 执行请求、检查 HTTP 状态码并解析 JSON。
|
|
func (c *DouyuClient) doJSON(req *http.Request, out any) error {
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, trimForLog(string(body), 160))
|
|
}
|
|
if err := json.Unmarshal(body, out); err != nil {
|
|
return fmt.Errorf("JSON 解析失败: %w, body=%s", err, trimForLog(string(body), 160))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// setBrowserHeaders 设置接近浏览器抓包的通用请求头。
|
|
func (c *DouyuClient) setBrowserHeaders(req *http.Request, cookie string) {
|
|
req.Header.Set("Accept", "application/json, text/plain, */*")
|
|
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5")
|
|
req.Header.Set("User-Agent", userAgent)
|
|
req.Header.Set("Origin", baseURL)
|
|
req.Header.Set("Referer", shopReferer)
|
|
req.Header.Set("Sec-Fetch-Dest", "empty")
|
|
req.Header.Set("Sec-Fetch-Mode", "cors")
|
|
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
|
if strings.TrimSpace(cookie) != "" {
|
|
req.Header.Set("Cookie", cookie)
|
|
}
|
|
}
|
|
|
|
// setJSONHeaders 设置 JSON POST 请求头。
|
|
func (c *DouyuClient) setJSONHeaders(req *http.Request, cookie string) {
|
|
c.setBrowserHeaders(req, cookie)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|