小店商品列表shujuok
This commit is contained in:
+2162
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,8 @@ const formData = ref({
|
||||
concurrency: 150,
|
||||
attempts: 2,
|
||||
retryStages: [] as RetryStage[],
|
||||
seckillMode: 1
|
||||
seckillMode: 1,
|
||||
xiaodianPageSize: 200
|
||||
})
|
||||
|
||||
const open = async () => {
|
||||
@@ -76,6 +77,18 @@ defineExpose({ open, close })
|
||||
<div class="form-hint">用于获取斗鱼服务端时间,一般不需要修改</div>
|
||||
</div>
|
||||
|
||||
<!-- 和平小店参数 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">和平小店商品列表</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">每页商品数</label>
|
||||
<input v-model.number="formData.xiaodianPageSize" class="form-input" type="number" min="1" max="200" step="1">
|
||||
<div class="form-hint">角色信息从账号 Cookie 自动获取,接口上限 200</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志设置 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">日志设置</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface Settings {
|
||||
attempts: number
|
||||
retryStages: RetryStage[]
|
||||
seckillMode: number // 0=标准, 1=激进
|
||||
xiaodianPageSize: number
|
||||
}
|
||||
|
||||
const defaultRetryStages: RetryStage[] = [
|
||||
@@ -36,7 +37,8 @@ const settings = ref<Settings>({
|
||||
concurrency: 150,
|
||||
attempts: 2,
|
||||
retryStages: [...defaultRetryStages],
|
||||
seckillMode: 1
|
||||
seckillMode: 1,
|
||||
xiaodianPageSize: 200
|
||||
})
|
||||
|
||||
export function useSettings() {
|
||||
@@ -56,7 +58,8 @@ export function useSettings() {
|
||||
retryStages: cfg.retryStages && cfg.retryStages.length > 0
|
||||
? cfg.retryStages
|
||||
: [...defaultRetryStages],
|
||||
seckillMode: cfg.seckillMode ?? 1
|
||||
seckillMode: cfg.seckillMode ?? 1,
|
||||
xiaodianPageSize: cfg.xiaodianPageSize || 200
|
||||
}
|
||||
} catch (e: any) {
|
||||
showToast('读取设置失败', e.message, 'error')
|
||||
|
||||
@@ -23,6 +23,10 @@ type Activity struct {
|
||||
|
||||
StoreHomeURL string
|
||||
CSRFURL string
|
||||
IsXiaodian bool // 和平小店商品列表使用 QQ 道具接口和响应格式。
|
||||
ActID string // 和平小店道具活动 ID。
|
||||
RoleParamURL string // 和平小店 H5 临时授权接口。
|
||||
RoleURL string // 和平小店角色解析接口。
|
||||
|
||||
BatchLimitURL string
|
||||
PreExchangeURL string
|
||||
@@ -89,9 +93,25 @@ var peacematch = &Activity{
|
||||
CTNURL: douyuBase + "/wgapi/livenc/liveweb/csrfApi/getCsrfCookie",
|
||||
}
|
||||
|
||||
// xiaodian 和平小店。商品查询前自动从斗鱼 Cookie 获取角色上下文。
|
||||
var xiaodian = &Activity{
|
||||
ID: "xiaodian",
|
||||
DisplayName: "和平小店",
|
||||
RoomID: "9263298",
|
||||
ActAlias: "20260623KDQFH",
|
||||
ActID: "46195",
|
||||
BaseURL: "https://app.daoju.qq.com",
|
||||
ShopReferer: "https://app.daoju.qq.com/",
|
||||
StoreHomeURL: "https://apps.game.qq.com/daoju/v3/recommend_xn_live_cjm/common",
|
||||
RoleParamURL: "https://www.douyu.com/japi/carnival/nc/txEmbed/getIframeUrl",
|
||||
RoleURL: "https://apps.game.qq.com/daoju/igw/live",
|
||||
IsXiaodian: true,
|
||||
}
|
||||
|
||||
var registry = map[string]*Activity{
|
||||
peace.ID: peace,
|
||||
peacematch.ID: peacematch,
|
||||
xiaodian.ID: xiaodian,
|
||||
}
|
||||
|
||||
// DefaultActivityID 默认活动,新分支默认启用「电竞手册」。
|
||||
@@ -99,7 +119,7 @@ const DefaultActivityID = "peacematch"
|
||||
|
||||
// List 返回所有注册活动,按注册顺序(精英手册在前)。
|
||||
func List() []*Activity {
|
||||
return []*Activity{peace, peacematch}
|
||||
return []*Activity{peace, peacematch, xiaodian}
|
||||
}
|
||||
|
||||
// Get 按 ID 获取活动;不存在时返回 nil。
|
||||
|
||||
@@ -79,6 +79,9 @@ func (a *App) SaveSettings(cfg types.Config) types.SimpleResult {
|
||||
if cfg.TraceMaxBody < 0 {
|
||||
cfg.TraceMaxBody = 0
|
||||
}
|
||||
if cfg.XiaodianPageSize <= 0 || cfg.XiaodianPageSize > 200 {
|
||||
cfg.XiaodianPageSize = 200
|
||||
}
|
||||
|
||||
// 校验
|
||||
if cfg.Concurrency <= 0 {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/constants"
|
||||
@@ -12,7 +14,21 @@ import (
|
||||
// LoadProducts 刷新商品列表。refresh=true 时从斗鱼实时拉取。
|
||||
func (a *App) LoadProducts(refresh bool) types.ProductPayload {
|
||||
if refresh {
|
||||
products, err := a.getClient().Products()
|
||||
cfg := a.getCfg()
|
||||
options := types.XiaodianProductListOptions{PageSize: cfg.XiaodianPageSize}
|
||||
var products []types.Product
|
||||
err := fmt.Errorf("没有可用账号 Cookie")
|
||||
accounts := a.scheduler.AccountSnapshot()
|
||||
for _, account := range accounts {
|
||||
if strings.TrimSpace(account.Cookie) == "" {
|
||||
continue
|
||||
}
|
||||
products, err = a.getClient().Products(account.Cookie, options)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
logger.LogEvent("商品", "warn", "账号 %s 获取和平小店角色失败,尝试下一个账号: %v", account.Name, err)
|
||||
}
|
||||
if err != nil {
|
||||
logger.LogEvent("商品", "error", "商品刷新失败: %v", err)
|
||||
return types.ProductPayload{Success: false}
|
||||
|
||||
+333
-3
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -212,15 +213,18 @@ func (c *DouyuClient) fetchServerTimeOnce() (types.TimeSample, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Products 拉取商城商品列表。
|
||||
func (c *DouyuClient) Products() ([]types.Product, error) {
|
||||
// Products 拉取当前活动的商品列表。
|
||||
func (c *DouyuClient) Products(cookie string, xiaodianOptions types.XiaodianProductListOptions) ([]types.Product, error) {
|
||||
if c.Activity.IsXiaodian {
|
||||
return c.xiaodianProducts(cookie, xiaodianOptions)
|
||||
}
|
||||
var result struct {
|
||||
types.APIResponse
|
||||
Data struct {
|
||||
List []types.Product `json:"list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := c.getJSON(c.Activity.StoreHomeURL, "", "商品列表", &result); err != nil {
|
||||
if err := c.getJSON(c.Activity.StoreHomeURL, cookie, "商品列表", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Error != 0 {
|
||||
@@ -229,6 +233,286 @@ func (c *DouyuClient) Products() ([]types.Product, error) {
|
||||
return result.Data.List, nil
|
||||
}
|
||||
|
||||
type xiaodianRecommendation struct {
|
||||
ErrCode string `json:"errcode"`
|
||||
ErrDesc string `json:"errdesc"`
|
||||
Data struct {
|
||||
ClientData struct {
|
||||
Items []xiaodianItem `json:"itemsdetail"`
|
||||
} `json:"client_data"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type xiaodianItem struct {
|
||||
GoodsID string `json:"iGoodsId"`
|
||||
GoodsName string `json:"sGoodsName"`
|
||||
Price string `json:"iPrice"`
|
||||
GoodsLeft string `json:"iGoodsLeft"`
|
||||
GoodsPic string `json:"sGoodsPic"`
|
||||
ShowBegin string `json:"dtShowBeginTime"`
|
||||
ShowEnd string `json:"dtShowEndTime"`
|
||||
ActionID string `json:"iActionId"`
|
||||
}
|
||||
|
||||
type xiaodianFlexibleString string
|
||||
|
||||
// UnmarshalJSON 兼容道具接口把同一字段返回为字符串或数字。
|
||||
func (s *xiaodianFlexibleString) UnmarshalJSON(data []byte) error {
|
||||
value := strings.TrimSpace(string(data))
|
||||
if value == "" || value == "null" {
|
||||
*s = ""
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(value, `"`) {
|
||||
var decoded string
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = xiaodianFlexibleString(decoded)
|
||||
return nil
|
||||
}
|
||||
*s = xiaodianFlexibleString(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type xiaodianRoleContext struct {
|
||||
OpenID xiaodianFlexibleString `json:"gameOpenId"`
|
||||
RoleID xiaodianFlexibleString `json:"roleId"`
|
||||
Type xiaodianFlexibleString `json:"type"`
|
||||
Area xiaodianFlexibleString `json:"area"`
|
||||
}
|
||||
|
||||
// xiaodianEmbedQuery 获取斗鱼为当前账号签发的短时道具城参数。
|
||||
func (c *DouyuClient) xiaodianEmbedQuery(cookie string) (url.Values, error) {
|
||||
uid := cookieValue(cookie, "acf_uid")
|
||||
stk := cookieValue(cookie, "acf_stk")
|
||||
ltkid := cookieValue(cookie, "acf_ltkid")
|
||||
if uid == "" || stk == "" || ltkid == "" {
|
||||
return nil, errors.New("斗鱼 Cookie 缺少 acf_uid、acf_stk 或 acf_ltkid,无法获取和平小店授权")
|
||||
}
|
||||
endpoint, err := url.Parse(c.Activity.RoleParamURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("和平小店授权接口地址无效: %w", err)
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("actAlias", c.Activity.ActAlias)
|
||||
query.Set("rid", c.Activity.RoomID)
|
||||
query.Set("token", uid+"_1_"+stk+"_0_"+ltkid)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setBrowserHeaders(req, cookie)
|
||||
req.Header.Set("Origin", constants.BaseURL)
|
||||
req.Header.Set("Referer", "https://www.douyu.com/topic/h5/hpxd01")
|
||||
var result struct {
|
||||
types.APIResponse
|
||||
Data struct {
|
||||
TXURLH5 string `json:"txurlH5"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if _, err := c.doJSON(req, &result, "获取小店 H5 参数"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Error != 0 {
|
||||
return nil, fmt.Errorf("获取和平小店授权失败: %s", result.Msg)
|
||||
}
|
||||
h5, err := url.Parse(result.Data.TXURLH5)
|
||||
if err != nil || result.Data.TXURLH5 == "" {
|
||||
return nil, errors.New("和平小店授权响应缺少 txurlH5")
|
||||
}
|
||||
return h5.Query(), nil
|
||||
}
|
||||
|
||||
// xiaodianRole 使用短时授权参数解析当前斗鱼账号绑定的游戏角色。
|
||||
func (c *DouyuClient) xiaodianRole(embedQuery url.Values) (xiaodianRoleContext, error) {
|
||||
endpoint, err := url.Parse(c.Activity.RoleURL)
|
||||
if err != nil {
|
||||
return xiaodianRoleContext{}, fmt.Errorf("和平小店角色接口地址无效: %w", err)
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("acctype", "livelink")
|
||||
query.Set("_jsvar", "info")
|
||||
query.Set("_service", "other.livelink.getrole.out")
|
||||
query.Set("_biz_code", "cjm")
|
||||
query.Set("_act_id", c.Activity.ActID)
|
||||
query.Set("_app_id", "2123")
|
||||
query.Set("isCode", "1")
|
||||
for _, key := range []string{"gameId", "actId", "appId", "livePlatId", "code", "timestamp", "v", "sig"} {
|
||||
query.Set(key, embedQuery.Get(key))
|
||||
}
|
||||
query.Set("authType", "delegate")
|
||||
query.Set("sAnchorId", c.Activity.RoomID)
|
||||
query.Set("sVideoId", "")
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return xiaodianRoleContext{}, err
|
||||
}
|
||||
c.setXiaodianHeaders(req)
|
||||
body, err := c.doText(req, "查询小店绑定角色", true)
|
||||
if err != nil {
|
||||
return xiaodianRoleContext{}, err
|
||||
}
|
||||
var result xiaodianRoleContext
|
||||
if err := parseXiaodianVariable(body, "info", &result); err != nil {
|
||||
return xiaodianRoleContext{}, fmt.Errorf("和平小店角色响应解析失败: %w", err)
|
||||
}
|
||||
switch strings.ToLower(string(result.Type)) {
|
||||
case "wx":
|
||||
result.Area = "1"
|
||||
case "qq":
|
||||
result.Area = "2"
|
||||
}
|
||||
areaID := parseXiaodianInt(string(result.Area))
|
||||
if areaID <= 0 {
|
||||
areaID = 1
|
||||
}
|
||||
result.Area = xiaodianFlexibleString(strconv.Itoa(areaID))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseXiaodianVariable(body []byte, name string, out any) error {
|
||||
text := strings.TrimSpace(string(body))
|
||||
marker := "var " + name
|
||||
start := strings.Index(text, marker)
|
||||
if start < 0 {
|
||||
return fmt.Errorf("响应中找不到 var %s", name)
|
||||
}
|
||||
text = strings.TrimSpace(text[start+len(marker):])
|
||||
if !strings.HasPrefix(text, "=") {
|
||||
return fmt.Errorf("var %s 格式无效", name)
|
||||
}
|
||||
text = strings.TrimSpace(strings.TrimPrefix(text, "="))
|
||||
text = strings.TrimSuffix(text, ";")
|
||||
return json.Unmarshal([]byte(text), out)
|
||||
}
|
||||
|
||||
// xiaodianProducts 查询和平小店全部商品,接口单页最多返回 200 条。
|
||||
func (c *DouyuClient) xiaodianProducts(cookie string, options types.XiaodianProductListOptions) ([]types.Product, error) {
|
||||
if strings.TrimSpace(cookie) == "" {
|
||||
return nil, errors.New("和平小店需要至少一个有效斗鱼 Cookie")
|
||||
}
|
||||
if options.PageSize <= 0 || options.PageSize > 200 {
|
||||
options.PageSize = 200
|
||||
}
|
||||
embedQuery, err := c.xiaodianEmbedQuery(cookie)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
role, err := c.xiaodianRole(embedQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role.OpenID == "" || role.RoleID == "" {
|
||||
return nil, errors.New("未获取到和平小店绑定角色,请确认该斗鱼账号已绑定游戏角色")
|
||||
}
|
||||
areaID := parseXiaodianInt(string(role.Area))
|
||||
if areaID <= 0 {
|
||||
areaID = 1
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(c.Activity.StoreHomeURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("和平小店商品接口地址无效: %w", err)
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("_service", "act.recommend.query")
|
||||
query.Set("_biz_code", "cjm")
|
||||
query.Set("_app_id", "2123")
|
||||
query.Set("app_id", "2123")
|
||||
query.Set("page_size", strconv.Itoa(options.PageSize))
|
||||
query.Set("fields", "iRelationGoodsid,dtModifyTime,sBuyLimitInfo,sGoodsWaterMark,dtShowBeginTime,dtShowEndTime,iSort,iGoodsId,sGoodsName,iJbPrice,iJbOrgPrice,iJb2Price,iJb2OrgPrice,iPrice,iOrgPrice,sGoodsPic,sGoodsDesc,sPayType,dtBeginTime,dtEndTime,iActionId,sExtShowInfo,sExtInfo,iCategoryId,dtRushBegin,dtRushEnd")
|
||||
query.Set("actionFields", "iActionId,sActionName,dtBeginTime,dtEndTime")
|
||||
query.Set("order_by", "dtShowBeginTime")
|
||||
query.Set("orderColumnBatch", `[{"key":"iSort","sort":"desc"},{"key":"dtShowBeginTime","sort":"desc"},{"key":"iGoodsId","sort":"asc"}]`)
|
||||
query.Set("desc", "1")
|
||||
query.Set("_act_id", c.Activity.ActID)
|
||||
query.Set("actid", c.Activity.ActID)
|
||||
query.Set("excludeFields", "iCategoryId_76")
|
||||
query.Set("userid", string(role.OpenID))
|
||||
query.Set("roleid", string(role.RoleID))
|
||||
query.Set("area", "1")
|
||||
query.Set("areaid", strconv.Itoa(areaID))
|
||||
query.Set("need_limit", "1")
|
||||
products := make([]types.Product, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for offset, pageNum := 0, 1; ; pageNum++ {
|
||||
pageQuery := make(url.Values, len(query))
|
||||
for key, values := range query {
|
||||
pageQuery[key] = append([]string(nil), values...)
|
||||
}
|
||||
pageQuery.Set("page_begin", strconv.Itoa(offset))
|
||||
pageQuery.Set("page_num", strconv.Itoa(pageNum))
|
||||
items, err := c.xiaodianProductPage(endpoint, pageQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.GoodsID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item.GoodsID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.GoodsID] = struct{}{}
|
||||
products = append(products, types.Product{
|
||||
CommodityID: item.GoodsID,
|
||||
CommodityName: item.GoodsName,
|
||||
Score: parseXiaodianInt(item.Price),
|
||||
Count: parseXiaodianInt(item.GoodsLeft),
|
||||
Pic: item.GoodsPic,
|
||||
})
|
||||
}
|
||||
if len(items) < options.PageSize {
|
||||
break
|
||||
}
|
||||
if pageNum >= 100 {
|
||||
return nil, errors.New("和平小店商品分页超出安全上限")
|
||||
}
|
||||
offset += options.PageSize
|
||||
}
|
||||
return products, nil
|
||||
}
|
||||
|
||||
func (c *DouyuClient) xiaodianProductPage(endpoint *url.URL, query url.Values) ([]xiaodianItem, error) {
|
||||
pageURL := *endpoint
|
||||
pageURL.RawQuery = query.Encode()
|
||||
req, err := http.NewRequest(http.MethodGet, pageURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setXiaodianHeaders(req)
|
||||
body, err := c.doText(req, "和平小店商品列表", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result xiaodianRecommendation
|
||||
if err := parseXiaodianVariable(body, "recommend", &result); err != nil {
|
||||
return nil, fmt.Errorf("和平小店商品响应解析失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != "" && result.ErrCode != "0" {
|
||||
return nil, fmt.Errorf("和平小店商品查询失败: %s", result.ErrDesc)
|
||||
}
|
||||
items := result.Data.ClientData.Items
|
||||
if items == nil {
|
||||
return nil, errors.New("和平小店商品响应格式异常")
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// parseXiaodianInt 将 QQ 道具接口中的字符串数值转为界面商品字段。
|
||||
func parseXiaodianInt(value string) int {
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// Score 查询账号积分余额。不同活动的积分接口方法/路径不同:
|
||||
// 和平手册用 POST /japi/oms/web/peace/credit/balance;
|
||||
// 电竞手册用 GET /wgapi/ordnc/activity/peacematch/score/balance。
|
||||
@@ -659,6 +943,41 @@ func (c *DouyuClient) doJSON(req *http.Request, out any, label string) (*http.Re
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// doText 执行返回 JavaScript 变量的接口请求,并复用统一网络诊断。
|
||||
func (c *DouyuClient) doText(req *http.Request, label string, redactBody bool) ([]byte, error) {
|
||||
start := time.Now()
|
||||
reqBody := c.Trace.RequestBody(req)
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
c.Trace.Record(label, req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "")
|
||||
c.logNetworkError(label, req, time.Since(start), err)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "")
|
||||
c.logNetworkError(label, req, time.Since(start), err)
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
traceBody := body
|
||||
if redactBody {
|
||||
traceBody = []byte("<redacted>")
|
||||
}
|
||||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, traceBody, "", 0, "")
|
||||
c.logNetworkHTTPError(label, req, resp.StatusCode, time.Since(start), body)
|
||||
return nil, fmt.Errorf("%s HTTP %d: %s", label, resp.StatusCode, utils.TrimForLog(string(body), 160))
|
||||
}
|
||||
traceBody := body
|
||||
if redactBody {
|
||||
traceBody = []byte("<redacted>")
|
||||
}
|
||||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, traceBody, "", 0, "")
|
||||
c.logNetworkSuccess(label, req, time.Since(start), 0, "")
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func shouldLogNetworkSuccess(label string) bool {
|
||||
return label != "服务时间"
|
||||
}
|
||||
@@ -857,3 +1176,14 @@ func (c *DouyuClient) setJSONHeaders(req *http.Request, cookie string) {
|
||||
c.setBrowserHeaders(req, cookie)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// setXiaodianHeaders 设置和平小店商品列表请求所需的浏览器头。
|
||||
func (c *DouyuClient) setXiaodianHeaders(req *http.Request) {
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh-Hans;q=0.9")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148, Douyu_IOS")
|
||||
req.Header.Set("Referer", "https://app.daoju.qq.com/")
|
||||
req.Header.Set("Sec-Fetch-Dest", "script")
|
||||
req.Header.Set("Sec-Fetch-Mode", "no-cors")
|
||||
req.Header.Set("Sec-Fetch-Site", "same-site")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -126,6 +128,84 @@ func TestNetworkDiagnosticIncludesActivityAndFieldNamesOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductsAutomaticallyResolvesXiaodianRoleAndPaginates(t *testing.T) {
|
||||
var goodsOffsets []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/embed":
|
||||
if !strings.Contains(r.Header.Get("Cookie"), "acf_uid=uid") {
|
||||
t.Error("授权请求未携带斗鱼 Cookie")
|
||||
}
|
||||
if got := r.URL.Query().Get("token"); got != "uid_1_stk_0_ltkid" {
|
||||
t.Errorf("token = %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"error":0,"data":{"txurlH5":"https://app.daoju.qq.com/?gameId=cjm&actId=18882&appId=bp_cf&livePlatId=douyu&code=code×tamp=1&v=2.0&sig=sig"}}`))
|
||||
case "/role":
|
||||
if got := r.URL.Query().Get("code"); got != "code" {
|
||||
t.Errorf("code = %q", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("_act_id"); got != "46195" {
|
||||
t.Errorf("_act_id = %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`var info = {"gameOpenId":"openid","roleId":1192083858,"type":"qq","area":1};`))
|
||||
case "/goods":
|
||||
offset := r.URL.Query().Get("page_begin")
|
||||
goodsOffsets = append(goodsOffsets, offset)
|
||||
if got := r.URL.Query().Get("areaid"); got != "2" {
|
||||
t.Errorf("areaid = %q", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("page_size"); got != "2" {
|
||||
t.Errorf("page_size = %q", got)
|
||||
}
|
||||
items := []map[string]string{}
|
||||
switch offset {
|
||||
case "0":
|
||||
items = []map[string]string{{"iGoodsId": "g1", "sGoodsName": "商品一", "iPrice": "10", "iGoodsLeft": "-1"}, {"iGoodsId": "g2", "sGoodsName": "商品二", "iPrice": "20", "iGoodsLeft": "3"}}
|
||||
case "2":
|
||||
items = []map[string]string{{"iGoodsId": "g2", "sGoodsName": "重复商品", "iPrice": "20", "iGoodsLeft": "3"}, {"iGoodsId": "g3", "sGoodsName": "商品三", "iPrice": "30", "iGoodsLeft": "0"}}
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"data": map[string]any{"client_data": map[string]any{"itemsdetail": items}}})
|
||||
_, _ = w.Write(append([]byte("var recommend="), append(body, ';')...))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
act := &activity.Activity{
|
||||
ID: "xiaodian",
|
||||
DisplayName: "和平小店",
|
||||
RoomID: "9263298",
|
||||
ActAlias: "20260623KDQFH",
|
||||
ActID: "46195",
|
||||
BaseURL: server.URL,
|
||||
ShopReferer: server.URL,
|
||||
RoleParamURL: server.URL + "/embed",
|
||||
RoleURL: server.URL + "/role",
|
||||
StoreHomeURL: server.URL + "/goods",
|
||||
IsXiaodian: true,
|
||||
}
|
||||
cli := New(act, "", &trace.Recorder{})
|
||||
cli.HTTPClient = server.Client()
|
||||
|
||||
products, err := cli.Products("acf_uid=uid; acf_stk=stk; acf_ltkid=ltkid", types.XiaodianProductListOptions{PageSize: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("Products() error = %v", err)
|
||||
}
|
||||
if len(products) != 3 {
|
||||
t.Fatalf("商品数量 = %d,期望 3", len(products))
|
||||
}
|
||||
if products[0].CommodityID != "g1" || products[2].CommodityID != "g3" {
|
||||
t.Fatalf("商品顺序 = %#v", products)
|
||||
}
|
||||
if products[1].Count != 3 || products[2].Score != 30 {
|
||||
t.Fatalf("商品字段 = %#v", products)
|
||||
}
|
||||
if got := strings.Join(goodsOffsets, ","); got != strings.Join([]string{"0", strconv.Itoa(2), strconv.Itoa(4)}, ",") {
|
||||
t.Fatalf("分页偏移 = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testCTNClient(t *testing.T, ctnURL string) *DouyuClient {
|
||||
t.Helper()
|
||||
act := &activity.Activity{
|
||||
|
||||
@@ -76,15 +76,16 @@ func envInt(key string, fallback int) int {
|
||||
// defaultConfig 返回内置默认配置。
|
||||
func defaultConfig() types.Config {
|
||||
return types.Config{
|
||||
Activity: activity.DefaultActivityID,
|
||||
TimeAPIURL: constants.DefaultTimeAPI,
|
||||
TraceEnabled: false,
|
||||
TraceMaxBody: 0,
|
||||
LogLevel: "info",
|
||||
Concurrency: constants.DefaultConcurrency,
|
||||
Attempts: constants.DefaultAttempts,
|
||||
RetryStages: defaultRetryStages(),
|
||||
SeckillMode: types.SeckillModeAggressive,
|
||||
Activity: activity.DefaultActivityID,
|
||||
TimeAPIURL: constants.DefaultTimeAPI,
|
||||
TraceEnabled: false,
|
||||
TraceMaxBody: 0,
|
||||
LogLevel: "info",
|
||||
Concurrency: constants.DefaultConcurrency,
|
||||
Attempts: constants.DefaultAttempts,
|
||||
RetryStages: defaultRetryStages(),
|
||||
SeckillMode: types.SeckillModeAggressive,
|
||||
XiaodianPageSize: 200,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,10 +139,18 @@ func LoadConfig(path string) types.Config {
|
||||
if len(cfg.RetryStages) == 0 {
|
||||
cfg.RetryStages = defaultRetryStages()
|
||||
}
|
||||
normalizeXiaodianConfig(&cfg)
|
||||
logger.LogEvent("配置", "success", "配置加载完成: %s", path)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// normalizeXiaodianConfig 约束和平小店分页参数,接口单页最大为 200。
|
||||
func normalizeXiaodianConfig(cfg *types.Config) {
|
||||
if cfg.XiaodianPageSize <= 0 || cfg.XiaodianPageSize > 200 {
|
||||
cfg.XiaodianPageSize = 200
|
||||
}
|
||||
}
|
||||
|
||||
// SaveConfig 将配置写入文件。
|
||||
func SaveConfig(path string, cfg types.Config) error {
|
||||
if path == "" {
|
||||
|
||||
+19
-1
@@ -8,6 +8,7 @@ import (
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -112,7 +113,7 @@ func (t *Recorder) RecordResponse(label string, req *http.Request, resp *http.Re
|
||||
Time: time.Now().In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||||
Label: label,
|
||||
Method: req.Method,
|
||||
URL: req.URL.Redacted(),
|
||||
URL: redactedURL(req.URL),
|
||||
Path: req.URL.Path,
|
||||
Status: status,
|
||||
Cost: cost,
|
||||
@@ -128,6 +129,23 @@ func (t *Recorder) RecordResponse(label string, req *http.Request, resp *http.Re
|
||||
_, _ = t.writer.Write([]byte(formatEntry(e)))
|
||||
}
|
||||
|
||||
// redactedURL 隐藏请求 URL 中的账号、角色与签名参数,避免 trace 落盘敏感信息。
|
||||
func redactedURL(raw *url.URL) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
copyURL := *raw
|
||||
query := copyURL.Query()
|
||||
for key := range query {
|
||||
switch strings.ToLower(key) {
|
||||
case "userid", "roleid", "openid", "code", "sig", "token", "sid":
|
||||
query.Set(key, "<hidden>")
|
||||
}
|
||||
}
|
||||
copyURL.RawQuery = query.Encode()
|
||||
return copyURL.String()
|
||||
}
|
||||
|
||||
// RequestBody 读取请求体用于追踪。
|
||||
func (t *Recorder) RequestBody(req *http.Request) []byte {
|
||||
if t == nil || !t.Enabled || req == nil || req.GetBody == nil {
|
||||
|
||||
+15
-9
@@ -7,15 +7,21 @@ import (
|
||||
|
||||
// Config 应用配置。
|
||||
type Config struct {
|
||||
TimeAPIURL string `json:"timeApiUrl"`
|
||||
Activity string `json:"activity"` // 当前活动 ID,见 internal/activity
|
||||
TraceEnabled bool `json:"traceEnabled"`
|
||||
TraceMaxBody int `json:"traceMaxBody"`
|
||||
LogLevel string `json:"logLevel"` // debug / info / warn / error
|
||||
Concurrency int `json:"concurrency"` // 抢购并发数
|
||||
Attempts int `json:"attempts"` // 单号重试次数
|
||||
RetryStages []RetryStage `json:"retryStages"` // 重试间隔阶段配置
|
||||
SeckillMode SeckillMode `json:"seckillMode"` // 抢购模式:0=标准, 1=激进
|
||||
TimeAPIURL string `json:"timeApiUrl"`
|
||||
Activity string `json:"activity"` // 当前活动 ID,见 internal/activity
|
||||
TraceEnabled bool `json:"traceEnabled"`
|
||||
TraceMaxBody int `json:"traceMaxBody"`
|
||||
LogLevel string `json:"logLevel"` // debug / info / warn / error
|
||||
Concurrency int `json:"concurrency"` // 抢购并发数
|
||||
Attempts int `json:"attempts"` // 单号重试次数
|
||||
RetryStages []RetryStage `json:"retryStages"` // 重试间隔阶段配置
|
||||
SeckillMode SeckillMode `json:"seckillMode"` // 抢购模式:0=标准, 1=激进
|
||||
XiaodianPageSize int `json:"xiaodianPageSize"`
|
||||
}
|
||||
|
||||
// XiaodianProductListOptions 是和平小店商品列表的角色与分页参数。
|
||||
type XiaodianProductListOptions struct {
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// ActivityInfo 活动概要,供前端下拉选择使用。
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Case Scope
|
||||
|
||||
## meta
|
||||
- case_id: 20260808-heping-shop-list
|
||||
- created: 2026-08-08T00:00:00+08:00
|
||||
- operator: local
|
||||
- primary_skill: browser-automation
|
||||
- lead_role: lead
|
||||
- specialist_roles: []
|
||||
|
||||
## auth
|
||||
- status: granted
|
||||
- basis: own_system
|
||||
- evidence_of_auth: user-provided capture and explicit feature request
|
||||
|
||||
## in_scope
|
||||
- assets: ["/Users/yml/Desktop/抓包/douyu/8-8和平小店/全部商品和购买记录-1.chlsj", "https://www.douyu.com/japi/carnival/nc/txEmbed/getIframeUrl", "https://apps.game.qq.com/daoju/igw/live", "https://apps.game.qq.com/daoju/v3/recommend_xn_live_cjm/common"]
|
||||
- surfaces: ["captured HTTP API", "local Go application"]
|
||||
- activities: ["offline capture analysis", "role-context request validation", "product-list request validation", "local feature implementation"]
|
||||
|
||||
## out_of_scope
|
||||
- assets: []
|
||||
- activities: ["account modification", "purchase submission", "load testing", "unrelated endpoint enumeration"]
|
||||
|
||||
## network_profile
|
||||
- mode: authorized_target_only
|
||||
- notes: Only the captured product-list endpoint may be requested for validation.
|
||||
|
||||
## deliverables
|
||||
- report: false
|
||||
- field_journal: false
|
||||
- diagrams: false
|
||||
- timeline: false
|
||||
|
||||
## constraints
|
||||
- timebox: feature implementation
|
||||
- stealth: low
|
||||
- data_handling: no_user_pii
|
||||
|
||||
## signoff
|
||||
- ready_for_act: true
|
||||
- checklist:
|
||||
- [x] auth.status = granted
|
||||
- [x] in_scope.assets non-empty OR offline sample path set
|
||||
- [x] network_profile.mode chosen
|
||||
- [x] out_of_scope reviewed
|
||||
Reference in New Issue
Block a user