HANDOFF.md 31 KB

得卡 DECA 爬虫项目 · 交接文档

用途:新开的 Claude 窗口读此文件即可接续工作,无需回溯历史对话。 目标平台:得卡 DECA App(api.decalive.com)。项目根目录:D:\work\2026-08-02(deca_spider)。 最后更新:2026/08/10。


1. 项目概况

抓取得卡 DECA 平台数据(商家 / 商品 / 卡密 / 拆卡报告 / 已售拼团),入 MySQL, 并对指定商家做「新品上架 + 进度过半」实时提醒。当前监控商家:881226408

  • Python:3.12.10
  • 公共库:charley-utils(editable 安装),直接 from mysql_pool import MySQLConnectionPool
  • 数据库配置:运行目录下 application.yml(mysql.host/port/username/password/db)。
  • 依赖:requestsparsellogurutenacityschedulepymysqlDBUtilsPyYAMLwxauto4

2. 文件清单与职责

文件 职责
deca_sold_core.py 核心库:签名/token/请求/解析全在这。被各爬虫 import(import deca_sold_core as core)。关键函数见下。
sold_history_spider.py 已售拼团历史全量抓取(一次性跑完存量)。
sold_daily_spider.py 已售拼团每日增量抓取(常驻定时)。
onsale_alert_spider.py 在售提醒:监控 881226408 在售商品,按上架时间提醒本轮窗口起点后新上架 + 进度过半。默认每天 20:30~次日06:00 常驻轮询、窗口外休眠;开始时间可命令行传参python onsale_alert_spider.py 17:00,2026/08/20)。
deca_wechat.py PC 版微信(wxauto4)发送:send_files() 发文件、send_text() 发文本。2026/08/11 起已停用、保留可切回
auto_send_wx_msg.py 企微群机器人发送(2026/08/11 起为主发送渠道,三脚本共用唯一一份)send_wechat_group_msg() 发 text/markdown_v2、send_wechat_group_file() 发文件(Excel)。只在根目录留一份,on_sale/stats/ 脚本用 sys.path 引导 import 根目录这份。换群只改这一处 WEBHOOK_URL,当前指测试群。
schema.sql 全部建表 DDL(含 deca_onsale_alert_record 提醒表)。
init_db.py schema.sql 逐条建表(幂等)。python init_db.py
stats/daily_report.py 已售每日报告:生成 Excel 单 Sheet 分区报告(平台/881../274../其他商家)。根目录跑 python stats/daily_report.py
stats/stats_sold.sql 统计 SQL(Navicat 手动跑):平台大盘 + 商家 881226408 明细,按 completed_at [昨天17点,今天3点] 窗口。
stats/export_teams_excel.py 球队.json 的 teams(中文名/英文名/价格/数量) 导出到 Excel。
stats.sql 早期统计 SQL(历史遗留,以 stats/stats_sold.sql 为准)。
buy_record_analysis/buy_record_spider.py 购买记录常驻采集:多商品自适应频率轮询详情页 purchaseRecords(10 条窗口),去重累积买家名单到 deca_buy_record。两种模式:WATCH_CODES 白名单单/多商品测试 vs 空则跟 MERCHANT_ID 商家全在售。详见子项目 README。
buy_record_analysis/README.md 购买记录采集子项目文档(数据模型/去重逻辑演进/使用说明)。
deca_team_spider.py 随机团 teams 采集(2026/08/11 新增;2026/08/20 由 on_sale/ 迁回根目录):每 5 分钟一轮,选队随机团抓 team-options 存表 + 算总价、剩余随机团存 snapshot + 用「原始总价 − 当前剩余」实时算总价。必须常驻——转成剩余随机后 team-options 永久返 29000,原始 cardCount 再无接口能补。
docs/选队随机与剩余随机_总价口径与采集_20260811.md 随机团总价口径完整设计文档(业务模型/公式/接口坑/数据模型/常见问题)。改这块前务必先读。
docs/在售商品进度时间序列_采集与统计_20260811.md 进度快照文档:表结构、变化才写机制、常用统计 SQL(画曲线/算速度/找热销时段)。做进度类统计前必读。
README.md 逆向分析文档(签名/token 机制)。

deca_sold_core.py 关键函数(供 import 复用)

  • PAGE_SIZE = 20:列表每页条数。
  • after_log(retry_state):tenacity 重试回调(业务函数首参约定为 log)。
  • make_signature(params, current_time):请求签名。
  • do_request(log, path, body, need_auth=False) / do_get(...):带重试的 POST/GET,返回 JSON。
  • ensure_token(log) / refresh_token / login:token 管理。ensure_token 加跨进程文件锁_token_lock)串行化「读→续签→写回」防多进程踩废 refreshToken;续签失败自动 login 密码登录兜底(带 5 次熔断)。(2026/08/20 根治,详见 §7)
  • parse_product(item) / parse_shop / parse_kami / parse_report:各实体解析成 dict。
  • run_pipeline(log, pool, incremental):历史/增量抓取总编排。
  • upsert_team_row / fetch_team_options_or_none / compute_random_team_amount_and_persist / backfill_team_amount(2026/08/11 新增):随机团 teams 明细存储与总价计算,供 fill_detailsdeca_team_spider 共用。

3. 数据库表(deca_ 前缀,详见 schema.sql)

  • deca_shop_recorddeca_product_recorddeca_kami_recorddeca_report_recorddeca_sold_record 等。
  • deca_groupbuy_team_record(2026/08/11 新增,随机团 teams 明细)关键列:
    • product_codeplay_type_namedata_source(team_options/snapshot)、 team_idunit_pricecard_count(选队阶段有;剩余快照 NULL)、available_stocksold_countsnapshot_total_quantitycaptured_at
    • 唯一键 (product_code, team_id, data_source):选队阶段每轮覆盖成最新;剩余快照冻结一次。
  • 两张商品表加列 team_total_amount decimal(14,2)
    • deca_onsale_product_record.team_total_amount / deca_product_record.team_total_amount
    • 随机团(选队随机/剩余随机)按 teams 逐队精算写入;固定价团保持 NULL。
    • 报告端统一 COALESCE(team_total_amount, unit_price*sold_count)
  • deca_onsale_product_progress_record(2026/08/11 新增,在售商品进度时间序列,append-only)关键列:
    • product_code (关联)、merchant_user_idsold_countavailable_stockcard_countprogress_pctunit_price(存历史值,随机团单价会变)、captured_at
    • 索引 (product_code, captured_at) + (captured_at)
    • 采集:buy_record_spider::snapshot_onsale_progress 每 60 秒一次,只对 sold_count 变化的商品 INSERT,避免"没卖动"重复占位。
    • 用途:进度曲线、售卖速度、热销时段等统计。SQL 举例见 docs 里的文档。
  • deca_onsale_alert_record(在售提醒状态表)关键列:
    • product_code(uk)、merchant_user_id(idx)、merchant_nametitleunit_pricecard_countsold_countprogress(decimal 5,2)、available_stockgroupbuy_status_nameshare_code(varchar 512, 旧分享码列;2026/08/11 起消息去链接后不再写入,保留不影响)、 new_notified(tinyint 0/1)、half_notified(tinyint 0/1)、 gmt_create_timegmt_modified_time
    • 去重靠 new_notified/half_notified 两个标记位,每类每商品只提醒一次。
  • deca_buy_record(购买记录累积表,buy_record_analysis/buy_record_spider.py 用)关键列:
    • product_codeuser_idnickname(服务端已脱敏)、card_countpurchased_at_text(相对文本原文)、purchased_at_ts(反推的绝对时间戳,秒)、 purchased_at(datetime)、first_seen_atgmt_create_timegmt_modified_time
    • 唯一键 uk_buy(product_code, user_id, card_count, purchased_at_ts) 仅作 DB 兜底; 主判重在应用层(product_code, user_id, card_count) 分组 + 反推 purchased_at_ts ±70s 视为同一笔(详见子项目 README「去重逻辑演进」)。

4. onsale_alert_spider.py 当前逻辑(重点)

配置区(文件顶部):

MERCHANT_ID = "881226408"     # 监控商家
HALF_THRESHOLD = 0.5          # 过半阈值
SEND_CHANNEL = "qywx"         # qywx=企微群机器人(默认) / pc=PC微信(wxauto4,可切回)
WX_TARGET = "得卡-通知"        # PC 微信发送目标(仅 SEND_CHANNEL="pc" 时用)
MIN_INTERVAL_SEC = 60         # 轮询随机间隔下限
MAX_INTERVAL_SEC = 90         # 轮询随机间隔上限
RUN_START = dtime(20, 30)     # 运行窗口开始默认 20:30;命令行可传参覆盖(2026/08/20),同时是「新上架」门槛
RUN_END   = dtime(6, 0)       # 运行窗口结束:次日 06:00(跨午夜;2026/08/15 由 03:00 延来)
MAX_PROD_PAGES = 100          # 在售翻页保护上限
# 注:COLD_START_PUSH 已废弃(2026/08/08)——不再有"首次查询发全量快照"这个开关。

每轮 run_once 行为

  1. 拉在售:fetch_onsale 复用 daily 的 fetch_all_onsale(home/search 免 token 拉全站在售、只拉不落库),再按 merchant_user_id 筛出本商家、按 product_code 去重。不再翻 on-sale-list
  2. 提醒门槛按「上架时间」判定(关键):对库里没见过的商品,打一次详情拿 publishAt(上架时间),与本轮窗口起点(最近的 RUN_START,默认 20:30、可传参,_window_start)比较:
    • 新品上架publishAt >= 窗口起点 → 记入新品提醒(new_notified 先记 0,发成功再置 1)。
    • 静默建档publishAt 早于窗口起点(或取不到上架时间)的老货 → 直接入库、new_notified=1不提醒
    • 进度过半sold/card 首次 ≥ 0.5 → 记入过半提醒(half_notified 逻辑独立不动)。
  3. 首次查询(库内该商家零记录)不再发全量快照——冷启动与常规轮走同一套判断(is_cold 只用于打日志),照样只提醒「窗口起点后新上架」,窗口起点前的老货一律静默建档。即:首次查询不会把当前所有在售商品打包推送,但提醒本轮窗口起点(默认 20:30)之后才上架的新品。
  4. 发送渠道(2026/08/11 起默认企微 SEND_CHANNEL="qywx":新品/过半各发一条 markdown_v2、一车结束战报每车一条,均走 auto_send_wx_msg.send_wechat_group_msg。PC 微信合并发送(_dispatch_pc_combined)保留、切回 "pc" 才用。

通知条目格式_fmt_item/_fmt_ended):标题 + 信息行(价格/份数或进度%/余·共)。2026/08/11 起不挂商品链接——企微渠道标题 markdown 加粗,PC 渠道纯文本直出。三类提醒:新商品上架 / 拼团进度过半 / 一车结束战报。

为什么去掉商品链接(2026/08/11 逆向结论):分享落地页前端拿 shareCode 调 POST /api/v1/app/groupbuy/share-detail(免 token/签名)。groupbuy/detail 免 token 返回的 data.shareCode,share-detail 一律 code=10001 分享链接已失效;只有带 token(登录态) 拉详情返回的 shareCode 才 code=0 成功。shareCode 是登录态绑定令牌,为不引入登录态(降账号风控)直接放弃链接:已删 get_share_code/_ensure_share_codeget_detail_metaget_publish_at_insert_alert 不再写 share_code 列。

运行python onsale_alert_spider.py(默认 20:30 开始、企微渠道,需 auto_send_wx_msg.WEBHOOK_URL 配好)。

  • 可指定开始时间(2026/08/20):位置参数或 --start,格式 HH:MM[:SS]。传 17:00 即窗口起点改 17:00——17:00 后才开始轮询,且只提醒 17:00 之后新上架的商品。

    python onsale_alert_spider.py 17:00          # 位置参数
    python onsale_alert_spider.py --start 17:00  # 等价写法
    

5. 待办 / 未决事项

  1. 企微 WEBHOOK_URL 换正式群:当前 WEBHOOK_URL测试群,正式上线换正式群机器人 key——只改根目录 auto_send_wx_msg.py 一处(三脚本共用这一份)。
  2. 登录改 refreshToken 续期(2026/08/20 已处理):token 以 refreshToken 续期为主 + 密码登录兜底(带 5 次熔断);多进程踩废 refreshToken 的根因已用跨进程锁根治(见 §7)。密码登录实测 code=0 可用、暂未触发阿里云验证码;若日后被验证码拦,login 会累计失败到 5 次熔断并需人工介入。
  3. stats_sold.sql 字段核对:第四段用到 p.completed_at,需确认 schema 里确有该列,否则调整。 4.(可选)PC 微信渠道已停用(deca_wechat),onsale_alert_spider.py 切回 SEND_CHANNEL="pc" 可复用。

6. 全局规范提醒(来自 CLAUDE.md)

  • 建表:表名 _record 结尾、自增 id 主键、时间字段固定 gmt_create_time/gmt_modified_time(datetime)。
  • 只增不改的数据走 INSERT IGNORE + 业务唯一键去重。
  • 数据库默认只读;写操作需先说明并获确认。
  • 函数强制完整 Google Style docstring;关键逻辑行内注释解释「为什么」。
  • 版本号写入前用 --version 实时探测,禁止凭记忆。

7. 变更记录

2026/08/20 · token 续签多进程踩踏根治 + 密码登录兜底 + team_spider 迁根目录

现象deca_team_spider.py 连日 team-options 全线失败、日志刷屏「token 续签无返回 access:当前账号长期未登录」(0814~0819 续签成功 0 次);同期已售抓取正常,故最初误判为账号问题。实测当前 refreshToken 续签 0.5s 成功 → 账号本身没坏。

根因(多进程踩踏):多个共用根目录 token.json 的进程 + 得卡 refreshToken「一次性轮换」(续签成功即发新 refresh、旧的立即作废)+ ensure_token 只认进程内存 refresh(原 if not _TOKEN.get('refresh'): load_token(),内存一旦有值就永不重读磁盘)。任一进程续签即轮换 refresh、其它常驻进程内存那份立刻作废;team_spider 是 5 分钟一轮的常驻进程,一旦被踩废便永久失败(从不 reload),只有免 token 的在售能采、team-options(need_auth) 全废。已售正常是因为 sold_daily 每天单次运行、每次新进程读磁盘最新 refresh,踩踏窗口极小。(早在 2026/08/06 §7 就预警过 refresh 轮换踩踏,但当时只统一为一份 token.json,未解决并发轮换 + 内存不 reload。)

修复(改 deca_sold_core.py

  1. 跨进程文件锁 _token_lock(Windows msvcrt / POSIX fcntl,进程崩溃由 OS 自动释放、无残留死锁;拿不到锁 30s 超时兜底放行)。
  2. ensure_token 重写:内存 access 未临期走免锁快路径;临期/缺失才抢锁,锁内先 load_token() 读磁盘最新 refresh、再双检(别的进程刚续好就直接复用、不重复续签),最后才续签。→ N 进程同时过期只续签 1 次、refresh 只轮换 1 次,物理上杜绝踩踏。
  3. 密码登录兜底 login:refreshToken 续签失败自动密码登录换新 token(接口 /api/v1/app/auth/password/login,签名同 make_signature,实测 code=0)。带 MAX_LOGIN_ATTEMPTS=5 次熔断:累计登录失败达 5 次不再请求登录接口(避免账号异常/验证码时狂调),登录成功或续签成功即清零。凭证在 core 配置区 LOGIN_PHONE/PASSWORD/COUNTRY_CODE(敏感,勿外传/勿提交公开仓库)。

验证:① 3 进程并发抢 token → 只续签 1 次、成功 3/3、0 长期未登录;② 置无效 refresh → 续签失败(复现「长期未登录」)→ 自动密码登录兜底 → token.json 复有效、熔断计数 0。详见 docs/优化记录_token踩踏根治与登录兜底_20260820.md

运维提醒:更新代码后需重启所有共用 token.json 的常驻进程(尤其 deca_team_spider.py,启动命令已变为根目录 python deca_team_spider.py),让进程内存捡起有效 refresh。


2026/08/20 · 在售提醒运行窗口开始时间改命令行可传参(onsale_alert_spider.py)

需求RUN_START 原写死 20:30,希望能按需指定——传 17:00 就 17:00 开始,且 17:00 之后新上架的才提醒。

改动RUN_START 这一个常量本就同时驱动「运行窗口起点」与「新上架时间门槛」两处逻辑,故只把它做成命令行可覆盖、默认 20:30,两处一起跟着变,无需拆两个参数。

  1. 新增 _parse_start_time(解析 HH:MM[:SS],非法格式 argparse 报错)、_parse_args(位置参数与 --start 等价,位置参数优先)。
  2. __main__ 解析后覆盖模块级 RUN_START 并打日志;未传则保持默认 20:30。
  3. 配套把写死的 "20:30" 文案改为读 RUN_START(docstring、窗口/首次运行/静默建档日志)。

用法python onsale_alert_spider.py 17:00--start 17:00;无参默认 20:30。

验证py_compile 通过(Python 3.12.10);argparse 逻辑隔离测试(默认/位置/--start/带秒/非法)全过。详见 docs/优化记录_deca_spider_20260820.md


2026/08/11 · 在售商品进度时间序列(deca_onsale_product_progress_record

背景:在售提醒能提示"进度过半"但只是事件,没有历史轨迹。主公做统计需要完整进度曲线(什么时候卖到多少百分比、每小时卖多少、热销时段)。

方案

  1. 新表 deca_onsale_product_progress_record(append-only 时间序列),字段见 §3。用 product_code 关联主表,商品静态信息不复制。
  2. buy_record_spider::snapshot_onsale_progress(新增函数),挂在 ingest_onsaleget_onsale_products 之后。每 60 秒(复用现有节奏)跑一次:先拿 progress 表每商品最新 sold_count 作基线,跟 onsale 主表当前状态比对,sold_count 变化 或 从未记录 才 INSERT——避免"没卖动"重复占位,数据量省 3~5 倍。
  3. unit_price 存历史值(随机团单价会变,主表覆盖后就丢了当时值)。

已验证:测试跑 2 轮:首轮写 201 条(基线)、2 秒后次轮只 9 条(真卖动的 9 个团)。热销例子:GB26080604252 短短 3 秒 sold 11055→11125,两行都留下。

完整设计docs/在售商品进度时间序列_采集与统计_20260811.md(含常用统计 SQL)。

运维:只要 buy_record_spider 常驻跑,本表自动增长。停机期间不记,跟 buy_record 一样属"停机=断档"。


2026/08/11 · 随机团总价口径重构(选队随机 / 剩余随机)

背景:得卡的"选队随机 / 剩余随机"其实是同一个团的两个阶段(选队卖不动→转剩余)。每支球队价格不同、便宜的先卖光——现有 unit_price × sold_count 严重失真(选队随机团甚至商品级 unit_price=0,直接算出 0 元;已售剩余随机的 top.soldCount/totalCardCount原箱母单聚合数,多个子团共用同一个 10697,unit×sold 会把 74727 算成 47.8 万)。

关键约束(实测)team-options 只在选队随机状态可查;一旦转成剩余随机就永久返 29000 "商家开启剩余随机中"任何接口都无法回补每队原始 cardCount(详情快照里也没有),必须在选队阶段提前抓、存表。

方案

  1. 新表 deca_groupbuy_team_record 存逐队明细(data_source 区分 team_options / snapshot);两张商品表加列 team_total_amount decimal(14,2)
  2. 新增 on_sale/deca_team_spider.py 常驻,每 5 分钟一轮:选队随机团抓 team-options→存表+算 Σ 单价×(cardCount-availableStock);剩余随机团存 detail snapshot(首次冻结)+ 用 Σ 单价×cardCount(存表原始) − detail.unitPrice×detail.availableStock 算实时总价。上线前已是剩余随机、库里没原始 cardCount 的老团,team_total_amount=NULL(数据缺失,报告端 COALESCE 回落原公式)。
  3. 已售侧 deca_sold_core.fill_details 顺手做剩余随机 snapshot 存表 + 算 Σ 单价×availableStock(不新增网络请求,复用已拉到的 detail);run_pipelinebackfill_team_amount 步骤 3.5 存量收敛。
  4. 报告端:on_sale/deca_on_sale_report.pyprod_sql 末尾 SELECT COALESCE(team_total_amount, unit_price*sold_count) AS total_amountstats/daily_report.py 4 处销售额 SQL 同样 COALESCE。

已验证:已完成剩余随机 12/12 存量回补成功(如 GB26081095285=74727.50、GB26081062215=70776.00、GB26081009714=51157.50);在售 team_spider 一轮跑通(选队 2 成功 / 剩余 3 成功,3 个失败是刚转剩余随机的正常业务态)。

完整设计:见 docs/选队随机与剩余随机_总价口径与采集_20260811.md(改这块前必读)。

运维deca_team_spider.py 需常驻(python deca_team_spider.py,2026/08/20 由 on_sale/ 迁回根目录);与 alert / sold_daily 共用根目录 token.json(续签已加跨进程锁 + 登录兜底防踩踏,见 §7 2026/08/20)。


2026/08/08 · 在售提醒改「上架时间门槛」+ 废弃冷启动快照(onsale_alert_spider.py)

  1. 废弃 COLD_START_PUSH:删除「首次查询发全量在售快照」这条分支与开关。冷启动不再推送快照。
  2. 新增运行窗口:仅每天 RUN_START(20:30)~次日 RUN_END(03:00) 轮询,窗口外休眠到下次开窗(_in_run_window / _seconds_to_window / schedule_task)。
  3. 新品判定改为按上架时间:新增 get_detail_meta(一次详情同时取 shareCode + publishAt)、_window_start_is_new_arrival。只有 publishAt >= 本轮窗口起点(20:30) 才当新品提醒,早于窗口起点或取不到时间的一律静默建档。冷启动与常规轮共用此判断,首次查询不再区别对待(仅日志提示)。
  4. 拉取免 token 化fetch_onsale 改为复用 fetch_all_onsale(home/search 免登录拉全站在售)再按商家筛,不再翻 on-sale-list。 > 注:下方 2026/08/04 记录中提到的"冷启动快照"分支,已由本次改版整体移除,仅作历史保留。

2026/08/04 · 在售提醒三处修复(onsale_alert_spider.py)

  1. share_code:库里 deca_onsale_alert_record 是加该列前建的旧结构,缺 share_code, 脚本查询报 1054 Unknown column。已 ALTER TABLE ... ADD COLUMN share_code varchar(512) ... AFTER groupbuy_status_name(与 schema.sql 对齐),其余列全部对齐。

  2. 修分享链接取值路径:详情接口 groupbuy/detail 的分享码在 data.shareCode(data 顶层), 原代码取 data.shareResp.shareCode(该字段实际为 null),导致提醒无链接。已改 get_share_code。 注:在售列表 on-sale-list 返回项不含 shareCode,分享码只能走详情接口,get_share_code 必需保留。

  3. new_notified「发失败不丢 + 改 0 能重发」(half_notified 逻辑不动):

    • 全新商品入库时 new_notified 先记 0,消息发送成功后才批量置 1(新增 _mark_new_notified)。 _dispatch / _dispatch_pc_combined 改为返回 bool;send_text 失败(如锁屏)→ 标记保持 0 → 下轮自动重发。
    • run_once 的 SELECT 补上 new_notified;已在表但 new_notified=0(上次发失败残留 / 人工改回 0) 的商品重新纳入新品提醒,发成功后再置 1。
    • 行为变化:冷启动快照也遵循「发成功才置位」,若首次快照发失败,下轮改为逐条以「新品上架」补发(不再重发整体快照)。

踩坑(PC 微信 wxauto4):报错 'NoneType' object has no attribute 'GroupControl' = wxauto4 抓不到微信主窗口, 根因通常是电脑锁屏(锁屏后 Windows 挂起桌面 UI 渲染,UI 自动化够不着窗口),或微信被关/最小化。 对策:跑该脚本时段别锁屏(电源设置关掉自动锁屏/睡眠);无人值守需锁屏则把 SEND_CHANNEL"qywx"(企微机器人走 HTTP,不受锁屏影响)。

2026/08/05 · 已售统计 SQL 改造(stats_sold.sql)

  1. 统计口径改为时间窗:从「整个昨天」改为按 completed_at 落在 [昨天 17:00:00, 今天 03:00:00](含两端) 的成交。 起点 (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR、终点 CURDATE() + INTERVAL 3 HOUR,每天跑一次。 注意:该窗口不覆盖当天 03:00~17:00 的成交(每天窗口为傍晚到次日凌晨),如需覆盖白天再调边界。
  2. Navicat 适配:去掉 SET @stat_day 会话变量,边界表达式内联进每段查询,两段 SELECT 各自独立、可单独选中执行 (原变量方案在只选中单条 SELECT 时因 SET 未执行导致查不出数据)。
  3. 去掉「商家ID」列:商家段结果不再输出 merchant_user_idGROUP BY 保留不影响)。 completed_at 为 varchar(32) 存标准 YYYY-MM-DD HH:MM:SS,与 datetime 边界直接比较即可,无需 STR_TO_DATE。

2026/08/05 · 新增已售每日报告脚本 + 统计脚本归拢 stats/

  • 新建 stats/daily_report.py:生成 Excel 单 Sheet 分区报告(时间窗口径同 stats_sold.sql)。结构: 平台汇总 → 881226408 汇总+每条组队明细 → 274584650 汇总+每条组队明细 → 其他商家打包汇总。 「一个拼团商品=一个组队」(组队售卖);明细列:团名/系列/类型/单价/份数/总份数/进度/总金额/中卡人数/中卡张数/成交时间/回放。 「人数」= 拆卡报告 hit_user_nickname 去重(近似,仅覆盖有报告的商品,非真实参团人头)。 从根目录跑:python stats/daily_report.py → 根目录输出 得卡已售每日报告.xlsx
  • 踩坑:MySQL 8 保留字 groups 不能直接当列别名(报 1064),改用 grp
  • 归拢:统计脚本集中到 stats/ —— daily_report.pystats_sold.sqlexport_teams_excel.py(球队.json→Excel)。 三者都从项目根目录运行(cwd=根,共用根的 application.yml,源文件如 球队.json 也在根)。
  • 数据现状deca_kami_record 为空(FILL_KAMI 关)→ 无球队维度;274584650 窗口内仅 1 拼团且无报告→人数为 0。

2026/08/06 · token.json 全局共用一份(根目录与 on_sale/ 子项目)

背景on_sale/ 子项目(在售采集 deca_daily_spider.py,每天 09:00/15:00)与根目录项目 (sold_daily_spider.py 03:00、onsale_alert_spider.py 默认 20:30~次日06:00,起点可传参)用的是同一个得卡账号, 两套代码各有一份自包含的 token 管理(ensure_token/refresh_token,逻辑逐行一致)。

问题TOKEN_FILE 原本是相对运行目录的 "token.json"。若各存一份副本,一旦后端 refreshToken 滚动更新(用一次换新、旧的作废),高频跑的一方(alert 一晚上刷很多次)会不断把 新 RT 写进自己那份,另一份副本的 RT 停在复制那一刻 → 迟早变死副本 → 续期失败 → 退密码登录撞验证码崩掉。

决策全局只维护一份 token.jsonD:\work\2026-08-02(deca_spider)\token.json)作单一真相源。

  • 根目录各脚本:cwd=根目录,TOKEN_FILE="token.json" 天然指向这份,不改
  • on_sale/deca_daily_spider.py:已把 TOKEN_FILE 改成绝对路径指向同一份(见该文件配置区,2026/08/06)。
  • 无并发写冲突:on_sale 的 09:00/15:00 落在根目录运行时段(默认 20:30~次日06:00)之外,两边不会同时刷 token。⚠️ 若用命令行把 alert 起点提前到 09:00/15:00 附近,需留意可能与 on_sale 刷 token 撞窗口。

运维:refreshToken 若彻底失效,只需 APP 重新登录抓包、把新 refreshToken 写回这一份 token.json, 两个项目一起恢复;不要再复制多份副本。(关联待办 5.2「登录改 refreshToken 续期」)

2026/08/06 · 新增购买记录采集子项目(buy_record_analysis/)

目标:绕开商品详情页「购买记录」板块固定只显示 10 条的限制,持续累积买家名单到 deca_buy_record

关键发现(反编译 + 真机实测双向验证):

  • 不存在独立的购买记录接口(反编译扫遍全库 90+ 个 api/v1/... 路径,无 purchase-records/buy-record 类端点)。
  • 详情接口 groupbuy/detail 请求体只有 code 一个字段,实测 15 种试探参数(page/pageSize/limit/purchaseRecordLimit 等)服务端 100% 忽略、恒返 10 条。
  • 响应也没有 hasMore/nextCursor 分页游标。 → 唯一可行路径 = 周期轮询详情 + 应用层去重累积

主脚本 buy_record_analysis/buy_record_spider.py

  • 两种模式WATCH_CODES 非空 → 白名单模式(单/多商品测试);空 → 走 MERCHANT_ID 商家全在售模式,动态刷新(下线自动移出)。
  • 自适应频率:每轮拉完取 10 条 purchasedAt 反推时间戳跨度 span,下次间隔 = span // 3,硬夹在 [MIN_INTERVAL_SEC=0, MAX_INTERVAL_SEC=600]。全局 GLOBAL_MIN_GAP_SEC=0.2 兜底防瞬时限流。
  • 代理core.USE_PROXY = True(快代理隧道)默认开。
  • 登录态:详情接口 groupbuy/detail 实测免登录(不带 token 也返回 code=0 + 10 条),故白名单模式全程不碰 token;仅商家模式拉在售列表 on-sale-list 需要 token(不带会 code=10002),该模式启动才 ensure_token(token.json 沿用全局单一策略)。
  • 建表:脚本不自动建表deca_buy_recordschema.sql / init_db.py 负责(职责单一)。
  • 保活main_task @retry(stop=100, wait=3600),挂了每小时重试。

去重踩过两次坑(含实测反例)

  1. (product_code, user_id, card_count, purchased_at_minute) 分钟桶 → 100% 重复入库。原因:相对文本每 60 秒 +1,反推 ts 漂移 60s 跨桶。
  2. (product_code, user_id, card_count) 名单粒度 → 漏单。反例:王**要 x10 在 10/12 分钟前各一笔(间隔 2 分钟)被合并成一条。
  3. 最终解 · 订单粒度 + 反推 ts ±70s 应用层判重:数学基础是「同一笔订单反推 ts 漂移永远 < 60s、不同笔订单间隔 ≥ 2 分钟时反推 ts 差 ≥ 60s」。DB 唯一键含 purchased_at_ts 仅作兜底;进程启动/新商品纳入时用 load_order_index 从库恢复 _order_idx 内存索引,避免重启后重复。实测反例场景(王**要多笔)已正确区分入库。

已知局限

  • 昵称脱敏是服务端行为(详情接口的 nickname 返回就是 D**l/匿名用户),客户端拿不到真实名。顺带发现拆卡报告 hit_user_nickname 不脱敏,但两表间无 user_id 关联字段,只能靠昵称文本模糊 join。若未来找到「userId → 用户资料」接口,可在本表加 real_nickname 列批量补拉。
  • 同一用户在 ~1 分钟内下两笔完全相同份数的单会被当同一笔(因 purchasedAt 只精确到分钟,数据上无法与漂移区分)。此情形极罕见。
  • 停机 = 断档:中断期间的买家永久丢失(10 条窗口早滚走了),生产必须 nohup / tmux / supervisor 保活。

清理test_buy_record.py 已删除(功能被 buy_record_spider.py 白名单模式全面覆盖,且旧 DDL 与新表结构不兼容)。子项目当前 3 个正式交付物:buy_record_spider.pyREADME.md、schema.sql 里的 deca_buy_record DDL 段(第 204 行起)。