ソースを参照

fix(auto_send_wx_msg): 为企微消息上传与发送添加网络重试机制

- 引入 tenacity 实现网络请求重试,解决 TLS 瞬断导致发送失败的问题
- 对素材上传和消息发送请求进行包装,自动重试网络异常
- 重试次数最多 4 次,指数退避等待,记录重试日志
- 确保重试时文件重新打开,避免空文件体上传
- 调整异常日志提示,标明重试次数失败

feat(stats): 扩展日常报告支持更多商家及真实购买记录口径

- 新增尼卡(538252487)和文泰(591544726)至重点商家与真实买家集合
- 真实购买记录口径扩展至魔都、卡皇、尼卡和文泰
- 统一已采购买记录商家使用真实买家参与人数与人均消费计算
- 平台汇总中真实买家商家集合动态构建,避免硬编码
- 明细、用户排行榜、购买记录覆盖检测均支持新增商家
- 调整相关注释与变量,反映多商家真实买家口径支持

feat(stats): 增加品类判定与回填功能,优化多 Sheet 报告结构

- 实现 classify_category 按标题和系列名判定品类(篮球、足球、棒球等)
- 新增 fetch_category_series 获取品类汇总与系列销售榜
- 品类判定结果可回写到数据库表 category 字段,减少查询时重复判定
- 品类·系列榜 Sheet 集成品类汇总与系列榜,支持占比展示
- 相关 SQL 和数据处理逻辑改进,提升报告准确度和展示效果
charley 3 週間 前
コミット
65dbc3dcdf

+ 315 - 0
deca_spider/HANDOFF.md

@@ -0,0 +1,315 @@
+# 得卡 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)。
+- 依赖:`requests`、`parsel`、`loguru`、`tenacity`、`schedule`、`pymysql`、`DBUtils`、`PyYAML`、`wxauto4`。
+
+---
+
+## 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_details` 与 `deca_team_spider` 共用。
+
+---
+
+## 3. 数据库表(`deca_` 前缀,详见 schema.sql)
+
+- `deca_shop_record`、`deca_product_record`、`deca_kami_record`、`deca_report_record`、`deca_sold_record` 等。
+- **`deca_groupbuy_team_record`**(2026/08/11 新增,随机团 teams 明细)关键列:
+  - `product_code`、`play_type_name`、`data_source`(team_options/snapshot)、
+    `team_id`、`unit_price`、`card_count`(选队阶段有;剩余快照 NULL)、`available_stock`、
+    `sold_count`、`snapshot_total_quantity`、`captured_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_id`、`sold_count`、`available_stock`、`card_count`、
+    `progress_pct`、`unit_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_name`、`title`、`unit_price`、
+    `card_count`、`sold_count`、`progress`(decimal 5,2)、`available_stock`、`groupbuy_status_name`、
+    `share_code`(varchar 512, 旧分享码列;**2026/08/11 起消息去链接后不再写入**,保留不影响)、
+    `new_notified`(tinyint 0/1)、`half_notified`(tinyint 0/1)、
+    `gmt_create_time`、`gmt_modified_time`。
+  - 去重靠 `new_notified`/`half_notified` 两个标记位,每类每商品只提醒一次。
+- **`deca_buy_record`**(购买记录累积表,`buy_record_analysis/buy_record_spider.py` 用)关键列:
+  - `product_code`、`user_id`、`nickname`(服务端已脱敏)、`card_count`、
+    `purchased_at_text`(相对文本原文)、`purchased_at_ts`(反推的绝对时间戳,秒)、
+    `purchased_at`(datetime)、`first_seen_at`、`gmt_create_time`、`gmt_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 当前逻辑(重点)
+
+**配置区**(文件顶部):
+```python
+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_code`、`get_detail_meta`→`get_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 之后新上架的商品。
+  ```bash
+  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_onsale` 里 `get_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_pipeline` 加 `backfill_team_amount` 步骤 3.5 存量收敛。
+4. 报告端:`on_sale/deca_on_sale_report.py` 的 `prod_sql` 末尾 SELECT `COALESCE(team_total_amount, unit_price*sold_count) AS total_amount`;`stats/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_id`(`GROUP 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.py`、`stats_sold.sql`、`export_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.json**(`D:\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_record` 由 `schema.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.py`、`README.md`、schema.sql 里的 `deca_buy_record` DDL 段(第 204 行起)。

+ 87 - 16
deca_spider/auto_send_wx_msg.py

@@ -12,6 +12,7 @@
 
 变更记录:
     2026/08/11 从 PC 版微信(wxauto)迁到企业微信群机器人;新增 send_wechat_group_file 发文件能力。
+    2026/08/27 上传/发送增加 tenacity 重试,专治 TLS 瞬断(UNEXPECTED_EOF_WHILE_READING)导致的偶发发送失败。
 """
 import os
 import re
@@ -19,6 +20,8 @@ import json
 
 import requests
 from loguru import logger
+from tenacity import (retry, stop_after_attempt, wait_exponential,
+                      retry_if_exception_type)
 
 # 企业微信群机器人 Webhook 地址(key 为群机器人凭证,按需替换;当前为测试群)
 # WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=b8d398e2-f27e-42ce-af78-336867460122" # 测试
@@ -29,6 +32,80 @@ UPLOAD_URL_TMPL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key=
 FILE_MIN_BYTES = 5                    # 企微限制:文件不得小于 5 字节
 FILE_MAX_BYTES = 20 * 1024 * 1024     # 企微限制:文件不得大于 20MB
 
+# ---- 重试配置:仅对网络/TLS 类瞬断重试,业务错误码(errcode!=0)不重试 ----
+RETRY_TIMES = 4          # 最多尝试次数(含首次)
+RETRY_WAIT_MIN = 2       # 退避最小间隔(秒)
+RETRY_WAIT_MAX = 8       # 退避最大间隔(秒),间隔按 2/4/8 递增
+
+
+def after_log(retry_state):
+    """tenacity 重试回调,记录每次尝试的结果。
+
+    约定:被 @retry 装饰的函数首个位置参数为 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")
+
+
+@retry(stop=stop_after_attempt(RETRY_TIMES),
+       wait=wait_exponential(multiplier=1, min=RETRY_WAIT_MIN, max=RETRY_WAIT_MAX),
+       retry=retry_if_exception_type(requests.exceptions.RequestException),
+       after=after_log, reraise=True)
+def _do_upload_media(log, url: str, file_path: str) -> requests.Response:
+    """执行素材上传的内部函数,带重试;每次尝试都重新打开文件。
+
+    重点:multipart 上传的文件流被读过一次就到 EOF,重试必须重新 open 文件,
+    否则第二次会传空体。故把 open 放进被重试的函数体内,保证每次从头读。
+
+    Args:
+        log: 日志对象(首参约定,供 after_log 取用)。
+        url (str): upload_media 接口地址(已含 key 与 type)。
+        file_path (str): 待上传文件路径。
+
+    Returns:
+        requests.Response: HTTP 状态正常的响应对象。
+
+    Raises:
+        requests.exceptions.RequestException: 连接/TLS/超时/HTTP 状态异常,触发重试。
+    """
+    with open(file_path, "rb") as f:
+        # 素材字段名必须为 media,且要带文件名(群里展示的就是这个名字)
+        files = {"media": (os.path.basename(file_path), f, "application/octet-stream")}
+        resp = requests.post(url, files=files, timeout=(5, 60))
+    resp.raise_for_status()  # HTTP 非 2xx 抛 HTTPError(属 RequestException),触发重试
+    return resp
+
+
+@retry(stop=stop_after_attempt(RETRY_TIMES),
+       wait=wait_exponential(multiplier=1, min=RETRY_WAIT_MIN, max=RETRY_WAIT_MAX),
+       retry=retry_if_exception_type(requests.exceptions.RequestException),
+       after=after_log, reraise=True)
+def _post_json_with_retry(log, url: str, **kwargs) -> requests.Response:
+    """带重试的 JSON POST(发文本 / 文件消息共用)。
+
+    JSON 请求体是 bytes,可安全重试;仅网络/TLS 类异常触发重试,业务错误码由调用方判断。
+
+    Args:
+        log: 日志对象(首参约定,供 after_log 取用)。
+        url (str): 请求地址。
+        **kwargs: 透传给 requests.post 的参数(headers / data / timeout 等)。
+
+    Returns:
+        requests.Response: HTTP 状态正常的响应对象。
+
+    Raises:
+        requests.exceptions.RequestException: 连接/TLS/超时/HTTP 状态异常,触发重试。
+    """
+    resp = requests.post(url, **kwargs)
+    resp.raise_for_status()
+    return resp
+
 
 def _extract_key(webhook_url: str) -> str | None:
     """从群机器人 Webhook 地址里抽出 key(上传素材接口要单独拼 key)。
@@ -128,10 +205,9 @@ def send_wechat_group_msg(log=None, items=None, mentioned_list=None,
 
     try:
         log.info(f"正在发送企微消息: {title}")
-        resp = requests.post(WEBHOOK_URL, headers=headers,
-                             data=json.dumps(data, ensure_ascii=False).encode("utf-8"),
-                             timeout=(5, 30))
-        resp.raise_for_status()
+        resp = _post_json_with_retry(log, WEBHOOK_URL, headers=headers,
+                                     data=json.dumps(data, ensure_ascii=False).encode("utf-8"),
+                                     timeout=(5, 30))
         result = resp.json()
         if result.get("errcode") not in (0, None):  # 企微业务错误码非 0 也算失败
             log.error(f"企微消息发送失败: {result}")
@@ -139,7 +215,7 @@ def send_wechat_group_msg(log=None, items=None, mentioned_list=None,
         log.success("企微消息发送成功")
         return result
     except requests.exceptions.RequestException as e:
-        log.error(f"企微消息发送失败: {e}")
+        log.error(f"企微消息发送失败(重试{RETRY_TIMES}次后仍失败): {e}")
         return None
 
 
@@ -166,18 +242,14 @@ def _upload_media(log, file_path: str, media_type: str = "file") -> str | None:
 
     url = UPLOAD_URL_TMPL.format(key=key, media_type=media_type)
     try:
-        with open(file_path, "rb") as f:
-            # 素材字段名必须为 media,且要带文件名(群里展示的就是这个名字)
-            files = {"media": (os.path.basename(file_path), f, "application/octet-stream")}
-            resp = requests.post(url, files=files, timeout=(5, 60))
-        resp.raise_for_status()
+        resp = _do_upload_media(log, url, file_path)  # 带重试上传,内部每次重新打开文件
         result = resp.json()
-        if result.get("errcode") != 0:
+        if result.get("errcode") != 0:  # 业务错误(如 key 失效/文件超限),重试无意义,直接失败
             log.error(f"企微素材上传失败: {result}")
             return None
         return result.get("media_id")
     except requests.exceptions.RequestException as e:
-        log.error(f"企微素材上传异常: {e}")
+        log.error(f"企微素材上传异常(重试{RETRY_TIMES}次后仍失败): {e}")
         return None
 
 
@@ -204,9 +276,8 @@ def send_wechat_group_file(log=None, file_path: str = None) -> dict | None:
     data = {"msgtype": "file", "file": {"media_id": media_id}}
     try:
         log.info(f"正在发送企微文件: {os.path.basename(file_path)}")
-        resp = requests.post(WEBHOOK_URL, headers={"Content-Type": "application/json"},
-                             data=json.dumps(data).encode("utf-8"), timeout=(5, 30))
-        resp.raise_for_status()
+        resp = _post_json_with_retry(log, WEBHOOK_URL, headers={"Content-Type": "application/json"},
+                                     data=json.dumps(data).encode("utf-8"), timeout=(5, 30))
         result = resp.json()
         if result.get("errcode") != 0:
             log.error(f"企微文件发送失败: {result}")
@@ -214,7 +285,7 @@ def send_wechat_group_file(log=None, file_path: str = None) -> dict | None:
         log.success(f"企微文件发送成功: {os.path.basename(file_path)}")
         return result
     except requests.exceptions.RequestException as e:
-        log.error(f"企微文件发送失败: {e}")
+        log.error(f"企微文件发送失败(重试{RETRY_TIMES}次后仍失败): {e}")
         return None
 
 

+ 0 - 208
deca_spider/on_sale/deca_sold_spider.py

@@ -1,208 +0,0 @@
-# -*- coding: utf-8 -*-
-# Author : Charley
-# Python : 3.12.10
-# Date   : 2026/08/03
-"""得卡 DECA 商家历史成交采集爬虫(独立任务)。
-
-抓取每个商家的「历史成交」groupbuy/merchant/sold-list,INSERT IGNORE 写入 deca_sold_record。
-历史成交为终态、只增不改,用 product_code 唯一键去重增量采集。
-deca_sold_record 预留 report_state / replay_state,供后续通过 product_code / live_id
-采集「拆卡报告」与「视频回放」。
-
-鉴权:本接口需 Authorization Bearer(需 token),复用 deca_daily_spider 的登录/续期逻辑
-(优先 refreshToken 续期,避开阿里云验证码)。签名规则同样复用。
-"""
-import sys
-import time
-
-import schedule
-from loguru import logger
-from tenacity import retry, stop_after_attempt, wait_fixed
-from mysql_pool import MySQLConnectionPool
-import deca_daily_spider as deca  # 复用 do_request / ensure_token / make_signature / after_log
-
-# 覆盖 deca 导入时装的 logger handler,本任务日志独立到 sold_*.log
-logger.remove()
-logger.add("./logs/sold_{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")
-
-PAGE_SIZE = 20     # 历史成交每页条数(抓包实测 20)
-MAX_PAGES = 200    # 单商家历史成交翻页保护上限
-
-
-def parse_sold(item: dict) -> dict | None:
-    """把历史成交列表项解析成 deca_sold_record 一行。
-
-    Args:
-        item (dict): sold-list 的 data.list 项(结构同在售商品列表)。
-
-    Returns:
-        dict | None: 与 deca_sold_record 列对应的数据字典;无 code 时返回 None。
-    """
-    code = item.get("code")
-    if not code:
-        return None
-    m = item.get("merchant") or {}
-    return {
-        "product_code": code,
-        "merchant_user_id": str(m.get("merchantUserID")) if m.get("merchantUserID") else None,
-        "merchant_name": m.get("merchantName"),
-        "title": item.get("title"),
-        "card_product_title": item.get("cardProductTitle"),
-        "cover_image_url": item.get("coverImageUrl"),
-        "unit_price": item.get("unitPrice"),
-        "min_unit_price": item.get("minUnitPrice"),
-        "max_unit_price": item.get("maxUnitPrice"),
-        "card_count": item.get("cardCount"),
-        "sold_count": item.get("soldCount"),
-        "available_stock": item.get("availableStock"),
-        "groupbuy_status": item.get("groupbuyStatus"),
-        "groupbuy_status_name": item.get("groupbuyStatusName"),
-        "play_type": item.get("playType"),
-        "live_id": item.get("liveId"),
-        "completed_at": item.get("completedAt") or None,   # 成交完成时间
-        "publicity_at": item.get("publicityAt") or None,
-    }
-
-
-def get_sold_list(log, merchant_user_id: str, pool) -> int:
-    """翻页拉取某商家历史成交,INSERT IGNORE 写入 deca_sold_record。
-
-    历史成交为终态,用 product_code 唯一键去重,只增不改。
-
-    Args:
-        log: 日志对象。
-        merchant_user_id (str): 商家用户 ID。
-        pool (MySQLConnectionPool): MySQL 连接池。
-
-    Returns:
-        int: 本商家写入的历史成交商品数(已去重)。
-    """
-    page = 1
-    saved = 0
-    total = None
-    while page <= MAX_PAGES:
-        body = {"merchantUserId": merchant_user_id, "page": page, "pageSize": PAGE_SIZE}
-        try:
-            resp = deca.do_request(log, "/api/v1/app/groupbuy/merchant/sold-list", body, need_auth=True)
-        except Exception as e:
-            log.error(f"商家 {merchant_user_id} 历史成交第 {page} 页请求失败: {e}")
-            break
-        if not resp or resp.get("code") != 0:
-            log.info(f"商家 {merchant_user_id} 历史成交返回异常: {resp.get('msg') if resp else None}")
-            break
-
-        data = resp.get("data") or {}
-        if total is None:
-            total = data.get("total")
-        items = data.get("list") or []
-        if not items:
-            break
-
-        rows = [r for r in (parse_sold(it) for it in items) if r]
-        if rows:
-            pool.insert_many(table="deca_sold_record", data_list=rows, ignore=True)
-            saved += len(rows)
-
-        # 翻页终止:已覆盖 total,或本页不足一页
-        if total is not None and page * PAGE_SIZE >= total:
-            break
-        if len(items) < PAGE_SIZE:
-            break
-        page += 1
-        time.sleep(0.2)
-
-    if saved:
-        log.info(f"商家 {merchant_user_id} 历史成交入库 {saved} 个(total={total})")
-    return saved
-
-
-def fill_sold_details(log, pool) -> int:
-    """给未补详情(publish_at 为空)的历史成交商品补上架/开售/结束时间。
-
-    调 groupbuy/detail 取 publishAt/saleStartAt/saleEndAt。历史成交为终态,
-    每个商品补一次即可(publish_at 非空后不再重复拉)。
-
-    Args:
-        log: 日志对象。
-        pool (MySQLConnectionPool): MySQL 连接池。
-
-    Returns:
-        int: 本轮成功补详情的商品数。
-    """
-    rows = pool.select_all(
-        "SELECT product_code FROM deca_sold_record WHERE publish_at IS NULL") or []
-    log.info(f"待补详情历史成交商品 {len(rows)} 个")
-    filled = 0
-    for (code,) in rows:
-        try:
-            d = deca.get_product_detail(log, code)
-            if not d:
-                continue
-            pool.update_one(
-                "UPDATE deca_sold_record SET publish_at=%s, sale_start_at=%s, sale_end_at=%s "
-                "WHERE product_code=%s",
-                (d.get("publishAt") or None, d.get("saleStartAt") or None,
-                 d.get("saleEndAt") or None, code))
-            filled += 1
-        except Exception as e:
-            log.error(f"历史成交商品 {code} 补详情失败: {e}")
-        time.sleep(0.3)  # 轻微限速
-    return filled
-
-
-@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=deca.after_log)
-def main_task(log):
-    """遍历库内所有商家,采集历史成交写入 deca_sold_record。
-
-    商家名单取自 deca_shop_record(由 deca_daily_spider 维护)。
-
-    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:
-        deca.ensure_token(log)  # 预取 token(后续请求自动续期)
-        rows = pool.select_all("SELECT merchant_user_id FROM deca_shop_record")
-        uids = [r[0] for r in rows] if rows else []
-        log.info(f"待采集商家 {len(uids)} 个")
-        for uid in uids:
-            try:
-                get_sold_list(log, uid, pool)
-            except Exception as e:
-                log.error(f"get_sold_list error(商家 {uid}): {e}")
-            time.sleep(0.3)  # 轻微限速
-
-        # 补详情:给未补(publish_at 为空)的历史成交商品拉 groupbuy/detail 的上架/开售/结束时间
-        try:
-            n = fill_sold_details(log, pool)
-            log.info(f"历史成交补详情完成,本轮 {n} 个")
-        except Exception as e:
-            log.error(f"fill_sold_details error: {e}")
-    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():
-    """定时任务入口:每天 03:00 采集一次商家历史成交。"""
-    main_task(log=logger)  # 立即跑一次(调试时取消注释)
-
-    schedule.every().day.at("03:00").do(main_task, log=logger)
-    while True:
-        schedule.run_pending()
-        time.sleep(1)
-
-
-if __name__ == "__main__":
-    schedule_task()

+ 0 - 387
deca_spider/on_sale/deca_team_spider.py

@@ -1,387 +0,0 @@
-# -*- 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(HANDOFF 已说明)。
-
-运行:项目根目录 `python on_sale/deca_team_spider.py`
-"""
-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/请求 + application.yml
-_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-if _ROOT not in sys.path:
-    sys.path.insert(0, _ROOT)
-# 切到项目根:token.json / application.yml / ./logs 都在根目录,
-# 否则从 on_sale/ 目录里启动时 cwd=on_sale/ 找不到根目录的 token.json,team-options(需 token) 会一直失败
-os.chdir(_ROOT)
-
-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()

+ 0 - 79
deca_spider/on_sale/deca_track.py

@@ -1,79 +0,0 @@
-# -*- coding: utf-8 -*-
-# Author : Charley
-# Python : 3.12.10
-# Date   : 2026/8/3 15:36
-import sys
-from mysql_pool import MySQLConnectionPool
-import schedule
-import time
-from deca_daily_spider import do_request
-from deca_daily_spider import logger
-
-
-def main_task():
-    logger.info(f"开始运行 {sys._getframe().f_code.co_name} 得卡采集任务" + "." * 40)
-
-    sql_pool = MySQLConnectionPool(log=logger)
-    if not sql_pool.check_pool_health():
-        logger.error("数据库连接池异常")
-        raise RuntimeError("数据库连接池异常")
-
-    try:
-        url = "/api/v1/app/home/recommend/groupbuy/list"
-        data = {
-            "page": 1,
-            "pageSize": 20,
-            "categoryParam": 0
-        }
-        response = do_request(logger, url, body=data)
-        # print(response.json())
-        # print(response)
-        if response:
-
-            seen = set()
-            dict_list = []
-            data_list = response.get("data", {}).get("list", [])
-
-            for data in data_list:
-                merchant = data.get("merchant", {})
-                merchant_name = merchant.get("merchantName", "")
-                if merchant_name != '魔都兄弟球星卡':
-                    continue
-
-                product_code = data.get("code")
-                if product_code in seen:
-                    continue
-                seen.add(product_code)
-
-                dict_list.append({
-                    # "merchant_user_id": merchant.get("merchantUserID", ""),
-                    "merchant_name": merchant_name,
-                    "product_code": product_code,
-                    "title": data.get("title"),
-                    "unit_price": data.get("unitPrice", 0),
-                    "card_count": data.get("cardCount", 0),
-                    "sold_count": data.get("soldCount", 0),
-                })
-            sql_pool.insert_many(table="deca_track_record", data_list=dict_list)
-
-    except Exception as e:
-        logger.error(f"{sys._getframe().f_code.co_name} error: {e}")
-    finally:
-        logger.info(f"得卡采集 {sys._getframe().f_code.co_name} 运行结束,等待下一轮" + "." * 20)
-
-def schedule_task():
-    """定时任务入口:每天 09:00 与 15:00 各运行一次 main_task。"""
-    # main_task()  # 立即跑一次(调试时取消注释)
-
-    schedule.every(1).minutes.do(main_task)
-    while True:
-        schedule.run_pending()
-        time.sleep(1)
-
-
-if __name__ == '__main__':
-    schedule_task()
-
-
-
-

+ 2 - 1
deca_spider/on_sale/km_spdier.py

@@ -127,5 +127,6 @@ def main(product_code: str):
 
 
 if __name__ == "__main__":
-    code = sys.argv[1] if len(sys.argv) > 1 else "GB26080614002"
+    # GB26081928662 / GB26082068604 / GB26082040642
+    code = sys.argv[1] if len(sys.argv) > 1 else "GB26082040642"
     main(code)

+ 18 - 2
deca_spider/onsale_alert_spider.py

@@ -362,6 +362,11 @@ def _is_sale_ended(data: dict, now_ts: int) -> tuple[bool, str]:
     与 buy_record_spider.is_sale_ended 同逻辑,内联于此避免 import 采集脚本引入其模块级副作用。
     详情请求失败时 data 为空 dict,两条件均不命中返回未结束——保守,防抓取抖动误判下架。
 
+    预售/未开卖的车:详情接口 availableStock 也返回 0(库存未分配)、soldCount=0、saleStartAt 在未来,
+    仅凭 availableStock<=0 会把它误判为「售罄」→ 发出假的「一车结束」战报(2026/08/24 修复 #342
+    预售车:publishAt 21:26 发布预告、saleStartAt 22:45 才开卖,其间短暂闪现在售列表又撤下被误判结束)。
+    故加两道护栏:① saleStartAt 未到直接判未结束;② 售罄须 soldCount>0(确实卖过)。
+
     Args:
         data (dict): 商品详情接口(groupbuy/detail) data 层,可能为空 dict。
         now_ts (int): 当前时间戳(秒)。
@@ -369,9 +374,20 @@ def _is_sale_ended(data: dict, now_ts: int) -> tuple[bool, str]:
     Returns:
         tuple[bool, str]: (是否已结束, 原因文本);未结束时原因为空串。
     """
+    # ① 开卖时间未到 → 预售态,绝不算结束(预售车 availableStock=0 是「未分配」而非「卖光」)
+    start_text = data.get("saleStartAt")
+    if start_text:
+        try:
+            start_ts = time.mktime(time.strptime(start_text, "%Y-%m-%d %H:%M:%S"))
+            if now_ts < start_ts:
+                return False, ""
+        except (ValueError, OverflowError):
+            pass
+    # ② 售罄:库存<=0 且确实卖出过(soldCount>0),避免预售/未开卖车(soldCount=0)被误判售罄
     stock = data.get("availableStock")
-    if stock is not None and stock <= 0:
-        return True, f"售罄(availableStock={stock})"
+    sold = data.get("soldCount")
+    if stock is not None and stock <= 0 and sold is not None and sold > 0:
+        return True, f"售罄(availableStock={stock}, soldCount={sold})"
     end_text = data.get("saleEndAt")
     if end_text:
         try:

+ 90 - 0
deca_spider/stats.sql

@@ -0,0 +1,90 @@
+-- ============================================================
+-- 得卡 DECA 统计 SQL(面向领导三类需求:新增 / 商家账号 / 商品售卖进度)
+-- 说明:
+--   * 主表 gmt_create_time = 首次入库时间(upsert 不更新它),故可代表「新发现」的日期。
+--   * 售卖进度趋势取自每日快照表 deca_product_daily_record(按 snapshot_date)。
+--   * 用到窗口函数 LAG,需 MySQL 8.0+。
+-- ============================================================
+
+-- ========== 一、新增 ==========
+
+-- 1.1 每日新增商家数(按首次入库日)
+SELECT DATE(gmt_create_time) AS dt, COUNT(*) AS new_shops
+FROM deca_shop_record
+GROUP BY DATE(gmt_create_time)
+ORDER BY dt DESC;
+
+-- 1.2 每日新增商品数(按首次入库日)
+SELECT DATE(gmt_create_time) AS dt, COUNT(*) AS new_products
+FROM deca_product_record
+GROUP BY DATE(gmt_create_time)
+ORDER BY dt DESC;
+
+-- 1.3 今日新增(商家 + 商品)汇总
+SELECT
+  (SELECT COUNT(*) FROM deca_shop_record    WHERE DATE(gmt_create_time) = CURDATE()) AS today_new_shops,
+  (SELECT COUNT(*) FROM deca_product_record WHERE DATE(gmt_create_time) = CURDATE()) AS today_new_products;
+
+
+-- ========== 二、商家账号 ==========
+
+-- 2.1 商家账号总览(按在售团购数倒序)
+SELECT merchant_user_id, merchant_name, fans_count, active_groupbuy_count,
+       gmt_create_time AS first_seen, gmt_modified_time AS last_seen
+FROM deca_shop_record
+ORDER BY active_groupbuy_count DESC, fans_count DESC;
+
+-- 2.2 商家维度的在售商品与售卖情况(关联商品表实时汇总)
+SELECT s.merchant_user_id, s.merchant_name, s.fans_count,
+       COUNT(p.product_code)                         AS product_cnt,
+       COALESCE(SUM(p.sold_count), 0)                AS total_sold,
+       COALESCE(SUM(p.card_count), 0)                AS total_card,
+       ROUND(SUM(p.sold_count) / NULLIF(SUM(p.card_count), 0) * 100, 2) AS sold_pct
+FROM deca_shop_record s
+LEFT JOIN deca_product_record p ON p.merchant_user_id = s.merchant_user_id
+GROUP BY s.merchant_user_id, s.merchant_name, s.fans_count
+ORDER BY total_sold DESC;
+
+
+-- ========== 三、商品售卖进度 ==========
+
+-- 3.1 全部商品当前售卖进度(已售/总数/剩余/进度百分比)
+SELECT product_code, merchant_name, title,
+       sold_count, card_count, available_stock,
+       ROUND(sold_count / NULLIF(card_count, 0) * 100, 2) AS sold_pct,
+       groupbuy_status_name, unit_price, gmt_modified_time AS updated_at
+FROM deca_product_record
+ORDER BY sold_pct DESC;
+
+-- 3.2 单个商品的每日售卖进度趋势(把 :code 换成具体 product_code)
+SELECT snapshot_date, sold_count, available_stock, card_count,
+       ROUND(sold_count / NULLIF(card_count, 0) * 100, 2) AS sold_pct
+FROM deca_product_daily_record
+WHERE product_code = :code
+ORDER BY snapshot_date;
+
+-- 3.3 每个商品「每日新卖出」增量(今日累计已售 - 昨日累计已售)
+SELECT product_code, snapshot_date, sold_count,
+       sold_count - LAG(sold_count) OVER (PARTITION BY product_code ORDER BY snapshot_date) AS daily_sold
+FROM deca_product_daily_record
+ORDER BY product_code, snapshot_date;
+
+-- 3.4 全站每日售出增量汇总(当天所有商品比前一天多卖出的份数合计)
+SELECT snapshot_date,
+       SUM(daily_sold) AS total_daily_sold
+FROM (
+    SELECT product_code, snapshot_date,
+           sold_count - LAG(sold_count) OVER (PARTITION BY product_code ORDER BY snapshot_date) AS daily_sold
+    FROM deca_product_daily_record
+) t
+WHERE daily_sold IS NOT NULL
+GROUP BY snapshot_date
+ORDER BY snapshot_date DESC;
+
+-- 3.5 即将售罄 / 售罄商品(剩余库存少或进度高,便于关注热销)
+SELECT product_code, merchant_name, title, sold_count, card_count, available_stock,
+       ROUND(sold_count / NULLIF(card_count, 0) * 100, 2) AS sold_pct, groupbuy_status_name
+FROM deca_product_record
+WHERE card_count > 0
+ORDER BY sold_pct DESC, available_stock ASC
+LIMIT 50;

+ 253 - 94
deca_spider/stats/daily_report.py

@@ -9,34 +9,41 @@
 
 报告结构(2026/08/14 由单 Sheet 分区改为多 Sheet,每 sheet 独立列宽、蓝条只覆盖本表宽度):
     Sheet 平台总览 :平台汇总 + 当日组齐环比(vs 昨日同窗口) + 商家 GMV 集中度(Top1/3/5/10) + 口径脚注
-    Sheet 品系列榜:当日各系列 GMV 榜(Top,含占比)
+    Sheet 品类·系列榜:当日品类汇总(成团数/GMV/占比,品类由标题判定) + 各系列 GMV 榜(Top,含品类列与占比)
     Sheet 商家GMV榜 :当日组齐 GMV 前 N 商家(含占比)
     Sheet 运营节奏 :重点商家当日运营快照(新开团/已组齐/规格) + 平台组齐时段分布(近7日24h)
     Sheet 魔都明细 :881226408 汇总 + 每条明细(含「参与人数(购买记录)」与售卖进度里程碑列;
                     汇总下附「购买记录覆盖检测」= 成交团 vs 已采购买记录,标注漏采多少 T(团))
     Sheet 用户排行榜(魔都):881226408 买家榜(deca_buy_record 按 user_id 聚合,参与金额倒序,
                     含 参与车数 / 参与金额 / 车均消费)(2026/08/17 新增)
-    Sheet 卡皇明细 :274584650 汇总 + 每条明细
+    Sheet 卡皇明细 :274584650 汇总 + 每条明细(2026/08/24 起同魔都:真实买家口径 + 进度里程碑 + 覆盖检测)
+    Sheet 用户排行榜(卡皇):274584650 买家榜(2026/08/24 新增)
+    Sheet 尼卡明细 :538252487 汇总 + 每条明细(2026/08/24 新增,同魔都扩展明细)
+    Sheet 用户排行榜(尼卡):538252487 买家榜(2026/08/24 新增)
     Sheet 其他商家 :其余商家各一行汇总(中卡近似口径)
     注:原「魔都已售进度检测」独立 sheet 已于 2026/08/11 并入魔都明细(尾部到 25/50/75% 用时列)。
+    注:2026/08/24 起卡皇/尼卡也接入购买记录采集,明细升级为魔都同款扩展版并各带用户排行榜;
+        三家统称 REAL_BUYER_MIDS(真实买家口径),其余商家仍走中卡近似。
 
 口径说明:
     - 销售额 = SUM(COALESCE(team_total_amount, sold_count * unit_price))
               随机团(选队随机/剩余随机)按 teams 逐队精算(team_total_amount,2026/08/11 起,
               见 docs/选队随机与剩余随机_总价口径与采集_20260811.md);固定价团回落原公式。
     - 成团数 = 该时段成交的拼团商品数
-    - 参与人数(魔都汇总 & 明细口径) = deca_buy_record 去重买家 user_id(真实参团人头;仅 881226408
-                 采了购买记录)。魔都汇总「参与人数(真实买家)」= 跨其全部成交团去重(2026/08/14 起由
-                 中卡近似切为真实买家);明细「参与人数(本团)」= 各团单独去重,故明细逐团相加(人次) ≥ 汇总。
+    - 参与人数(魔都/卡皇/尼卡 汇总 & 明细口径) = deca_buy_record 去重买家 user_id(真实参团人头;
+                 REAL_BUYER_MIDS 三家采了购买记录,2026/08/24 起由仅魔都扩为三家)。各家汇总
+                 「参与人数(真实买家)」= 跨其全部成交团去重;明细「参与人数(本团)」= 各团单独去重,
+                 故明细逐团相加(人次) ≥ 汇总。
     - 中卡用户数(近似)(平台大盘/其他商家口径) = 拆卡报告 hit_user_nickname 去重(仅覆盖 report_state=1
                  有报告的商品;这些商家未采购买记录,只能用中卡用户近似,非真实参团人头,偏低)
     - 均拼单价 = 销售额 / 成团数
-    - 人均消费 = 销售额 / 参与人数(魔都为真实买家;平台/其他商家为按中卡近似,偏高,仅供参考)
+    - 人均消费 = 销售额 / 参与人数(魔都/卡皇/尼卡为真实买家;平台/其他商家为按中卡近似,偏高,仅供参考)
     - 卡密表 deca_kami_record 当前为空(FILL_KAMI 关),故无「球队」维度,明细按商品维度出。
 
 从项目根目录运行:python stats/daily_report.py(cwd=根目录,读根目录 application.yml)
 """
 import os
+import re
 import sys
 import time
 # 把项目根目录加入 import 路径:企微发送模块 auto_send_wx_msg.py 只在根目录留一份(WEBHOOK_URL 单点维护)
@@ -57,11 +64,17 @@ logger.add("./logs/daily_report_{time:YYYYMMDD}.log", encoding="utf-8", rotation
 # 企微发送:报告生成后把 Excel 发到企业微信群机器人(只发表格,不发图;群由 auto_send_wx_msg.WEBHOOK_URL 决定)
 SEND_WECHAT = True
 
-# 两个要出「汇总 + 明细」的重点商家;其余商家统一进「其他商家汇总」
-FOCUS_MERCHANTS = ["881226408", "274584650"]
-# 魔都兄弟球星卡:其每条明细走扩展版——多「参与人数」列(deca_buy_record 去重买家),
-# 尾部并入售卖进度里程碑(从 progress 表算到 25/50/75% 各用了多久;首张快照已越过阈值则留空)。仅本商家如此。
-MODDU_MID = "881226408"
+# 要出「汇总 + 明细」的重点商家;其余商家统一进「其他商家汇总」
+# (2026/08/24 新增尼卡拆卡 538252487;2026/08/25 新增文泰卡屋 591544726)
+FOCUS_MERCHANTS = ["881226408", "274584650", "538252487", "591544726"]
+# 已采真实购买记录(deca_buy_record)、可用「真实买家去重」口径的商家集合。
+# 2026/08/24:由单商家(仅魔都)扩为三家;2026/08/25:再加文泰——购买记录爬虫 buy_record_spider 现已并行采
+# 魔都/卡皇/尼卡/文泰,故这些家的:汇总参与人数(真实买家去重)、明细「参与人数(本团)」列、售卖进度里程碑
+# (到25/50/75%用时)、购买记录覆盖检测、用户排行榜,全部走真实买家口径(原仅魔都如此)。其余商家仍走中卡近似。
+REAL_BUYER_MIDS = {"881226408", "274584650", "538252487", "591544726"}
+# 商家 ID → 简称:用于「用户排行榜(简称)」的 sheet 名与标题、覆盖检测提示文案
+MERCHANT_SHORT_NAMES = {"881226408": "魔都", "274584650": "卡皇", "538252487": "尼卡", "591544726": "文泰"}
+MODDU_MID = "881226408"  # 保留:魔都为首个接入真实购买记录的商家,扩展明细列结构/sheet 顺序以其为基准
 OUT_PREFIX = "得卡已售每日报告"     # 输出文件名前缀,实际文件名后缀加运行当天日期
 
 # 时间窗过滤(p 别名):[昨天17:00, 今天06:00](2026/08/15 由 03:00 延到 06:00,凌晨仍在播)
@@ -77,9 +90,10 @@ DETAIL_COLS = [
     ("中卡人数", "中卡人数", False),   # 该团拆卡报告 hit_user_nickname 去重(中卡近似),放开售时间前
     ("开售时间", "开售时间", False), ("成交时间", "成交时间", False), ("售卖时长", "售卖时长", False),
 ]
-# 魔都(881226408)专属明细:在「中卡人数」前插「参与人数」(deca_buy_record 去重买家 user_id),
-# 并在尾部并入售卖进度里程碑(到 25/50/75% 用时,源 deca_onsale_product_progress_record)。
-# 原「魔都已售进度检测」独立 sheet 于 2026/08/11 并入本明细,不再单独出 sheet。
+# 已采购买记录商家(REAL_BUYER_MIDS:魔都/卡皇/尼卡)扩展明细:在「中卡人数」前插「参与人数」
+# (deca_buy_record 去重买家 user_id),并在尾部并入售卖进度里程碑(到 25/50/75% 用时,源
+# deca_onsale_product_progress_record)。原「魔都已售进度检测」独立 sheet 于 2026/08/11 并入本明细。
+# 常量名沿用 MODDU 前缀(历史沿革),2026/08/24 起卡皇/尼卡明细也复用此列规格。
 MODDU_DETAIL_COLS = [
     ("序号", "序号", False), ("团名(商品标题)", "团名", False),
     ("系列", "系列", False), ("类型", "类型", False), ("单价", "单价", True),
@@ -95,16 +109,17 @@ SECTION_SPAN = len(MODDU_DETAIL_COLS)
 # 汇总表指标键(商家/平台,dict 取值键,与显示标签解耦)
 SUMMARY_HEADERS = ["销售额", "成团数", "参与人数", "均拼单价", "人均消费"]
 
-# 平台大盘竖排汇总行:(显示标签, dict取值键)。参与人数为「魔都真实买家 + 其他商家中卡去重」
-# 的混合口径(2026/08/14 起,见 fetch_platform_summary),故标签显式标注,避免误当纯真实人头。
+# 平台大盘竖排汇总行:(显示标签, dict取值键)。参与人数为「REAL_BUYER_MIDS(魔都/卡皇/尼卡/文泰…)真实买家
+# + 其他商家中卡去重」的混合口径(2026/08/14 起、真实买家逐步扩到多家,见 fetch_platform_summary),
+# 标签用「重点商家真实」不写死家数,新增商家无需再改此处,避免误当纯真实人头。
 PLATFORM_ROWS = [
     ("商家数", "商家数"), ("销售额", "销售额"), ("成团数", "成团数"),
-    ("参与人数(魔都真实+其他中卡)", "参与人数"), ("均拼单价", "均拼单价"),
+    ("参与人数(重点商家真实+其他中卡)", "参与人数"), ("均拼单价", "均拼单价"),
     ("人均消费", "人均消费"),
 ]
 
 # 其他商家汇总表列:(显示表头, dict取值键, 是否金额格式)。这些商家未采购买记录,参与人数
-# 只能用中卡用户近似,故表头标注「(近似)」,与魔都真实买家口径区分。
+# 只能用中卡用户近似,故表头标注「(近似)」,与魔都/卡皇/尼卡的真实买家口径区分。
 OTHER_COLS = [
     ("商家名", "商家名", False), ("销售额", "销售额", True), ("成团数", "成团数", False),
     ("中卡用户数(近似)", "参与人数", False), ("均拼单价", "均拼单价", True),
@@ -117,7 +132,7 @@ WIN_P_YDAY = ("p.completed_at >= (CURDATE() - INTERVAL 2 DAY) + INTERVAL 17 HOUR
               "AND p.completed_at <= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 6 HOUR")
 TOP_SERIES = 15              # 产品系列销售榜展示条数
 TOP_MERCHANT = 10            # 商家 GMV 榜展示条数(监测清单要「GMV前十商家」)
-TOP_USERS = None              # 魔都用户排行榜展示条数(按参与金额倒序取前 N;仅魔都采了购买记录)
+TOP_USERS = None              # 用户排行榜展示条数(按参与金额倒序取前 N;魔都/卡皇/尼卡各出一榜,None=全展示)
 CONC_TOPS = (1, 3, 5, 10)    # GMV 集中度统计的 TopN 档(Top1/3/5/10 占平台总 GMV)
 HOUR_DIST_DAYS = 7           # 组齐时段分布回看天数(反映平台 24h 组齐节奏)
 
@@ -193,9 +208,10 @@ def get_window(pool) -> tuple[str, str]:
 def fetch_platform_summary(pool) -> dict:
     """统计平台大盘汇总(时间窗内全部已售商品)。
 
-    参与人数为混合口径(2026/08/14 起):魔都(881226408)采了真实购买记录,用 deca_buy_record
-    去重真实买家;其余商家未采购买记录,仍用拆卡报告 hit_user_nickname 去重的中卡用户近似。
-    两部分人群标识不同(魔都=user_id,其他=昵称)、无法跨口径去重,故直接相加,属近似上界。
+    参与人数为混合口径(2026/08/14 起;2026/08/24 真实买家由仅魔都扩到魔都/卡皇/尼卡三家):
+    REAL_BUYER_MIDS 三家采了真实购买记录,用 deca_buy_record 去重真实买家;其余商家未采购买记录,
+    仍用拆卡报告 hit_user_nickname 去重的中卡用户近似。两部分人群标识不同(真实买家=user_id,
+    其他=昵称)、且三家真实买家之间也未跨商家去重(同一 user_id 跨家买会各记一次),故直接相加,属近似上界。
 
     Args:
         pool (MySQLConnectionPool): MySQL 连接池。
@@ -203,25 +219,28 @@ def fetch_platform_summary(pool) -> dict:
     Returns:
         dict: 含 销售额/商家数/成团数/参与人数/均拼单价/人均消费 六项。
     """
+    real_mids = list(REAL_BUYER_MIDS)
+    ph = ",".join(["%s"] * len(real_mids))   # 中卡子查询要排除全部真实买家商家
     sql = f"""
     SELECT
         ROUND(SUM(COALESCE(p.team_total_amount, p.sold_count * p.unit_price)), 2) AS amount,
         COUNT(DISTINCT p.merchant_user_id)                               AS merchants,
         COUNT(*)                                                          AS grp,
-        -- 其他商家(非魔都)中卡用户去重;魔都单独用真实买家,不计入此子查询
+        -- 非真实买家商家的中卡用户去重;魔都/卡皇/尼卡单独用真实买家,不计入此子查询
         (SELECT COUNT(DISTINCT r.hit_user_nickname)
            FROM deca_report_record r
            JOIN deca_product_record pp ON pp.product_code = r.product_code
-          WHERE pp.merchant_user_id <> %s
+          WHERE pp.merchant_user_id NOT IN ({ph})
             AND pp.completed_at >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR
             AND pp.completed_at <= CURDATE() + INTERVAL 6 HOUR
             AND r.hit_user_nickname IS NOT NULL AND r.hit_user_nickname <> '') AS others_people
     FROM deca_product_record p
     WHERE {WIN_P} AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
     """
-    amount, merchants, groups, others_people = pool.select_all(sql, (MODDU_MID,))[0]
-    # 平台参与人数 = 魔都真实买家(deca_buy_record 去重) + 其他商家中卡用户去重
-    people = _fetch_real_buyers(pool, MODDU_MID) + (others_people or 0)
+    amount, merchants, groups, others_people = pool.select_all(sql, tuple(real_mids))[0]
+    # 平台参与人数 = 三家真实买家(deca_buy_record 各自去重后求和) + 其他商家中卡用户去重
+    real_people = sum(_fetch_real_buyers(pool, m) for m in real_mids)
+    people = real_people + (others_people or 0)
     return _pack_summary(amount, groups, people, extra={"商家数": merchants})
 
 
@@ -253,9 +272,9 @@ def fetch_merchant_summary(pool, mid: str) -> dict:
     """
     row = pool.select_all(sql, (mid, mid))
     mname, amount, groups, people = row[0] if row else (None, None, 0, 0)
-    # 魔都(881226408)采了真实购买记录:参与人数改用 deca_buy_record 去重真实买家,人均消费随之
-    # 按真实人头计(覆盖上面 people 的中卡近似值);其余重点商家无购买记录,仍沿用中卡近似。
-    if mid == MODDU_MID:
+    # 魔都/卡皇/尼卡(REAL_BUYER_MIDS)采了真实购买记录:参与人数改用 deca_buy_record 去重真实买家,
+    # 人均消费随之按真实人头计(覆盖上面 people 的中卡近似值);其余重点商家无购买记录,仍沿用中卡近似。
+    if mid in REAL_BUYER_MIDS:
         people = _fetch_real_buyers(pool, mid)
     d = _pack_summary(amount, groups, people)
     d["商家名"] = mname or mid
@@ -371,7 +390,8 @@ def _pack_summary(amount, groups, people, extra: dict = None) -> dict:
 def _fetch_real_buyers(pool, mid: str) -> int:
     """查某商家时间窗内 deca_buy_record 去重真实买家数(跨其全部成交团)。
 
-    仅魔都(881226408)采了真实购买记录,故只有它能用此口径;其余商家该表无数据、返回 0。
+    魔都/卡皇/尼卡(REAL_BUYER_MIDS)采了真实购买记录,可用此口径(2026/08/24 由仅魔都扩为三家);
+    其余商家该表无数据、返回 0。
 
     Args:
         pool (MySQLConnectionPool): MySQL 连接池。
@@ -396,7 +416,7 @@ def _summary_rows(is_real: bool) -> list[tuple]:
     """按参与人数口径生成重点商家竖排汇总的(显示标签, 取值键)行规格。
 
     Args:
-        is_real (bool): True=该商家参与人数为 deca_buy_record 真实买家(魔都),标签用
+        is_real (bool): True=该商家参与人数为 deca_buy_record 真实买家(魔都/卡皇/尼卡),标签用
             「参与人数(真实买家)」;False=中卡用户近似,标签用「中卡用户数(近似)」,人均消费
             标签相应标注「(按中卡近似)」。
 
@@ -410,7 +430,10 @@ def _summary_rows(is_real: bool) -> list[tuple]:
 
 
 def fetch_moddu_details(pool, mid: str) -> list[dict]:
-    """取「魔都」商家(mid)时间窗内每个拼团(组队)的扩展明细,按总金额倒序。
+    """取某「已采真实购买记录」商家(mid)时间窗内每个拼团(组队)的扩展明细,按总金额倒序。
+
+    2026/08/24:函数名沿用 moddu(魔都),但已泛化到 REAL_BUYER_MIDS 三家(魔都/卡皇/尼卡)——
+    SQL 全按 mid 参数查、对任意商家成立,故复用同一函数、按传入 mid 出各商家扩展明细。
 
     在标准明细基础上多两类字段(原「魔都已售进度检测」独立 sheet 于 2026/08/11 并入此处):
         - 参与人数:deca_buy_record 去重买家 user_id(真实参团人头;仅本商家采了购买记录)。
@@ -478,7 +501,7 @@ def fetch_moddu_details(pool, mid: str) -> list[dict]:
 
 
 def fetch_moddu_user_ranking(pool, mid: str, top_n: int) -> tuple[list[dict], int]:
-    """取「魔都」商家时间窗内的用户参与排行(按参与金额倒序,取前 top_n)。
+    """取某「已采真实购买记录」商家时间窗内的用户参与排行(按参与金额倒序,取前 top_n)。
 
     「一个拼团商品 = 一辆车(组队)」,以 deca_buy_record 购买记录按买家 user_id 聚合:
         - 参与车数 = COUNT(DISTINCT product_code),该买家窗口内参与的不同团数。
@@ -486,7 +509,7 @@ def fetch_moddu_user_ranking(pool, mid: str, top_n: int) -> tuple[list[dict], in
           「购买份数 × 团单价」估算;固定价团精确,随机团(选队随机/剩余随机)每队价不同,
           此处按标称单价近似。同一买家在同一团的多条购买记录已由 SUM 累加。
         - 车均消费 = 参与金额 ÷ 参与车数。
-    仅魔都(881226408)采了购买记录,故只有它能出此榜
+    魔都/卡皇/尼卡(REAL_BUYER_MIDS)采了购买记录,均可出此榜(2026/08/24 由仅魔都扩为三家)
 
     Args:
         pool (MySQLConnectionPool): MySQL 连接池。
@@ -526,11 +549,12 @@ def fetch_moddu_user_ranking(pool, mid: str, top_n: int) -> tuple[list[dict], in
 
 
 def fetch_moddu_missing_teams(pool, mid: str) -> dict:
-    """对比「魔都」成交明细与购买记录覆盖,算出漏采购买记录的 T(团)。
+    """对比某「已采真实购买记录」商家成交明细与购买记录覆盖,算出漏采购买记录的 T(团)。
 
-    魔都明细每条 = 一个成交拼团商品(T),来自 deca_product_record;购买记录 deca_buy_record
-    是另路采集的。个别团在采到购买记录前就满仓成交下架,会「漏采」——本函数以时间窗内成交
-    团为基准,找出 deca_buy_record 里没有对应 product_code 的团,供魔都明细标注覆盖缺口。
+    2026/08/24:函数名沿用 moddu,但已泛化到 REAL_BUYER_MIDS 三家(魔都/卡皇/尼卡),按 mid 参数查。
+    明细每条 = 一个成交拼团商品(T),来自 deca_product_record;购买记录 deca_buy_record 是另路采集的。
+    个别团在采到购买记录前就满仓成交下架,会「漏采」——本函数以时间窗内成交团为基准,找出
+    deca_buy_record 里没有对应 product_code 的团,供各商家明细标注覆盖缺口。
 
     Args:
         pool (MySQLConnectionPool): MySQL 连接池。
@@ -778,28 +802,122 @@ def fetch_groupbuy_compare(pool) -> dict:
     return {"today": _window_metrics(pool, WIN_P), "yday": _window_metrics(pool, WIN_P_YDAY)}
 
 
-def fetch_series_ranking(pool, top_n: int) -> tuple[list, float]:
-    """取当日窗口内各产品系列的销售榜(按 GMV 倒序)及全窗口总 GMV(算占比用)。
+def classify_category(title: str, series: str = "") -> str:
+    """从标题(+系列名)判定得卡品类(平台无独立品类字段,只能按关键词判)。
+
+    规则:优先中文运动词(篮球/NBA、足球/FIFA/世界杯、棒球/MLB、橄榄/NFL)——运动词多作标题前缀出现、判准率高;
+    再判 TCG(宝可梦、海贼王、游戏王);都不中归「其他」。实测 2053 团仅 5 个落「其他」,且那 5 个标题本就写
+    「其他运动」/为冷门 TCG(Weiss Schwarz),判类可靠。
+
+    Args:
+        title (str): 商品标题。
+        series (str, optional): 系列名(series_name),一并参与匹配。Defaults to ""。
+
+    Returns:
+        str: 品类名(篮球/足球/棒球/橄榄球/宝可梦/海贼王/游戏王/其他)。
+    """
+    t = f"{title} {series}"
+    tl = t.lower()
+    if "篮球" in t or "nba" in tl:
+        return "篮球"
+    if "足球" in t or "fifa" in tl or "世界杯" in t or "英超" in t or "欧冠" in t:
+        return "足球"
+    if "棒球" in t or "mlb" in tl:
+        return "棒球"
+    if "橄榄" in t or "nfl" in tl:
+        return "橄榄球"
+    if (any(k in t for k in ["宝可梦", "寶可夢", "皮卡丘", "朋友派对", "乐园腾龙", "绿宝石",
+                             "超级梦想", "超梦", "卡牌151", "黑白闪", "狂热", "朱紫"])
+            or re.search(r"\bsv\d", tl) or " ex " in f" {tl} "):
+        return "宝可梦"
+    if (any(k in t for k in ["海贼", "航海王", "路飞", "艾斯", "索隆", "娜美"])
+            or re.search(r"op-?\d", tl) or re.search(r"st-?\d", tl)):
+        return "海贼王"
+    if "游戏王" in t or "遊戲王" in t or "ygo" in tl:
+        return "游戏王"
+    return "其他"
+
+
+def fetch_category_series(pool, top_n: int) -> tuple[list, float, list, float]:
+    """取当日窗口内「品类汇总」与「系列榜」(均按 GMV 倒序),供品类·系列榜 sheet。
+
+    平台无品类字段,逐团用 classify_category(标题+系列) 判类后在 Python 聚合:
+      - 品类汇总:每品类的 成团数 / GMV(全部品类,倒序)。
+      - 系列榜:每系列的 成团数 / GMV / 所属品类(取该系列下出现团数最多的品类),按 GMV 取前 top_n。
 
     Args:
         pool (MySQLConnectionPool): MySQL 连接池。
-        top_n (int): 取前 N 个系列。
+        top_n (int): 系列榜取前 N。
 
     Returns:
-        tuple[list, float]: (rows, total_gmv);rows 每项 (系列名, 成团数, GMV)。
+        tuple[list, float, list, float]: (cat_rows, cat_total, series_rows, series_total)。
+            cat_rows 每项 (品类, 成团数, GMV);series_rows 每项 (系列, 品类, 成团数, GMV);
+            两个 total 为 GMV 合计(算占比分母,二者相等=全窗口 GMV)。
     """
     sql = f"""
-    SELECT COALESCE(NULLIF(p.series_name, ''), '(未标系列)')        AS series,
-           COUNT(*)                                                 AS grp,
-           ROUND(SUM(COALESCE(p.team_total_amount, p.sold_count * p.unit_price)), 2) AS gmv
+    SELECT p.title, COALESCE(NULLIF(p.series_name, ''), '(未标系列)') AS series,
+           ROUND(COALESCE(p.team_total_amount, p.sold_count * p.unit_price), 2) AS gmv
     FROM deca_product_record p
     WHERE {WIN_P} AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
-    GROUP BY series
-    ORDER BY gmv DESC
     """
     rows = pool.select_all(sql) or []
-    total = sum(float(r[2]) for r in rows if r[2] is not None)   # 全部系列合计(算占比分母)
-    return rows[:top_n], total
+    cat_agg = {}     # 品类 -> [成团数, GMV]
+    ser_agg = {}     # 系列 -> {"grp": n, "gmv": x, "cat": {品类: 团数}}
+    for title, series, gmv in rows:
+        g = float(gmv) if gmv is not None else 0.0
+        cat = classify_category(title or "", series or "")
+        ca = cat_agg.setdefault(cat, [0, 0.0]); ca[0] += 1; ca[1] += g
+        sa = ser_agg.setdefault(series, {"grp": 0, "gmv": 0.0, "cat": {}})
+        sa["grp"] += 1; sa["gmv"] += g
+        sa["cat"][cat] = sa["cat"].get(cat, 0) + 1
+    total = round(sum(v[1] for v in cat_agg.values()), 2)
+    cat_rows = sorted([(k, v[0], round(v[1], 2)) for k, v in cat_agg.items()],
+                      key=lambda x: x[2], reverse=True)
+    series_rows = sorted(
+        [(name, max(v["cat"], key=v["cat"].get), v["grp"], round(v["gmv"], 2))
+         for name, v in ser_agg.items()],
+        key=lambda x: x[3], reverse=True)[:top_n]
+    return cat_rows, total, series_rows, total
+
+
+# 需维护 category 品类字段的表:品类由 classify_category 从标题判定后落库,方便直接按品类查询/聚合(GROUP BY category)
+CATEGORY_TABLES = ("deca_product_record", "deca_onsale_product_record")
+
+
+def backfill_category(pool, tables: tuple = CATEGORY_TABLES, only_null: bool = True) -> int:
+    """把品类判定结果(classify_category)落库到各表的 category 列,返回累计更新行数。
+
+    平台无品类字段、品类靠标题判定;本函数将判定结果写入 category 列,供后续直接按品类查库(无需每次重算)。
+    分类逻辑与报告共用 classify_category,口径一致。按品类分组、分块 UPDATE,减少 SQL 往返。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        tables (tuple[str], optional): 要回填的表名。Defaults to CATEGORY_TABLES。
+        only_null (bool, optional): True 只补 category IS NULL 的行(日常报告前调用,仅补新增,成本低);
+            False 全量重算(分类规则调整后手动全刷一次)。Defaults to True。
+
+    Returns:
+        int: 累计更新行数。
+    """
+    from collections import defaultdict
+    total = 0
+    for tbl in tables:
+        where = "WHERE category IS NULL" if only_null else ""
+        rows = pool.select_all(
+            f"SELECT product_code, title, COALESCE(series_name, '') FROM {tbl} {where}") or []
+        by_cat = defaultdict(list)
+        for code, title, series in rows:
+            if code is None:
+                continue
+            by_cat[classify_category(title or "", series or "")].append(code)
+        for cat, codes in by_cat.items():
+            for i in range(0, len(codes), 500):            # 分块,避免超长 IN 列表
+                chunk = codes[i:i + 500]
+                ph = ",".join(["%s"] * len(chunk))
+                pool.update_one(
+                    f"UPDATE {tbl} SET category=%s WHERE product_code IN ({ph})", (cat, *chunk))
+        total += sum(len(c) for c in by_cat.values())
+    return total
 
 
 def fetch_merchant_gmv_ranking(pool) -> tuple[list, float, dict]:
@@ -1052,22 +1170,33 @@ def _build_overview_sheet(ws, win: tuple, platform: dict, compare: dict,
         r += 1
 
 
-def _build_series_sheet(ws, series_rows: list, series_total: float):
-    """构建「产品系列榜」sheet:当日各系列 GMV 榜 + 占比。
+def _build_category_series_sheet(ws, cat_rows: list, cat_total: float,
+                                 series_rows: list, series_total: float):
+    """构建「品类·系列榜」sheet:上段品类汇总(成团数/GMV/占比) + 下段系列榜(带品类列)。
 
     Args:
         ws: openpyxl worksheet。
-        series_rows (list): [(系列名, 成团数, GMV)]。
-        series_total (float): 全窗口总 GMV(算占比分母)。
+        cat_rows (list): [(品类, 成团数, GMV)],按 GMV 倒序(全部品类)。
+        cat_total (float): 品类 GMV 合计(算占比分母)。
+        series_rows (list): [(系列, 品类, 成团数, GMV)],按 GMV 倒序、已截断 Top N。
+        series_total (float): 系列 GMV 合计(=全窗口 GMV,算占比分母)。
     """
-    _set_widths(ws, [36, 10, 16, 10])
-    r = _write_section_title(ws, 1, f"产品系列销售榜(当日 Top{TOP_SERIES},按 GMV)", span=4)
-    srows = [[name, int(g), float(gmv) if gmv is not None else 0,
-              (float(gmv) / series_total if (series_total and gmv is not None) else None)]
-             for name, g, gmv in series_rows]
-    _write_hgrid(ws, r, ["系列", "成团数", "GMV", "占比"], srows,
-                 money_cols=(2,), pct_cols=(3,), start_col=1)
-    ws.freeze_panes = "A3"   # 冻结标题条 + 表头
+    _set_widths(ws, [36, 12, 16, 10, 10])
+    # 上段:品类汇总(成团数 + GMV + 占比)
+    r = _write_section_title(ws, 1, "品类汇总(当日,按 GMV 倒序;品类由标题判定)", span=5)
+    crows = [[c, int(g), float(gmv),
+              (float(gmv) / cat_total if cat_total else None)] for c, g, gmv in cat_rows]
+    r = _write_hgrid(ws, r, ["品类", "成团数", "GMV", "占比"], crows,
+                     money_cols=(2,), pct_cols=(3,), start_col=1)
+    r += 1
+    # 下段:系列榜(比原版多「品类」列)
+    r = _write_section_title(ws, r, f"产品系列销售榜(当日 Top{TOP_SERIES},按 GMV)", span=5)
+    srows = [[name, cat, int(g), float(gmv),
+              (float(gmv) / series_total if series_total else None)]
+             for name, cat, g, gmv in series_rows]
+    _write_hgrid(ws, r, ["系列", "品类", "成团数", "GMV", "占比"], srows,
+                 money_cols=(3,), pct_cols=(4,), start_col=1)
+    ws.freeze_panes = "A2"   # 冻结顶部品类汇总标题
 
 
 def _build_mrank_sheet(ws, mrank_rows: list, mrank_total: float):
@@ -1109,7 +1238,8 @@ def _build_ops_sheet(ws, ops: list, hour_dist: list):
 
 
 def _build_detail_sheet(ws, title: str, summ: dict, details: list, cols: list,
-                        is_real: bool, span: int, widths: list, miss_info: dict = None):
+                        is_real: bool, span: int, widths: list, miss_info: dict = None,
+                        short_name: str = None):
     """构建单个重点商家的明细 sheet:汇总(缩到 B/C 列) + 每条组队明细(从 A 列起)。
 
     Args:
@@ -1118,23 +1248,26 @@ def _build_detail_sheet(ws, title: str, summ: dict, details: list, cols: list,
         summ (dict): 该商家汇总数据。
         details (list[dict]): 每条组队明细。
         cols (list[tuple]): 明细列规格(DETAIL_COLS / MODDU_DETAIL_COLS)。
-        is_real (bool): 参与人数是否真实买家口径(魔都 True,其余 False)。
+        is_real (bool): 参与人数是否真实买家口径(魔都/卡皇/尼卡 True,其余 False)。
         span (int): 标题条覆盖列数(= 明细列数)。
         widths (list[float]): 各列宽度。
         miss_info (dict, optional): 购买记录覆盖检测(fetch_moddu_missing_teams 返回);非 None
-            时在汇总块下方加「购买记录覆盖检测」小节,标注漏采多少 T 并列出漏团。仅魔都传入。
-            Defaults to None。
+            时在汇总块下方加「购买记录覆盖检测」小节,标注漏采多少 T 并列出漏团。仅已采购买记录的
+            商家(魔都/卡皇/尼卡)传入。Defaults to None。
+        short_name (str, optional): 该商家简称(魔都/卡皇/尼卡),用于覆盖检测提示里指向对应
+            「用户排行榜(简称)」sheet。Defaults to None。
     """
     _set_widths(ws, widths)
     r = _write_section_title(ws, 1, title, span=span)
     # 汇总缩到 B/C 列:标签落宽的 B(团名列)、数值落 C,避开 A=序号 的窄列
     r = _write_summary_block(ws, r, _summary_rows(is_real=is_real), summ, start_col=2)
     r += 1
-    # 购买记录覆盖检测(仅魔都传入):成交团 vs 已采购买记录,漏采的 T 逐个列出(文本向右溢出显示)
+    # 购买记录覆盖检测(仅已采购买记录商家传入):成交团 vs 已采购买记录,漏采的 T 逐个列出(文本向右溢出显示)
     if miss_info is not None:
         r = _write_section_title(ws, r, "购买记录覆盖检测(成交团 vs 已采购买记录)", span=span)
+        rank_sheet = f"用户排行榜({short_name})" if short_name else "用户排行榜"
         cov = (f"成交 {miss_info['成交团数']} 团 · 采到购买记录 {miss_info['有记录团数']} 团 · "
-               f"漏采 {miss_info['漏采团数']} 团(用户排行见「用户排行榜(魔都)」sheet)")
+               f"漏采 {miss_info['漏采团数']} 团(用户排行见「{rank_sheet}」sheet)")
         # 漏采 >0 时用深蓝加粗字提醒;0 时常规字
         ws.cell(row=r, column=1, value=cov).font = FONT_HEADER if miss_info["漏采团数"] else FONT_CELL
         r += 1
@@ -1152,19 +1285,22 @@ def _build_detail_sheet(ws, title: str, summ: dict, details: list, cols: list,
     ws.freeze_panes = f"A{hdr_row + 1}"          # 冻结到明细表头,滚动时表头常驻
 
 
-def _build_user_ranking_sheet(ws, rows: list, total_users: int, top_n: int):
-    """构建「用户排行榜(魔都)」sheet:按参与金额倒序的买家榜(参与车数/参与金额/车均消费)。
+def _build_user_ranking_sheet(ws, rows: list, total_users: int, top_n: int, short_name: str = "魔都"):
+    """构建「用户排行榜(简称)」sheet:按参与金额倒序的买家榜(参与车数/参与金额/车均消费)。
+
+    2026/08/24:由仅魔都泛化到魔都/卡皇/尼卡三家,靠 short_name 区分标题与所属商家。
 
     Args:
         ws: openpyxl worksheet。
         rows (list[dict]): 用户排行数据(fetch_moddu_user_ranking 返回,已倒序截断)。
-        total_users (int): 窗口内魔都全部参与买家数(用于标题展示)。
+        total_users (int): 窗口内该商家全部参与买家数(用于标题展示)。
         top_n (int): 榜单展示上限(用于标题展示)。
+        short_name (str, optional): 商家简称(魔都/卡皇/尼卡),用于标题。Defaults to "魔都"。
     """
     _set_widths(ws, [8, 22, 16, 12, 16, 14])
     cap = "全部展示" if top_n is None else f"取前 {min(len(rows), top_n)}"
     r = _write_section_title(
-        ws, 1, f"用户排行榜 · 魔都(共 {total_users} 人参与,{cap},按参与金额倒序)", span=6)
+        ws, 1, f"用户排行榜 · {short_name}(共 {total_users} 人参与,{cap},按参与金额倒序)", span=6)
     grid = [[i + 1, d["用户昵称"], d["user_id"], d["参与车数"], d["参与金额"], d["车均消费"]]
             for i, d in enumerate(rows)]
     r = _write_hgrid(ws, r, ["排名", "用户昵称", "user_id", "参与车数", "参与金额", "车均消费"],
@@ -1213,44 +1349,63 @@ def _build_others_sheet(ws, others: list):
 DETAIL_WIDTHS_MODDU = [8, 48, 16, 11, 13, 9, 8, 14, 14, 19, 19, 12, 11, 11, 11]  # 15 列(含里程碑)
 DETAIL_WIDTHS_STD = [8, 48, 16, 11, 13, 9, 8, 14, 19, 19, 12]                    # 11 列(标准)
 # 重点商家 ID → 明细 sheet 名(其余走商家名兜底)
-DETAIL_SHEET_NAMES = {"881226408": "魔都明细", "274584650": "卡皇明细"}
+DETAIL_SHEET_NAMES = {"881226408": "魔都明细", "274584650": "卡皇明细", "538252487": "尼卡明细", "591544726": "文泰明细"}
 
 
 def build_report(pool, out: str):
     """汇总各段数据并生成多 Sheet Excel 报告(每一大项一个 sheet,各自独立列宽)。
 
-    Sheet 顺序:平台总览 / 产品系列榜 / 商家GMV榜 / 运营节奏 / 魔都明细 / 用户排行榜(魔都) /
-        卡皇明细 / 其他商家。
+    Sheet 顺序(2026/08/24 起卡皇/尼卡、08/25 起文泰均走真实买家扩展明细 + 各带用户排行榜):
+        平台总览 / 品类·系列榜 / 商家GMV榜 / 运营节奏 /
+        魔都明细 / 用户排行榜(魔都) / 卡皇明细 / 用户排行榜(卡皇) / 尼卡明细 / 用户排行榜(尼卡) /
+        文泰明细 / 用户排行榜(文泰) / 其他商家。
+        规则:FOCUS_MERCHANTS 逐个出明细 sheet;属 REAL_BUYER_MIDS 的商家紧跟其「用户排行榜(简称)」。
 
     Args:
         pool (MySQLConnectionPool): MySQL 连接池。
         out (str): 导出的 xlsx 路径。
     """
+    # 报告前顺带把新成交团/在售品的品类落库到 category 列(方便按品类查库);只补 NULL 行、成本低
+    try:
+        n_cat = backfill_category(pool, only_null=True)
+        if n_cat:
+            logger.info(f"品类字段回填 {n_cat} 行(category IS NULL)")
+    except Exception as e:
+        logger.warning(f"品类字段回填跳过: {e}")   # 回填失败不阻塞报告产出
     win = get_window(pool)
     platform = fetch_platform_summary(pool)
     compare = fetch_groupbuy_compare(pool)                       # 当日 vs 昨日组齐环比
-    series_rows, series_total = fetch_series_ranking(pool, TOP_SERIES)   # 产品系列销售榜
+    cat_rows, cat_total, series_rows, series_total = fetch_category_series(pool, TOP_SERIES)  # 品类汇总 + 系列榜
     mrank_rows, mrank_total, mrank_conc = fetch_merchant_gmv_ranking(pool)  # 商家 GMV 榜 + 集中度
     ops = [fetch_focus_ops_snapshot(pool, mid) for mid in FOCUS_MERCHANTS]  # 重点商家运营快照
     hour_dist = fetch_completion_hour_dist(pool, HOUR_DIST_DAYS)  # 组齐时段 24h 分布
-    # 魔都(MODDU_MID)明细走扩展版(带参与人数 + 进度里程碑),其余重点商家走标准明细
+    # 已采购买记录的商家(REAL_BUYER_MIDS=魔都/卡皇/尼卡)明细走扩展版(带参与人数 + 进度里程碑),
+    # 其余重点商家走标准明细(中卡近似)
     focus = []
     for mid in FOCUS_MERCHANTS:
         summ = fetch_merchant_summary(pool, mid)
-        if mid == MODDU_MID:
+        if mid in REAL_BUYER_MIDS:
             focus.append((mid, summ, fetch_moddu_details(pool, mid), MODDU_DETAIL_COLS))
         else:
             focus.append((mid, summ, fetch_merchant_details(pool, mid), DETAIL_COLS))
     others = fetch_other_merchants(pool, FOCUS_MERCHANTS)
-    # 魔都用户排行(仅魔都采了购买记录) + 购买记录覆盖检测(成交团 vs 已采购买记录,看漏几个 T)
-    moddu_user_rank, moddu_user_total = fetch_moddu_user_ranking(pool, MODDU_MID, TOP_USERS)
-    moddu_miss = fetch_moddu_missing_teams(pool, MODDU_MID)
+    # 各已采购买记录商家各自算:用户排行(按参与金额倒序) + 购买记录覆盖检测(成交团 vs 已采购买记录,看漏几个 T)
+    rank_by_mid = {}   # {mid: (rows, total_users)}
+    miss_by_mid = {}   # {mid: miss_info}
+    for mid in FOCUS_MERCHANTS:
+        if mid in REAL_BUYER_MIDS:
+            rank_by_mid[mid] = fetch_moddu_user_ranking(pool, mid, TOP_USERS)
+            miss_by_mid[mid] = fetch_moddu_missing_teams(pool, mid)
 
-    # 口径脚注(放平台总览底部;解释两种「参与人数」口径的差别)
+    # 口径脚注(放平台总览底部;解释两种「参与人数」口径的差别)。
+    # 真实买家口径商家名按 FOCUS 顺序取 REAL_BUYER_MIDS 的简称动态拼接,新增商家自动纳入文案、无需再改此处。
+    real_names = "/".join(MERCHANT_SHORT_NAMES.get(m, m) for m in FOCUS_MERCHANTS if m in REAL_BUYER_MIDS)
     notes = [
-        "注:① 魔都兄弟球星卡「参与人数(真实买家)」= deca_buy_record 真实购买记录去重买家(跨其全部成交团);"
-        "各商家明细「参与人数(本团)」为各团单独去重买家,故明细逐团相加(人次) ≥ 汇总(跨团去重人头)。",
-        "  ② 平台大盘参与人数 = 魔都真实买家 + 其他商家中卡去重(两口径人群标识不同、无法跨口径去重,直接相加,属近似上界)。",
+        f"注:① {real_names} 各家「参与人数(真实买家)」= deca_buy_record 真实购买记录去重买家(跨其全部成交团);"
+        "各商家明细「参与人数(本团)」为各团单独去重买家,故明细逐团相加(人次) ≥ 汇总(跨团去重人头)。"
+        "(新接入商家历史团购买记录可能为 0,随后续采集逐日补齐。)",
+        f"  ② 平台大盘参与人数 = {real_names} 真实买家 + 其他商家中卡去重(两口径人群标识不同、且各真实买家商家间"
+        "也未跨商家去重,直接相加,属近似上界)。",
         "  ③ 其他商家未采购买记录,「中卡用户数(近似)」= 拆卡报告 hit_user_nickname 去重(仅报告命中/中卡用户,"
         "非真实参团人头,偏低),其「人均消费(按中卡近似)」据此计算、偏高,仅供参考。",
     ]
@@ -1259,23 +1414,27 @@ def build_report(pool, out: str):
     ws = wb.active
     ws.title = "平台总览"
     _build_overview_sheet(ws, win, platform, compare, mrank_conc, notes)
-    _build_series_sheet(wb.create_sheet("产品系列榜"), series_rows, series_total)
+    # 品类·系列榜(Excel sheet 名禁用「/」,故用中点「·」)
+    _build_category_series_sheet(wb.create_sheet("品类·系列榜"), cat_rows, cat_total, series_rows, series_total)
     _build_mrank_sheet(wb.create_sheet("商家GMV榜"), mrank_rows, mrank_total)
     _build_ops_sheet(wb.create_sheet("运营节奏"), ops, hour_dist)
     # 每个重点商家单独一个明细 sheet(各自独立列宽,互不迁就)
     for (mid, summ, details, cols) in focus:
         sheet_name = DETAIL_SHEET_NAMES.get(mid, f"{summ['商家名'][:8]}明细")
         ws_d = wb.create_sheet(sheet_name)
-        is_real = (mid == MODDU_MID)
+        is_real = (mid in REAL_BUYER_MIDS)
         widths = DETAIL_WIDTHS_MODDU if is_real else DETAIL_WIDTHS_STD
-        # 魔都明细尾部附「购买记录覆盖检测」(漏采团数);其余商家无购买记录、不检测
-        miss = moddu_miss if mid == MODDU_MID else None
+        short = MERCHANT_SHORT_NAMES.get(mid)
+        # 已采购买记录商家的明细尾部附「购买记录覆盖检测」(漏采团数);其余商家无购买记录、不检测
+        miss = miss_by_mid.get(mid)
         _build_detail_sheet(ws_d, f"{summ['商家名']} · 汇总(成交时间窗 {win[0]} ~ {win[1]})",
-                            summ, details, cols, is_real, len(cols), widths, miss_info=miss)
-        # 魔都明细后紧跟「用户排行榜(魔都)」sheet,让魔都相关表相邻
-        if mid == MODDU_MID:
-            _build_user_ranking_sheet(wb.create_sheet("用户排行榜(魔都)"),
-                                      moddu_user_rank, moddu_user_total, TOP_USERS)
+                            summ, details, cols, is_real, len(cols), widths,
+                            miss_info=miss, short_name=short)
+        # 已采购买记录商家的明细后紧跟其「用户排行榜(简称)」sheet,让同商家相关表相邻
+        if mid in REAL_BUYER_MIDS:
+            rank_rows, rank_total = rank_by_mid[mid]
+            _build_user_ranking_sheet(wb.create_sheet(f"用户排行榜({short})"),
+                                      rank_rows, rank_total, TOP_USERS, short_name=short)
     _build_others_sheet(wb.create_sheet("其他商家"), others)
 
     wb.save(out)

+ 74 - 0
deca_spider/stats/run_scheduler.py

@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/24
+"""得卡 DECA · 报告统一调度启动器(一个进程同时跑:每日报告 + 魔都周报)。
+
+背景:daily_report.py 与 weekly_report.py 各自有独立的 schedule 阻塞循环,原需分别起两个进程。
+本文件把两者的 run_once 注册到同一个 schedule,单进程单循环统一调度:
+    - 每日报告 daily_report.run_once :每天 09:10(生成 + 发企微,口径见 daily_report.py)
+    - 魔都周报 weekly_report.run_once:每周一 09:20(生成 + 发企微,口径见 weekly_report.py)
+
+不改动 daily_report.py / weekly_report.py,只复用它们的 run_once(各报告仍可单独 python 直接跑)。
+
+日志:统一写 stats/logs/scheduler_YYYYMMDD.log(单进程一份,便于排障)。两个报告脚本各自
+      standalone 运行时仍写自己的日志文件,互不影响。
+
+运行:python run_scheduler.py(从 stats 目录启动;本文件会 chdir 到自身目录,保证 mysql_pool
+      读 stats/application.yml、每日报告产出也落 stats)。Ctrl+C 退出。
+"""
+import os
+import sys
+import time
+
+# 固定工作目录到本文件所在的 stats:mysql_pool 读此目录 application.yml,daily 报告(cwd 相对)产出也落此
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+os.chdir(BASE_DIR)
+sys.path.insert(0, os.path.dirname(BASE_DIR))   # 根目录加入 path(auto_send_wx_msg 等公共模块在根目录)
+
+import schedule
+from loguru import logger
+
+# 导入两个报告模块(复用其 run_once)。导入会触发各模块级 logger 配置,下面再统一覆盖为调度器日志。
+import daily_report
+import weekly_report
+
+# 统一日志:覆盖两个模块 import 时各自加的 sink,调度器只留一份合并日志
+logger.remove()
+logger.add(os.path.join(BASE_DIR, "logs", "scheduler_{time:YYYYMMDD}.log"),
+           encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="INFO", retention="7 day")
+
+
+def _run_daily():
+    """调度回调:跑每日报告;异常内部兜底,避免单次失败拖垮整个调度循环。"""
+    try:
+        daily_report.run_once(logger)
+    except Exception as e:
+        logger.error(f"每日报告运行异常: {e}")
+
+
+def _run_weekly():
+    """调度回调:跑魔都周报;异常内部兜底,避免单次失败拖垮整个调度循环。"""
+    try:
+        weekly_report.run_once(logger)
+    except Exception as e:
+        logger.error(f"魔都周报运行异常: {e}")
+
+
+def main():
+    """注册两个定时任务到同一 schedule 并进入单循环调度。"""
+    # 调试:需启动时立即各跑一次,取消下面两行注释
+    _run_daily()
+    # _run_weekly()
+    schedule.every().day.at("09:10").do(_run_daily)        # 每日报告:每天 09:10
+    schedule.every().monday.at("09:20").do(_run_weekly)    # 魔都周报:每周一 09:20(错开 10 分钟)
+    logger.info("调度器启动:每日报告 每天 09:10 / 魔都周报 每周一 09:20(Ctrl+C 退出)")
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    main()

+ 280 - 0
deca_spider/stats/weekly_report.py

@@ -0,0 +1,280 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/24
+"""得卡 DECA · 魔都兄弟球星卡「周报」统计(单 Sheet:魔都明细)。
+
+在每日报告(daily_report.py)之外新增的每周任务:把「魔都明细」sheet 的口径由「单日单场」
+放宽到「上一个完整自然周(周一~周日)」,统计该周内魔都(881226408)所有成交组队(拼团商品)。
+
+与每日报告的关系:
+    - 明细列、汇总块、样式、里程碑用时算法,全部直接复用 daily_report,本文件不重复实现
+      渲染/样式,只重写 2 个「周口径」取数函数(把时间窗 WIN_P 换成 WIN_W)。
+    - 不含每日报告魔都明细尾部的「购买记录覆盖检测」小节(主公要求周报去掉)。
+    - daily_report.py 一个字不改;本文件为纯新增。
+
+时间窗口(WIN_W):上一个自然周 [上周一 00:00:00, 本周一 00:00:00)(左闭右开,含上周一~上周日
+    整 7 天,按 completed_at 自然日历切分)。基准用 MySQL WEEKDAY()(0=周一..6=周日)从
+    CURDATE() 回退到本周一,再减 7 天得上周一——故本任务定在每周一早上跑,正好汇总刚结束的完整周。
+    注:魔都夜间场常成交到次日凌晨,按自然日历切分时,某周日夜场溢出到周一 00:xx 的团会计入
+    「下一周」,此为主公选定的自然周(周一~周日)口径,非漏统计。
+
+明细列(16 列,与 daily_report 魔都明细 sheet 完全一致,见 daily_report.MODDU_DETAIL_COLS):
+    序号/团名(商品标题)/系列/类型/单价/总份数/进度%/总金额/参与人数(本团)/中卡人数/
+    开售时间/成交时间/售卖时长/到25%用时/到50%用时/到75%用时
+
+口径说明(同 daily_report):
+    - 销售额 = SUM(COALESCE(team_total_amount, sold_count * unit_price))(随机团按 teams 精算)。
+    - 成团数 = 该周成交的拼团商品数。
+    - 参与人数(本团) = 各团 deca_buy_record 去重买家 user_id;汇总「参与人数(真实买家)」= 跨周内
+      全部成交团去重(故明细逐团相加人次 ≥ 汇总去重人头)。
+    - 中卡人数 = 该团拆卡报告 hit_user_nickname 去重(中卡近似)。
+    - 到 25/50/75% 用时 = deca_onsale_product_progress_record 首次 pct≥X 的快照时刻 − 开售时间;
+      首张快照已越阈值(坍缩)则留空。progress 表 2026/08/11 上线,更早的团相应列可能为空。
+
+从 stats 目录运行:python weekly_report.py(cwd=stats,mysql_pool 读 stats/application.yml)。
+"""
+import os
+import sys
+import time
+from datetime import timedelta
+
+import schedule
+from loguru import logger
+from openpyxl import Workbook
+
+# 把项目根目录加入 import 路径(与 daily_report 一致,便于潜在的根目录模块导入)
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, os.path.dirname(BASE_DIR))
+
+from mysql_pool import MySQLConnectionPool
+# 复用每日报告的渲染层/样式/列规格/无窗口依赖的纯算法(本文件不重复实现这些)
+from daily_report import (
+    MODDU_MID, MODDU_DETAIL_COLS, DETAIL_WIDTHS_MODDU,
+    _pack_summary, _build_detail_sheet,
+)
+
+# 日志:按天切分文件,保留 7 天(常驻定时运行)。放本文件所在目录的 logs/,不依赖 cwd。
+# 注:导入 daily_report 时其模块级已 logger.add 过每日报告 sink,这里 remove 后只保留周报 sink。
+logger.remove()
+logger.add(os.path.join(BASE_DIR, "logs", "weekly_report_{time:YYYYMMDD}.log"),
+           encoding="utf-8", rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="INFO", retention="7 day")
+
+OUT_PREFIX = "得卡-魔都-已售周报告"     # 输出文件名前缀(只查魔都一家),后缀加「上周一_上周日」两个日期
+# 企微发送:报告生成后把 Excel 发到企业微信群机器人(群由 auto_send_wx_msg.WEBHOOK_URL 决定,与每日报告同群)
+SEND_WECHAT = True
+
+# ---- 周时间窗(WIN_W):上一个自然周 [上周一 00:00:00, 本周一 00:00:00) 左闭右开 ----
+# 本周一:WEEKDAY() 0=周一..6=周日,从今天回退到本周一 00:00:00(DATE,不含时分秒即 00:00:00)
+_THIS_MONDAY = "(CURDATE() - INTERVAL WEEKDAY(CURDATE()) DAY)"
+# 上周一 = 本周一 - 7 天
+_LAST_MONDAY = f"({_THIS_MONDAY} - INTERVAL 7 DAY)"
+
+
+def _win(alias: str) -> str:
+    """生成某表别名在「上一个自然周」窗口内的 completed_at 过滤子句。
+
+    Args:
+        alias (str): SQL 中 deca_product_record 的表别名(如 "p" / "pp")。
+
+    Returns:
+        str: 形如 "p.completed_at >= 上周一 AND p.completed_at < 本周一" 的过滤子句。
+    """
+    return (f"{alias}.completed_at >= {_LAST_MONDAY} "
+            f"AND {alias}.completed_at < {_THIS_MONDAY}")
+
+
+WIN_W = _win("p")   # 主表 p 的周窗口子句(供各取数 SQL 拼接)
+
+
+def get_week_window(pool):
+    """取上一个自然周的起止日期(供报告标题与文件名展示)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        tuple[date, date]: (上周一 date, 上周日 date)。上周日 = 本周一 - 1 天。
+    """
+    wk_start, this_monday = pool.select_all(
+        f"SELECT {_LAST_MONDAY}, {_THIS_MONDAY}")[0]
+    last_sunday = this_monday - timedelta(days=1)   # 本周一(右开界) 前一天即上周日
+    return wk_start, last_sunday
+
+
+def fetch_moddu_summary_week(pool, mid: str) -> dict:
+    """统计魔都商家「上一个自然周」的汇总(销售额/成团数/参与人数/均拼单价/人均消费)。
+
+    参与人数用 deca_buy_record 去重真实买家(跨周内全部成交团),人均消费随之按真实人头计。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+
+    Returns:
+        dict: 含 商家名/商家ID/销售额/成团数/参与人数/均拼单价/人均消费。
+    """
+    sql = f"""
+    SELECT
+        MAX(p.merchant_name)                                              AS mname,
+        ROUND(SUM(COALESCE(p.team_total_amount, p.sold_count * p.unit_price)), 2) AS amount,
+        COUNT(*)                                                          AS grp
+    FROM deca_product_record p
+    WHERE p.merchant_user_id = %s AND {WIN_W}
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    """
+    mname, amount, groups = pool.select_all(sql, (mid,))[0]
+    # 参与人数(真实买家) = 周内该商家全部成交团的 deca_buy_record 去重 user_id
+    people = pool.select_all(f"""
+    SELECT COUNT(DISTINCT b.user_id)
+    FROM deca_buy_record b
+    JOIN deca_product_record pp ON pp.product_code = b.product_code
+    WHERE pp.merchant_user_id = %s AND {_win('pp')}
+    """, (mid,))[0][0] or 0
+    d = _pack_summary(amount, groups, people)
+    d["商家名"] = mname or mid
+    d["商家ID"] = mid
+    return d
+
+
+def fetch_moddu_details_week(pool, mid: str) -> list[dict]:
+    """取魔都商家「上一个自然周」内每个拼团(组队)的扩展明细,按总金额倒序。
+
+    列与算法同 daily_report.fetch_moddu_details,仅时间窗由单日单场换为上一个自然周(WIN_W):
+        - 参与人数:deca_buy_record 去重买家 user_id(本团真实参团人头)。
+        - 到 25/50/75% 用时:deca_onsale_product_progress_record 首次 pct≥X 的快照时刻 − 开售时间;
+          首张快照已越阈值(坍缩)则留空(判定见 daily_report._milestone_used)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        mid (str): 商家 merchant_user_id。
+
+    Returns:
+        list[dict]: 每条含 团名/系列/类型/单价/总份数/进度/总金额/参与人数/中卡人数/开售时间/
+            成交时间/售卖时长/到25%用时/到50%用时/到75%用时。
+    """
+    # 里程碑/时长算法直接复用 daily_report(避免重复实现坍缩判定逻辑)
+    from daily_report import _fmt_duration, _milestone_used
+    sql = f"""
+    SELECT
+        p.title, p.series_name, p.spec_name, p.unit_price, p.sold_count, p.card_count,
+        ROUND(COALESCE(p.team_total_amount, p.sold_count * p.unit_price), 2) AS amount,
+        p.sale_start_at, p.completed_at,
+        TIMESTAMPDIFF(SECOND, p.sale_start_at, p.completed_at)            AS duration_secs,
+        (SELECT COUNT(DISTINCT b.user_id) FROM deca_buy_record b
+          WHERE b.product_code = p.product_code)                          AS buyers,
+        -- 中卡人数:该团拆卡报告去重命中用户(hit_user_nickname),中卡近似口径
+        (SELECT COUNT(DISTINCT r.hit_user_nickname) FROM deca_report_record r
+          WHERE r.product_code = p.product_code
+            AND r.hit_user_nickname IS NOT NULL AND r.hit_user_nickname <> '') AS hit_users,
+        -- 该商品最早一条进度快照时刻:用于判定里程碑是否「坍缩」(首张快照已越阈值则用时不可信)
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code)                          AS first_cap,
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code AND pr.progress_pct >= 25)  AS t25,
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code AND pr.progress_pct >= 50)  AS t50,
+        (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
+          WHERE pr.product_code = p.product_code AND pr.progress_pct >= 75)  AS t75
+    FROM deca_product_record p
+    WHERE p.merchant_user_id = %s AND {WIN_W}
+      AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
+    ORDER BY amount DESC
+    """
+    rows = pool.select_all(sql, (mid,)) or []
+    result = []
+    for (title, series, spec, price, sold, card, amount, start, completed,
+         duration_secs, buyers, hit_users, first_cap, t25, t50, t75) in rows:
+        progress = round(sold / card * 100, 1) if card else None  # 售卖进度百分比
+        result.append({
+            "团名": title, "系列": series, "类型": spec, "单价": price,
+            "总份数": card, "进度": progress, "总金额": amount,
+            "参与人数": buyers,
+            "中卡人数": hit_users,                       # 该团拆卡报告去重命中用户(中卡近似)
+            "开售时间": start, "成交时间": completed,   # 开售=sale_start_at,成交=completed_at
+            "售卖时长": _fmt_duration(duration_secs),   # 成交-开售,也即整团总时长
+            # 到 X% 用时:仅当 tX 之前还有更早快照(未坍缩)时才输出真实穿越耗时,否则留空
+            "到25%用时": _milestone_used(t25, first_cap, start),
+            "到50%用时": _milestone_used(t50, first_cap, start),
+            "到75%用时": _milestone_used(t75, first_cap, start),
+        })
+    return result
+
+
+def build_week_report(pool, out: str, title: str):
+    """生成单 Sheet(魔都明细)周报 Excel。
+
+    仅一个 sheet「魔都明细」:汇总块 + 每条组队明细(16 列),结构/样式复用
+    daily_report._build_detail_sheet。不含「购买记录覆盖检测」小节(miss_info=None)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        out (str): 导出的 xlsx 路径。
+        title (str): sheet 顶部分区标题(含商家名与周成交时间窗)。
+    """
+    summ = fetch_moddu_summary_week(pool, MODDU_MID)
+    details = fetch_moddu_details_week(pool, MODDU_MID)
+
+    wb = Workbook()
+    ws = wb.active
+    ws.title = "魔都明细"
+    # is_real=True(魔都为真实买家口径);span/widths 取魔都明细专属规格;
+    # miss_info=None → 不输出「购买记录覆盖检测」小节(主公要求周报去掉此块)
+    _build_detail_sheet(ws, title, summ, details, MODDU_DETAIL_COLS,
+                        True, len(MODDU_DETAIL_COLS), DETAIL_WIDTHS_MODDU, miss_info=None)
+    wb.save(out)
+
+
+def run_once(log) -> str:
+    """连库生成上一个自然周的魔都明细周报,落地到 stats 目录并发送到企业微信群。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        str: 生成的 xlsx 绝对路径;数据库连接池异常时返回空串。
+    """
+    log.info("开始生成魔都周报" + "." * 30)
+    pool = MySQLConnectionPool(log=log)
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        return ""
+    wk_start, last_sunday = get_week_window(pool)
+    title = (f"魔都兄弟球星卡 · 周汇总"
+             f"(成交自然周 {wk_start} 00:00:00 ~ {last_sunday} 23:59:59)")
+    # 输出锚定到本脚本所在目录(stats),文件名带「上周一_上周日」两个日期,便于归档区分
+    out_file = os.path.join(BASE_DIR, f"{OUT_PREFIX}_{wk_start:%Y%m%d}_{last_sunday:%Y%m%d}.xlsx")
+    build_week_report(pool, out_file, title)
+    log.info(f"周报已生成 -> {out_file}")
+
+    # 发企微群(只发 Excel;失败仅告警,不影响报告产出)——与每日报告同群同方式
+    if SEND_WECHAT:
+        try:
+            from auto_send_wx_msg import send_wechat_group_file
+            send_wechat_group_file(log=log, file_path=out_file)   # 只发 Excel,不发图
+        except Exception as e:
+            log.warning(f"企微发送跳过: {e}")
+    return out_file
+
+
+def main():
+    """命令行一次性生成(手动/调试用)。"""
+    run_once(logger)
+
+
+def schedule_task():
+    """定时入口:每周一 09:20 生成上一个自然周的魔都明细周报。
+
+    错开每日报告(09:10)10 分钟;周一早上跑正好汇总刚结束的完整自然周(上周一~周日)。
+    """
+    # run_once(logger)  # 立即跑一次(调试时取消注释)
+    schedule.every().monday.at("09:20").do(run_once, logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    schedule_task()

BIN
deca_spider/得卡平台数据监测清单.docx