|
@@ -0,0 +1,672 @@
|
|
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
|
|
+# Author : Charley
|
|
|
|
|
+# Python : 3.12.10
|
|
|
|
|
+# Date : 2026/08/05
|
|
|
|
|
+"""得卡 DECA 购买记录常驻采集脚本(多商品自适应频率)。
|
|
|
|
|
+
|
|
|
|
|
+功能:
|
|
|
|
|
+ 一个进程干两件事(方案A,2026/08/08 合并):
|
|
|
|
|
+ (1) 每分钟复用 daily 的免 token home/search 拉全站在售,落 deca_onsale_* 三张表(供 deca_on_sale_report 查库出报告);
|
|
|
|
|
+ (2) 从库里取本商家(MERCHANT_ID)在售 code,采其购买记录写 deca_buy_record。
|
|
|
|
|
+ 购买记录按「近 10 条购买的时间跨度」动态调节采集频率——
|
|
|
|
|
+ 卖得快(跨度短)密采、卖得慢(跨度长)稀采;商品下线、售卖结束或售罄自动停采(含白名单模式)。
|
|
|
|
|
+ 数据写 deca_buy_record;去重用「倒序滑动窗口序列对齐」:purchaseRecords 是最近 10 条按时间倒序、
|
|
|
|
|
+ 新单只从顶部推入,故按「本次窗口 (userId,cardCount) 序列相对上次整体下移了几位」求出顶部新增的 k 条,
|
|
|
|
|
+ 只入库这 k 条——不依赖会漂移的反推时间戳,且能正确区分同用户多笔相同份数的单(靠位移而非时间)。
|
|
|
|
|
+ 兜底:进程重启首轮 / 窗口整体换新(间隔内卖出≥10 笔)无法对齐时,退回反推时间戳「动态容差」判重
|
|
|
|
|
+ (秒 70s / 分钟 90s / 小时 3660s / 天 90000s)+ DB 唯一键,无重叠时告警疑似漏采。
|
|
|
|
|
+
|
|
|
|
|
+停采(重点):
|
|
|
|
|
+ 详情返回 availableStock<=0(售罄)或已过 saleEndAt(到结束时间)即判定售卖结束,移出监控并加入
|
|
|
|
|
+ _ended_codes 黑名单,避免白名单模式下对已结束商品死循环重采「固定的最后 10 条」造成重复入库。
|
|
|
|
|
+
|
|
|
|
|
+自适应节奏:
|
|
|
|
|
+ 下次间隔 ≈ 10 条跨度 / 3,硬夹在 [MIN_INTERVAL_SEC, MAX_INTERVAL_SEC]。
|
|
|
|
|
+ 典型档位(当前 MIN=0,测试期让服务端限流自己说话):
|
|
|
|
|
+ - 极热(10 条 3s 内)→ 立即再刷(由 GLOBAL_MIN_GAP_SEC 兜底≥0.2s)
|
|
|
|
|
+ - 热(10 条 1min 内)→ 20s 一次
|
|
|
|
|
+ - 中(10 条 5min 内)→ 100s 一次
|
|
|
|
|
+ - 慢(10 条 30min 内)→ 600s 一次触顶
|
|
|
|
|
+
|
|
|
|
|
+登录态:
|
|
|
|
|
+ - **全链路免 token**:详情接口 groupbuy/detail 免登录;在售列表改用免 token 的 home/search(复用 daily),
|
|
|
|
|
+ 落库后查库拿商家在售 code,不再走需 token 的 on-sale-list。彻底摆脱登录/验证码。
|
|
|
|
|
+
|
|
|
|
|
+保护:
|
|
|
|
|
+ - 全局请求节流 GLOBAL_MIN_GAP_SEC,避免瞬时高并发触发限流
|
|
|
|
|
+ - main_task @retry(stop=100, wait=3600):挂了每小时重试,跑到手动停
|
|
|
|
|
+ - 快代理隧道请求(走 deca_sold_core)
|
|
|
|
|
+
|
|
|
|
|
+前置:
|
|
|
|
|
+ - 表 deca_buy_record 及 deca_onsale_* 需先建好(schema.sql)。
|
|
|
|
|
+ - 依赖 on_sale/deca_on_sale_daily_spider.py 的复用函数(get_shop_list/get_onsale_products/fill_product_details);
|
|
|
|
|
+ 该文件保留、不再单独常驻跑。报告由 deca_on_sale_report.py 独立定时查库生成。
|
|
|
|
|
+
|
|
|
|
|
+运行:项目根目录 `python buy_record_analysis/buy_record_spider.py`(采集);
|
|
|
|
|
+ 报告另跑 `python on_sale/deca_on_sale_report.py`(可加 loop 定时,见该文件)。
|
|
|
|
|
+"""
|
|
|
|
|
+import os
|
|
|
|
|
+import re
|
|
|
|
|
+import sys
|
|
|
|
|
+import time
|
|
|
|
|
+from datetime import datetime
|
|
|
|
|
+
|
|
|
|
|
+# 挂靠项目根:复用核心签名/token/请求/代理层,让 application.yml、token.json 生效
|
|
|
|
|
+_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
+sys.path.insert(0, _ROOT)
|
|
|
|
|
+os.chdir(_ROOT)
|
|
|
|
|
+
|
|
|
|
|
+from loguru import logger
|
|
|
|
|
+from tenacity import retry, stop_after_attempt, wait_fixed
|
|
|
|
|
+import deca_sold_core as core
|
|
|
|
|
+from mysql_pool import MySQLConnectionPool
|
|
|
|
|
+# 复用 daily 的免 token 采集/落库(home/search 全站在售 → deca_onsale_* 三张表);daily 文件保留、不再单独常驻
|
|
|
|
|
+# 注:本 import 会触发 daily 模块级 logger 配置,但下方 buy_record 的 logger.remove/add 在其后,会覆盖回本脚本日志
|
|
|
|
|
+from on_sale.deca_on_sale_daily_spider import get_shop_list, get_onsale_products, fill_product_details
|
|
|
|
|
+
|
|
|
|
|
+# ==================== 配置 ====================
|
|
|
|
|
+core.USE_PROXY = True # 详情接口(groupbuy/detail)走快代理隧道;全链路免 token
|
|
|
|
|
+
|
|
|
|
|
+MERCHANT_ID = "881226408" # 监控商家:采其在售商品的购买记录(WATCH_CODES 为空时生效)
|
|
|
|
|
+WATCH_CODES = [] # 指定商品白名单:非空则只盯这些 code、跳过在售发现;空则走 MERCHANT_ID 全在售(默认)
|
|
|
|
|
+ONSALE_INGEST_SEC = 60 # 全站在售落库 + 刷新监控队列间隔(1 分钟,复用 daily 免 token home/search)
|
|
|
|
|
+SHOP_DISCOVER_SEC = 600 # 商家发现 + 今日新增补详情间隔(10 分钟,变化慢无需每分钟)
|
|
|
|
|
+MIN_INTERVAL_SEC = 0 # 单商品购买记录最小采集间隔(测试期设 0:让实测数据决定是否要抬)
|
|
|
|
|
+MAX_INTERVAL_SEC = 600 # 单商品购买记录最大采集间隔(10 分钟)
|
|
|
|
|
+# 快车(小批量拼团)专用:份数小的商品售罄极快(实测最快 10 份滑窗约 64s),需比 MAX_INTERVAL_SEC 更小的上限密采,
|
|
|
|
|
+# 否则「刚上架 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 余量)
|
|
|
|
|
+GLOBAL_MIN_GAP_SEC = 0.2 # 全局相邻两次详情请求最小间隔(≈每秒 5 次上限)
|
|
|
|
|
+IDLE_SLEEP_SEC = 1 # 主循环空转 sleep(没到点时)
|
|
|
|
|
+DEDUP_TOLERANCE_SEC = 70 # 秒级基准判重容差:同(商品,user,份数)下反推时间戳相差<=此值视为同一笔;小时/天级按 _TOL_BY_UNIT 放大
|
|
|
|
|
+
|
|
|
|
|
+TABLE = "deca_buy_record"
|
|
|
|
|
+ONSALE_TABLE = "deca_onsale_product_record" # 在售商品表:daily 复用函数落库,本脚本查库拿商家在售 code
|
|
|
|
|
+PROGRESS_TABLE = "deca_onsale_product_progress_record" # 2026/08/11 新增:进度时间序列,append-only、变化才写
|
|
|
|
|
+DETAIL_PATH = "/api/v1/app/groupbuy/detail"
|
|
|
|
|
+
|
|
|
|
|
+# 日志:按天切分文件,保留 7 天;stderr 同步 INFO 便于观察
|
|
|
|
|
+logger.remove()
|
|
|
|
|
+logger.add(os.path.join(_ROOT, "logs", "{time:YYYYMMDD}_buy_record.log"),
|
|
|
|
|
+ encoding="utf-8", rotation="00:00",
|
|
|
|
|
+ format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
|
|
|
|
|
+ level="DEBUG", retention="7 day")
|
|
|
|
|
+# logger.add(sys.stderr, level="INFO",
|
|
|
|
|
+# format="[{time:HH:mm:ss}] {level} {message}")
|
|
|
|
|
+
|
|
|
|
|
+# 相对时间文本:捕获数字 + 单位
|
|
|
|
|
+_REL_RE = re.compile(r"^(\d+)\s*(秒|分钟|小时|天)前$")
|
|
|
|
|
+
|
|
|
|
|
+# 动态判重容差(秒):相对时间越粗,反推时间戳随采集时刻漂移越大(幅度≈单位桶宽),容差须≥桶宽才能吸附同一笔的漂移副本。
|
|
|
|
|
+# 秒/分钟保持小容差(<同用户多笔的最小间隔~2min)以区分真实多笔;小时/天放大到略大于桶宽(3600/86400)。
|
|
|
|
|
+_TOL_BY_UNIT = {"秒": DEDUP_TOLERANCE_SEC, "分钟": 90, "小时": 3660, "天": 90000}
|
|
|
|
|
+
|
|
|
|
|
+# 全局请求节流:记录上次请求时刻,本轮请求前 sleep 到最小间隔
|
|
|
|
|
+_last_req_ts = 0.0
|
|
|
|
|
+
|
|
|
|
|
+# 订单去重内存索引:{(product_code, user_id, card_count): [已入库订单的反推时间戳...]}
|
|
|
|
|
+# 判重靠「反推时间戳 ± 动态容差(_TOL_BY_UNIT)」——同一笔订单变老后的漂移能吸附回锚点,不同笔可区分
|
|
|
|
|
+_order_idx: dict = {}
|
|
|
|
|
+
|
|
|
|
|
+# 已判定售卖结束的商品黑名单:移出监控后加入,_refresh_monitored 跳过,避免白名单模式反复重新纳入
|
|
|
|
|
+_ended_codes: set = set()
|
|
|
|
|
+
|
|
|
|
|
+# 上次窗口快照:{code: [(user_id, card_count), ...]}(index0 最新),供「序列对齐」求本轮顶部新增的 k 条
|
|
|
|
|
+_last_window: dict = {}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ==================== 工具函数 ====================
|
|
|
|
|
+def after_log(retry_state):
|
|
|
|
|
+ """tenacity 重试回调,记录每次尝试的结果。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ retry_state: tenacity RetryCallState。
|
|
|
|
|
+ """
|
|
|
|
|
+ log = retry_state.args[0] if retry_state.args else logger
|
|
|
|
|
+ if retry_state.outcome.failed:
|
|
|
|
|
+ log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
|
|
|
|
|
+ else:
|
|
|
|
|
+ log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def parse_relative_ago(text: str, now_ts: int) -> int:
|
|
|
|
|
+ """把「X 秒/分钟/小时/天前」或「刚刚」反推为绝对秒级时间戳。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ text (str): 相对时间原文(来自 purchaseRecords.purchasedAt)。
|
|
|
|
|
+ now_ts (int): 采集时刻的秒级 unix 时间戳。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ int: 反推的绝对购买时间戳(秒);无法识别时兜底返回 now_ts。
|
|
|
|
|
+ """
|
|
|
|
|
+ if not text:
|
|
|
|
|
+ return now_ts
|
|
|
|
|
+ t = text.strip()
|
|
|
|
|
+ if t in ("刚刚", "刚才", "现在"):
|
|
|
|
|
+ return now_ts
|
|
|
|
|
+ m = _REL_RE.match(t)
|
|
|
|
|
+ if not m:
|
|
|
|
|
+ return now_ts
|
|
|
|
|
+ n = int(m.group(1))
|
|
|
|
|
+ unit = m.group(2)
|
|
|
|
|
+ mult = {"秒": 1, "分钟": 60, "小时": 3600, "天": 86400}[unit]
|
|
|
|
|
+ return now_ts - n * mult
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def dedup_tolerance(text: str) -> int:
|
|
|
|
|
+ """按相对时间文本的单位返回该记录的判重容差(秒)。
|
|
|
|
|
+
|
|
|
|
|
+ 相对时间越粗(小时/天),反推时间戳随采集时刻的漂移越大(幅度≈单位桶宽),
|
|
|
|
|
+ 需用大容差才能把同一笔订单变老后的漂移副本吸附回锚点,否则会被误判为新订单重复入库。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ text (str): 相对时间原文(如 "1分钟前" / "3小时前")。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ int: 该粒度下的判重容差秒数;无法识别时返回秒级基准 DEDUP_TOLERANCE_SEC。
|
|
|
|
|
+ """
|
|
|
|
|
+ m = _REL_RE.match((text or "").strip())
|
|
|
|
|
+ if not m:
|
|
|
|
|
+ return DEDUP_TOLERANCE_SEC
|
|
|
|
|
+ return _TOL_BY_UNIT.get(m.group(2), DEDUP_TOLERANCE_SEC)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def is_sale_ended(data: dict, now_ts: int) -> tuple[bool, str]:
|
|
|
|
|
+ """根据详情返回判断商品售卖是否已结束(售罄或到结束时间)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ data (dict): 详情接口 data 层。
|
|
|
|
|
+ now_ts (int): 当前秒级时间戳。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ tuple[bool, str]: (是否已结束, 原因文本)。未结束时原因为空串。
|
|
|
|
|
+ """
|
|
|
|
|
+ stock = data.get("availableStock")
|
|
|
|
|
+ if stock is not None and stock <= 0:
|
|
|
|
|
+ return True, f"售罄(availableStock={stock})"
|
|
|
|
|
+ end_text = data.get("saleEndAt")
|
|
|
|
|
+ if end_text:
|
|
|
|
|
+ try:
|
|
|
|
|
+ end_ts = time.mktime(time.strptime(end_text, "%Y-%m-%d %H:%M:%S"))
|
|
|
|
|
+ if now_ts >= end_ts:
|
|
|
|
|
+ return True, f"已过结束时间(saleEndAt={end_text})"
|
|
|
|
|
+ except (ValueError, OverflowError):
|
|
|
|
|
+ pass
|
|
|
|
|
+ return False, ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def throttled_do_request(log, path: str, body: dict, need_auth: bool = False) -> dict | None:
|
|
|
|
|
+ """核心签名 POST 请求 + 全局最小间隔节流。
|
|
|
|
|
+
|
|
|
|
|
+ 做请求前 sleep 到 `_last_req_ts + GLOBAL_MIN_GAP_SEC`,防止多商品并发瞬时打满。
|
|
|
|
|
+ 详情/在售列表都走这里,共用同一根节流线。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ log: 日志对象。
|
|
|
|
|
+ path (str): 接口相对路径。
|
|
|
|
|
+ body (dict): 请求体(同时用于签名)。
|
|
|
|
|
+ need_auth (bool, optional): 是否带 Bearer token。详情接口不需要(False),
|
|
|
|
|
+ 在售列表需要(True)。Defaults to False。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ dict | None: 响应 JSON。
|
|
|
|
|
+ """
|
|
|
|
|
+ global _last_req_ts
|
|
|
|
|
+ now = time.time()
|
|
|
|
|
+ wait = _last_req_ts + GLOBAL_MIN_GAP_SEC - now
|
|
|
|
|
+ if wait > 0:
|
|
|
|
|
+ time.sleep(wait)
|
|
|
|
|
+ resp = core.do_request(log, path, body, need_auth=need_auth)
|
|
|
|
|
+ _last_req_ts = time.time()
|
|
|
|
|
+ return resp
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def compute_next_interval(span_sec: int, card_count: int | None = None) -> int:
|
|
|
|
|
+ """按 10 条时间跨度算下次采集间隔(span/3,硬夹进 [MIN, 上限])。
|
|
|
|
|
+
|
|
|
|
|
+ 上限按是否「快车」区分:小批量商品(card_count<=FAST_LANE_MAX_COUNT,如 30 份拼团)售罄极快
|
|
|
|
|
+ (实测最快 10 份滑窗约 64s),上限压到 FAST_LANE_MAX_INTERVAL_SEC 密采;其余商品沿用 MAX_INTERVAL_SEC。
|
|
|
|
|
+ span<=0 有两种情形——刚上架还没人买(purchaseRecords 空)、或最近 10 条落在同一相对时间桶(超热)——
|
|
|
|
|
+ 对快车都返回其小上限快探,避免旧逻辑误判为最慢 600s,导致快售拼团在两次采集间隙整场漏采。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ span_sec (int): 10 条 purchaseRecords 最新与最老之间的秒数差。
|
|
|
|
|
+ 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
|
|
|
|
|
+ interval = span_sec // 3
|
|
|
|
|
+ return max(MIN_INTERVAL_SEC, min(cap, interval))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ==================== 订单判重 ====================
|
|
|
|
|
+def load_order_index(pool, code: str):
|
|
|
|
|
+ """从库里恢复某商品已入库订单的反推时间戳到内存索引(进程启动/新商品纳入时调)。
|
|
|
|
|
+
|
|
|
|
|
+ 进程重启会丢失内存索引,靠这步从 DB 恢复,避免重启后把老订单当新订单重复入库。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ pool: MySQL 连接池。
|
|
|
|
|
+ code (str): 商品编码。
|
|
|
|
|
+ """
|
|
|
|
|
+ rows = pool.select_all(
|
|
|
|
|
+ f"SELECT user_id, card_count, purchased_at_ts FROM {TABLE} WHERE product_code=%s", (code,)) or []
|
|
|
|
|
+ for uid, cc, ts in rows:
|
|
|
|
|
+ _order_idx.setdefault((code, str(uid), cc), []).append(int(ts))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def is_duplicate_order(code: str, uid: str, cc: int, ts: int, tol: int) -> bool:
|
|
|
|
|
+ """判断一条购买记录是否为已入库订单的相对时间漂移(非新订单)。
|
|
|
|
|
+
|
|
|
|
|
+ 在同 (code, uid, cc) 下的已入库时间戳里找是否有一个与 ts 相差 <= tol;
|
|
|
|
|
+ 有则视为同一笔订单的漂移(重复),无则视为新订单。tol 由该记录粒度动态给出
|
|
|
|
|
+ (见 dedup_tolerance),粗粒度用大容差以吸附漂移副本。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ code (str): 商品编码。
|
|
|
|
|
+ uid (str): 买家用户 ID。
|
|
|
|
|
+ cc (int): 购买份数。
|
|
|
|
|
+ ts (int): 本条记录反推的绝对购买时间戳(秒)。
|
|
|
|
|
+ tol (int): 本条记录的判重容差(秒),来自 dedup_tolerance。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ bool: True 表示是已有订单的漂移(应跳过),False 表示新订单(应入库)。
|
|
|
|
|
+ """
|
|
|
|
|
+ for ts_existing in _order_idx.get((code, str(uid), cc), []):
|
|
|
|
|
+ if abs(ts - ts_existing) <= tol:
|
|
|
|
|
+ return True
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def align_new_orders(prev_keys: list, curr_keys: list) -> tuple[int, bool]:
|
|
|
|
|
+ """用倒序滑动窗口的整体位移,求本轮从顶部新增的订单数。
|
|
|
|
|
+
|
|
|
|
|
+ purchaseRecords 是倒序滑动窗口:新单只从顶部推入、旧单整体下移。故存在最小 k,使
|
|
|
|
|
+ curr_keys[k:] 与 prev_keys[:len(curr_keys)-k] 逐元素 (user_id, card_count) 相等
|
|
|
|
|
+ (旧记录整体下移 k 位);则 curr_keys[:k] 即本轮新增。取最小 k(最大重叠)作最保守估计,
|
|
|
|
|
+ 残余错位由调用方的动态容差 + DB 唯一键二次兜底。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ prev_keys (list): 上次窗口的 (user_id, card_count) 序列(index0 最新);无上次窗口传 None/[]。
|
|
|
|
|
+ curr_keys (list): 本次窗口的 (user_id, card_count) 序列(index0 最新)。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ tuple[int, bool]: (新增条数 k, 是否可靠对齐)。窗口整体换新/无上次窗口无法对齐时,
|
|
|
|
|
+ 返回 (len(curr_keys), False),表示需退回容差兜底。
|
|
|
|
|
+ """
|
|
|
|
|
+ n = len(curr_keys)
|
|
|
|
|
+ if not prev_keys:
|
|
|
|
|
+ return n, False # 无上次窗口(重启首轮):交给兜底
|
|
|
|
|
+ for k in range(0, n + 1):
|
|
|
|
|
+ overlap = n - k
|
|
|
|
|
+ if overlap == 0:
|
|
|
|
|
+ return n, False # 与上次完全不重叠:整窗换新,疑似漏采
|
|
|
|
|
+ if overlap > len(prev_keys):
|
|
|
|
|
+ continue # 上次窗口不够长,尝试更大的 k
|
|
|
|
|
+ if curr_keys[k:] == prev_keys[:overlap]:
|
|
|
|
|
+ return k, True # 旧记录整体下移 k 位对齐成功,顶部 k 条为新增
|
|
|
|
|
+ return n, False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ==================== 商品源(复用 daily 免 token 采集 + 查库)====================
|
|
|
|
|
+def snapshot_onsale_progress(log, pool) -> int:
|
|
|
|
|
+ """采集当前在售商品的进度快照到 deca_onsale_product_progress_record(append-only,变化才写)。
|
|
|
|
|
+
|
|
|
|
|
+ 每次 get_onsale_products 更新完 deca_onsale_product_record 后调一次:先从 progress 表拿每个
|
|
|
|
|
+ 商品的最新 sold_count 做基线(一次 JOIN 一次性拿全),跟 deca_onsale_product_record 当前值
|
|
|
|
|
+ 对比,**sold_count 变化 或 从未记录过的商品** 才 INSERT 一行——避免"没卖动"的团重复占位。
|
|
|
|
|
+
|
|
|
|
|
+ 与 deca_onsale_product_daily_record(每日单点)互补:本表是分钟级细粒度时间序列,供后续
|
|
|
|
|
+ 画进度曲线、算售卖速度、找热销时段等统计。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ log: 日志对象。
|
|
|
|
|
+ pool: MySQL 连接池。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ int: 本轮写入的新快照行数。
|
|
|
|
|
+ """
|
|
|
|
|
+ # 1) 基线:progress 表每个商品的最新一条 sold_count(表可能为空 → baseline={})
|
|
|
|
|
+ baseline_rows = pool.select_all(
|
|
|
|
|
+ f"SELECT p.product_code, p.sold_count "
|
|
|
|
|
+ f"FROM {PROGRESS_TABLE} p "
|
|
|
|
|
+ f"INNER JOIN (SELECT product_code, MAX(captured_at) AS max_ts "
|
|
|
|
|
+ f" FROM {PROGRESS_TABLE} GROUP BY product_code) t "
|
|
|
|
|
+ f" ON p.product_code=t.product_code AND p.captured_at=t.max_ts") or []
|
|
|
|
|
+ baseline = {code: sold for code, sold in baseline_rows}
|
|
|
|
|
+
|
|
|
|
|
+ # 2) 本轮 onsale 表里所有在售商品的当前状态(get_onsale_products 刚 upsert 过)
|
|
|
|
|
+ curr = pool.select_all(
|
|
|
|
|
+ f"SELECT product_code, merchant_user_id, sold_count, available_stock, card_count, unit_price "
|
|
|
|
|
+ f"FROM {ONSALE_TABLE} WHERE is_on_sale=1") or []
|
|
|
|
|
+
|
|
|
|
|
+ # 3) Python 对比:sold_count 与基线不同 或 从未记录 → 加入待插入
|
|
|
|
|
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
+ rows_to_insert = []
|
|
|
|
|
+ for code, mid, sold, avail, card, up in curr:
|
|
|
|
|
+ if code is None:
|
|
|
|
|
+ continue
|
|
|
|
|
+ if code in baseline and baseline[code] == sold:
|
|
|
|
|
+ continue # 未变化,跳过
|
|
|
|
|
+ pct = None
|
|
|
|
|
+ if card and sold is not None:
|
|
|
|
|
+ try:
|
|
|
|
|
+ pct = round(sold * 100.0 / card, 2)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pct = None
|
|
|
|
|
+ rows_to_insert.append({
|
|
|
|
|
+ "product_code": code, "merchant_user_id": mid,
|
|
|
|
|
+ "sold_count": sold, "available_stock": avail, "card_count": card,
|
|
|
|
|
+ "progress_pct": pct, "unit_price": up, "captured_at": now,
|
|
|
|
|
+ })
|
|
|
|
|
+ if rows_to_insert:
|
|
|
|
|
+ pool.insert_many(table=PROGRESS_TABLE, data_list=rows_to_insert, ignore=False)
|
|
|
|
|
+ log.info(f"[进度快照] 变化写入 {len(rows_to_insert)} 条(当前在售 {len(curr)} 个 / 基线 {len(baseline)} 个)")
|
|
|
|
|
+ return len(rows_to_insert)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def ingest_onsale(log, pool, discover: bool):
|
|
|
|
|
+ """复用 daily 逻辑免 token 采集全站在售并落三张表。
|
|
|
|
|
+
|
|
|
|
|
+ 每轮都拉全站在售 get_onsale_products(写 deca_onsale_product_record + 每日快照 + 下架对账);
|
|
|
|
|
+ 紧跟一步 snapshot_onsale_progress 记录本轮进度变化(分钟级时间序列,供后续统计)。
|
|
|
|
|
+ discover=True 时额外做商家发现 get_shop_list 与今日新增商品补详情 fill_product_details——
|
|
|
|
|
+ 这两步变化慢,按 SHOP_DISCOVER_SEC 降频触发。各步独立 try/except,单步失败不拖垮整轮。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ log: 日志对象。
|
|
|
|
|
+ pool: MySQL 连接池。
|
|
|
|
|
+ discover (bool): 本轮是否附带商家发现 + 新增补详情(低频触发)。
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ n = get_onsale_products(log, pool)
|
|
|
|
|
+ log.info(f"[在售落库] 全站在售写入/更新 {n} 个")
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log.error(f"get_onsale_products error: {e}")
|
|
|
|
|
+ # 进度时间序列:紧跟 onsale 表更新之后跑(此时表里就是最新一轮的 sold_count/available_stock)
|
|
|
|
|
+ try:
|
|
|
|
|
+ snapshot_onsale_progress(log, pool)
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log.error(f"snapshot_onsale_progress error: {e}")
|
|
|
|
|
+ if discover:
|
|
|
|
|
+ try:
|
|
|
|
|
+ n = get_shop_list(log, pool)
|
|
|
|
|
+ log.info(f"[商家发现] 去重商家 {n} 个")
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log.error(f"get_shop_list error: {e}")
|
|
|
|
|
+ try:
|
|
|
|
|
+ n = fill_product_details(log, pool)
|
|
|
|
|
+ log.info(f"[补详情] 今日新增补详情 {n} 个")
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log.error(f"fill_product_details error: {e}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def fetch_on_sale_products(log, merchant_id: str, pool) -> list:
|
|
|
|
|
+ """从库里查某商家当前在售商品(deca_onsale_product_record,由 ingest_onsale 落库)。
|
|
|
|
|
+
|
|
|
|
|
+ 在售数据已由 ingest_onsale 复用 daily 的 home/search(免 token)落库,这里只查库拿该商家
|
|
|
|
|
+ is_on_sale=1 的商品,避免再打一次需 token 的 on-sale-list 接口。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ log: 日志对象。
|
|
|
|
|
+ merchant_id (str): 商家用户 ID。
|
|
|
|
|
+ pool: MySQL 连接池。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ list[dict]: 每项含 code / title / sold_count / card_count / available_stock。
|
|
|
|
|
+ """
|
|
|
|
|
+ rows = pool.select_all(
|
|
|
|
|
+ f"SELECT product_code, title, sold_count, card_count, available_stock, merchant_user_id, merchant_name "
|
|
|
|
|
+ f"FROM {ONSALE_TABLE} WHERE merchant_user_id=%s AND is_on_sale=1", (merchant_id,)) or []
|
|
|
|
|
+ out = []
|
|
|
|
|
+ for pc, title, sold, cc, stock, mid, mname in rows:
|
|
|
|
|
+ out.append({
|
|
|
|
|
+ "code": pc,
|
|
|
|
|
+ "title": title,
|
|
|
|
|
+ "sold_count": sold,
|
|
|
|
|
+ "card_count": cc,
|
|
|
|
|
+ "available_stock": stock,
|
|
|
|
|
+ "merchant_user_id": mid,
|
|
|
|
|
+ "merchant_name": mname,
|
|
|
|
|
+ })
|
|
|
|
|
+ return out
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ==================== 单商品采集 ====================
|
|
|
|
|
+def build_row(rec: dict, code: str, now_ts: int, now_dt: str, meta: dict) -> dict | None:
|
|
|
|
|
+ """把一条 purchaseRecords 项映射为 deca_buy_record 行字典。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ rec (dict): 单条 purchaseRecords 项。
|
|
|
|
|
+ code (str): 商品编码。
|
|
|
|
|
+ now_ts (int): 本轮采集时刻的秒级时间戳。
|
|
|
|
|
+ now_dt (str): now_ts 对应的 datetime 文本。
|
|
|
|
|
+ meta (dict): 该商品冗余描述,含 title / merchant_user_id / merchant_name(来自监控队列/查库),随行入库便于查看。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ dict | None: 行字典;缺 userId 时返回 None。
|
|
|
|
|
+ """
|
|
|
|
|
+ uid = rec.get("userId")
|
|
|
|
|
+ if not uid:
|
|
|
|
|
+ return None
|
|
|
|
|
+ pat_text = rec.get("purchasedAt") or ""
|
|
|
|
|
+ pat_ts = parse_relative_ago(pat_text, now_ts)
|
|
|
|
|
+ return {
|
|
|
|
|
+ "product_code": code,
|
|
|
|
|
+ "merchant_user_id": meta.get("merchant_user_id"),
|
|
|
|
|
+ "merchant_name": meta.get("merchant_name"),
|
|
|
|
|
+ "title": meta.get("title"),
|
|
|
|
|
+ "user_id": str(uid),
|
|
|
|
|
+ "nickname": rec.get("nickname"),
|
|
|
|
|
+ "card_count": rec.get("cardCount"),
|
|
|
|
|
+ "purchased_at_text": pat_text,
|
|
|
|
|
+ "purchased_at_ts": pat_ts,
|
|
|
|
|
+ "purchased_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(pat_ts)),
|
|
|
|
|
+ "first_seen_at": now_dt,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def poll_product(log, pool, code: str, meta: dict) -> tuple:
|
|
|
|
|
+ """对单个商品拉一次详情、判售卖是否结束、入库、算 span。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ log: 日志对象。
|
|
|
|
|
+ pool: MySQL 连接池。
|
|
|
|
|
+ code (str): 商品编码。
|
|
|
|
|
+ meta (dict): 该商品冗余描述(title/merchant_user_id/merchant_name),随每条购买记录入库便于查看。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ tuple[int, int, bool, str]: (span_sec, new_count, ended, end_reason) ——
|
|
|
|
|
+ 10 条时间跨度秒数、本轮新增入库条数、是否售卖结束、结束原因文本(未结束为空串)。
|
|
|
|
|
+ """
|
|
|
|
|
+ resp = throttled_do_request(log, DETAIL_PATH, {"code": code})
|
|
|
|
|
+ data = (resp or {}).get("data") or {}
|
|
|
|
|
+
|
|
|
|
|
+ now_ts = int(time.time())
|
|
|
|
|
+ ended, end_reason = is_sale_ended(data, now_ts) # 结束也先把本轮记录收尾入库,再由主循环移出
|
|
|
|
|
+
|
|
|
|
|
+ recs = data.get("purchaseRecords") or []
|
|
|
|
|
+ if not recs:
|
|
|
|
|
+ return 0, 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]
|
|
|
|
|
+ if not rows:
|
|
|
|
|
+ return 0, 0, ended, end_reason
|
|
|
|
|
+
|
|
|
|
|
+ # 计算 span:records[0] 最新、records[-1] 最老,均是相对时间反推
|
|
|
|
|
+ ts_new = rows[0]["purchased_at_ts"]
|
|
|
|
|
+ ts_old = rows[-1]["purchased_at_ts"]
|
|
|
|
|
+ span_sec = max(0, ts_new - ts_old)
|
|
|
|
|
+
|
|
|
|
|
+ # 主判重:倒序滑动窗口序列对齐——只有从顶部新增的 k 条才是本轮新订单
|
|
|
|
|
+ curr_keys = [(r["user_id"], r["card_count"]) for r in rows]
|
|
|
|
|
+ prev_keys = _last_window.get(code)
|
|
|
|
|
+ k, reliable = align_new_orders(prev_keys, curr_keys)
|
|
|
|
|
+
|
|
|
|
|
+ if reliable:
|
|
|
|
|
+ # 对齐可靠:顶部 k 条直接入库,不再走时间容差(否则会误杀同用户短间隔的多笔真实订单)
|
|
|
|
|
+ new_rows = rows[:k]
|
|
|
|
|
+ for row in new_rows:
|
|
|
|
|
+ _order_idx.setdefault((code, row["user_id"], row["card_count"]), []).append(row["purchased_at_ts"])
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 无法对齐(进程重启首轮 / 窗口整体换新):整窗退回动态容差 + DB 唯一键兜底
|
|
|
|
|
+ if prev_keys:
|
|
|
|
|
+ log.warning(f"[{code}] 窗口无重叠,疑似漏采(采集间隔内卖出≥{len(rows)}笔),建议提高采集频率")
|
|
|
|
|
+ new_rows = []
|
|
|
|
|
+ for row in rows:
|
|
|
|
|
+ tol = dedup_tolerance(row["purchased_at_text"])
|
|
|
|
|
+ if is_duplicate_order(code, row["user_id"], row["card_count"], row["purchased_at_ts"], tol):
|
|
|
|
|
+ continue
|
|
|
|
|
+ new_rows.append(row)
|
|
|
|
|
+ _order_idx.setdefault((code, row["user_id"], row["card_count"]), []).append(row["purchased_at_ts"])
|
|
|
|
|
+
|
|
|
|
|
+ if new_rows:
|
|
|
|
|
+ # DB 唯一键(含 purchased_at_ts)兜底防并发/异常重复;主判重靠窗口对齐已完成
|
|
|
|
|
+ pool.insert_many(table=TABLE, data_list=new_rows, ignore=True)
|
|
|
|
|
+
|
|
|
|
|
+ _last_window[code] = curr_keys # 更新窗口快照,供下轮对齐
|
|
|
|
|
+ return span_sec, len(new_rows), ended, end_reason
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ==================== 主循环 ====================
|
|
|
|
|
+def _refresh_monitored(log, pool, monitored: dict, merchant_id: str, now: float):
|
|
|
|
|
+ """刷新监控队列:新商品立即入队,下线商品移出队列。
|
|
|
|
|
+
|
|
|
|
|
+ WATCH_CODES 非空 → 只监控白名单里的 code(跳过在售发现,无下线逻辑)。
|
|
|
|
|
+ WATCH_CODES 为空 → 查库拿 merchant_id 的在售商品(由 ingest_onsale 落库),动态增删。
|
|
|
|
|
+ 每个商品首次纳入监控时,从库里恢复其订单去重索引(避免重启后重复入库)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ log: 日志对象。
|
|
|
|
|
+ pool: MySQL 连接池(用于恢复订单去重索引)。
|
|
|
|
|
+ monitored (dict): 监控状态字典 {code: {next_run_ts, interval, title, ...}},就地更新。
|
|
|
|
|
+ merchant_id (str): 商家 ID。
|
|
|
|
|
+ now (float): 当前时间戳。
|
|
|
|
|
+ """
|
|
|
|
|
+ if WATCH_CODES:
|
|
|
|
|
+ # 单/多商品白名单模式:只挂想盯的 code,不动其他
|
|
|
|
|
+ for code in WATCH_CODES:
|
|
|
|
|
+ if code in _ended_codes:
|
|
|
|
|
+ continue # 售卖已结束,不再重新纳入监控
|
|
|
|
|
+ if code not in monitored:
|
|
|
|
|
+ load_order_index(pool, code) # 从库恢复订单去重索引
|
|
|
|
|
+ monitored[code] = {
|
|
|
|
|
+ "next_run_ts": now,
|
|
|
|
|
+ "interval": MIN_INTERVAL_SEC,
|
|
|
|
|
+ "title": f"(白名单 code) {code}",
|
|
|
|
|
+ "sold_count": None,
|
|
|
|
|
+ "card_count": None,
|
|
|
|
|
+ "merchant_user_id": None,
|
|
|
|
|
+ "merchant_name": None,
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info(f"[+] 纳入监控 {code} | 白名单模式 | 已恢复历史订单索引")
|
|
|
|
|
+ log.info(f"当前监控商品数:{len(monitored)}(白名单模式,共 {len(WATCH_CODES)} 个 code)")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ products = fetch_on_sale_products(log, merchant_id, pool)
|
|
|
|
|
+ live_codes = {p["code"] for p in products}
|
|
|
|
|
+ # 新增:立即到点采
|
|
|
|
|
+ for p in products:
|
|
|
|
|
+ if p["code"] in _ended_codes:
|
|
|
|
|
+ continue # 售卖已结束,跳过(正常也会自然从在售列表消失)
|
|
|
|
|
+ if p["code"] not in monitored:
|
|
|
|
|
+ load_order_index(pool, p["code"]) # 从库恢复订单去重索引
|
|
|
|
|
+ monitored[p["code"]] = {
|
|
|
|
|
+ "next_run_ts": now,
|
|
|
|
|
+ "interval": MIN_INTERVAL_SEC,
|
|
|
|
|
+ "title": p["title"],
|
|
|
|
|
+ "sold_count": p["sold_count"],
|
|
|
|
|
+ "card_count": p["card_count"],
|
|
|
|
|
+ "merchant_user_id": p["merchant_user_id"],
|
|
|
|
|
+ "merchant_name": p["merchant_name"],
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info(f"[+] 纳入监控 {p['code']} | {p['title']} | 售 {p['sold_count']}/{p['card_count']}")
|
|
|
|
|
+ # 下线:移出
|
|
|
|
|
+ for code in list(monitored.keys()):
|
|
|
|
|
+ if code not in live_codes:
|
|
|
|
|
+ log.info(f"[-] 移出监控 {code} | {monitored[code].get('title')}")
|
|
|
|
|
+ del monitored[code]
|
|
|
|
|
+ _last_window.pop(code, None) # 下线同步清窗口快照,重新上架时按重启首轮兜底
|
|
|
|
|
+ log.info(f"当前监控商品数:{len(monitored)}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
|
|
|
|
|
+def main_task(log):
|
|
|
|
|
+ """常驻主流程:每分钟落全站在售(免 token) + 采本商家在售商品的购买记录。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ log: 日志对象。
|
|
|
|
|
+
|
|
|
|
|
+ Raises:
|
|
|
|
|
+ RuntimeError: 数据库连接池异常,触发外层每小时重试。
|
|
|
|
|
+ """
|
|
|
|
|
+ mode = f"白名单模式 codes={WATCH_CODES}" if WATCH_CODES else f"商家模式 merchant={MERCHANT_ID}"
|
|
|
|
|
+ log.info(f"购买记录+在售常驻采集启动 | {mode} | 购买记录频率 [{MIN_INTERVAL_SEC}s, {MAX_INTERVAL_SEC}s] "
|
|
|
|
|
+ f"| 在售落库每 {ONSALE_INGEST_SEC}s | 全链路免 token | 详情代理={core.USE_PROXY}")
|
|
|
|
|
+ pool = MySQLConnectionPool(log=log)
|
|
|
|
|
+ if not pool.check_pool_health():
|
|
|
|
|
+ log.error("数据库连接池异常")
|
|
|
|
|
+ raise RuntimeError("db pool 异常")
|
|
|
|
|
+
|
|
|
|
|
+ monitored: dict = {} # {code: {next_run_ts, interval, title, sold_count, card_count}}
|
|
|
|
|
+ last_ingest = 0.0 # 上次「在售落库 + 刷新监控」时刻
|
|
|
|
|
+ last_discover = 0.0 # 上次「商家发现 + 补详情」时刻(按 SHOP_DISCOVER_SEC 降频)
|
|
|
|
|
+
|
|
|
|
|
+ while True:
|
|
|
|
|
+ now = time.time()
|
|
|
|
|
+
|
|
|
|
|
+ # 1) 每 ONSALE_INGEST_SEC:商家模式先落全站在售(免token)再查库刷新监控;白名单模式只按固定 code 刷新
|
|
|
|
|
+ if now - last_ingest >= ONSALE_INGEST_SEC:
|
|
|
|
|
+ try:
|
|
|
|
|
+ if not WATCH_CODES:
|
|
|
|
|
+ discover = (now - last_discover >= SHOP_DISCOVER_SEC)
|
|
|
|
|
+ ingest_onsale(log, pool, discover)
|
|
|
|
|
+ if discover:
|
|
|
|
|
+ last_discover = now
|
|
|
|
|
+ _refresh_monitored(log, pool, monitored, MERCHANT_ID, now)
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log.error(f"在售落库/刷新监控异常: {e}")
|
|
|
|
|
+ last_ingest = now
|
|
|
|
|
+
|
|
|
|
|
+ # 2) 找到点的商品,取 next_run_ts 最早的一个跑
|
|
|
|
|
+ due_codes = [c for c, s in monitored.items() if s["next_run_ts"] <= now]
|
|
|
|
|
+ if not due_codes:
|
|
|
|
|
+ time.sleep(IDLE_SLEEP_SEC)
|
|
|
|
|
+ continue
|
|
|
|
|
+ code = min(due_codes, key=lambda c: monitored[c]["next_run_ts"])
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ span_sec, n_rows, ended, end_reason = poll_product(log, pool, code, monitored[code])
|
|
|
|
|
+ if ended:
|
|
|
|
|
+ # 售卖结束:本轮收尾记录已在 poll_product 入库,这里移出监控 + 拉黑,避免死循环重采固定的最后 10 条
|
|
|
|
|
+ _ended_codes.add(code)
|
|
|
|
|
+ _last_window.pop(code, None) # 清窗口快照,避免复活时误对齐
|
|
|
|
|
+ title = monitored[code]["title"]
|
|
|
|
|
+ monitored.pop(code, None)
|
|
|
|
|
+ log.info(f"[{code}] 售卖已结束({end_reason}),停止采集并移出监控 | {title}")
|
|
|
|
|
+ continue
|
|
|
|
|
+ interval = compute_next_interval(span_sec, monitored[code].get("card_count"))
|
|
|
|
|
+ monitored[code]["next_run_ts"] = time.time() + interval
|
|
|
|
|
+ monitored[code]["interval"] = interval
|
|
|
|
|
+ log.info(f"[{code}] 响应 {n_rows} 条 | 10条跨度 {span_sec}s | 下次 {interval}s 后 | {monitored[code]['title']}")
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log.error(f"[{code}] 采集失败: {e}")
|
|
|
|
|
+ # 失败退避:60s 后重试;避免异常商品阻塞全局
|
|
|
|
|
+ monitored[code]["next_run_ts"] = time.time() + 60
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def schedule_task():
|
|
|
|
|
+ """脚本入口:直接进入 main_task(其 tenacity retry 保证挂了每小时重试)。"""
|
|
|
|
|
+ main_task(log=logger)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ schedule_task()
|