Bläddra i källkod

feat(deca_spider): 添加买家重叠度分析与团队团购采集模块

- 新增 cohort_overlap_analysis.py,实现两拨买家按「卡种关键词 + 成交窗口」的重叠度对比
- 实现成交窗口锚定逻辑,支持查询与统计买家重叠率及Jaccard指数
- 写入Excel包含重叠度汇总与详细商品买家数
- 新增 deca_team_spider.py,采集随机团(选队随机与剩余随机)团队明细数据
- 设计多进程安全的Token续签与密码登录兜底机制,防止refreshToken踩踏
- 团队采集支持team-options与snapshot数据源,并计算实时已售金额
- 采集结果持久化至MySQL,更新商品表team_total_amount字段反映最新销售状态
charley 1 månad sedan
förälder
incheckning
91c2efbfca

+ 4 - 4
deca_spider/README.md

@@ -14,8 +14,8 @@
 | # | 脚本 | 干什么 | 启动命令 | 频率 | 用到的表 | 需 token |
 |---|---|---|---|---|---|---|
 | 1 ★ | `buy_record_analysis/buy_record_spider.py` | **在售数据主采集 + 购买记录**:①每分钟免 token 拉全站在售落库 + 每 60s 记进度快照;②采本商家购买记录(自适应频率、售罄/结束自动停采) | `python buy_record_analysis/buy_record_spider.py` | 常驻循环(自适应) | 写 `deca_onsale_shop_record`、`deca_onsale_product_record`、`deca_onsale_product_daily_record`、`deca_onsale_product_progress_record`、`deca_buy_record` | 否 |
-| 2 ★ | `on_sale/deca_team_spider.py` | **随机团总价采集**:选队随机抓 team-options 存队伍明细+算总价;剩余随机存 snapshot 实时算。转成剩余随机后 team-options 永久失效,故必须常驻不断采 | `python on_sale/deca_team_spider.py` | 常驻,每 5 分钟一轮 | 写 `deca_groupbuy_team_record`;更新 `deca_onsale_product_record.team_total_amount` | 部分(选队 team-options 需) |
-| 3 | `onsale_alert_spider.py` | **在售提醒(企微)**:监控 881226408,按上架时间提醒「窗口起点(20:30)后新上架」+ 进度过半 + 一车结束战报 | `python onsale_alert_spider.py` | 常驻,仅 20:30~次日 03:00 轮询 | 读写 `deca_onsale_alert_record`;读 `deca_buy_record`(结束战报的参与人数) | 否 |
+| 2 ★ | `deca_team_spider.py` | **随机团总价采集**:选队随机抓 team-options 存队伍明细+算总价;剩余随机存 snapshot 实时算。转成剩余随机后 team-options 永久失效,故必须常驻不断采 | `python deca_team_spider.py`(2026/08/20 由 on_sale/ 迁回根目录) | 常驻,每 5 分钟一轮 | 写 `deca_groupbuy_team_record`;更新 `deca_onsale_product_record.team_total_amount` | 部分(选队 team-options 需) |
+| 3 | `onsale_alert_spider.py` | **在售提醒(企微)**:监控 881226408,按上架时间提醒「窗口起点后新上架」+ 进度过半 + 一车结束战报 | `python onsale_alert_spider.py [HH:MM]`(默认 20:30,可传如 `17:00`) | 常驻,默认 20:30~次日 06:00 轮询(起点可传参) | 读写 `deca_onsale_alert_record`;读 `deca_buy_record`(结束战报的参与人数) | 否 |
 | 4 | `sold_daily_spider.py` | **已售拼团每日增量**:商家→已售→详情→拆卡报告+回放(连续无新页早停) | `python sold_daily_spider.py` | 常驻定时(每天 **08:00**,代码实际值;docstring 写的 03:00 已过时) | 写 `deca_shop_record`、`deca_product_record`、`deca_report_record`、`deca_groupbuy_team_record`、`deca_kami_record`(默认关) | **是** |
 | 5 | `on_sale/deca_on_sale_report.py` | **在售每日报告(企微 Excel)**:查库生成多 Sheet Excel 发群 | `python on_sale/deca_on_sale_report.py loop` | 定时 09:00/15:00/20:00/01:00 | 读 `deca_onsale_shop_record`、`deca_onsale_product_record`、`deca_onsale_product_daily_record` | 否 |
 | 6 | `stats/daily_report.py` | **已售每日报告(企微 Excel)**:成交时间窗 [昨天17点,今天3点] 的平台/商家汇总+明细 | `python stats/daily_report.py` | 定时(每天 09:10) | 读 `deca_product_record`、`deca_report_record`、`deca_buy_record`、`deca_onsale_product_progress_record` | 否 |
@@ -82,8 +82,8 @@
 
 | 脚本 | 状态 |
 |---|---|
-| `on_sale/deca_sold_spider.py`(`deca_sold_record`) | 旧在售版已售采集,已被根目录 `sold_daily_spider.py` 取代 |
-| `on_sale/deca_track.py`(`deca_track_record`) | 历史遗留追踪脚本 |
+| ~~`on_sale/deca_sold_spider.py`~~(`deca_sold_record`) | 旧在售版已售采集,已被根目录 `sold_daily_spider.py` 取代 → **2026/08/20 脚本已删除;表 `deca_sold_record` 本就不存在** |
+| ~~`on_sale/deca_track.py`~~(`deca_track_record`) | 历史遗留追踪脚本 → **2026/08/20 脚本已删除;表 `deca_track_record`(278 行) 已 DROP** |
 | `on_sale/km_spdier.py`(`deca_onsale_kami_record`) | 在售卡密清单采集,量大、默认不跑 |
 | `11.py` | 临时探测脚本(含旧 shareCode 样本),可删 |
 | `*_bak.py` | 备份文件 |

+ 222 - 0
deca_spider/buy_record_analysis/cohort_overlap_analysis.py

@@ -0,0 +1,222 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/20
+"""得卡 DECA · 两拨买家(按「卡种关键词 + 成交窗口」各圈一批)的重叠度对比。
+
+场景:
+    对比「昨天买 signature 的人」和「前天买 delight 的人」是否是同一批人——即按
+    (标题关键词, 成交窗口) 各自圈出一个去重买家集合 A、B,求 A∩B。可用于分析用户
+    在不同卡种 / 不同日期间的追逐迁移与忠诚度。
+
+口径说明(2026/08/20 按主公要求校准):
+    - 成交窗口按「卖货当晚」锚定:锚定日 X 的窗口 = [X 17:00:00, X+1 06:00:00]。
+      例如「昨天(08-19)」= [08-19 17:00, 08-20 06:00];「前天(08-18)」= [08-18 17:00, 08-19 06:00]。
+    - 用精确时间边界(purchased_at BETWEEN start AND end),白天 06:00~17:00 的成交不计入
+      (避免商品次日上午仍在卖的尾单被误算进来)。
+    - 关键词:对商品标题做 LOWER(title) LIKE '%kw%' 匹配(大小写不敏感)。
+    - 重叠度:|A∩B|,并分别给出占 A、占 B 的比例及 Jaccard(|A∩B|/|A∪B|)。
+
+魔都(881226408)是唯一采了真实购买记录的商家,故全库 deca_buy_record 天然是魔都口径。
+从 buy_record_analysis 目录运行:python cohort_overlap_analysis.py(读同目录 application.yml)。
+"""
+import os
+from datetime import date, datetime, timedelta
+
+from loguru import logger
+from openpyxl import Workbook
+from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
+
+from mysql_pool import MySQLConnectionPool
+
+# ==== 两拨人配置:label 仅用于展示,keyword=标题关键词,anchor=锚定日(卖货当晚那天) ====
+# 窗口 = [anchor 17:00:00, anchor+1 06:00:00]
+COHORT_A = {"label": "昨天 signature", "keyword": "signature", "anchor": "2026-08-19"}
+COHORT_B = {"label": "前天 delight",   "keyword": "delight",   "anchor": "2026-08-18"}
+OUT_PREFIX = "买家重叠对比"        # 输出文件名前缀,实际文件名后缀带当天日期
+
+
+def cohort_window(anchor: str) -> tuple[str, str]:
+    """把锚定日 X 转成成交窗口 [X 17:00:00, X+1 06:00:00]。
+
+    Args:
+        anchor (str): 锚定日 'YYYY-MM-DD'(卖货当晚那天)。
+
+    Returns:
+        tuple[str, str]: (窗口起, 窗口止),均为 'YYYY-MM-DD HH:MM:SS' 字符串。
+    """
+    d0 = datetime.strptime(anchor, "%Y-%m-%d")
+    start = d0.replace(hour=17, minute=0, second=0)
+    end = (d0 + timedelta(days=1)).replace(hour=6, minute=0, second=0)
+    return start.strftime("%Y-%m-%d %H:%M:%S"), end.strftime("%Y-%m-%d %H:%M:%S")
+
+
+def fetch_cohort(pool, keyword: str, start: str, end: str) -> tuple[set, list[tuple]]:
+    """按「标题关键词 + 成交窗口」圈出去重买家集合,并返回涉及的商品明细。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        keyword (str): 商品标题关键词(大小写不敏感子串匹配)。
+        start (str): 窗口起 'YYYY-MM-DD HH:MM:SS'(含)。
+        end (str): 窗口止 'YYYY-MM-DD HH:MM:SS'(含)。
+
+    Returns:
+        tuple[set, list[tuple]]: (买家 user_id 集合, [(product_code, title, 该商品买家数), ...])。
+    """
+    like = f"%{keyword.lower()}%"
+    users = {r[0] for r in pool.select_all("""
+        SELECT DISTINCT user_id FROM deca_buy_record
+        WHERE purchased_at >= %s AND purchased_at <= %s AND LOWER(title) LIKE %s
+    """, (start, end, like)) or []}
+    prods = pool.select_all("""
+        SELECT product_code, MAX(title), COUNT(DISTINCT user_id) AS u
+        FROM deca_buy_record
+        WHERE purchased_at >= %s AND purchased_at <= %s AND LOWER(title) LIKE %s
+        GROUP BY product_code ORDER BY u DESC
+    """, (start, end, like)) or []
+    return users, prods
+
+
+def analyze(pool, ca: dict, cb: dict) -> dict:
+    """计算两拨买家的重叠度指标。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        ca (dict): A 组配置,含 label/keyword/anchor。
+        cb (dict): B 组配置,含 label/keyword/anchor。
+
+    Returns:
+        dict: 含各组人数、商品数、成交窗口、交集人数、占 A/占 B 比例、Jaccard、
+            仅 A/仅 B 人数、商品明细。
+    """
+    sa, ea = cohort_window(ca["anchor"])
+    sb, eb = cohort_window(cb["anchor"])
+    ua, pa = fetch_cohort(pool, ca["keyword"], sa, ea)
+    ub, pb = fetch_cohort(pool, cb["keyword"], sb, eb)
+    inter = ua & ub
+    union = ua | ub
+    return {
+        "A": {**ca, "window": f"{sa} ~ {ea}", "n_users": len(ua), "n_prod": len(pa), "prods": pa},
+        "B": {**cb, "window": f"{sb} ~ {eb}", "n_users": len(ub), "n_prod": len(pb), "prods": pb},
+        "overlap": len(inter),
+        "pct_of_a": round(len(inter) / len(ua) * 100, 2) if ua else 0.0,
+        "pct_of_b": round(len(inter) / len(ub) * 100, 2) if ub else 0.0,
+        "jaccard": round(len(inter) / len(union) * 100, 2) if union else 0.0,
+        "only_a": len(ua - ub),
+        "only_b": len(ub - ua),
+    }
+
+
+def print_report(res: dict):
+    """把重叠度结果打印到控制台。
+
+    Args:
+        res (dict): analyze 的返回结果。
+    """
+    a, b = res["A"], res["B"]
+    print("=" * 74)
+    print(f"A 组:{a['label']}  [{a['window']}]")
+    print(f"     -> {a['n_prod']} 个商品 / {a['n_users']} 个去重买家")
+    print(f"B 组:{b['label']}  [{b['window']}]")
+    print(f"     -> {b['n_prod']} 个商品 / {b['n_users']} 个去重买家")
+    print("-" * 74)
+    print(f"两组重叠人数(A∩B)  : {res['overlap']} 人")
+    print(f"  占 A 组比例       : {res['pct_of_a']}%(A 里有多少人也在 B)")
+    print(f"  占 B 组比例       : {res['pct_of_b']}%(B 里有多少人也在 A)")
+    print(f"  Jaccard(交/并)    : {res['jaccard']}%")
+    print(f"  仅 A 独有         : {res['only_a']} 人")
+    print(f"  仅 B 独有         : {res['only_b']} 人")
+    print("=" * 74)
+
+
+# ---- Excel 样式 ----
+FONT_TITLE = Font(name="Microsoft YaHei", bold=True, size=13, color="1F2A44")
+FONT_HEADER = Font(name="Microsoft YaHei", bold=True, color="FFFFFF")
+FONT_CELL = Font(name="Microsoft YaHei", size=10)
+FILL_HEADER = PatternFill("solid", fgColor="4472C4")
+FILL_SUB = PatternFill("solid", fgColor="D9E1F2")
+ALIGN_C = Alignment(horizontal="center", vertical="center")
+ALIGN_L = Alignment(horizontal="left", vertical="center")
+_side = Side(style="thin", color="BFBFBF")
+BORDER = Border(left=_side, right=_side, top=_side, bottom=_side)
+
+
+def _prod_sheet(ws, title: str, prods: list[tuple]):
+    """在给定 sheet 写「商品编码 / 标题 / 买家数」明细表。
+
+    Args:
+        ws: openpyxl worksheet。
+        title (str): 顶部标题。
+        prods (list[tuple]): [(product_code, title, users), ...]。
+    """
+    ws.column_dimensions["A"].width = 18
+    ws.column_dimensions["B"].width = 62
+    ws.column_dimensions["C"].width = 10
+    ws.cell(1, 1, title).font = FONT_TITLE
+    for i, h in enumerate(["商品编码", "标题", "买家数"], 1):
+        c = ws.cell(2, i, h); c.font = FONT_HEADER; c.fill = FILL_HEADER
+        c.alignment = ALIGN_C; c.border = BORDER
+    r = 3
+    for pc, t, u in prods:
+        for i, v in enumerate([pc, t, u], 1):
+            c = ws.cell(r, i, v); c.font = FONT_CELL; c.border = BORDER
+            c.alignment = ALIGN_C if i != 2 else ALIGN_L
+        r += 1
+
+
+def export_excel(out: str, res: dict):
+    """导出重叠度对比 Excel(汇总 sheet + A/B 两组商品明细 sheet)。
+
+    Args:
+        out (str): 导出 xlsx 路径。
+        res (dict): analyze 的返回结果。
+    """
+    a, b = res["A"], res["B"]
+    wb = Workbook()
+    ws = wb.active
+    ws.title = "重叠汇总"
+    ws.column_dimensions["A"].width = 26
+    ws.column_dimensions["B"].width = 46
+    ws.cell(1, 1, "两拨买家重叠度对比").font = FONT_TITLE
+    rows = [
+        ("A 组", a["label"]),
+        ("A 组成交窗口", a["window"]),
+        ("A 组商品数 / 买家数", f"{a['n_prod']} 个商品 / {a['n_users']} 人"),
+        ("B 组", b["label"]),
+        ("B 组成交窗口", b["window"]),
+        ("B 组商品数 / 买家数", f"{b['n_prod']} 个商品 / {b['n_users']} 人"),
+        ("重叠人数(A∩B)", f"{res['overlap']} 人"),
+        ("占 A 组比例", f"{res['pct_of_a']}%"),
+        ("占 B 组比例", f"{res['pct_of_b']}%"),
+        ("Jaccard(交/并)", f"{res['jaccard']}%"),
+        ("仅 A 独有", f"{res['only_a']} 人"),
+        ("仅 B 独有", f"{res['only_b']} 人"),
+    ]
+    r = 2
+    for label, val in rows:
+        c1 = ws.cell(r, 1, label); c1.font = FONT_CELL; c1.fill = FILL_SUB; c1.border = BORDER
+        c2 = ws.cell(r, 2, val);   c2.font = FONT_CELL; c2.border = BORDER; c2.alignment = ALIGN_L
+        r += 1
+    ws.cell(r + 1, 1, "注:成交窗口按卖货当晚锚定 [X 17:00, X+1 06:00](精确时间边界,白天不计入);"
+                      "关键词对标题做大小写不敏感子串匹配;仅魔都(881226408)采了真实购买记录。").font = FONT_CELL
+
+    _prod_sheet(wb.create_sheet("A组商品明细"), f"A 组:{a['label']}  [{a['window']}]", a["prods"])
+    _prod_sheet(wb.create_sheet("B组商品明细"), f"B 组:{b['label']}  [{b['window']}]", b["prods"])
+    wb.save(out)
+
+
+def main():
+    """连库计算两拨买家重叠度,打印结果并导出 Excel。"""
+    pool = MySQLConnectionPool(log=logger)
+    if not pool.check_pool_health():
+        logger.error("数据库连接池异常")
+        return
+    res = analyze(pool, COHORT_A, COHORT_B)
+    print_report(res)
+    out = os.path.abspath(f"{OUT_PREFIX}_{date.today():%Y%m%d}.xlsx")
+    export_excel(out, res)
+    print("\n已生成 Excel:", out)
+
+
+if __name__ == "__main__":
+    main()

+ 274 - 0
deca_spider/buy_record_analysis/user_overlap_analysis.py

@@ -0,0 +1,274 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/20
+"""得卡 DECA · 指定商品买家与「之前每一天 / 之前全部合并」的用户重叠度分析。
+
+背景:
+    魔都(881226408)是唯一采了真实购买记录(deca_buy_record)的商家。给定一个目标商品,
+    它的买家(去重 user_id)构成一个集合,需要衡量这批人里有多少是「回头客」——即在该商品
+    成交业务日之前的每一天、以及之前全部天合并里,也出现过。
+
+口径说明:
+    - 业务日:与每日报告一致,成交窗口 [D-1 17:00, D 06:00]。等价地按 user 购买时间
+      business_day = DATE(purchased_at + INTERVAL 7 HOUR) 归日(把 17:00 之后算次日,
+      06:00 之前算当日,白天成交极少也一并归入当日),保证每条记录唯一归属一天、无缝隙。
+    - 目标商品买家集:该 product_code 下全部去重 user_id(不再按窗口截断,这批人就是「这些用户」)。
+    - 「之前的每一天」:目标商品业务日之前的每个业务日 d,该日全部魔都购买用户去重集。
+    - 「之前全部合并」:之前所有业务日的用户并集。
+    - 重叠度:以目标商品买家数为分母,overlap / 目标人数(这批人里有多少也在那一天/之前买过)。
+
+从 buy_record_analysis 目录运行:python user_overlap_analysis.py(cwd 读同目录 application.yml)。
+"""
+import os
+from datetime import date
+
+from loguru import logger
+from openpyxl import Workbook
+from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
+
+from mysql_pool import MySQLConnectionPool
+
+TARGET_PRODUCT = "GB26081987545"   # 目标商品编码
+OUT_PREFIX = "用户重叠度分析"        # 输出文件名前缀,实际文件名后缀带目标商品与当天日期
+
+
+def _business_day_expr(col: str) -> str:
+    """构造「购买时间 -> 业务日」的 SQL 表达式(17:00 分界,等价成交窗口)。
+
+    Args:
+        col (str): 购买时间列名(如 purchased_at)。
+
+    Returns:
+        str: 形如 DATE(col + INTERVAL 7 HOUR) 的 SQL 片段。
+    """
+    return f"DATE({col} + INTERVAL 7 HOUR)"
+
+
+def fetch_user_day_pairs(pool) -> list[tuple[str, date]]:
+    """拉取全库(魔都)去重的(买家user_id, 业务日)成员关系。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        list[tuple[str, date]]: 每项 (user_id, business_day),已按 (user_id, 业务日) 去重。
+    """
+    sql = f"""
+    SELECT DISTINCT user_id, {_business_day_expr('purchased_at')} AS bday
+    FROM deca_buy_record
+    WHERE purchased_at IS NOT NULL
+    """
+    return pool.select_all(sql) or []
+
+
+def fetch_target_meta(pool, product_code: str) -> dict:
+    """取目标商品的基本信息与其买家集合、成交业务日。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        product_code (str): 目标商品编码。
+
+    Returns:
+        dict: 含 title/merchant/买家集合 users(set)/买家数/业务日 bday/购买时间起止。
+    """
+    info = pool.select_all(f"""
+        SELECT MAX(title), MAX(merchant_user_id), MAX(merchant_name),
+               MIN(purchased_at), MAX(purchased_at),
+               COUNT(DISTINCT user_id),
+               MAX({_business_day_expr('purchased_at')})
+        FROM deca_buy_record WHERE product_code = %s
+    """, (product_code,))[0]
+    users = {r[0] for r in pool.select_all(
+        "SELECT DISTINCT user_id FROM deca_buy_record WHERE product_code = %s", (product_code,)) or []}
+    return {
+        "title": info[0], "merchant_id": info[1], "merchant": info[2],
+        "buy_from": info[3], "buy_to": info[4],
+        "user_count": int(info[5] or 0), "users": users,
+        "bday": info[6],
+    }
+
+
+def analyze(pool, product_code: str) -> tuple[dict, list[dict], dict]:
+    """计算目标商品买家与「之前每一天」及「之前全部合并」的用户重叠度。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        product_code (str): 目标商品编码。
+
+    Returns:
+        tuple[dict, list[dict], dict]: (target, per_day, union_prev)。
+            target   : 目标商品元信息(fetch_target_meta 结果,去掉 users 集合)。
+            per_day  : 之前每个业务日一项,含 业务日/当天人数/重叠人数/占目标%/占当天%。
+            union_prev: 之前全部合并 含 合并去重人数/重叠人数/占目标%/新客数/新客占目标%。
+    """
+    target = fetch_target_meta(pool, product_code)
+    target_users = target["users"]
+    n_target = len(target_users)
+    tgt_bday = target["bday"]
+
+    # 按业务日聚合用户集合(仅取目标商品业务日「之前」的天)
+    day_users: dict[date, set] = {}
+    for uid, bday in fetch_user_day_pairs(pool):
+        if bday < tgt_bday:                      # 只要「之前」的天
+            day_users.setdefault(bday, set()).add(uid)
+
+    # 逐日重叠
+    per_day = []
+    for bday in sorted(day_users):
+        du = day_users[bday]
+        ov = len(target_users & du)
+        per_day.append({
+            "业务日": bday,
+            "当天购买人数": len(du),
+            "重叠人数": ov,
+            "占目标买家%": round(ov / n_target * 100, 2) if n_target else 0.0,
+            "占当天%": round(ov / len(du) * 100, 2) if du else 0.0,
+        })
+
+    # 之前全部合并
+    union_users = set().union(*day_users.values()) if day_users else set()
+    ov_union = len(target_users & union_users)
+    new_users = n_target - ov_union              # 目标买家里之前从没出现过的「新客」
+    union_prev = {
+        "之前合并去重人数": len(union_users),
+        "重叠人数(回头客)": ov_union,
+        "占目标买家%": round(ov_union / n_target * 100, 2) if n_target else 0.0,
+        "新客数": new_users,
+        "新客占目标%": round(new_users / n_target * 100, 2) if n_target else 0.0,
+        "之前天数": len(day_users),
+    }
+
+    target.pop("users", None)                    # 集合不外传,避免污染返回体
+    return target, per_day, union_prev
+
+
+# ---- 控制台打印 ----
+def print_report(product_code: str, target: dict, per_day: list[dict], union_prev: dict):
+    """把两组重叠度结果打印到控制台。
+
+    Args:
+        product_code (str): 目标商品编码。
+        target (dict): 目标商品元信息。
+        per_day (list[dict]): 逐日重叠结果。
+        union_prev (dict): 之前全部合并结果。
+    """
+    print("=" * 78)
+    print(f"目标商品: {product_code}")
+    print(f"  标题   : {target['title']}")
+    print(f"  商家   : {target['merchant']}(ID {target['merchant_id']})  业务日: {target['bday']}")
+    print(f"  成交时段: {target['buy_from']} ~ {target['buy_to']}")
+    print(f"  买家数(去重 user_id): {target['user_count']} 人  <- 重叠度分母")
+    print("=" * 78)
+
+    print("\n【数据一】与「之前的每一天」逐日重叠")
+    print(f"{'业务日':<12}{'当天人数':>8}{'重叠人数':>8}{'占目标%':>9}{'占当天%':>9}")
+    print("-" * 48)
+    for d in per_day:
+        print(f"{str(d['业务日']):<12}{d['当天购买人数']:>8}{d['重叠人数']:>8}"
+              f"{d['占目标买家%']:>8.2f}%{d['占当天%']:>8.2f}%")
+
+    print("\n【数据二】与「之前全部天合并」的重叠")
+    print(f"  之前业务日数        : {union_prev['之前天数']} 天")
+    print(f"  之前合并去重买家数  : {union_prev['之前合并去重人数']} 人")
+    print(f"  重叠人数(回头客)    : {union_prev['重叠人数(回头客)']} 人 "
+          f"(占目标买家 {union_prev['占目标买家%']}%)")
+    print(f"  新客数(之前没出现过): {union_prev['新客数']} 人 "
+          f"(占目标买家 {union_prev['新客占目标%']}%)")
+    print("=" * 78)
+
+
+# ---- Excel 导出 ----
+FONT_TITLE = Font(name="Microsoft YaHei", bold=True, size=13, color="1F2A44")
+FONT_HEADER = Font(name="Microsoft YaHei", bold=True, color="FFFFFF")
+FONT_CELL = Font(name="Microsoft YaHei", size=10)
+FILL_HEADER = PatternFill("solid", fgColor="4472C4")
+FILL_SUB = PatternFill("solid", fgColor="D9E1F2")
+ALIGN_C = Alignment(horizontal="center", vertical="center")
+ALIGN_L = Alignment(horizontal="left", vertical="center")
+_side = Side(style="thin", color="BFBFBF")
+BORDER = Border(left=_side, right=_side, top=_side, bottom=_side)
+
+
+def export_excel(out: str, product_code: str, target: dict, per_day: list[dict], union_prev: dict):
+    """导出重叠度分析 Excel(单 sheet:目标信息 + 逐日重叠表 + 合并重叠汇总)。
+
+    Args:
+        out (str): 导出 xlsx 路径。
+        product_code (str): 目标商品编码。
+        target (dict): 目标商品元信息。
+        per_day (list[dict]): 逐日重叠结果。
+        union_prev (dict): 之前全部合并结果。
+    """
+    wb = Workbook()
+    ws = wb.active
+    ws.title = "用户重叠度"
+    widths = [16, 12, 12, 12, 12]
+    for i, w in enumerate(widths, 1):
+        ws.column_dimensions[chr(64 + i)].width = w
+
+    r = 1
+    ws.cell(r, 1, f"用户重叠度分析 · {product_code}").font = FONT_TITLE
+    r += 1
+    for label, val in [("标题", target["title"]),
+                       ("商家", f"{target['merchant']}(ID {target['merchant_id']})"),
+                       ("业务日", str(target["bday"])),
+                       ("成交时段", f"{target['buy_from']} ~ {target['buy_to']}"),
+                       ("买家数(去重,分母)", target["user_count"])]:
+        c1 = ws.cell(r, 1, label); c1.font = FONT_CELL; c1.fill = FILL_SUB; c1.border = BORDER
+        c2 = ws.cell(r, 2, val);   c2.font = FONT_CELL; c2.border = BORDER; c2.alignment = ALIGN_L
+        ws.merge_cells(start_row=r, start_column=2, end_row=r, end_column=5)
+        r += 1
+    r += 1
+
+    # 数据一:逐日重叠
+    ws.cell(r, 1, "【数据一】与之前每一天逐日重叠").font = FONT_TITLE
+    r += 1
+    heads = ["业务日", "当天购买人数", "重叠人数", "占目标买家%", "占当天%"]
+    for i, h in enumerate(heads, 1):
+        c = ws.cell(r, i, h); c.font = FONT_HEADER; c.fill = FILL_HEADER
+        c.alignment = ALIGN_C; c.border = BORDER
+    r += 1
+    for d in per_day:
+        vals = [str(d["业务日"]), d["当天购买人数"], d["重叠人数"], d["占目标买家%"], d["占当天%"]]
+        for i, v in enumerate(vals, 1):
+            c = ws.cell(r, i, v); c.font = FONT_CELL; c.border = BORDER
+            c.alignment = ALIGN_L if i == 1 else ALIGN_C
+        r += 1
+    r += 1
+
+    # 数据二:合并重叠
+    ws.cell(r, 1, "【数据二】与之前全部天合并的重叠").font = FONT_TITLE
+    r += 1
+    for label, val in [("之前业务日数", f"{union_prev['之前天数']} 天"),
+                       ("之前合并去重买家数", f"{union_prev['之前合并去重人数']} 人"),
+                       ("重叠人数(回头客)", f"{union_prev['重叠人数(回头客)']} 人"),
+                       ("回头客占目标买家%", f"{union_prev['占目标买家%']}%"),
+                       ("新客数(之前没出现过)", f"{union_prev['新客数']} 人"),
+                       ("新客占目标买家%", f"{union_prev['新客占目标%']}%")]:
+        c1 = ws.cell(r, 1, label); c1.font = FONT_CELL; c1.fill = FILL_SUB; c1.border = BORDER
+        c2 = ws.cell(r, 2, val);   c2.font = FONT_CELL; c2.border = BORDER; c2.alignment = ALIGN_L
+        ws.merge_cells(start_row=r, start_column=2, end_row=r, end_column=5)
+        r += 1
+    r += 1
+    ws.cell(r, 1, "注:业务日按成交窗口[D-1 17:00, D 06:00]归日;重叠度分母=目标商品去重买家数;"
+                  "回头客=在之前任一天也买过的人;新客=之前从未出现过的人。").font = FONT_CELL
+
+    wb.save(out)
+
+
+def main():
+    """连库计算目标商品的用户重叠度,打印两组数据并导出 Excel。"""
+    pool = MySQLConnectionPool(log=logger)
+    if not pool.check_pool_health():
+        logger.error("数据库连接池异常")
+        return
+    target, per_day, union_prev = analyze(pool, TARGET_PRODUCT)
+    print_report(TARGET_PRODUCT, target, per_day, union_prev)
+    out = os.path.abspath(f"{OUT_PREFIX}_{TARGET_PRODUCT}_{date.today():%Y%m%d}.xlsx")
+    export_excel(out, TARGET_PRODUCT, target, per_day, union_prev)
+    print("\n已生成 Excel:", out)
+
+
+if __name__ == "__main__":
+    main()

+ 147 - 9
deca_spider/deca_sold_core.py

@@ -21,6 +21,7 @@ import os
 import time
 import json
 import hashlib
+from contextlib import contextmanager
 from datetime import datetime
 from decimal import Decimal, ROUND_HALF_UP
 
@@ -33,7 +34,13 @@ BASE = "https://api.decalive.com"  # 业务域名(来源:抓包)
 SALT = "biu_card_nbclass"          # 签名固定 salt(逆向自 mf/c0.java 硬编码常量)
 
 USE_PROXY = False     # 代理开关:默认直连,遇 IP 风控再置 True
-TOKEN_FILE = "token.json"       # token 持久化文件(进程重启复用),仅靠 refreshToken 续期
+TOKEN_FILE = "token.json"       # token 持久化文件(进程重启复用),refreshToken 续期为主、密码登录兜底
+
+# 密码登录兜底凭证(敏感信息,勿外传/勿提交公开仓库;换账号改此三项)
+LOGIN_COUNTRY_CODE = "86"
+LOGIN_PHONE = "13014617614"
+LOGIN_PASSWORD = "pass2022"
+MAX_LOGIN_ATTEMPTS = 5          # 登录兜底熔断:累计登录失败达此次数后不再请求登录接口,避免账号异常时狂调
 
 # 公共请求头(来源:抓包;currentTime/signature/Authorization 每次动态补)
 BASE_HEADERS = {
@@ -48,6 +55,7 @@ BASE_HEADERS = {
 
 # access token 缓存:access=当前令牌,refresh=续签令牌,exp=过期秒级时间戳
 _TOKEN = {"access": None, "refresh": None, "exp": 0}
+_login_fail_count = 0           # 密码登录累计失败次数(成功清零);达 MAX_LOGIN_ATTEMPTS 触发熔断
 
 # 已售流程表名(无前缀)
 T_SHOP = "deca_shop_record"
@@ -195,6 +203,78 @@ def do_get(log, path: str, params: dict = None, need_auth: bool = False) -> dict
 
 
 # ==================== 登录 / Token 管理 ====================
+# 跨进程 token 锁:多进程(team_spider / sold_daily / onsale_alert / buy_record 等)共用同一份
+# token.json,而得卡 refreshToken 是「一次性轮换」的(续签成功即发新 refresh、旧的立即作废)。
+# 若各进程并发续签就会互相踩废 refresh,被服务端判「当前账号长期未登录」。用一把独立 .lock 文件锁
+# 把「读→续签→写回」整段串行化,任一时刻只允许一个进程轮换 refresh(2026/08/20 根治踩踏)。
+_LOCK_FILE = TOKEN_FILE + ".lock"
+
+try:
+    import msvcrt  # Windows 原生文件区域锁:进程崩溃时由 OS 自动释放,无残留锁导致的死锁风险
+
+    def _lock_acquire(fd):
+        """非阻塞抢占锁文件第 1 字节;被占用时抛 OSError。"""
+        fd.seek(0)
+        msvcrt.locking(fd.fileno(), msvcrt.LK_NBLCK, 1)
+
+    def _lock_release(fd):
+        """释放锁文件第 1 字节。"""
+        fd.seek(0)
+        msvcrt.locking(fd.fileno(), msvcrt.LK_UNLCK, 1)
+except ImportError:                          # 非 Windows:退化用 POSIX flock
+    try:
+        import fcntl
+
+        def _lock_acquire(fd):
+            """非阻塞 flock 独占锁;被占用时抛 OSError。"""
+            fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+
+        def _lock_release(fd):
+            """释放 flock。"""
+            fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
+    except ImportError:                      # 两种锁都不可用(极罕见):无锁,靠 ensure_token 的 reload 兜底自愈
+        _lock_acquire = _lock_release = None
+
+
+@contextmanager
+def _token_lock(log=None, timeout=30):
+    """跨进程互斥锁上下文:串行化 token 的「读→续签→写回」,防多进程踩废 refreshToken。
+
+    Args:
+        log (optional): 日志对象,拿不到锁超时时告警。Defaults to None。
+        timeout (int, optional): 获取锁的最长自旋等待秒数。Defaults to 30。
+
+    Yields:
+        None: 持锁期间执行临界区(load_token / refresh_token / save_token)。
+    """
+    if _lock_acquire is None:                # 无可用锁原语:直接放行,退化为无锁(配合 reload 仍能自愈)
+        yield
+        return
+    f = open(_LOCK_FILE, "a+")
+    start = time.time()
+    got = False
+    try:
+        while True:
+            try:
+                _lock_acquire(f)
+                got = True
+                break
+            except OSError:                  # 锁被别的进程持有
+                if time.time() - start > timeout:
+                    # 极端兜底:等超时不再死等(续签仅 ~0.5s,正常永不触发),放行避免拖垮业务
+                    (log or logger).warning(f"获取 token 锁超时({timeout}s),本次跳过锁保护")
+                    break
+                time.sleep(0.2)
+        yield
+    finally:
+        if got:
+            try:
+                _lock_release(f)
+            except OSError:
+                pass
+        f.close()
+
+
 def save_token():
     """把当前 _TOKEN(access/refresh/exp) 持久化到 TOKEN_FILE。"""
     try:
@@ -228,6 +308,7 @@ def refresh_token(log) -> bool:
     Returns:
         bool: 续签成功返回 True,否则 False。
     """
+    global _login_fail_count
     if not _TOKEN.get("refresh"):
         return False
     try:
@@ -244,6 +325,7 @@ def refresh_token(log) -> bool:
             _TOKEN["refresh"] = data["refreshToken"]
         _TOKEN["exp"] = time.time() + expires_in
         save_token()
+        _login_fail_count = 0   # 续签成功=账号健康,重置密码登录熔断计数
         log.info(f"token 续签成功,有效 {expires_in}s")
         return True
     except Exception as e:
@@ -251,10 +333,58 @@ def refresh_token(log) -> bool:
         return False
 
 
+def login(log) -> bool:
+    """密码登录兜底:refreshToken 续签失效时,用账号密码换取全新一套 token 并持久化。
+
+    带熔断保护:累计登录失败达 MAX_LOGIN_ATTEMPTS 次后直接返回 False、不再请求登录接口,
+    避免账号异常(风控/验证码/密码错)时无限狂调登录;登录成功即清零计数。仅在 ensure_token
+    的跨进程锁内调用,保证多进程同一时刻至多一个进程登录、不互相顶号。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        bool: 登录成功并写入有效 token 返回 True;失败或已熔断返回 False。
+    """
+    global _login_fail_count
+    if _login_fail_count >= MAX_LOGIN_ATTEMPTS:
+        log.error(f"密码登录已连续失败 {_login_fail_count} 次达上限({MAX_LOGIN_ATTEMPTS}),"
+                  "熔断不再登录,请人工检查账号(密码/风控/验证码)")
+        return False
+    body = {"countryCode": LOGIN_COUNTRY_CODE, "password": LOGIN_PASSWORD, "phone": LOGIN_PHONE}
+    try:
+        resp = do_request(log, "/api/v1/app/auth/password/login", body, need_auth=False)
+        data = (resp or {}).get("data") or {}
+        access = data.get("accessToken")
+        refresh = data.get("refreshToken")
+        if not access or not refresh:
+            _login_fail_count += 1
+            log.warning(f"密码登录未返回有效 token(第 {_login_fail_count}/{MAX_LOGIN_ATTEMPTS} 次失败): "
+                        f"{resp.get('msg') if resp else None}")
+            return False
+        expires_in = int(data.get("expiresIn") or 900)
+        _TOKEN["access"] = access
+        _TOKEN["refresh"] = refresh
+        _TOKEN["exp"] = time.time() + expires_in
+        save_token()
+        _login_fail_count = 0
+        log.info(f"密码登录成功,access 有效 {expires_in}s(refreshToken 已更新)")
+        return True
+    except Exception as e:
+        _login_fail_count += 1
+        log.warning(f"密码登录异常(第 {_login_fail_count}/{MAX_LOGIN_ATTEMPTS} 次失败): {e}")
+        return False
+
+
 def ensure_token(log) -> str:
-    """返回有效 access token:缓存有效→直接用;临期或缺失→用 refreshToken 续期。
+    """返回有效 access token:缓存有效→免锁直接用;临期或缺失→加跨进程锁读磁盘最新 refresh 续期。
 
-    仅走 token 续签,不再有账号密码登录兜底;refreshToken 失效时抛错,需更新 token.json。
+    多进程共用同一份 token.json 且 refreshToken 一次性轮换,故续签必须串行 + 每次读磁盘最新:
+        1) 内存 access 未临期(距过期 >60s)→ 走免锁快路径直接返回(占绝大多数调用,性能无损)。
+        2) 临期/缺失 → 抢跨进程锁;锁内先 load_token() 拾取磁盘最新 refresh(可能别的进程刚轮换出来),
+           再双检 access(可能等锁期间别的进程已续好,直接复用、不重复续签),最后才 refresh_token()。
+    如此 N 个进程同时过期也只续签 1 次、refresh 只轮换 1 次,从根上杜绝互相踩废(2026/08/20 根治)。
+    续签失败再走密码登录兜底(login,带 MAX_LOGIN_ATTEMPTS 次熔断,2026/08/20 加);两者都失败才抛错。
 
     Args:
         log: 日志对象。
@@ -265,14 +395,22 @@ def ensure_token(log) -> str:
     Raises:
         RuntimeError: refreshToken 缺失或续期失败时抛出。
     """
+    # 快路径:内存 access 仍在有效期 → 免锁直接返回(绝大多数调用走这里)
     if _TOKEN.get("access") and time.time() < _TOKEN["exp"] - 60:
         return _TOKEN["access"]
-    if not _TOKEN.get("refresh"):
-        load_token()
-    if _TOKEN.get("refresh") and refresh_token(log):
-        return _TOKEN["access"]
-    raise RuntimeError("无法获取 access token:refreshToken 缺失或失效。"
-                       "请在 APP 重新登录后更新 token.json(设备已 root,跑 get_token.py 直读本地抓取)。")
+    with _token_lock(log):
+        load_token()  # 锁内读磁盘最新 refresh:常驻进程不再用僵死的内存旧值
+        # 双检:等锁期间别的进程可能已续好新 access,直接复用,避免重复续签+多余轮换
+        if _TOKEN.get("access") and time.time() < _TOKEN["exp"] - 60:
+            return _TOKEN["access"]
+        if _TOKEN.get("refresh") and refresh_token(log):
+            return _TOKEN["access"]
+        # refreshToken 续签失败(失效/被踩废超出自愈)→ 密码登录兜底(带 MAX_LOGIN_ATTEMPTS 次熔断)
+        log.warning("refreshToken 续签失败,转密码登录兜底")
+        if login(log):
+            return _TOKEN["access"]
+    raise RuntimeError("无法获取 access token:refreshToken 续签与密码登录兜底均失败(或登录已熔断)。"
+                       "请检查账号(密码/风控/验证码)或在 APP 重新登录后更新 token.json(跑 get_token.py 直读本地抓取)。")
 
 
 # ==================== 1. 商家列表 ====================

+ 384 - 0
deca_spider/deca_team_spider.py

@@ -0,0 +1,384 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/11
+"""得卡 DECA · 随机团 teams 明细高频采集(选队随机 + 剩余随机)。
+
+背景:
+  卡牌拼团分两种"随机团"玩法,同一团会经历两阶段:
+    · 选队随机(playTypeName 以「选队随机」开头):每支球队一价,team-options 接口(带 token)
+      返回逐队 cardCount / availableStock / unitPrice;已售数 = cardCount − availableStock。
+    · 剩余随机(playTypeName == 剩余随机):卖不动后转成的兜底阶段。团购价变成"剩余池加权
+      均价"、买家随机开一张剩余卡。team-options **对剩余随机永远返 29000**、详情 snapshot
+      冻结在转换那一刻(每队只有 unitPrice/availableStock,无原始 cardCount)。
+
+关键结论:**一旦转成剩余随机,原始 cardCount 就再也没接口能补回**。所以在选队阶段必须持续
+抓 team-options 存表(deca_groupbuy_team_record),转剩余后我们才能用「存表的原始总价 − 当前
+剩余货值」算出实时已售额。转剩余前没被我们抓到的老团,team_total_amount 留 NULL、报告端
+标注"原始数据缺失"。
+
+一轮做什么:
+  1) 从 deca_onsale_product_record 拉当前在售·选队随机团 → team-options →
+     upsert 到 teams 表(data_source=team_options,同 (code, team_id) 覆盖成最新,captured_at
+     记本轮时刻) → 算 Σ 单价×(cardCount − availableStock) 写回 team_total_amount。
+  2) 从 deca_onsale_product_record 拉当前在售·剩余随机团 → 详情 snapshot 首次存表
+     (data_source=snapshot;冻结不变,重复见到直接跳过) → 若库里有该团选队阶段的原始
+     cardCount,则算 Σ 单价×cardCount − detail.unitPrice × detail.availableStock 写回
+     team_total_amount;否则留 NULL。
+
+登录态:
+  - team-options 强制带 token(不带返 10002);
+  - 详情接口 groupbuy/detail 免登录。
+  - 与 alert / sold_daily 共用同一份根目录 token.json;续签已加跨进程锁防踩踏
+    (deca_sold_core._token_lock,2026/08/20 根治多进程互相踩废 refreshToken 的问题)。
+
+运行:项目根目录 `python deca_team_spider.py`(2026/08/20 由 on_sale/ 迁回根目录)
+"""
+import os
+import sys
+import time
+from datetime import datetime
+from decimal import Decimal, ROUND_HALF_UP
+
+import schedule
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+
+# 切到脚本所在目录(项目根):token.json / application.yml / ./logs 均在此,
+# 保证从任意工作目录启动都能正确定位(2026/08/20 迁回根目录,去掉原「双 dirname + sys.path.insert」hack)
+os.chdir(os.path.dirname(os.path.abspath(__file__)))
+
+import deca_sold_core as core              # noqa: E402
+from mysql_pool import MySQLConnectionPool  # noqa: E402
+
+# ==================== 配置 ====================
+INTERVAL_SEC = 300              # 采集间隔:5 分钟一轮(选队随机变化较快、又不至于压接口)
+BETWEEN_ITEM_SEC = 0.3          # 单商品之间的最小间隔,避免瞬时限流
+MAX_ITEM_PER_ROUND = 500        # 单轮采集商品数上限(保护;正常远小于此)
+
+ONSALE_TABLE = "deca_onsale_product_record"
+TEAM_TABLE   = "deca_groupbuy_team_record"
+
+# ==================== 日志 ====================
+logger.remove()
+logger.add("./logs/team_spider_{time:YYYYMMDD}.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}")
+
+
+def after_log(retry_state):
+    """tenacity 重试回调,业务函数首参约定为 log。
+
+    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 _round2(x) -> Decimal | None:
+    """Decimal 化并四舍五入到 2 位小数。
+
+    Args:
+        x: 数值(int/float/str/Decimal),None/异常直接返回 None。
+
+    Returns:
+        Decimal | None: 保留 2 位;None 表示不可算。
+    """
+    if x is None:
+        return None
+    try:
+        return Decimal(str(x)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
+    except Exception:
+        return None
+
+
+def fetch_onsale_random(log, pool) -> tuple[list, list]:
+    """从 deca_onsale_product_record 查当前在售·选队随机 / 剩余随机团 code 列表。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+
+    Returns:
+        tuple[list[str], list[str]]: (选队随机 codes, 剩余随机 codes)。
+    """
+    rows = pool.select_all(
+        f"SELECT product_code, play_type_name FROM {ONSALE_TABLE} "
+        f"WHERE is_on_sale=1 AND play_type_name IS NOT NULL "
+        f"LIMIT {MAX_ITEM_PER_ROUND}") or []
+    xd, sy = [], []
+    for code, ptn in rows:
+        if not ptn:
+            continue
+        if "选队随机" in ptn:
+            xd.append(code)
+        elif "剩余随机" in ptn:
+            sy.append(code)
+    log.info(f"[发现] 在售·选队随机 {len(xd)} / 剩余随机 {len(sy)}")
+    return xd, sy
+
+
+@retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
+def fetch_team_options(log, code: str) -> list | None:
+    """打 team-options 接口拿一个团的逐队 teams(带 token)。
+
+    Args:
+        log: 日志对象。
+        code (str): 商品编码。
+
+    Returns:
+        list | None: teams 列表;接口报 29000(已转剩余随机)或失败时返回 None(None 是业务态,
+                     不视为需要重试的错误——只有网络级异常才会被 tenacity 拦下重试)。
+    """
+    r = core.do_request(log, "/api/v1/app/groupbuy/team-options", {"code": code}, need_auth=True)
+    if not r or r.get("code") != 0:
+        return None    # 29000 = 商家开启剩余随机中,正常业务态
+    return (r.get("data") or {}).get("list") or []
+
+
+@retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
+def fetch_detail(log, code: str) -> dict | None:
+    """打 detail 接口拿详情(免 token)。
+
+    Args:
+        log: 日志对象。
+        code (str): 商品编码。
+
+    Returns:
+        dict | None: data 字典;失败返回 None。
+    """
+    d = core.do_request(log, "/api/v1/app/groupbuy/detail", {"code": code}, need_auth=False)
+    return (d or {}).get("data") or None
+
+
+def upsert_team_row(pool, code: str, ptn: str, source: str, team: dict,
+                    captured_at: str, snap_total: int | None = None) -> None:
+    """把一条 team 明细 upsert 进 deca_groupbuy_team_record。
+
+    唯一键 (product_code, team_id, data_source) 冲突时按最新覆盖——
+    这就把「在售团每轮覆盖成最新」自动做掉了。
+
+    Args:
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+        ptn (str): 采集时的 playTypeName。
+        source (str): 数据来源 'team_options' / 'snapshot'。
+        team (dict): 单条 team 数据(team-options 项 或 snapshot.teams 项)。
+        captured_at (str): 采集时刻 YYYY-MM-DD HH:MM:SS。
+        snap_total (int | None, optional): 剩余随机 snapshot.totalQuantity。Defaults to None。
+    """
+    tid = team.get("teamId")
+    if tid is None:
+        return
+    cc = team.get("cardCount")                 # 选队阶段有;剩余快照无 → NULL
+    av = team.get("availableStock")
+    up = _round2(team.get("unitPrice"))
+    sold = (cc - av) if (cc is not None and av is not None) else None
+    pool._execute(
+        f"INSERT INTO {TEAM_TABLE} "
+        f"(product_code, play_type_name, data_source, team_id, team_name_en, team_name_zh, "
+        f" team_logo_image_url, unit_price, card_count, available_stock, sold_count, "
+        f" snapshot_total_quantity, captured_at) "
+        f"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
+        f"ON DUPLICATE KEY UPDATE "
+        f"  play_type_name=VALUES(play_type_name), team_name_en=VALUES(team_name_en), "
+        f"  team_name_zh=VALUES(team_name_zh), team_logo_image_url=VALUES(team_logo_image_url), "
+        f"  unit_price=VALUES(unit_price), card_count=VALUES(card_count), "
+        f"  available_stock=VALUES(available_stock), sold_count=VALUES(sold_count), "
+        f"  snapshot_total_quantity=VALUES(snapshot_total_quantity), "
+        f"  captured_at=VALUES(captured_at)",
+        (code, ptn, source, tid, team.get("teamNameEn"), team.get("teamNameZh"),
+         team.get("teamLogoImageUrl"), up, cc, av, sold, snap_total, captured_at),
+        commit=True)
+
+
+def has_snapshot(pool, code: str) -> bool:
+    """判断某团是否已存过剩余随机 snapshot(冻结值,只需存一次)。
+
+    Args:
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+
+    Returns:
+        bool: True 已存过、可跳过;False 未存。
+    """
+    row = pool.select_one(
+        f"SELECT 1 FROM {TEAM_TABLE} WHERE product_code=%s AND data_source='snapshot' LIMIT 1",
+        (code,))
+    return bool(row)
+
+
+def get_original_total(pool, code: str) -> Decimal | None:
+    """从 teams 表查某团选队阶段抓过的「原始总价」= Σ 单价×cardCount。
+
+    Args:
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+
+    Returns:
+        Decimal | None: 原始总价;缺 team-options 记录时返回 None。
+    """
+    row = pool.select_one(
+        f"SELECT SUM(unit_price * card_count) FROM {TEAM_TABLE} "
+        f"WHERE product_code=%s AND data_source='team_options' AND card_count IS NOT NULL",
+        (code,))
+    if not row or row[0] is None:
+        return None
+    return _round2(row[0])
+
+
+def update_team_total(pool, code: str, amount: Decimal | None) -> None:
+    """把算好的团总价写回 deca_onsale_product_record.team_total_amount。
+
+    Args:
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+        amount (Decimal | None): 总价;None 会显式清空(表示"数据缺失")。
+    """
+    pool.update_one(
+        f"UPDATE {ONSALE_TABLE} SET team_total_amount=%s WHERE product_code=%s",
+        (amount, code))
+
+
+def process_xuandui(log, pool, code: str, captured_at: str) -> bool:
+    """处理一个在售·选队随机团:抓 team-options → 存表 → 算总价 → 回写商品。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+        captured_at (str): 本轮采集时刻。
+
+    Returns:
+        bool: True 成功;False 接口不可用(如刚转成剩余随机)。
+    """
+    teams = fetch_team_options(log, code)
+    if not teams:
+        return False   # 可能这一瞬间刚转成剩余随机,本轮跳过;下轮以剩余随机身份进另一分支
+    total = Decimal("0")
+    ptn = "选队随机"       # 精确 name 已在商品行 play_type_name;此处只作 teams 表内的存档
+    for t in teams:
+        upsert_team_row(pool, code, ptn, "team_options", t, captured_at)
+        cc = t.get("cardCount") or 0
+        av = t.get("availableStock") or 0
+        up = Decimal(str(t.get("unitPrice") or "0"))
+        total += up * (cc - av)
+    update_team_total(pool, code, _round2(total))
+    return True
+
+
+def process_shengyu(log, pool, code: str, captured_at: str) -> bool:
+    """处理一个在售·剩余随机团:首次存 snapshot;每轮从 detail 取实时剩余算总价回写。
+
+    公式:team_total_amount = 原始总价(存表 Σ 单价×cardCount) − detail.unitPrice × detail.availableStock。
+    若库里没有该团选队阶段的 cardCount 记录,team_total_amount 留 NULL 表示"数据缺失"。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+        code (str): 商品编码。
+        captured_at (str): 本轮采集时刻(仅首次存 snapshot 时用)。
+
+    Returns:
+        bool: True 处理成功;False 详情拉失败。
+    """
+    dd = fetch_detail(log, code)
+    if not dd:
+        return False
+    # 1) 首次存 snapshot(冻结不变,只存一次)
+    if not has_snapshot(pool, code):
+        snap = dd.get("remainingRandomTeamSnapshot") or {}
+        for t in (snap.get("teams") or []):
+            upsert_team_row(pool, code, "剩余随机", "snapshot", t, captured_at,
+                            snap_total=snap.get("totalQuantity"))
+    # 2) 每轮重算实时总价
+    original = get_original_total(pool, code)
+    if original is None:
+        # 转剩余随机前没被我们抓到过 team-options → 拿不到原始总价,显式留 NULL
+        update_team_total(pool, code, None)
+        log.warning(f"[数据缺失] {code} 无选队阶段 team-options 记录,team_total_amount=NULL")
+        return True
+    up_now = Decimal(str(dd.get("unitPrice") or "0"))
+    av_now = int(dd.get("availableStock") or 0)
+    remaining_value = up_now * av_now
+    total = original - remaining_value
+    if total < 0:
+        # 边界:原始总价是"名义价",理论上不会 <0;出现即为数据异常,记日志、置 None
+        log.warning(f"[异常] {code} original={original} - remaining={remaining_value} <0,置 NULL")
+        update_team_total(pool, code, None)
+    else:
+        update_team_total(pool, code, _round2(total))
+    return True
+
+
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
+def main_task(log):
+    """采集主函数:一轮遍历在售随机团、更新 teams 表与 team_total_amount。
+
+    挂了每小时重试(无人值守);单轮内各商品独立 try/except,单个失败不拖垮整轮。
+
+    Args:
+        log: 日志对象。
+
+    Raises:
+        RuntimeError: 数据库连接池异常时抛出以触发重试。
+    """
+    log.info(f"开始运行 {sys._getframe().f_code.co_name}" + "." * 40)
+    pool = MySQLConnectionPool(log=log)
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        raise RuntimeError("数据库连接池异常")
+    try:
+        xd_codes, sy_codes = fetch_onsale_random(log, pool)
+        captured_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+        # 选队随机:每轮全量刷新 team-options
+        ok_xd = fail_xd = 0
+        for c in xd_codes:
+            try:
+                if process_xuandui(log, pool, c, captured_at):
+                    ok_xd += 1
+                else:
+                    fail_xd += 1
+            except Exception as e:
+                fail_xd += 1
+                log.error(f"[选队随机·失败] {c}: {e}")
+            time.sleep(BETWEEN_ITEM_SEC)
+        log.info(f"[选队随机] 处理完成: 成功 {ok_xd} 失败 {fail_xd}")
+
+        # 剩余随机:每轮从 detail 取实时剩余、更新总价
+        ok_sy = fail_sy = 0
+        for c in sy_codes:
+            try:
+                if process_shengyu(log, pool, c, captured_at):
+                    ok_sy += 1
+                else:
+                    fail_sy += 1
+            except Exception as e:
+                fail_sy += 1
+                log.error(f"[剩余随机·失败] {c}: {e}")
+            time.sleep(BETWEEN_ITEM_SEC)
+        log.info(f"[剩余随机] 处理完成: 成功 {ok_sy} 失败 {fail_sy}")
+    except Exception as e:
+        log.error(f"{sys._getframe().f_code.co_name} error: {e}")
+    finally:
+        log.info(f"{sys._getframe().f_code.co_name} 运行结束,等待下一轮" + "." * 20)
+
+
+def schedule_task():
+    """定时入口:每 INTERVAL_SEC 秒跑一次 main_task。启动时立即跑一次。"""
+    main_task(log=logger)   # 启动即刻跑一轮,避免等 5 分钟才开始
+    schedule.every(INTERVAL_SEC).seconds.do(main_task, log=logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    schedule_task()

+ 67 - 12
deca_spider/onsale_alert_spider.py

@@ -17,13 +17,20 @@
 生成的 shareCode」,免 token 详情返回的 shareCode 一律被判失效;为不引入登录态、降账号风控,
 提醒消息直接不挂链接。三类提醒都发:新商品上架 / 拼团进度过半 / 一车结束战报。
 
-运行时段:仅每天 20:30~次日 06:00 轮询(RUN_START/RUN_END 控制),其余时间休眠到下次 20:30 再跑。
-
-从根目录运行:python onsale_alert_spider.py(默认企微渠道,需 WEBHOOK_URL 配好)
+运行时段:仅每天 RUN_START~次日 06:00 轮询(RUN_START/RUN_END 控制),其余时间休眠到下次开窗再跑。
+RUN_START 默认 20:30,可用命令行传参覆盖(见下),窗口起点同时是「新上架」时间门槛:只提醒
+publishAt 晚于该起点的商品。
+
+从根目录运行:
+    python onsale_alert_spider.py            # 默认 20:30 开始
+    python onsale_alert_spider.py 17:00      # 改为 17:00 开始:17:00 后的新上架才提醒
+    python onsale_alert_spider.py --start 17:00
+(默认企微渠道,需 WEBHOOK_URL 配好)
 """
 import sys
 import time
 import random
+import argparse
 from datetime import datetime, time as dtime, timedelta
 
 from loguru import logger
@@ -50,7 +57,7 @@ WX_TARGET = "得卡-通知"               # PC 微信发送目标(好友备注
 # COLD_START_PUSH 已废弃(2026/08/08):改为「只提醒本轮窗口起点(20:30)后新上架」,冷启动不再发全量快照
 MIN_INTERVAL_SEC = 60              # 轮询间隔随机下限(秒)
 MAX_INTERVAL_SEC = 90             # 轮询间隔随机上限(秒),每轮在 [下限,上限] 取随机数,打散规律降风控
-RUN_START = dtime(20, 30)          # 运行窗口开始:每天 20:30
+RUN_START = dtime(20, 30)          # 运行窗口开始默认 20:30;可由命令行传参覆盖(见 _parse_args),同时作为「新上架」时间门槛
 RUN_END = dtime(6, 0)              # 运行窗口结束:次日 06:00(窗口跨午夜;2026/08/15 由 03:00 延到 06:00,昨天 5 点还在播)
 MAX_PROD_PAGES = 100               # 在售商品翻页保护上限
 T_ALERT = "deca_onsale_alert_record"
@@ -95,13 +102,15 @@ def get_publish_at(log, code: str) -> str | None:
 
 
 def _window_start(now: datetime) -> datetime:
-    """本轮运行窗口的起点(最近一个已过去的 RUN_START=20:30),作为「新上架」时间门槛。
+    """本轮运行窗口的起点(最近一个已过去的 RUN_START,默认 20:30),作为「新上架」时间门槛。
+
+    RUN_START 可由命令行传参覆盖,故此处读全局值而非写死时刻。
 
     Args:
         now (datetime): 当前时间。
 
     Returns:
-        datetime: 20:30~午夜 → 今天 20:30;午夜~03:00 → 昨天 20:30(跨午夜窗口取窗口起点那天)。
+        datetime: 当天时刻 >= RUN_START → 今天 RUN_START;否则 → 昨天 RUN_START(跨午夜窗口取窗口起点那天)。
     """
     if now.time() >= RUN_START:
         return datetime.combine(now.date(), RUN_START)
@@ -513,7 +522,7 @@ def run_once(log, pool):
                           "card": ccount, "sold": scount}
     is_cold = len(existing) == 0  # 冷启动:库内该商家零记录(仅作日志提示,逻辑与常规轮一致)
     if is_cold:
-        log.info("首次运行:库内该商家零记录,只提醒本轮窗口起点(20:30)后新上架的商品,老货静默建档")
+        log.info(f"首次运行:库内该商家零记录,只提醒本轮窗口起点({RUN_START:%H:%M})后新上架的商品,老货静默建档")
 
     onsale_codes = {r["product_code"] for r in products}  # 本轮在售 code 集合
     # 结束对账:只播报本进程运行后见过在售、之后确认结束的车(历史已结束车不补发;置位防重发)
@@ -529,7 +538,7 @@ def run_once(log, pool):
     half_items = []    # 进度过半提醒文案
     pending_new = []   # 待发新品的 product_code:仅在提醒发送成功后才置 new_notified=1
 
-    window_start = _window_start(datetime.now())  # 「新上架」时间门槛:本轮窗口起点(最近的 20:30)
+    window_start = _window_start(datetime.now())  # 「新上架」时间门槛:本轮窗口起点(最近的 RUN_START,默认 20:30)
 
     for r in products:
         code = r["product_code"]
@@ -538,7 +547,7 @@ def run_once(log, pool):
         over_half = ratio >= HALF_THRESHOLD
 
         if code not in existing:
-            # 库里没有的候选:打一次详情拿上架时间,只有 publishAt 晚于窗口起点(20:30)才算「本轮新上架」
+            # 库里没有的候选:打一次详情拿上架时间,只有 publishAt 晚于窗口起点(RUN_START,默认 20:30)才算「本轮新上架」
             publish_at = get_publish_at(log, code)
             if _is_new_arrival(publish_at, window_start):
                 # 真·本轮新上架:new_notified 先记 0,发送成功再置 1(发失败下轮自动重发,不丢)
@@ -642,18 +651,18 @@ def _seconds_to_window(now: datetime) -> int:
         int: 需休眠的秒数;若当天 RUN_START 已过则顺延到次日。
     """
     start = now.replace(hour=RUN_START.hour, minute=RUN_START.minute, second=0, microsecond=0)
-    if start <= now:                # 当天 20:30 已过 → 顺延到次日 20:30
+    if start <= now:                # 当天 RUN_START 已过 → 顺延到次日同一时刻
         start += timedelta(days=1)
     return int((start - now).total_seconds())
 
 
 def schedule_task():
-    """常驻循环:仅在每天 20:30~次日06:00 运行;窗口内每轮随机间隔轮询,窗口外休眠到下次开窗。"""
+    """常驻循环:仅在每天 RUN_START~次日 RUN_END 运行;窗口内每轮随机间隔轮询,窗口外休眠到下次开窗。"""
     while True:
         now = datetime.now()
         if not _in_run_window(now):
             wait = _seconds_to_window(now)
-            logger.info(f"当前不在运行窗口(20:30~次日06:00),休眠 {wait}s 到 20:30 再跑")
+            logger.info(f"当前不在运行窗口({RUN_START:%H:%M}~次日{RUN_END:%H:%M}),休眠 {wait}s 到 {RUN_START:%H:%M} 再跑")
             time.sleep(wait)
             continue
         main_task(log=logger)
@@ -662,6 +671,52 @@ def schedule_task():
         time.sleep(wait)
 
 
+def _parse_start_time(text: str) -> dtime:
+    """把命令行传入的开始时间文本解析为 datetime.time。
+
+    Args:
+        text (str): 开始时间文本,格式 "HH:MM" 或 "HH:MM:SS",如 "20:30" / "17:00"。
+
+    Returns:
+        dtime: 解析出的 time 对象。
+
+    Raises:
+        argparse.ArgumentTypeError: 格式非法(非 HH:MM[:SS] 或时分秒越界)时抛出,供 argparse 提示用户。
+    """
+    text = text.strip()
+    for fmt in ("%H:%M:%S", "%H:%M"):
+        try:
+            return datetime.strptime(text, fmt).time()
+        except ValueError:
+            continue
+    raise argparse.ArgumentTypeError(f"开始时间格式非法:{text!r},应为 HH:MM 或 HH:MM:SS,如 20:30")
+
+
+def _parse_args() -> argparse.Namespace:
+    """解析命令行参数,取运行窗口开始时间(默认 20:30)。
+
+    支持位置参数与 --start 两种写法,二者等价,方便直接 `python xxx.py 17:00`。
+
+    Returns:
+        argparse.Namespace: 含 start(datetime.time) 属性;未传时为默认 RUN_START。
+    """
+    parser = argparse.ArgumentParser(
+        description="得卡 DECA 在售提醒:可指定运行窗口开始时间(该时间后的新上架才提醒)")
+    parser.add_argument(
+        "start", nargs="?", type=_parse_start_time, default=None,
+        help="运行窗口开始时间 HH:MM[:SS],默认 20:30;位置参数写法,如 17:00")
+    parser.add_argument(
+        "--start", dest="start_opt", type=_parse_start_time, default=None,
+        help="运行窗口开始时间 HH:MM[:SS],与位置参数等价,如 --start 17:00")
+    return parser.parse_args()
+
+
 if __name__ == "__main__":
     # logger.add(sys.stderr, level="INFO")  # 控制台同步输出,便于观察
+    _args = _parse_args()
+    # 位置参数优先,其次 --start,都未传则保持默认 RUN_START(20:30)
+    _start = _args.start or _args.start_opt
+    if _start is not None:
+        RUN_START = _start  # 覆盖模块级默认,窗口判定与「新上架」门槛均随之改变
+        logger.info(f"运行窗口开始时间由命令行指定为 {RUN_START:%H:%M}")
     schedule_task()

+ 1 - 1
deca_spider/sold_daily_spider.py

@@ -50,7 +50,7 @@ def main_task(log):
 
 def schedule_task():
     """定时任务入口:每天 03:00 增量采集一次。"""
-    main_task(log=logger)  # 立即跑一次(调试时取消注释)
+    # main_task(log=logger)  # 立即跑一次(调试时取消注释)
 
     schedule.every().day.at("08:00").do(main_task, log=logger)
     while True:

+ 0 - 0
deca_spider/token.json.lock