Просмотр исходного кода

perf(spider): 优化购买记录采集间隔及预售状态处理

- 将快车时段全站在售发现间隔由20秒改为10秒,加快新上架车辆发现速度
- 将单商品购买记录最大采集间隔由600秒改为30秒,杜绝大间隔漏采情况
- 统一空闲及首次探测间隔为10秒,预售未开卖探测间隔调整为60秒,减少无效请求
- 采集间隔计算逻辑优化,爆发时统一顶格密采,空闲与预售状态分别稀探与密探
- 新增is_presale函数判断商品是否处于预售未开卖期
- 购买记录为空时,根据预售状态分别返回不同span标记,以区别处理采集间隔
charley 3 недель назад
Родитель
Сommit
8efd78bb29
1 измененных файлов с 49 добавлено и 23 удалено
  1. 49 23
      deca_spider/buy_record_analysis/buy_record_spider.py

+ 49 - 23
deca_spider/buy_record_analysis/buy_record_spider.py

@@ -85,12 +85,13 @@ ONSALE_INGEST_SEC = 60             # 全站在售落库 + 刷新监控队列间
 # 从没进过 deca_onsale_product_record → 从没纳入监控 → deca_buy_record 0 条 → 战报「0 人参与拆卡」)。
 # 故该时段把「全站在售落库 + 刷新监控队列」的节奏压到 FAST_WINDOW_INGEST_SEC 密探,给快车 6~9 次被发现的机会;
 # 时段外恢复 ONSALE_INGEST_SEC,不额外增加白天的全站请求量。全站 home/search 免 token 走直连,加密只抬直连请求量、不涉登录态。
-FAST_WINDOW_INGEST_SEC = 20        # 快车时段全站在售发现间隔(20s,与快车轮询上限 FAST_LANE_MAX_INTERVAL_SEC 对称)
+FAST_WINDOW_INGEST_SEC = 10        # 快车时段全站在售发现间隔(2026/08/28 由 20s 改 10s):更早发现新上架车,缩短上架→纳入监控→追首条的延迟
 FAST_WINDOW_START = dtime(20, 0)   # 快车高发时段开始:20:00
 FAST_WINDOW_END = dtime(6, 0)      # 快车高发时段结束:次日 06:00(窗口跨午夜)
 SHOP_DISCOVER_SEC = 600            # 商家发现 + 今日新增补详情间隔(10 分钟,变化慢无需每分钟)
 MIN_INTERVAL_SEC = 0               # 单商品购买记录最小采集间隔(测试期设 0:让实测数据决定是否要抬)
-MAX_INTERVAL_SEC = 600             # 单商品购买记录最大采集间隔(10 分钟)
+MAX_INTERVAL_SEC = 30              # 单商品购买记录最大采集间隔(2026/08/27 由 600s 改 30s):
+                                   # 任何车卖再慢也 ≤30s 采一次,杜绝慢档 600s 上限在两次采集间隙的漏采窗口
 # 快车(小批量拼团)专用:份数小的商品售罄极快(实测最快 10 份滑窗约 64s),需比 MAX_INTERVAL_SEC 更小的上限密采,
 # 否则「刚上架 span=0 → 排 600s」会让 3~8 分钟售罄的拼团在两次采集间隙整场漏采(2026/08/10 修复)
 FAST_LANE_MAX_COUNT = 100          # 份数<=此值视为「快车」(本商家快车=30份、大车=313+),涵盖未来 20/40/50 份的小批量快车
@@ -100,11 +101,15 @@ FAST_LANE_MAX_INTERVAL_SEC = 20    # 快车最大采集间隔(实测最快 10
 # 这对任意尺寸都成立,故所有车 span==0 时统一压到 HOT_INTERVAL_SEC 顶格密采(贴单车物理上限 ≈10条/RTT≈6.7份/秒)。
 # 背景:原箱大车(上万份)开售头几分钟 15~35 份/秒秒空(实测 9786 份 24s 卖 830)、31 份快车 ~60s 秒空,旧逻辑对
 # 非快车 span<=0 返回 600s、快车返回 20s,都追不上爆发翻窗(大车覆盖率仅 40%、部分 31 份快车 58~74%)。
-# 大车额外单列「空闲探测」:预售/未开卖(无购买记录 span<0)时每 BIG_TEAM_IDLE_INTERVAL_SEC 探一次,保证开售爆发
-# 一起即在 ~10s 内切到密采;快车空闲沿用其 20s、普通车沿用 600s。售卖转慢(span>0)一律按 span//3 回退。
+# 空闲探测(span<0,无购买记录/刚纳入):密探尽快追首条,开售爆发一起即在 ~10s 内切密采。
+# 【2026/08/27~28:空闲探测已统一 IDLE_PROBE_SEC(10s)、预售 PRESALE_IDLE_SEC(60s),不再分档;慢档上限 MAX_INTERVAL_SEC(30s)】
 HOT_INTERVAL_SEC = 1               # 爆发(任意尺寸,最近10条同桶 span==0)采集间隔(1s,实际受 RTT≈1.5s 自然限流,逼近单车采集上限)
-BIG_TEAM_MIN_COUNT = 1000          # 份数>=此值视为「大车」(原箱类,如 9786/10697 份):额外走空闲探测,开售即密采
-BIG_TEAM_IDLE_INTERVAL_SEC = 10    # 大车空闲/预售(本轮无购买记录)采集间隔(10s 中频探测,爆发一起即在 ~10s 内追上)
+# 空闲/首次探测间隔(大中小车统一,2026/08/27):刚纳入 / 首次接口延迟响应0条 / 预售时,还不知道车卖多快,
+# 一律 10s 密探尽快追上首条购买记录——尤其中等份数车常 6 分钟售罄、每~20s 卖>10 份,20s 采一次会翻窗漏采,故 10s。
+# 采到首条后即转 span 自适应(卖快密采、卖慢自动稀采),不再密探。
+IDLE_PROBE_SEC = 10                # 空闲/首次探测间隔(所有档统一 10s,宁密勿漏)
+PRESALE_IDLE_SEC = 60              # 预售未开卖(saleStartAt 在未来)探测间隔(60s 稀探,2026/08/28):预售期没人能买、
+                                   # 购买记录必空,10s 密探纯浪费;到开卖时间 poll 自动转 IDLE_PROBE_SEC 密探
 GLOBAL_MIN_GAP_SEC = 0.2           # 全局相邻两次详情请求最小间隔(≈每秒 5 次上限,防封的硬速率天花板)
 # 并发采集(2026/08/24):实测单条详情请求 ~1~2.7s(含长尾),串行吞吐仅 0.4~0.9 次/秒,
 # 远低于 GLOBAL_MIN_GAP_SEC 允许的 5 次/秒——瓶颈是请求延迟(latency-bound)而非节流。多商家(21+ 在售)后,
@@ -262,6 +267,29 @@ def is_sale_ended(data: dict, now_ts: int) -> tuple[bool, str]:
     return False, ""
 
 
+def is_presale(data: dict, now_ts: int) -> bool:
+    """判断商品是否处于「预售未开卖」(saleStartAt 在未来)。
+
+    预售期没人能买、购买记录必空,无需 10s 密探,60s 稀探即可省请求;到开卖时间自动转密探。
+    saleStartAt 缺失/解析失败按「已开卖」处理(保守走密探,不漏)。
+
+    Args:
+        data (dict): 详情接口 data 层。
+        now_ts (int): 当前秒级时间戳。
+
+    Returns:
+        bool: True=预售未开卖;False=已开卖或无法判断。
+    """
+    start_text = data.get("saleStartAt")
+    if not start_text:
+        return False
+    try:
+        start_ts = time.mktime(time.strptime(start_text, "%Y-%m-%d %H:%M:%S"))
+        return now_ts < start_ts
+    except (ValueError, OverflowError):
+        return False
+
+
 def throttled_do_request(log, path: str, body: dict, need_auth: bool = False) -> dict | None:
     """核心签名 POST 请求 + 全局令牌桶限速(多线程安全)。
 
@@ -292,16 +320,17 @@ def throttled_do_request(log, path: str, body: dict, need_auth: bool = False) ->
 
 
 def compute_next_interval(span_sec: int, card_count: int | None = None) -> int:
-    """按 10 条时间跨度算下次采集间隔,按商品规模分三档(大车 / 快车 / 普通)
+    """按 10 条时间跨度算下次采集间隔:空闲/预售探测、爆发顶格、卖着按跨度自适应
 
     span_sec 三态:>0=有真实跨度(按 span//3 自适应);==0=最近 10 条同分钟桶(超热爆发);
-    <0=本轮无购买记录(空闲/预售,由 poll_product 以 -1 标记)。
+    ==-1=已开卖但本轮无购买记录(接口延迟/暂无人买);<=-2=预售未开卖(均由 poll_product 标记)。
 
     统一「爆发密采」(2026/08/25 优化 A):span==0 对任意尺寸都表示「上一分钟卖了≥10份=正在热卖」,
     故所有车 span==0 时一律返回 HOT_INTERVAL_SEC 顶格密采,不再按尺寸区分(修掉旧逻辑「快车 span==0→20s、
     普通/大车→600s」追不上爆发翻窗、31 份快车 58~74%、大车 40% 的漏采)。尺寸只影响另两态:
-      - 空闲(span<0,本轮无购买记录/预售):大车用 BIG_TEAM_IDLE_INTERVAL_SEC(开售即密采)、快车用 FAST_LANE 档、
-        普通用 MAX_INTERVAL_SEC;决定「多快发现它开卖」。
+      - 空闲(span<0,本轮无购买记录/预售):预售未开卖(span=-2)用 PRESALE_IDLE_SEC(60s)稀探省请求;已开卖但暂无记录(span=-1)
+        大中小车统一 IDLE_PROBE_SEC(10s)密探追首条(2026/08/27~28,不再分档);
+        决定「多快发现它开卖 / 追上首条购买记录」。采到首条后即转 span 自适应,不影响真慢车稀采。
       - 转慢(span>0,有跨度):一律 span//3,硬夹进 [MIN, 对应档上限 cap],售卖变慢自动回退。
 
     Args:
@@ -312,20 +341,16 @@ def compute_next_interval(span_sec: int, card_count: int | None = None) -> int:
         int: 下次采集间隔(秒)。
     """
     cc = card_count or 0
-    is_big = cc >= BIG_TEAM_MIN_COUNT
-    is_fast = 0 < cc <= FAST_LANE_MAX_COUNT
-    if is_big:
-        cap, idle = MAX_INTERVAL_SEC, BIG_TEAM_IDLE_INTERVAL_SEC
-    elif is_fast:
-        cap, idle = FAST_LANE_MAX_INTERVAL_SEC, FAST_LANE_MAX_INTERVAL_SEC
-    else:
-        cap, idle = MAX_INTERVAL_SEC, MAX_INTERVAL_SEC
-    if span_sec < 0:      # 本轮无购买记录:空闲/预售 → 按尺寸的 idle 探测
-        return idle
+    if span_sec <= -2:    # 预售未开卖:没人能买、购买记录必空 → 稀探省请求;到点开卖 poll 会改给 -1 转密探
+        return PRESALE_IDLE_SEC
+    if span_sec < 0:      # 已开卖但本轮无购买记录(刚纳入/接口延迟响应0条):所有档统一 10s 密探,尽快追上首条
+        return IDLE_PROBE_SEC
     if span_sec == 0:     # 最近 10 条同桶:爆发(任意尺寸) → 统一顶格密采
         return HOT_INTERVAL_SEC
-    interval = span_sec // 3
-    return max(MIN_INTERVAL_SEC, min(cap, interval))
+    # 有跨度:span//3 自适应(3x 余量防翻窗),夹进 [MIN, 慢档上限 cap]。
+    # cap 只按「快车 vs 其余」分:快车份数小、卖极快,用更小上限;其余(含原大车)统一 MAX_INTERVAL_SEC。
+    cap = FAST_LANE_MAX_INTERVAL_SEC if 0 < cc <= FAST_LANE_MAX_COUNT else MAX_INTERVAL_SEC
+    return max(MIN_INTERVAL_SEC, min(cap, span_sec // 3))
 
 
 # ==================== 订单判重 ====================
@@ -579,7 +604,8 @@ def poll_product(log, pool, code: str, meta: dict) -> tuple:
 
     recs = data.get("purchaseRecords") or []
     if not recs:
-        return -1, 0, ended, end_reason        # 无购买记录:空闲/预售,标记 span=-1(区别于同桶超热的 span=0)
+        # 无购买记录:预售未开卖→-2(稀探省请求);已开卖但接口延迟/暂无人买→-1(密探追首条)。均区别于同桶超热 span=0
+        return (-2 if is_presale(data, now_ts) else -1), 0, ended, end_reason
 
     now_dt = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now_ts))
     rows = [r for r in (build_row(x, code, now_ts, now_dt, meta) for x in recs) if r]