Przeglądaj źródła

feat(buy_record): 优化采集间隔策略支持爆发密采与大车空闲探测

- 新增爆发密采逻辑,最近10条购买记录同一分钟桶时统一按1秒间隔密采
- 定义大车(≥1000份)空闲探测间隔为10秒,预售及无购买时适用
- 区分商品规模,调整采集间隔上限和空闲探测策略
- 修改 compute_next_interval 函数,支持爆发、空闲、转慢三种状态分档调节
- 更新返回值定义,空闲状态标记为 -1 以便分档判断
- 解决大车及快车爆发期间漏采问题,提高数据采集及时性和准确性
charley 3 tygodni temu
rodzic
commit
aa3f77fb80
1 zmienionych plików z 39 dodań i 15 usunięć
  1. 39 15
      deca_spider/buy_record_analysis/buy_record_spider.py

+ 39 - 15
deca_spider/buy_record_analysis/buy_record_spider.py

@@ -94,6 +94,16 @@ MAX_INTERVAL_SEC = 600             # 单商品购买记录最大采集间隔(1
 # 否则「刚上架 span=0 → 排 600s」会让 3~8 分钟售罄的拼团在两次采集间隙整场漏采(2026/08/10 修复)
 FAST_LANE_MAX_COUNT = 100          # 份数<=此值视为「快车」(本商家快车=30份、大车=313+),涵盖未来 20/40/50 份的小批量快车
 FAST_LANE_MAX_INTERVAL_SEC = 20    # 快车最大采集间隔(实测最快 10 份滑窗~64s,20s 留约 3x 余量)
+# 爆发密采(所有尺寸) + 大车空闲探测(2026/08/25 优化 A):
+# 「爆发」判定 = 最近 10 条购买记录全落同一分钟桶(span==0) = 上一分钟至少卖了 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 回退。
+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 内追上)
 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+ 在售)后,
@@ -281,25 +291,38 @@ 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 条时间跨度算下次采集间隔(span/3,硬夹进 [MIN, 上限])。
+    """按 10 条时间跨度算下次采集间隔,按商品规模分三档(大车 / 快车 / 普通)。
 
-    上限按是否「快车」区分:小批量商品(card_count<=FAST_LANE_MAX_COUNT,如 30 份拼团)售罄极快
-    (实测最快 10 份滑窗约 64s),上限压到 FAST_LANE_MAX_INTERVAL_SEC 密采;其余商品沿用 MAX_INTERVAL_SEC。
-    span<=0 有两种情形——刚上架还没人买(purchaseRecords 空)、或最近 10 条落在同一相对时间桶(超热)——
-    对快车都返回其小上限快探,避免旧逻辑误判为最慢 600s,导致快售拼团在两次采集间隙整场漏采。
+    span_sec 三态:>0=有真实跨度(按 span//3 自适应);==0=最近 10 条同分钟桶(超热爆发);
+    <0=本轮无购买记录(空闲/预售,由 poll_product 以 -1 标记)。
+
+    统一「爆发密采」(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//3,硬夹进 [MIN, 对应档上限 cap],售卖变慢自动回退。
 
     Args:
-        span_sec (int): 10 条 purchaseRecords 最新与最老之间的秒数差。
-        card_count (int, optional): 商品总份数,用于判定是否快车。None 时按非快车处理。Defaults to None。
+        span_sec (int): 10 条 purchaseRecords 最新与最老的秒数差;0=同桶爆发,<0=本轮无购买记录(空闲)
+        card_count (int, optional): 商品总份数,用于分档(仅影响空闲探测与慢档上限)。None 按普通处理。Defaults to None。
 
     Returns:
         int: 下次采集间隔(秒)。
     """
-    is_fast = card_count is not None and card_count <= FAST_LANE_MAX_COUNT
-    cap = FAST_LANE_MAX_INTERVAL_SEC if is_fast else MAX_INTERVAL_SEC
-    if span_sec <= 0:
-        # 快车:刚上架没人买/超热同桶 → 快探到小上限;非快车:维持原「最大间隔兜底」600s
-        return cap if is_fast else MAX_INTERVAL_SEC
+    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 == 0:     # 最近 10 条同桶:爆发(任意尺寸) → 统一顶格密采
+        return HOT_INTERVAL_SEC
     interval = span_sec // 3
     return max(MIN_INTERVAL_SEC, min(cap, interval))
 
@@ -544,7 +567,8 @@ def poll_product(log, pool, code: str, meta: dict) -> tuple:
 
     Returns:
         tuple[int, int, bool, str]: (span_sec, new_count, ended, end_reason) ——
-            10 条时间跨度秒数、本轮新增入库条数、是否售卖结束、结束原因文本(未结束为空串)。
+            10 条时间跨度秒数(0=最近10条同桶超热,-1=本轮无购买记录/空闲,供 compute_next_interval 分档调频)、
+            本轮新增入库条数、是否售卖结束、结束原因文本(未结束为空串)。
     """
     resp = throttled_do_request(log, DETAIL_PATH, {"code": code})
     data = (resp or {}).get("data") or {}
@@ -554,12 +578,12 @@ def poll_product(log, pool, code: str, meta: dict) -> tuple:
 
     recs = data.get("purchaseRecords") or []
     if not recs:
-        return 0, 0, ended, end_reason
+        return -1, 0, ended, end_reason        # 无购买记录:空闲/预售,标记 span=-1(区别于同桶超热的 span=0)
 
     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]
     if not rows:
-        return 0, 0, ended, end_reason
+        return -1, 0, ended, end_reason        # 有 recs 但无有效 userId:同样按空闲处理
 
     # 计算 span:records[0] 最新、records[-1] 最老,均是相对时间反推
     ts_new = rows[0]["purchased_at_ts"]