Przeglądaj źródła

feat(wechat): 字符重试机制优化企微群机器人发送模块

- 新增企业微信群机器人发送模块,实现文本、markdown_v2、文件发送功能
- 引入 tenacity 重试库,针对网络/TLS 瞬断增加自动重试机制
- 对上传文件与发送消息函数均加装重试,避免 TLS UNEXPECTED_EOF_WHILE_READING 错误导致发送失败
- 优化文件上传逻辑,保证重试时每次均重新打开文件流避免空文件体上传
- 增加发送文本与markdown内容构建工具,支持列表编号与分割线格式
- 封装对外发送函数,简化调用方使用,支持日志注入以便调用方接入日志系统
- 配置文件中添加 MySQL 连接默认参数
- 提供示例与自测入口,确保模块独立调试成功
charley 3 tygodni temu
rodzic
commit
dacb127039

+ 196 - 0
jiwu_spider/HANDOFF.md

@@ -0,0 +1,196 @@
+# 集物星球爬虫 · 加密/接入 交接文档
+
+> 新窗口接续用。核心:签名 `X_SIG_1` 已破解;**必须用 `curl_cffi`** 发请求(`requests` 会被 TLS 风控判为非真机、返回 `code:200` 空壳,`curl_cffi` 才出 `code:1` 真数据);sessionId 靠脚本自登录拿。以上均已实测验证。域名 `https://api.jiwuplanet.com`,全 POST+JSON。
+
+## 1. 签名 X_SIG_1(核心加密流程,已逐字节验证)
+
+每个请求带 6 个头:`X_VERSION / X_TIME / X_SID / X_RAND / X_PLATFORM / X_SIG_1`。
+
+```
+X_SIG_1 = base64_std( HMAC_SHA256( key, msg ) )
+key = "EahycFpDpewggO2rksvQwTlnhCbTHFx6"          # 生产环境密钥(逆向所得)
+msg = path + "2.12.2" + "150" + sessionId + X_TIME + X_RAND + canonicalBody
+```
+
+- `path`:URL 路径(不含域名/query),如 `/search/app/index/top`
+- `X_VERSION`="2.12.2",`X_PLATFORM`="150"(常量)
+- `X_TIME`:秒级时间戳字符串 `str(int(time.time()))`
+- `X_RAND`:6 位随机 `str(random.randint(100000,999999))`
+- `canonicalBody`:`json.dumps(body, sort_keys=True, separators=(",",":"), ensure_ascii=False)`——递归按 key 排序、紧凑、非 ASCII 不转义;**实际发送的 body 就是这个串**(与被签名内容完全一致)
+- **sessionId 三处必须同值**:`X_SID` 头 = body 里 `sessionId` 字段 = 签名串里的 sessionId
+
+**验证基准**(用抓包固定参数复算,结果一致):
+path=`/search/app/index/top`,X_TIME=`1787045062`,X_SID=`ccab0d5917cd4e289d07b820e4497989`,X_RAND=`295778`,body=`{"currentPage":"1","limit":"10","productType":"3","sessionId":"ccab0d5917cd4e289d07b820e4497989","systemBusinessType":5}` → X_SIG_1 = `tZuw8vRCz2DVb6RSLv0KBlKgcctmBCxi07SiDH3em+k=`(与真实抓包逐字符相同)。
+
+## 2. TLS 指纹(关键,否则拿不到数据)
+
+- **必须 `curl_cffi`(`impersonate="chrome"`)**。`pip install curl_cffi`。
+- **直连**:`proxies={"http":None,"https":None}`(本机有 `HTTP(S)_PROXY=127.0.0.1:7890` 的 Clash,间歇开着,`requests` 默认走它会卡死)。
+- 记忆点:`code:200 + data.result:[]` = 被风控/会话无效的空壳;`code:1 + data.records[...]` = 真成功。
+
+## 3. sessionId / 脚本自登录(已验证可用)
+
+- **登录态最小化(2026/08/19 实测)**:`jiwu_core.do_request(..., need_auth=False)` 默认免登录——用随机未绑定 sid 签名、**不调 regLogin**。实测免登录:在售 `index/top`、已售 `corp/history`、详情 `merchantGoodsId`(随机 sid 即出 `code:1`);需登录(`need_auth=True`):商家 `hotRecommend`、购买记录 `publicity/user/group/pager`、拆卡报告 `gift/report`(随机 sid 返 `code:200` 空壳)。
+- sessionId = 客户端自造 32 位小写 hex,`regLogin` 上报后服务端沿用该值。
+- 自登录:`POST /acct/user/regLogin`,成功 `code==1`,返回 `data.sessionId`。
+- 账号:`mobile=19521500850`, `loginPassword=pass2022`, `deviceNumber=40b03f4bec96675f`(账号 ccc123, userId 21588067)。
+- 登录 body(自造 sid 填进去):
+
+```json
+{"appVersions":"2.12.2","deviceNumber":"40b03f4bec96675f","identificationNumber":"19521500850","ip":"10.1.10.1","language":"ZH","loginPassword":"pass2022","loginType":"ACCOUNT","mobile":"19521500850","mobileCode":"86","platformInfo":"Google Pixel 5","platformType":"ANDROID_USER","sessionId":"<自造sid>","systemBusinessType":6,"terminalVersions":"11"}
+```
+
+**登录 / 会话流程图**(核心:无 token,客户端自造 `sessionId`;「签名是门票、sessionId 是身份」;三处 sessionId 头 `X_SID`/body/签名串须同值):
+
+```mermaid
+flowchart TD
+    A["do_request(path, body, need_auth)"] --> B{"need_auth?"}
+    B -- "False 免登录(默认)" --> C["get_anon_sid()<br/>随机自造 sid,不调 regLogin"]
+    B -- "True 需登录" --> D["ensure_session()"]
+    D --> E{"缓存 sid 未过期?<br/>SESSION_TTL=1800s"}
+    E -- "是" --> F["复用缓存 sid"]
+    E -- "否" --> G["login(): 自造 sid<br/>→ regLogin 绑定 → 写缓存"]
+    C --> H["sessionId 写入 body.sessionId + 头 X_SID + 签名串<br/>(三处同值)"]
+    F --> H
+    G --> H
+    H --> I["_sign(): X_SIG_1 = base64(HMAC_SHA256(key, msg))<br/>msg = path+版本+平台+sessionId+时间+随机+body"]
+    I --> J["curl_cffi POST(impersonate=chrome,直连)"]
+    J --> K{"code == 1 ?"}
+    K -- "是" --> L["成功,返回 JSON"]
+    K -- "否 · 免登录" --> M["轮换匿名 sid,下轮再来"]
+    K -- "否 · 需登录" --> N["清缓存 → 重登,最多再试 1 次"]
+```
+
+- 免登录(`need_auth=False`,默认):在售 `index/top`、已售 `corp/history`、详情 `merchantGoodsId`——匿名随机 sid,不暴露账号。
+- 需登录(`need_auth=True`):商家 `hotRecommend`、购买记录 `publicity/user/group/pager`、拆卡报告 `gift/report`——`ensure_session` 复用/续期真会话。
+- 动机:登录态是账号被风控关联/封号主要抓手,走「最小登录面」,能免登录就不登录。
+
+## 4. 自包含验证脚本(存成 verify.py,`python verify.py` 应打印 code=1 + ccc123 + 真实商品)
+
+```python
+import hmac, hashlib, base64, json, time, random
+from curl_cffi import requests as creq
+KEY = "EahycFpDpewggO2rksvQwTlnhCbTHFx6"; BASE = "https://api.jiwuplanet.com"
+NOP = {"http": None, "https": None}
+def cb(o): return json.dumps(o, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
+def post(path, body, sid):
+    body = dict(body); body["sessionId"] = sid; b = cb(body)
+    xt = str(int(time.time())); xr = str(random.randint(100000, 999999))
+    msg = path + "2.12.2" + "150" + sid + xt + xr + b
+    sig = base64.b64encode(hmac.new(KEY.encode(), msg.encode(), hashlib.sha256).digest()).decode()
+    h = {"X_VERSION": "2.12.2", "X_TIME": xt, "X_SID": sid, "X_RAND": xr, "X_PLATFORM": "150",
+         "X_SIG_1": sig, "Content-Type": "application/json;charset=UTF-8",
+         "User-Agent": "okhttp/3.12.10", "Host": "api.jiwuplanet.com"}
+    return creq.post(BASE + path, headers=h, data=b.encode(), timeout=20, impersonate="chrome", proxies=NOP).json()
+# 1) 自登录
+sid = hashlib.md5(f"{time.time()}{random.random()}".encode()).hexdigest()
+acct = {"appVersions":"2.12.2","deviceNumber":"40b03f4bec96675f","identificationNumber":"19521500850",
+        "ip":"10.1.10.1","language":"ZH","loginPassword":"pass2022","loginType":"ACCOUNT","mobile":"19521500850",
+        "mobileCode":"86","platformInfo":"Google Pixel 5","platformType":"ANDROID_USER","systemBusinessType":6,"terminalVersions":"11"}
+j = post("/acct/user/regLogin", acct, sid); d = j.get("data") or {}; sid = d.get("sessionId") or sid
+print("登录:", j.get("code"), d.get("nickName"), d.get("userId"))
+# 2) 在售列表
+j = post("/search/app/index/top", {"currentPage":"1","limit":"10","productType":"3","systemBusinessType":5}, sid)
+dd = j.get("data") or {}; recs = dd.get("records") or []
+print("在售:", j.get("code"), "total=", dd.get("totalCount"), "首条=", recs[0]["goodsName"] if recs else None)
+```
+
+## 5. 接口清单(成功 code==1,列表在 data.records,data.totalCount)
+
+| 板块 | path | 关键参数 |
+|---|---|---|
+| 在售 | `/search/app/index/top` | currentPage,limit,productType(1福袋2变风3错版卡4原盒),systemBusinessType:5 |
+| 商家(142家) | `/search/app/index/corp/hotRecommend` | currentPage,limit,systemBusinessType:6 |
+| 已售 | `/search/app/corp/history` | corpInfoId,currentPage,limit,systemBusinessType:5 |
+| 商品详情 | `/search/app/merchantGoodsId`(**免登录**) | goodsId,systemBusinessType:5。监控判结束用。⚠️ 库存字段=`stock`(总)/`surplusStock`(剩余)、价格=`price`/`highestPrice`,**与 index/top 的 stockAmount/residueStockAmount 不同名**。(返回里 `goodsBuyRspDtoList` 只有 6 条预览,不是全量购买记录) |
+| **购买记录** | `/order/merchant/app/query/gift/publicity/user/group/pager`(赠品公示·玩家维度,**需登录**) | currentPage,giftBusinessName:"",goodsId,limit,systemBusinessType:5。每条=一个买家 **userId/userNick/picId/count**,翻页拿全量(如 1660823 共 18 人)。**不 DB 去重**,靠已售表 `buy_fetched` 状态位控制每商品抓一次(取全→批量入库→置位)。~~publicity/pager~~(不带 user/group) 是幻影接口勿用 |
+| 拆卡报告 | `/goods/gift/report/query/search/pager`(**需登录,sbt=6**) | goodsId,currentPage,limit。字段:giftReportId(唯一递增)/serialItemName(开出卡名)/resAddrList/winnerStatus/anonymousStatus/userId(**有值**)/corpId/corpName/goodsName/username(脱敏)/createTime/myGiftStatus。已售商品可事后翻页查全量 |
+
+- **jake corpInfoId=100716(Jake球星卡)**,**九叔 corpInfoId=100715(九叔的喷火龙)**。
+- **金额字段 ÷ 1000000 = 元**(实测 amount=99000000 → App ¥99.00。旧文档 ÷10000 是错的)。**入库前已用 `core.to_yuan` 换算成元存 DECIMAL(12,2)**,库里/报告直接是元;监控读实时接口(原始值)仍 ÷1000000 展示。
+- 商品字段:goodsId/goodsName/productType/corpInfoId/corpInfoName/amount/highestPrice/lowestPrice/stockAmount/residueStockAmount/specificationName/soldTime/liveReplayUrl…
+
+## 6. 环境注意(血泪教训)
+
+- **Bash 跑在隔离沙箱**:看不到真实磁盘文件、写入也不落盘。**别用 `ls`/`python xxx.py` 判断文件在不在**(会误报"文件不存在")。跑脚本让主公在本机跑,或用"关闭沙箱 + 同一条命令内 heredoc 建+跑"。
+- **Write 工具写真实盘**、主公能看到;但偶发不稳,关键文件建议同时贴聊天由主公复制。
+- 逆向产物在 `decompiled/`(jadx,包名 com.jiwu.star),签名逻辑在 `com/jiwu/star/api/LogInterceptor.java`;密钥在 `EnvironmentManager`,HMAC 在 `HmacUtils.hmacSHA256Base64`。
+
+## 7. 五大板块进度(全部已落地,2026/08/19)
+
+- 已验证:接口全通、签名/自登录/TLS 全打通、企微推送已通(测试群 webhook key `b8d398e2-f27e-42ce-af78-336867460122`)。
+- 参考骨架:`D:\work\2026-08-02(deca_spider)`——**整个流程/产出/逻辑与之一致,只是加解密不同**。报告都是 openpyxl 生成 Excel 走 `send_wechat_group_file` 发文件(不是 markdown);已售报告里重点商家各占一个独立 sheet。
+
+| 板块 | 脚本 | 说明 |
+|---|---|---|
+| 1 在售抓取 | `jw_onsale_spider.py` | **免登录**全量翻 index/top upsert `jw_onsale_product_record`+下架对账;同步写每日快照 `jw_onsale_daily_record`(商品+日期 upsert 保最新,供在售趋势差分);每天 09/15/20/01 四档 |
+| 2+3 已售+购买记录+拆卡报告 | `jw_sold_spider.py` | hotRecommend(需登录)商家→corp/history(免登录)逐商家只增 `jw_sold_product_record`;再对**全站已售商品**抓:购买记录=赠品公示玩家维度 `publicity/user/group/pager`(**需登录**, 翻页拿全量, fetch-once)落 `jw_player_record`;拆卡报告 `gift/report`(**需登录**, 结束 REPORT_REFETCH_DAYS 天内每轮重查、INSERT IGNORE 去重)落 `jw_report_record`;每天 08:00(配合已售报告业务日窗口, 06:00 后再采) |
+| 4 每日报告 | `jw_onsale_report.py`(09/15/20/01 四档) + `jw_sold_report.py`(09:10) | 只读库→Excel→企微发文件;共用样式库 `jw_report_excel.py`(openpyxl)。在售每档各出一份带小时文件、含「在售趋势」sheet(快照差分);已售按业务日窗口 [昨17:00,今06:00]、COALESCE(finish_time,soldout_time) 过滤,Jake/九叔 各独立 sheet(汇总+明细+购买人数),金额已是元 |
+| 5 在售监控 | `jw_onsale_alert.py` | 手写轮询(默认全天、每轮随机 60~90s),全量翻 index/top(免登录) 按 corpInfoId 过滤 Jake(100716)/九叔(100715);三类告警 新品上架/进度过半(≥50%)/一车结束,去重靠 `jw_onsale_alert_record` 三标记位 + 内存 `_seen_onsale`;企微 markdown 推送 |
+
+- 依赖:`openpyxl`(报告用)已装。运行=5 个常驻进程各自 `python xxx.py`(在售/已售/两报告/监控)。schema 共 **7 张表**(在售商品/已售商品/商家/购买记录/拆卡报告/监控告警/在售每日快照 `jw_onsale_daily_record`)。
+- **建表提醒**:schema 经多轮调整(金额列改 DECIMAL 存元;两商品表加 12 个详情列 + `detail_fetched`;已售表加 `buy_fetched`;`jw_player_record` 改玩家维度买家、无唯一键靠状态位去重;新增在售每日快照表 `jw_onsale_daily_record`)。`CREATE IF NOT EXISTS` 不会改已存在的表,故建议 **DROP 掉全部 jw_ 表后 `python init_db.py` 重建**(只加新表可直接重跑 init_db)。
+- 登录态最小化:`hotRecommend`(商家列表)、`publicity/user/group/pager`(购买记录)、`gift/report`(拆卡报告) need_auth=True;在售/已售/商品详情全免登录。
+- 人数口径:已售报告「购买人数」取自 `jw_player_record`(COUNT DISTINCT user_id);监控结束战报「购买人数」=玩家维度接口 totalCount(实时准确)。
+- 详情字段坑:`merchantGoodsId` 用 `stock`/`surplusStock`/`price`/`highestPrice`,**别用 index/top 的 stockAmount/residueStockAmount**(监控 confirm_ended/send_ended 已按详情字段名修正)。
+- 详情补全:两商品表带一组详情字段(img/goods_type/price/original_price/plan_up_time/off_shelf_time/stock/surplus_stock/status/collection_card_name/gift_way/random_way/**gift_series(赠品系列=cardGoods.giftSeries, 报告"系列"列用它)**),由 `jw_detail.enrich_detail`(merchantGoodsId, **免登录**) 逐商品补,各表 `detail_fetched` 状态位控制每商品补一次(在售 upsert 不覆盖这些列)。**img = FILE_DOMAIN + resList 首图 resAddr**,`FILE_DOMAIN=https://files.jiwustar.com/`(逆向 EnvironmentManager 正式环境);collection_card_name/gift_way/random_way 在详情 `cardGoods` 子对象。
+- **两处待主公实测确认(不影响跑,只影响判定精度)**:
+  1. 监控「新品上架」用列表字段 `soldTime`(上架/开售时间) ≥ 本场窗口起点(最近一个 RUN_START) 判新(`is_new_arrival`/`_window_start`)。若实测 `soldTime` 语义不同,改这两处。
+  2. 监控「一车结束」二次确认:打 `/search/app/merchantGoodsId` 详情,`surplusStock<=0`(售罄) 或 已过 `offShelfTime`(下架/销售结束时间) 判结束(`confirm_ended`)。若 offShelfTime 语义不同,改此处。
+- 临时/探测脚本(_probe3/verify_sign/scan/tls_replay/测试_能否写py)已删。
+
+## 8. 变更记录(每次修改/优化后在此追加一条,方便重开窗口接续)
+
+### 2026/08/27(企微发送加重试,修偶发 TLS 瞬断导致报表发不出)
+- **现象**:08/27 早上在售/已售两份日报都**生成成功**,但发企微都失败,日志同一报错 `企微素材上传异常: ... SSLEOFError(8, '[SSL: UNEXPECTED_EOF_WHILE_READING]...')`(`onsale_report_20260827.log:14`、`sold_report_20260827.log:12`)。
+- **根因**:不是业务错误(key/文件/文案都正常,手动握手 `qyapi.weixin.qq.com:443` 秒连 TLSv1.3)。是**上传 Excel 素材(`upload_media`)时到腾讯的 TLS 连接偶发被中间网络设备掐断**(OpenSSL 3.0 把「对端没发 close_notify 就断开」从静默 EOF 改成硬报错 `UNEXPECTED_EOF_WHILE_READING`)。放大器:`auto_send_wx_msg._upload_media` 原为裸 try/except,**一次瞬断就 return None 整份放弃、零重试**,两轮恰好都撞上。
+- **修复**(`auto_send_wx_msg.py`):按骨架规范加 tenacity 重试。新增 `after_log` 回调 + 两个带 `@retry` 的内部函数 `_do_upload_media`(文件上传)/`_post_json_with_retry`(文案/文件消息);策略 = **仅对 `RequestException`(网络/TLS)重试、间隔 2→4→8s 递增、最多 4 次**,业务错误码(errcode≠0)不重试直接失败。关键:上传函数**把 `open` 放进被重试的函数体内**,每次重试重开文件(multipart 流读过就到 EOF,不重开会传空体)。发送目标/业务逻辑/对外签名不变。
+- **实测**:测试群(key=b8d398e2)文案+文件 errcode 均 0,通过。**依赖**:`tenacity`(已装)。
+- **待办提醒**:上线正式群时只改 `auto_send_wx_msg.py:25` 的 `WEBHOOK_URL`(现指测试群,正式群 key=2d41b34f 已注释在下一行)。
+
+### 2026/08/21(大检查:报告数据修复 + 全表审计)
+- **一键启动 `run_all.py`**(主公要部署服务器):把 5 个常驻任务(在售抓取/已售抓取/在售日报/已售日报/在售监控)各拉成独立子进程并守护——巡检存活、退出的隔 5s 自动重启、Ctrl+C 优雅关闭。服务器只跑 `python run_all.py` 即可(各任务仍各自维护 schedule/日志、互不干扰;单跑调试仍可 `python jw_xxx.py`)。README §7 运行已改为推荐 run_all。
+- **在售概览对齐 deca**:`jw_onsale_report.build_overview` 加指标「今日累计已售(份)」= `SUM(sold_count) WHERE is_on_sale=1`;改用 `指标|数值` 表头(write_table)替代原标题条;「今日新上架商品」→「今日新增商品」。⚠️「今日新增商家」今天因重建商家表(所有商家 gmt_create_time=今天)暂显全量 82,**以后不再 DROP 重建商家表**即恢复为当天真正新增。
+- **报告「gift_series」列序前移**:`gift_series` 由表末尾 ALTER `MODIFY ... AFTER goods_ip_name` 移到 IP名 后(两商品表);schema 同步。纯物理列序、不动数据。
+- **报告「系列」字段取错修复**(主公发现):报告的「系列」原用 `goods_ip_name`(goodsIPName,只是 IP 大类如"球星卡/宝可梦"),但 App「赠品详情/赠品系列」页显示的**系列**是 `cardGoods.giftSeries`(如"2025-26 Topps Chrome Updates Hobby")。giftSeries 只在商品详情(merchantGoodsId)里。修复:两商品表加详情列 `gift_series`(schema + ALTER 现有表);`jw_detail` DETAIL_COLS 增 gift_series、parse 取 `cardGoods.giftSeries`(兜底顶层 `specifications`);已售报告 产品系列榜按 gift_series 分组、明细「系列」列用 gift_series;在售报告 商品明细「IP系列」列改为「系列」用 gift_series。已 reset detail_fetched 重跑 enrich_detail 回填(已售 123/123 到位)。**注**:gift_series 靠详情补全,新商品由 enrich_detail 自动填、无需改 parse。
+- **已售 GMV/人均=0 根因修复**:`jw_sold_report._sold_gmv` 原用 `sold=stock−residue`,但已售历史 `corp/history` 的 `residueStockAmount` 对成交(组齐)团恒 = 总份数(实测全 123 行 residue==stock,且 `SUM(购买记录 buy_count)==stock_amount`)→ 算出 sold=0、GMV=0。改为 **已售团 `sold=stock_amount`**(成交即全售出)。GMV/人均/占比/系列榜/GMV榜/集中度/进度% 全部随之修正。
+- **参与人数:人次→跨团去重人头**:原平台/明细汇总/其他商家用「各团去重买家相加」(人次),与用户榜(去重人头)对不上、也不符 deca。新增 `fetch_corp_distinct_buyers`/`fetch_platform_distinct_buyers`(JOIN 购买记录 COUNT DISTINCT user_id),平台/各商家汇总均用**跨团去重人头**;人均消费=GMV/人头。明细列「参与人数(本团)」仍用 buyers_map(每团去重)。
+- **商家表字段名修复 + 最终定案**(主公反馈 jw_shop_record 除 id 外全空):`parse_shop` 原字段名写错(hotRecommend 无 corpName/fansAmount/goodsAmount 等)→全 NULL。查清 hotRecommend 记录字段实为 `corpInfoId`/`corpInfoName`/`number`(**粉丝数**,主公确认)/`saleNum`(**在售商品数**,与在售表吻合)/`corpLogo`;`userId` 是**查询者本人账号id**(每行恒等=21588067)、非商家id,不存;好评/新品/简介 接口体系根本无(`CommonShop` 模型也没有)。
+  **`jw_shop_record` 最终定为 `corp_info_id/corp_name/fans_amount(number)/onsale_amount(saleNum)`**(中途曾一度精简掉 fans_amount,后确认 number=粉丝又加回)。parse_shop/get_shops/schema 已改、DROP 重建回填。在售报告「今日新增商家/其他商家」两 sheet 列 = `商家|粉丝数|在售商品数|今日新上架`(粉丝取 shop 表、在售数/新上架从在售商品表 JOIN)。
+- **商家列表翻页 bug 修复(只入库了 10 个商家)**:`get_shops` 末页判定原为 `len(recs) < PAGE_LIMIT(20)`,但 hotRecommend 服务端**每页固定只回 10 条**(响应 `data.limit=10`,无视请求的 limit=20)→ 第 1 页 10<20 就误判末页停了,只入库 10 个商家。改为**用响应里的实际每页大小 `data.limit` 判末页**(`len(recs) < data.limit` 才停)。实测全量翻页 **82 个商家(9 页)**、其中 29 个有在售。已重跑 get_shops 入库 82 家(粉丝 Top: Jake 426/卡愣子潮玩 184/棠棠潮玩 80…)。
+- **已售改为循环库中全量商家**(主公提出):`main_task` 原为「`corp_ids = get_shops()` 返回啥就循环啥」(只本轮 hotRecommend 返回的)。改为 `get_shops()` 先刷新商家表,再 `SELECT corp_info_id FROM jw_shop_record` 取**全量商家**循环抓已售(受 TARGET_CORPS 可选过滤)。理由:hotRecommend 每天轮换只返一批热门商家,`jw_shop_record` 随天数累积覆盖更全;对全量商家跑 corp/history(免登录)才不漏。代价:每轮多几十个 corp/history 请求(多数无已售、秒回空),免登录无账号风险。
+- **live_replay_url 拼完整域名**:接口返回的 `liveReplayUrl` 是相对路径 `/live/xxx.mp4`,需拼直播域名 `https://play.jiwustar.com`(逆向 EnvironmentManager RELEASE 的 `BASE_PULL_STREAM_URL=https://play.jiwustar.com/live/`,路径已含 /live/ 故只拼 host)。新增 `jw_sold_spider.full_live_url()`,parse_sold 用它;库内历史 123 条已 `UPDATE ... CONCAT('https://play.jiwustar.com', live_replay_url) WHERE live_replay_url LIKE '/live/%'` 回填、残留 0。
+- **在售「类型」列补 str()**:`jw_onsale_report.build_products` 的 `PRODUCT_TYPE_NAME.get(ptype,...)` → `get(str(ptype),...)`,与已售统一,防 product_type 为 int 时显示数字码。
+- **查清的非 bug(真实数据)**:① `corp/history` 实测仅 Jake(100716) 有 123 条已售,九叔(100715)/羽佳/其他 totalCount=0 → **已售报告只有 Jake、九叔明细/其他商家空是真实数据**,非漏抓。② hotRecommend 是「热门推荐」商家、每页 10 条需翻页(全量约 82 家,见翻页修复条);不含好评/新品/简介(粉丝=number 有)。
+- **fork 深审确认无误**(勿误改):在售 `sold=stock−residue`(在售剩余是真实值,口径对)、GMV/占比/人均/环比除零护栏、窗口边界含两端、金额=单价映射、用户榜 SUM(buy_count×amount)、col_types 与列数对齐、pct 传 0~1。全表 NULL 审计:已售/购买记录/拆卡报告各列均正常填充(仅 pic_id 匿名买家空、finish_time 今日团 25 条空但 soldout_time 兜底)。
+
+### 2026/08/20
+- **拆卡报告表改名**:`jw_gift_report_record` → `jw_report_record`。同步改:`schema.sql`(CREATE TABLE,索引名 `uk_gift_report_id`/列名 `gift_report_id` 跟列走、不动)、`jw_sold_spider.py`(insert_many 目标表 + 重查去重 SQL + 顶部注释/docstring)、`README.md`、本文档。**需重建库**(DROP 旧表后 `python init_db.py`)。
+- **购买记录表改名**:`jw_buy_record` → `jw_player_record`。同步改:`schema.sql`(CREATE TABLE,索引 `idx_goods`/`idx_user` 不含表名、不动)、`jw_sold_spider.py`(insert_many 目标表 + 注释/docstring)、`jw_sold_report.py`(购买人数 COUNT DISTINCT SQL + docstring)、`README.md`、本文档。**需重建库**(DROP 旧表后 `python init_db.py`)。
+- **监控脚本改名**:`jw_onsale_monitor.py` → `jw_onsale_alert.py`(与表名 `jw_onsale_alert_record` 配对、贴合"上架提醒"交付物)。纯文件重命名,无其它 .py `import` 它(独立常驻脚本直接跑);模块 docstring 未自指文件名、不用改。同步改:`README.md`(目录树/板块表/配置项/待实测项)、本文档。**不涉及库变更。**
+- **对齐 deca 两套报告口径**(主公确认):
+  - **已售业务日窗口**:`jw_sold_report.py` 的 `WIN_SOLD` 从 `DATE(gmt_create_time)=CURDATE()` 改为 `COALESCE(finish_time, soldout_time)` 落在 `[昨17:00, 今06:00]`(含两端),对齐 deca 的 completed_at 口径;平台总览新增「统计窗口」展示。配套:已售采集 `jw_sold_spider.py` 定时 `00:30 → 08:00`(窗口 06:00 关闭后再采、报告 09:10 前齐全),并清掉 `fetch_sold_for_corp` 里调试用的 `print(j)`、把 `schedule_task` 里启动即跑的 `main_task(log=logger)` 注释回去。
+  - **在售四档 + 完整复刻**:`jw_onsale_spider.py`/`jw_onsale_report.py` 定时都从单点改成 `09/15/20/01` 四档(上午/下午/晚上/凌晨场);报告每档各出一份带小时文件名(`_%Y%m%d_%H时`)。新增每日快照表 `jw_onsale_daily_record`(唯一键 goods_id+snapshot_date,四档 upsert 只刷量价、保留商品/商家/日期),采集每页 `upsert_daily_snapshot` 同步写入;报告加「在售趋势」sheet:`fetch_onsale_trend` 按快照集合差分(当日集合−前日集合=净新增商家/拼团),列头 日期|在售商家数|在售拼团数|新增商家数(差分)|新增拼团数(差分),倒序最近 4 天。
+  - **需建新表**:`jw_onsale_daily_record` 已进 `schema.sql`,重跑 `python init_db.py` 即可(CREATE IF NOT EXISTS 只补新表)。同步改 README(板块表/表清单/运行命令/DROP 列表)。
+- **在售报告补齐到 6 sheet(对齐 deca)**:`jw_onsale_report.py` 新增两 sheet——「其他商家」(存量商家 `gmt_create_time!=CURDATE()`,商家表 LEFT JOIN 在售表,列 商家/粉丝数/在售商品数/今日新增商品,在售数倒序 LIMIT 100) 和「上架时段分布」(按 `HOUR(sold_time)` 24 桶、近 7 日,带 █ 条形)。最终顺序:概览/今日新增商家/其他商家/商品明细/在售趋势/上架时段分布。已实测生成 6 sheet 通过。
+- **alert 同步 deca 最新逻辑**(`jw_onsale_alert.py`,得卡 `onsale_alert_spider.py` 2026/08 改版):
+  1. **新品判断**:由「soldTime 最近 60 分钟」改为「soldTime ≥ 本场窗口起点(最近一个 RUN_START)」,新增 `_window_start()`;删除 `NEW_ARRIVAL_LOOKBACK_MIN`。
+  2. **结束判断**:`confirm_ended` 由「仅 surplusStock≤0」改为「surplusStock≤0 **OR** 已过 offShelfTime(下架/销售结束时间)」,二者任一即结束。
+  3. **运行窗口**:`RUN_ALL_DAY` 由 True 改 **False**,对齐 deca 窗口制 [20:30, 06:00];RUN_START 同时是新品判定门槛。**如集物直播时段不同,改 RUN_START/RUN_END 或设回 RUN_ALL_DAY=True。**
+  4. **命令行时间参数**(对齐 deca `argparse`):`_parse_start_time`(校验 HH:MM/HH:MM:SS→规范化 HH:MM) + `_parse_args`(位置参数 `start` 与 `--start` 等价、位置优先);`__main__` 里不传保持默认 RUN_START,传了则 `RUN_START=_start` 覆盖模块全局(`_window_start`/`_in_run_window`/`_seconds_to_next_window` 三处裸读该全局,一改齐变)。用法:`python jw_onsale_alert.py 17:00` 或 `--start 17:00`。已实测 --help/校验/非法格式报错通过。
+  - 其余(三标记位+`_seen_onsale`去重、发成功才置位/过半先置位、免登录全站取数、企微 markdown 不挂链接)集物本就与 deca 一致,未动。
+- **已售报告重写为 9 sheet(对齐 deca 8 sheet + 集物两重点商家都有购买数据各加一个用户榜)**:`jw_sold_report.py` 全量重写。sheet:①平台总览(KPI+当日环比vs昨日同窗口 `WIN_SOLD_YDAY`+商家GMV集中度Top1/3/5/10) ②产品系列榜(goods_ip_name,Top15) ③商家GMV榜(Top10) ④运营节奏(重点商家快照:已售团数/参与人数/规格分布 + 成交时段分布 `HOUR(COALESCE(finish,soldout))` 24桶近7日) ⑤Jake明细(汇总+逐团明细+购买记录覆盖检测 buy_fetched) ⑥Jake用户排行榜(jw_player_record join 已售表算金额) ⑦九叔明细 ⑧九叔用户排行榜 ⑨其他商家。GMV=已售数×单价元、已售数=stock-residue、参与人数=jw_player_record 去重 user_id。deca 的「进度25/50/75%里程碑」列集物无进度轨迹、略去。已实测 9 sheet 生成通过。同步改 README。
+- **已售报告排版对齐 deca 实际输出**(读了 deca `stats/得卡已售每日报告_*.xlsx` + `stats/daily_report.py:988-1210` build 代码逐 sheet 比对,首版是「重新设计」标签对不上、返工):标题「集物星球 · 已售每日统计报告」+「成交时间窗」;平台汇总用 deca 标签(商家数/销售额/成团数/参与人数/均拼单价/人均消费);环比行(组齐GMV/成团数/活跃商家数/T均单价);运营快照(今日新开团/已组齐/规格分布,新开团=sold_time 落窗口内);明细列(序号\|团名(商品标题)\|系列\|类型\|单价\|总份数\|进度%\|总金额\|参与人数(本团)\|开售时间\|成交时间\|售卖时长);覆盖检测用「成交X团·采到Y团·漏采Z团(用户排行见「…」sheet)」+漏采逐行「 - {goods_id} {goods_name}」。**两处有意差异**:集物两重点商家都有真实购买记录→参与人数全用真实去重买家(deca 非重点商家用中卡近似)、其他商家亦然;集物无进度轨迹→去掉 25/50/75%里程碑列。售卖时长=成交时间-开售时间「X小时Y分」。已实测发测试群通过。
+- **已售报告排版 bug 修复(标题截断)**:主公反馈"显示不完全/格式乱"。根因:`write_section_title` 用**合并单元格**——合并区内文字无法溢出,列窄(尤其空数据 autosize 压到最小)时长标题被切在合并区右边界("占平台组齐总 ("、"当日 Top15,"都是这么被切的;公式栏文字其实是全的,纯显示截断)。**修法照抄 deca `_write_section_title`/`_style_row`:改为「不合并 + 给若干列铺蓝底(空单元格) + 文字写 A 列不换行」**,让文字自然溢出到右侧有色空单元格上(非合并单元格文字能无限溢出、永不截断);铺色跨度固定 = 调用方给的 `ncols`(**同一 sheet 内所有分区标题条传同一 ncols → 色条等长、严谨**;之前用随标题长度自适应的跨度导致同 sheet 内长短不一被主公吐槽,已改)。为保证窄表(空数据 autosize 压窄)时色条仍罩住长标题:`_autosize` 改「只增不减」(不覆盖预设更宽列宽),并给 `build_series`/`build_merchant_rank` 首列预设 24/22 宽。已用脚本全 sheet 复检:各 sheet 标题条 span 统一 + 每条色条宽 ≥ 标题显示宽(全部罩住)。
+- **登录/会话流程图(README + HANDOFF)**:README 新增 §1.1「登录 / 会话流程(jiwu_core)」、HANDOFF §3 末尾同步一份,用 Mermaid flowchart 画 `do_request` 决策链(免登录匿名 sid vs 需登录 ensure_session/login → 三处 sessionId 同值 → _sign 签名 → curl_cffi 发 → code==1 判定/失效重试),并列出免登录/需登录各接口与"最小登录面"动机。纯文档。另:新增 `write_title()`(大标题不合并不填色、自然溢出);`build_overview` 大标题改 write_title、"成交时间窗"改纯文本行(不再当 KV 挤窄 B 列折 4 行)。KV 蓝底标签/橙底数值保留(deca `_write_summary_block` 就是这配色)。**注**:样式库在售/已售共用,两报告同时生效。**数据全 0 属正常**:已售表 60 行成交时间在 08-17~08-19 白天、不落今天窗口 [昨17:00,今06:00],非 bug。
+
+### 2026/08/19
+- **金额单位更正**:所有金额 `÷1000000=元`(实测 amount=99000000→App ¥99.00),原文档写的 ÷10000 是错的。且**入库前用 `core.to_yuan` 换算成元存 `DECIMAL(12,2)`**(parse_product/parse_sold/parse_detail_fields),报告 `yuan()` 改透传不再除;监控读实时接口(原始值)仍 ÷1000000 展示。
+- **购买记录接口定案**:`/order/merchant/app/query/gift/publicity/user/group/pager`(赠品公示·玩家维度,需登录),翻页拿全量买家(userId/userNick/picId/count)。曾误用详情内嵌 `goodsBuyRspDtoList`(仅6条预览)和幻影接口 `publicity/pager`,均已废弃。`jw_player_record` 无唯一键,靠已售表 `buy_fetched` 状态位「取全→入库→置位」控制每商品抓一次。
+- **拆卡报告**:`gift/report`(需登录,sbt=6) 落 `jw_report_record`;全站已售、结束 `REPORT_REFETCH_DAYS`(3)天内每轮重查(INSERT IGNORE 补迟到更新)。
+- **详情补全**:新增 `jw_detail.py`,两商品表补 12 个详情列 + `detail_fetched`(img=FILE_DOMAIN+resList首图;FILE_DOMAIN=`https://files.jiwustar.com/`;collection_card_name/gift_way/random_way 在 cardGoods)。
+- **登录态最小化**:`do_request(need_auth=False)` 默认免登录;仅 hotRecommend/购买记录/拆卡报告 需登录。
+- **板块结构**:在售(`jw_onsale_spider`)独立日更;购买记录+拆卡报告并入已售(`jw_sold_spider`)。曾短暂把在售+购买记录合并成 `jw_onsale_buy_spider`,后因购买记录可对已售事后查全量而拆回、该文件已删。
+- **详情字段坑**:merchantGoodsId 用 stock/surplusStock/price/highestPrice(≠ index/top 的 stockAmount/residueStockAmount);监控 confirm_ended/send_ended 已按详情字段名修正。
+- **自检修正**:清掉遗留的幻影接口/旧字段引用(`jiwu_core` __main__ 自测第 3 项改玩家维度接口;相关 docstring/注释)。全量 `py_compile` 通过、无悬空函数引用。
+- **⚠️ 待优化(下次再聊)**:监控 `fetch_watch_onsale` 每 60~90s 全量翻 index/top 4 个 productType 的所有页——全站在售量大时请求数偏高、有风控风险。可选优化:加大轮询间隔 / 只翻前 N 页(新品通常靠前) / 找更轻的按商家在售接口。

+ 219 - 0
jiwu_spider/README.md

@@ -0,0 +1,219 @@
+# 集物星球(jiwuplanet)爬虫
+
+集物星球 App 数据采集项目:在售 / 已售 / 购买记录三路抓取,每日在售·已售 Excel 报告自动推企业微信,以及针对重点商家(Jake / 九叔)的在售上架实时监控。
+
+- 域名:`https://api.jiwuplanet.com`,全部 `POST + JSON`。
+- 业务成功码 `code == 1`;`code == 200 且 data 为空` 是被风控/会话无效的空壳。
+- 整体流程、产出与逻辑对齐参考项目 `deca_spider`,仅加解密(签名算法)不同。
+
+> 逆向 / 接入的完整细节(签名逐字节验证、TLS 指纹、自登录抓包基准)见 [`HANDOFF.md`](./HANDOFF.md)。
+
+---
+
+## 1. 反爬与接入要点
+
+| 关键点 | 说明 |
+|---|---|
+| 请求签名 | 每请求带 6 个头 `X_VERSION / X_TIME / X_SID / X_RAND / X_PLATFORM / X_SIG_1`;`X_SIG_1 = base64(HMAC_SHA256(key, msg))`,拼接与密钥见 `jiwu_core._sign`(密钥常量 `API_SIGN_SECRET_KEY`)。 |
+| TLS 指纹 | **必须用 `curl_cffi`(`impersonate="chrome"`)**发请求;`requests` 的指纹会被判为非真机、返回空壳。已封装在 `jiwu_core._post`。 |
+| 直连 | 显式 `proxies={"http":None,"https":None}`,绕开本机 Clash 代理环境变量。 |
+| 鉴权 | 无 token,`sessionId` 即会话;客户端自造 32 位 hex 经 `regLogin` 绑定,服务端沿用。三处(头 `X_SID`、body `sessionId`、签名串)须同值。脚本自登录见 `jiwu_core.login`(账号常量 `LOGIN_ACCOUNT`)。 |
+| 金额单位 | 接口原始值 ÷ 1000000 = 元(实测 99000000 → ¥99.00)。**入库前已由 `core.to_yuan` 换算成元存 DECIMAL**,故库里/报告里的金额直接就是元。 |
+
+---
+
+## 1.1 登录 / 会话流程(`jiwu_core`)
+
+核心:**无 token,客户端自造 `sessionId`;「签名是门票、sessionId 是身份」**。免登录接口用随机身份糊弄过签名即可取数,只有服务端强制认身份的接口才 `regLogin` 绑一个真身份。三处 `sessionId`(头 `X_SID`、body、签名串)必须同值。
+
+```mermaid
+flowchart TD
+    A["do_request(path, body, need_auth)"] --> B{"need_auth?"}
+    B -- "False 免登录(默认)" --> C["get_anon_sid()<br/>随机自造 sid,不调 regLogin"]
+    B -- "True 需登录" --> D["ensure_session()"]
+    D --> E{"缓存 sid 未过期?<br/>SESSION_TTL=1800s"}
+    E -- "是" --> F["复用缓存 sid"]
+    E -- "否" --> G["login(): 自造 sid<br/>→ regLogin 绑定 → 写缓存"]
+    C --> H["sessionId 写入 body.sessionId + 头 X_SID + 签名串<br/>(三处同值)"]
+    F --> H
+    G --> H
+    H --> I["_sign(): X_SIG_1 = base64(HMAC_SHA256(key, msg))<br/>msg = path+版本+平台+sessionId+时间+随机+body"]
+    I --> J["curl_cffi POST(impersonate=chrome,直连)"]
+    J --> K{"code == 1 ?"}
+    K -- "是" --> L["成功,返回 JSON"]
+    K -- "否 · 免登录" --> M["轮换匿名 sid,下轮再来"]
+    K -- "否 · 需登录" --> N["清缓存 → 重登,最多再试 1 次"]
+```
+
+- **免登录**(`need_auth=False`,默认):在售 `index/top`、已售 `corp/history`、商品详情 `merchantGoodsId`——匿名随机 sid,不暴露账号。
+- **需登录**(`need_auth=True`):商家列表 `hotRecommend`、购买记录 `publicity/user/group/pager`、拆卡报告 `gift/report`——`ensure_session` 复用/续期真会话(`SESSION_TTL` 内复用,超时自动重登)。
+- 设计动机:登录态是账号被风控关联/封号的主要抓手,故走「最小登录面」——能免登录就不登录。
+
+---
+
+## 2. 目录结构
+
+```
+2026-08-17(jiwu_spider)/
+├── jiwu_core.py            # 核心库:签名、自登录拿 sessionId、curl_cffi 通用请求
+├── auto_send_wx_msg.py     # 企业微信群机器人:发文本/markdown、发文件(Excel)
+├── schema.sql              # 建表 DDL(6 张 jw_ 表)
+├── init_db.py              # 读 schema.sql 幂等建表
+├── run_all.py              # ★一键启动:5 个常驻任务各拉子进程 + 守护自动重启(服务器部署跑这个)
+├── requirements.txt        # 依赖清单(版本实测;pip install -r requirements.txt)
+│
+├── jw_detail.py            # 商品详情补全工具(merchantGoodsId 免登录,补两表详情字段 + img)
+├── jw_onsale_spider.py     # 板块1 在售抓取(免登录,每日)+ 详情补全
+├── jw_sold_spider.py       # 板块2 已售抓取 + 详情补全 + 拆卡报告 + 购买记录(全站已售)
+│
+├── jw_report_excel.py      # 报表 Excel 样式工具库(openpyxl,两报告共用)
+├── jw_onsale_report.py     # 板块4 在售日报 → 企微
+├── jw_sold_report.py       # 板块4 已售日报 → 企微(Jake/九叔 各独立 sheet)
+├── jw_onsale_alert.py    # 板块5 在售监控 / 上架提醒
+│
+├── application.yml         # MySQL 连接配置(mysql_pool 读取)
+├── HANDOFF.md              # 逆向/接入交接文档
+└── reports/  logs/         # Excel 报表留档 / 运行日志(自动生成)
+```
+
+数据库连接池 `mysql_pool.MySQLConnectionPool` 来自全局公共库 `charley-utils`,读运行目录下的 `application.yml`。
+
+---
+
+## 3. 五大板块
+
+| # | 板块 | 脚本 | 定时 | 落表 / 产出 |
+|---|---|---|---|---|
+| 1 | 在售抓取 | `jw_onsale_spider.py` | 每天 09/15/20/01 四档 | 免登录全量抓 index/top → `jw_onsale_product_record`(upsert + 下架对账 `is_on_sale`);同步写每日快照 `jw_onsale_daily_record`(供在售趋势差分) |
+| 2+3 | 已售抓取 + 购买记录 + 拆卡报告 | `jw_sold_spider.py` | 每天 08:00 | 商家列表(需登录)→各商家已售 corp/history(免登录) `jw_sold_product_record`;再对全站已售商品抓:详情补全(免登录) + 拆卡报告 `jw_report_record`(需登录) + 购买记录 `jw_player_record`(玩家维度, 需登录) |
+| 4 | 每日报告 | `jw_onsale_report.py` / `jw_sold_report.py` | 在售 09/15/20/01 四档 / 已售 09:10 | 生成 Excel → 企微发文件;只读库不抓取(在售每档各出一份带小时的文件) |
+| 5 | 在售监控 | `jw_onsale_alert.py` | 手写轮询(默认全天,每轮 60~90s 随机) | 三类告警推企微;去重状态落 `jw_onsale_alert_record` |
+
+> 购买记录与拆卡报告都并入「已售抓取」,对**全站已售商品**抓取:
+> - **购买记录** = 赠品公示·玩家维度 `publicity/user/group/pager`(需登录),翻页拿全量买家,字段 userId/userNick/picId/count。**不做 DB 去重**:靠 `jw_sold_product_record.buy_fetched` 状态位控制每商品抓一次——翻页取全→批量入库成功→再置 `buy_fetched=1`,失败不置位、下轮重试(买家列表售罄后即固定)。
+> - **拆卡报告** = `gift/report`(需登录),有 `giftReportId`+`userId`+中奖状态;因**更新可能不及时**,对**结束 `REPORT_REFETCH_DAYS`(默认3) 天内的已售商品每轮重查**(`INSERT IGNORE` 按 giftReportId 去重、自然补齐),老车只补抓从未抓过的。
+
+**板块4 报告细节**:openpyxl 生成 Excel(`jw_report_excel.py` 提供统一样式),通过 `auto_send_wx_msg.send_wechat_group_file` 以「文件消息」发到企微群。
+
+- 在售日报(6 sheet,对齐 deca):`概览` / `今日新增商家` / `其他商家`(存量商家 `gmt_create_time!=CURDATE()`)/ `商品明细`(今日新上架行淡红高亮)/ `在售趋势`(按每日快照差分,最近 4 天在售商家/拼团数与净新增)/ `上架时段分布`(`HOUR(sold_time)` 24 桶、近 7 日、█ 条形)。每天 09/15/20/01 四档各生成一份带小时的独立文件。
+- 已售日报(9 sheet,对齐 deca):`平台总览`(KPI + 当日环比vs昨日同窗口 + 商家GMV集中度Top1/3/5/10)/ `产品系列榜`(按 goods_ip_name) / `商家GMV榜` / `运营节奏`(重点商家快照 + 成交时段分布) / `Jake明细`(汇总+逐团明细+购买记录覆盖检测) / `Jake用户排行榜` / `九叔明细` / `九叔用户排行榜` / `其他商家`。GMV=已售数×单价元;参与人数取 `jw_player_record` 去重 user_id。**业务日窗口**:成交完成时间 `COALESCE(finish_time, soldout_time)` 落在 `[昨17:00, 今06:00]`(含两端)算「昨天」的已售(对齐 deca 的 completed_at 口径,凌晨仍开播故延到 06:00);采集 08:00 先落库、报告 09:10 再读。
+
+**板块5 监控细节**:`index/top` 无按商家过滤参数,故全量翻 4 个 `productType` 后按 `corp_info_id` 客户端过滤重点商家。三类告警:
+
+- 🆕 **新品上架**:监控内首次出现且 `soldTime ≥ 本场窗口起点(最近一个 RUN_START)`(对齐 deca 最新逻辑);更早上架的老货静默建档不刷屏。
+- 🔥 **进度过半**:`(总份数-剩余)/总份数 ≥ HALF_THRESHOLD`(0.5),首次达标才发。
+- 🏁 **一车结束**:商品从在售列表消失 + 打详情二次确认 `surplusStock<=0`(售罄) **或** 已过 `offShelfTime`(下架/销售结束时间),任一即结束;战报含标题/价格/售出件数/购买人数(玩家维度 totalCount)。
+
+运行窗口:对齐 deca 走窗口制 `[RUN_START 20:30, RUN_END 06:00]`(`RUN_ALL_DAY=False`),`RUN_START` 同时是「本场新上架」判定门槛;集物直播时段不同可改 `RUN_START/RUN_END` 或设 `RUN_ALL_DAY=True` 全天。去重靠 `jw_onsale_alert_record` 的 `new_notified / half_notified / ended_notified` 三标记位 + 进程内 `_seen_onsale` 集合(只播报程序运行后才结束的车)。新品/结束为「发送成功才置位」,失败下轮补发;过半「先置位再发」。
+
+---
+
+## 4. 数据表(`schema.sql`)
+
+| 表 | 用途 | 主要唯一键 |
+|---|---|---|
+| `jw_onsale_product_record` | 在售商品最新状态 | `goods_id` |
+| `jw_sold_product_record` | 已售商品(只增) | `goods_id` |
+| `jw_shop_record` | 商家最新状态 | `corp_info_id` |
+| `jw_player_record` | 购买记录(玩家维度买家列表,只增;不 DB 去重,靠已售表 `buy_fetched` 控制每商品抓一次) | 无唯一键 |
+| `jw_report_record` | 拆卡报告(含 userId/中奖状态,只增) | `gift_report_id` |
+| `jw_onsale_alert_record` | 在售监控提醒去重状态 | `goods_id` |
+| `jw_onsale_daily_record` | 在售每日快照(商品+日期,四档 upsert 保最新,供在售趋势差分) | `goods_id + snapshot_date` |
+
+统一规范:自增 `id` 物理主键;时间字段 `gmt_create_time / gmt_modified_time`;金额字段(`amount/price/original_price/highest_price/lowest_price`)**入库前已 ÷1000000 换算成「元」存 DECIMAL(12,2)**。
+
+**详情补全字段**:两个商品表都带一组来自商品详情 `merchantGoodsId`(免登录)的字段——`img`(商品主图完整URL) / `goods_type` / `price` / `original_price` / `plan_up_time` / `off_shelf_time` / `stock` / `surplus_stock` / `status` / `collection_card_name` / `gift_way` / `random_way`。这些列表接口没有,由 `jw_detail.enrich_detail` 逐商品补拉,用各表 `detail_fetched` 状态位控制**每商品只补一次**(在售的列表 upsert 不覆盖这些列)。
+- `img` = `FILE_DOMAIN` + `resList` 首图 `resAddr`;`FILE_DOMAIN = https://files.jiwustar.com/`(逆向 `EnvironmentManager` 正式环境所得,接口只给相对路径)。
+- `collection_card_name / gift_way / random_way` 来自详情的 `cardGoods` 子对象。
+
+---
+
+## 5. 接口清单
+
+| 板块 | path | 登录 | 关键参数 / 说明 |
+|---|---|---|---|
+| 在售列表 | `/search/app/index/top` | 🟢 免登录 | currentPage, limit, productType(1福袋2变风盒3错版卡4原盒), systemBusinessType:5 |
+| 已售历史 | `/search/app/corp/history` | 🟢 免登录 | corpInfoId, currentPage, limit, systemBusinessType:5 |
+| 商品详情 | `/search/app/merchantGoodsId` | 🟢 免登录 | goodsId, systemBusinessType:5(监控判结束用;字段 stock/surplusStock/price/highestPrice) |
+| 购买记录 | `/order/merchant/app/query/gift/publicity/user/group/pager` | 🔴 需登录 | 赠品公示·玩家维度:currentPage, giftBusinessName:"", goodsId, limit, systemBusinessType:5;每条=一个买家 **userId/userNick/picId/count**,翻页拿全量 |
+| 拆卡报告 | `/goods/gift/report/query/search/pager` | 🔴 需登录 | goodsId, currentPage, limit, **systemBusinessType:6**;字段 giftReportId(唯一)/serialItemName/userId/winnerStatus/createTime |
+| 商家列表 | `/search/app/index/corp/hotRecommend` | 🔴 需登录 | currentPage, limit, systemBusinessType:6 |
+| 登录 | `/acct/user/regLogin` | — | 见 `jiwu_core.LOGIN_ACCOUNT` |
+
+**登录态最小化**(已实测,2026/08/19):`jiwu_core.do_request` 默认 `need_auth=False` 走免登录(匿名随机 sid 签名、不调 regLogin,不暴露账号);只有商家列表 `hotRecommend`、购买记录 `publicity/user/group/pager`、拆卡报告 `gift/report` 实测服务端强制登录,才传 `need_auth=True`。即**在售/已售/商品详情抓取全程零登录**。
+
+重点商家:**Jake球星卡 `corpInfoId=100716`**、**九叔的喷火龙 `corpInfoId=100715`**。
+
+---
+
+## 6. 环境与安装
+
+- Python 3.12.10
+- 依赖:`curl_cffi`、`loguru`、`tenacity`、`schedule`、`requests`、`openpyxl`,以及全局公共库 `charley-utils`(提供 `mysql_pool` / `YamlLoader`,依赖 `pymysql`、`DBUtils`、`PyYAML`)。
+
+```bash
+pip install -r requirements.txt
+# charley-utils 为本地 editable 安装:pip install -e D:\work\common\charley-utils
+```
+
+配置 `application.yml` 里的 MySQL 连接(host/port/username/password/db)。
+
+---
+
+## 7. 运行
+
+```bash
+# 1) 建表(幂等 CREATE IF NOT EXISTS)。
+#    schema 经多轮调整,若库里已有旧结构的表,CREATE IF NOT EXISTS 不会改动它们,
+#    建议先 DROP 掉全部 jw_ 表再重建:
+#    mysql> DROP TABLE IF EXISTS jw_onsale_product_record, jw_sold_product_record, jw_shop_record,
+#           jw_player_record, jw_report_record, jw_onsale_alert_record, jw_onsale_daily_record;
+python init_db.py
+
+# 2) 一键启动全部任务(推荐,服务器部署用这个)
+#    run_all.py 会把下面 5 个任务各拉成独立子进程并守护(崩溃自动重启)
+python run_all.py
+```
+
+**单跑(仅调试用)**:想单独跑某个任务时直接跑对应脚本即可,不必用 run_all。
+
+```bash
+python jw_onsale_spider.py       # 在售(每天 09/15/20/01 四档,免登录,含每日快照)
+python jw_sold_spider.py         # 已售 + 拆卡报告 + 购买记录(每天 08:00)
+python jw_onsale_report.py       # 在售日报(09/15/20/01 四档发企微)
+python jw_sold_report.py         # 已售日报(09:10 发企微)
+python jw_onsale_alert.py        # 在售监控(默认窗口起点 20:30)
+# python jw_onsale_alert.py 17:00 或 --start 17:00   # 传窗口起点(该时间后新上架才提醒)
+```
+
+生产部署:直接 `python run_all.py`(自带子进程守护/自动重启);如需开机自启/进程托管,可再用 nssm / supervisor / pm2 托管 `run_all.py` 这一个进程即可。各脚本 `schedule_task()` 里可取消注释 `main_task/run_once` 立即跑一次用于调试。
+
+---
+
+## 8. 关键配置项
+
+| 位置 | 配置 | 说明 |
+|---|---|---|
+| `auto_send_wx_msg.WEBHOOK_URL` | 企微群机器人 webhook | 当前为测试群,上线换正式群只改这一处 |
+| `jw_onsale_alert.WATCH_CORPS` | `{100716, 100715}` | 监控的重点商家 |
+| `jw_onsale_alert.HALF_THRESHOLD` | `0.5` | 进度过半阈值 |
+| `jw_onsale_alert.RUN_ALL_DAY` / `RUN_START` / `RUN_END` | 运行窗口 | 默认 False 走 [20:30, 06:00] 跨午夜窗口(对齐 deca,RUN_START 兼作新品判定门槛);置 True 则全天 |
+| `jw_sold_report.FOCUS_CORPS` | Jake / 九叔 | 已售报告独立成 sheet 的商家 |
+| `jiwu_core.SESSION_TTL` | `1800` | sessionId 复用时长,超时自动重登 |
+| `jiwu_core.NO_PROXY` | 直连 | 如需代理在此改 |
+
+---
+
+## 9. 待实测确认(不影响运行,仅影响判定精度)
+
+1. **新品上架判定**:`jw_onsale_alert.is_new_arrival` 假定列表字段 `soldTime` 为上架时间(依 schema 注释)。若实测为售罄/成交时间,需改判定逻辑或改用其它上架时间字段。
+2. **一车结束二次确认**:`jw_onsale_alert.confirm_ended` 依赖商品详情返回 `surplusStock`(剩余库存)。若语义不同,需调整结束判定。
+
+---
+
+## 10. 注意事项
+
+- 登录态最小化:业务接口均由 `jiwu_core` 统一带 `sessionId` + 签名,会话失效自动重登一次重试。
+- 抓取与报告解耦:报告脚本只读库、不触发抓取,抓取由各 spider 按自身定时落库。
+- 报告 Excel 存 `reports/` 目录、文件名带日期、发送后不删除(留档)。
+- 日志按天切分存 `logs/`,保留 7 天。

+ 98 - 0
jiwu_spider/YamlLoader.py

@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2025/12/22 10:44
+import os, re
+import yaml
+
+regex = re.compile(r'^\$\{(?P<ENV>[A-Z_\-]+:)?(?P<VAL>[\w.]+)}$')
+
+
+class YamlConfig:
+    def __init__(self, config):
+        self.config = config
+
+    def get(self, key: str):
+        return YamlConfig(self.config.get(key))
+
+    def getValueAsString(self, key: str):
+        try:
+            match = regex.match(self.config[key])
+            group = match.groupdict()
+            if group['ENV'] is not None:
+                env = group['ENV'][:-1]
+                return os.getenv(env, group['VAL'])
+            return None
+        except:
+            return self.config[key]
+
+    def getValueAsInt(self, key: str):
+        try:
+            match = regex.match(self.config[key])
+            group = match.groupdict()
+            if group['ENV'] is not None:
+                env = group['ENV'][:-1]
+                return int(os.getenv(env, group['VAL']))
+            return 0
+        except:
+            return int(self.config[key])
+
+    def getValueAsBool(self, key: str):
+        try:
+            match = regex.match(self.config[key])
+            group = match.groupdict()
+            if group['ENV'] is not None:
+                env = group['ENV'][:-1]
+                return bool(os.getenv(env, group['VAL']))
+            return False
+        except:
+            return bool(self.config[key])
+
+
+def _resolve_path(path: str) -> str:
+    """
+    解析 yaml 文件路径,按优先级查找:
+      1) 绝对路径或 cwd 下存在 → 直接用(保留旧行为,向后兼容)
+      2) 调用方主脚本所在目录 → 兜底,方便打包后从任意 cwd 启动
+    :param path: (str) 用户传入的路径,默认 'application.yml'
+    :return: (str) 实际可读取的完整路径;找不到则返回原 path 让 open() 抛错
+    """
+    # 1) 旧行为:cwd 或绝对路径
+    if os.path.exists(path):
+        return path
+
+    # 2) 主脚本目录(__main__.__file__)
+    try:
+        import __main__
+        main_file = getattr(__main__, '__file__', None)
+        if main_file:
+            candidate = os.path.join(os.path.dirname(os.path.abspath(main_file)), path)
+            if os.path.exists(candidate):
+                return candidate
+    except Exception:
+        pass
+
+    return path
+
+
+def readYaml(path: str = 'application.yml', profile: str = None) -> YamlConfig:
+    """
+    读取 yaml 配置。
+    :param path: (str) yaml 文件路径,默认 'application.yml'。
+                       优先 cwd / 绝对路径(保留旧行为),找不到再 fallback 到主脚本所在目录。
+    :param profile: (str) 可选环境后缀,如 'dev' 会额外加载 'application-dev.yml' 并 update
+    :return: (YamlConfig) 配置访问对象
+    :raises FileNotFoundError: cwd 和主脚本目录都找不到时抛出
+    """
+    real_path = _resolve_path(path)
+    with open(real_path, encoding='utf-8') as fd:
+        conf = yaml.load(fd, Loader=yaml.FullLoader)
+
+    if profile is not None:
+        result = real_path.rsplit('.', 1)
+        profiledYaml = f'{result[0]}-{profile}.{result[1]}'
+        if os.path.exists(profiledYaml):
+            with open(profiledYaml, encoding='utf-8') as fd:
+                conf.update(yaml.load(fd, Loader=yaml.FullLoader))
+
+    return YamlConfig(conf)

+ 6 - 0
jiwu_spider/application.yml

@@ -0,0 +1,6 @@
+mysql:
+  host: ${MYSQL_HOST:100.64.0.25}
+  port: ${MYSQL_PROT:3306}
+  username: ${MYSQL_USERNAME:crawler}
+  password: ${MYSQL_PASSWORD:Pass2022}
+  db: ${MYSQL_DATABASE:crawler}

+ 301 - 0
jiwu_spider/auto_send_wx_msg.py

@@ -0,0 +1,301 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/18
+"""企业微信群机器人通用发送模块(文本 / markdown_v2 / 文件)。
+
+对外入口:
+    - send_wechat_group_msg :发文本或 markdown_v2 文案,items 元素可为字符串或 (名称, 链接) 元组。
+    - send_wechat_group_file:发文件(先 upload_media 拿 media_id,再发 file 消息),供报表脚本发 Excel。
+
+作为工具库被其它脚本 import,不在模块级配置 loguru sink,日志默认落到调用方的 logger。
+
+变更记录:
+    2026/08/18 从 deca 项目照搬到集物星球项目;WEBHOOK_URL 待替换为本项目专用群机器人。
+    2026/08/27 上传/发送增加 tenacity 重试,专治 TLS 瞬断(UNEXPECTED_EOF_WHILE_READING)导致的偶发发送失败。
+"""
+import os
+import re
+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"
+# WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=2d41b34f-8e30-479b-a39d-331f1d5c233f"
+# 素材上传接口:发文件/图片前先把素材传上去换 media_id(type=file/voice;文件 5B~20MB,media_id 有效期 3 天)
+UPLOAD_URL_TMPL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key={key}&type={media_type}"
+
+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)。
+
+    Args:
+        webhook_url (str): 形如 ...webhook/send?key=xxxx 的 Webhook 地址。
+
+    Returns:
+        str | None: 抽到的 key;地址不含 key 时返回 None。
+    """
+    m = re.search(r"key=([0-9a-fA-F\-]+)", webhook_url)
+    return m.group(1) if m else None
+
+
+def build_markdown_content(items: list, title: str) -> str:
+    """把列表拼成 markdown 文案(每条编号,条间加分割线)。
+
+    Args:
+        items (list): 元素为字符串或 (名称, 链接) 元组。
+        title (str): 文案标题(渲染为四级标题)。
+
+    Returns:
+        str: 拼好的 markdown 文本。
+    """
+    md_content = f"#### {title}\n"
+    for i, item in enumerate(items, 1):
+        if isinstance(item, tuple) and len(item) == 2:
+            name, link = item
+            md_content += f"{i}. [{name}]({link})\n"
+        elif isinstance(item, str):
+            md_content += f"{i}. {item}\n"
+        else:
+            md_content += f"{i}. {str(item)}\n"
+
+        if i < len(items):
+            md_content += "\n---\n\n"
+    return md_content
+
+
+def build_text_content(items: list) -> str:
+    """把列表拼成纯文本文案(每条编号,条间加分割线)。
+
+    Args:
+        items (list): 元素为字符串或 (名称, 链接) 元组。
+
+    Returns:
+        str: 拼好的纯文本。
+    """
+    content = ""
+    for i, item in enumerate(items, 1):
+        if isinstance(item, tuple) and len(item) == 2:
+            name, link = item
+            content += f"{i}. {name}: {link}\n"
+        elif isinstance(item, str):
+            content += f"{i}. {item}\n"
+        else:
+            content += f"{i}. {str(item)}\n"
+
+        if i < len(items):
+            content += "----------------------------------\n"
+    return content
+
+
+def send_wechat_group_msg(log=None, items=None, mentioned_list=None,
+                          msg_type="markdown", title="🚀 提醒通知") -> dict | None:
+    """发送文本 / markdown_v2 消息到企业微信群机器人。
+
+    Args:
+        log (loguru.Logger, optional): 日志对象;不传用全局 logger。Defaults to None。
+        items (list, optional): 消息条目,元素为字符串或 (名称, 链接) 元组。Defaults to None。
+        mentioned_list (list, optional): text 类型下 @ 的成员手机号/@all 列表。Defaults to None。
+        msg_type (str, optional): 消息类型 text / markdown。Defaults to "markdown"。
+        title (str, optional): markdown 文案标题。Defaults to "🚀 提醒通知"。
+
+    Returns:
+        dict | None: 企微返回的 JSON;发送失败返回 None。
+    """
+    if items is None:
+        items = []
+    if log is None:
+        log = logger
+
+    headers = {"Content-Type": "application/json"}
+    if msg_type == "text":
+        data = {
+            "msgtype": "text",
+            "text": {
+                "content": build_text_content(items),
+                "mentioned_list": mentioned_list if mentioned_list else [],
+            },
+        }
+    else:  # 默认 markdown_v2
+        data = {
+            "msgtype": "markdown_v2",
+            "markdown_v2": {"content": build_markdown_content(items, title)},
+        }
+
+    try:
+        log.info(f"正在发送企微消息: {title}")
+        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}")
+            return None
+        log.success("企微消息发送成功")
+        return result
+    except requests.exceptions.RequestException as e:
+        log.error(f"企微消息发送失败(重试{RETRY_TIMES}次后仍失败): {e}")
+        return None
+
+
+def _upload_media(log, file_path: str, media_type: str = "file") -> str | None:
+    """把本地文件上传到企微群机器人素材接口,换取 media_id。
+
+    Args:
+        log (loguru.Logger): 日志对象。
+        file_path (str): 本地文件绝对/相对路径。
+        media_type (str, optional): 素材类型 file / voice。Defaults to "file"。
+
+    Returns:
+        str | None: 上传成功返回 media_id(有效期 3 天);文件不存在/超限/上传失败返回 None。
+    """
+    key = _extract_key(WEBHOOK_URL)
+    if not key:
+        log.error("Webhook 地址里没解析到 key,无法上传素材")
+        return None
+
+    size = os.path.getsize(file_path)
+    if not (FILE_MIN_BYTES <= size <= FILE_MAX_BYTES):  # 企微限制 5B~20MB
+        log.error(f"文件大小 {size} 字节超出企微限制(5B~20MB):{file_path}")
+        return None
+
+    url = UPLOAD_URL_TMPL.format(key=key, media_type=media_type)
+    try:
+        resp = _do_upload_media(log, url, file_path)  # 带重试上传,内部每次重新打开文件
+        result = resp.json()
+        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"企微素材上传异常(重试{RETRY_TIMES}次后仍失败): {e}")
+        return None
+
+
+def send_wechat_group_file(log=None, file_path: str = None) -> dict | None:
+    """发送一个本地文件到企业微信群机器人(自动先上传素材换 media_id 再发 file 消息)。
+
+    Args:
+        log (loguru.Logger, optional): 日志对象;不传用全局 logger。Defaults to None。
+        file_path (str, optional): 待发送文件路径(如 Excel 报表)。Defaults to None。
+
+    Returns:
+        dict | None: 企微返回的 JSON;文件不存在/上传失败/发送失败返回 None。
+    """
+    if log is None:
+        log = logger
+    if not file_path or not os.path.isfile(file_path):
+        log.error(f"待发送文件不存在: {file_path}")
+        return None
+
+    media_id = _upload_media(log, file_path, "file")
+    if not media_id:
+        return None
+
+    data = {"msgtype": "file", "file": {"media_id": media_id}}
+    try:
+        log.info(f"正在发送企微文件: {os.path.basename(file_path)}")
+        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}")
+            return None
+        log.success(f"企微文件发送成功: {os.path.basename(file_path)}")
+        return result
+    except requests.exceptions.RequestException as e:
+        log.error(f"企微文件发送失败(重试{RETRY_TIMES}次后仍失败): {e}")
+        return None
+
+
+if __name__ == "__main__":
+    # 自测:发送一条示例 markdown 消息
+    import sys
+    logger.remove()
+    logger.add(sys.stderr, level="INFO")
+    send_wechat_group_msg(
+        items=["示例商品A(单价¥199,100份)", "示例商品B(单价¥299,50份)"],
+        title="🧪 集物星球企微机器人自测",
+    )

+ 52 - 0
jiwu_spider/init_db.py

@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/18
+"""读取 schema.sql 幂等建表(集物星球爬虫库)。"""
+import os
+import sys
+
+from loguru import logger
+from mysql_pool import MySQLConnectionPool
+
+
+def init_db(log) -> None:
+    """读取同目录 schema.sql,逐条执行建表语句。
+
+    Args:
+        log: 日志对象。
+
+    Raises:
+        FileNotFoundError: schema.sql 不存在时抛出。
+    """
+    sql_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql")
+    if not os.path.isfile(sql_path):
+        raise FileNotFoundError(f"schema.sql 不存在: {sql_path}")
+
+    with open(sql_path, "r", encoding="utf-8") as f:
+        content = f.read()
+
+    pool = MySQLConnectionPool(log=log)
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        return
+
+    # 按分号切分逐条执行,跳过空行与注释行
+    stmts = [s.strip() for s in content.split(";") if s.strip()]
+    for stmt in stmts:
+        lines = [ln for ln in stmt.splitlines() if not ln.strip().startswith("--")]
+        clean = "\n".join(lines).strip()
+        if not clean:
+            continue
+        try:
+            pool._execute(clean, commit=True)
+            head = clean.splitlines()[0][:60]
+            log.success(f"执行成功: {head}")
+        except Exception as e:
+            log.warning(f"执行失败(可能已存在): {e}")
+
+
+if __name__ == "__main__":
+    logger.remove()
+    logger.add(sys.stderr, level="INFO", format="[{time:HH:mm:ss}] {level} {message}")
+    init_db(logger)

+ 277 - 0
jiwu_spider/jiwu_core.py

@@ -0,0 +1,277 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/8/18 19:19
+"""集物星球爬虫核心库:签名、脚本自登录拿 sessionId、通用请求(curl_cffi 过 TLS 风控)。
+
+ 三大关键点:
+     1. 请求头签名 X_SIG_1 = base64(HMAC_SHA256(key, msg)),msg 拼接顺序见 _sign。
+     2. 必须用 curl_cffi(impersonate=chrome)发请求——服务端按 TLS/JA3 指纹识别客户端,
+        python requests 的指纹会被判为非真机,接口静默返回 code:200 空壳(data.result=[]);
+        curl_cffi 才能拿到真正的 code:1 数据。
+     3. sessionId 由客户端自造、regLogin 上报绑定,服务端沿用该值;请求头 X_SID、body.sessionId、
+        签名串里的 sessionId 三处必须同值。业务接口成功码是 code==1。
+ """
+import sys
+import time
+import json
+import hmac
+import base64
+import random
+import hashlib
+
+from curl_cffi import requests as creq
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+
+BASE_URL = "https://api.jiwuplanet.com"
+API_SIGN_SECRET_KEY = "EahycFpDpewggO2rksvQwTlnhCbTHFx6"  # 生产环境签名密钥(逆向所得)
+X_VERSION = "2.12.2"  # App 版本号
+X_PLATFORM = "150"  # 平台常量
+IMPERSONATE = "chrome"  # curl_cffi TLS 指纹,过服务端风控的关键
+NO_PROXY = {"http": None, "https": None}  # 显式直连,忽略系统 HTTP(S)_PROXY(127.0.0.1:7890)
+BIZ_OK_CODE = 1  # 业务接口成功码(登录/查询成功均为 1)
+PRICE_DIVISOR = 1000000  # 金额原始值 ÷ 此 = 元(实测 amount=99000000 → App 显示 ¥99.00)
+
+# 脚本自登录账号(无人值守续期用;账密为运行必需,如需可挪到 application.yml)
+LOGIN_ACCOUNT = {
+    "appVersions": X_VERSION, "deviceNumber": "40b03f4bec96675f",
+    "identificationNumber": "19521500850", "ip": "10.1.10.1", "language": "ZH",
+    "loginPassword": "pass2022", "loginType": "ACCOUNT", "mobile": "19521500850",
+    "mobileCode": "86", "platformInfo": "Google Pixel 5", "platformType": "ANDROID_USER",
+    "systemBusinessType": 6, "terminalVersions": "11",
+}
+
+SESSION_TTL = 1800  # sessionId 复用时长(秒),超时重新登录
+_session = {"sid": None, "ts": 0.0}  # 登录态 sessionId 内存缓存
+_anon = {"sid": None}  # 免登录接口用的匿名 sid(随机自造、不经 regLogin,进程内复用;被风控则轮换)
+
+
+def after_log(retry_state):
+    """tenacity 重试回调,记录每次尝试的结果。
+
+    Args:
+        retry_state: tenacity 传入的 RetryCallState,args[0] 约定为 log。
+    """
+    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 canonical_body(body_obj: dict) -> str:
+    """把请求体规范化为被签名/被发送的紧凑串(等价 App 端 Gson 处理)。
+
+    Args:
+        body_obj (dict): 原始请求体字典。
+
+    Returns:
+        str: 递归按 key 排序、紧凑、非 ASCII 不转义的 JSON 串;既用于签名也用于实际发送。
+    """
+    return json.dumps(body_obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
+
+
+def to_yuan(raw) -> float | None:
+    """把接口原始金额换算成元(÷PRICE_DIVISOR,保留 2 位),供入库前统一转换。
+
+    Args:
+        raw (int | float | None): 接口原始金额。
+
+    Returns:
+        float | None: 元(保留 2 位);raw 为空返回 None。
+    """
+    return round(raw / PRICE_DIVISOR, 2) if isinstance(raw, (int, float)) else None
+
+
+def gen_session_id() -> str:
+    """自造一个 32 位小写 hex 会话标识(服务端沿用此值作会话)。
+
+    Returns:
+        str: 32 位十六进制字符串。
+    """
+    return hashlib.md5(f"{time.time()}-{random.random()}".encode()).hexdigest()
+
+
+def _sign(path: str, body_str: str, session_id: str) -> dict:
+    """按签名算法生成带 X_SIG_1 的完整请求头。
+
+    Args:
+        path (str): URL 路径(encodedPath,不含 host/query)。
+        body_str (str): 规范化后的请求体字符串。
+        session_id (str): 会话标识,与 body 内 sessionId 一致。
+
+    Returns:
+        dict: 含 6 个 X_* 头及基础头的请求头字典。
+    """
+    x_time = str(int(time.time()))  # 秒级时间戳
+    x_rand = str(random.randint(100000, 999999))  # 6 位随机数
+    msg = path + X_VERSION + X_PLATFORM + session_id + x_time + x_rand + body_str
+    sig = base64.b64encode(
+        hmac.new(API_SIGN_SECRET_KEY.encode(), msg.encode(), hashlib.sha256).digest()
+    ).decode()
+    return {
+        "X_VERSION": X_VERSION, "X_TIME": x_time, "X_SID": session_id,
+        "X_RAND": x_rand, "X_PLATFORM": X_PLATFORM, "X_SIG_1": sig,
+        "Content-Type": "application/json;charset=UTF-8",
+        "User-Agent": "okhttp/3.12.10", "Host": "api.jiwuplanet.com",
+    }
+
+
+@retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
+def _post(log, path: str, body_obj: dict, session_id: str) -> dict:
+    """用 curl_cffi 发一次带签名的 POST 请求(带重试)。
+
+    Args:
+        log: 日志对象(首参,供 after_log 取用)。
+        path (str): 接口路径。
+        body_obj (dict): 请求体(须已含 sessionId)。
+        session_id (str): 会话标识。
+
+    Returns:
+        dict: 响应 JSON。
+
+    Raises:
+        RuntimeError: HTTP 状态非 200 时抛出以触发重试。
+    """
+    body_str = canonical_body(body_obj)
+    headers = _sign(path, body_str, session_id)
+    r = creq.post(BASE_URL + path, headers=headers, data=body_str.encode("utf-8"),
+                  timeout=20, impersonate=IMPERSONATE, proxies=NO_PROXY)
+    if r.status_code != 200:
+        log.error(f"请求失败 HTTP {r.status_code}: {path}")
+        raise RuntimeError(f"HTTP {r.status_code}")
+    return r.json()
+
+
+def login(log) -> str:
+    """脚本自登录:自造 sessionId 调 regLogin 绑定,返回可用会话标识并写入缓存。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        str: 登录成功后可用的 sessionId;失败返回空串。
+    """
+    sid = gen_session_id()
+    body = dict(LOGIN_ACCOUNT)
+    body["sessionId"] = sid
+    try:
+        j = _post(log, "/acct/user/regLogin", body, sid)
+    except Exception as e:
+        log.error(f"登录请求异常: {e}")
+        return ""
+    if j.get("code") != BIZ_OK_CODE:
+        log.error(f"登录失败: code={j.get('code')} msg={j.get('message')}")
+        return ""
+    data = j.get("data") or {}
+    ret_sid = data.get("sessionId") or sid
+    _session["sid"] = ret_sid
+    _session["ts"] = time.time()
+    log.success(f"登录成功 nickName={data.get('nickName')} userId={data.get('userId')} sid={ret_sid}")
+    return ret_sid
+
+
+def ensure_session(log) -> str:
+    """取可用 sessionId:内存缓存未过期直接用,否则重新登录。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        str: 可用的 sessionId;登录失败返回空串。
+    """
+    if _session["sid"] and (time.time() - _session["ts"] < SESSION_TTL):
+        return _session["sid"]
+    return login(log)
+
+
+def get_anon_sid() -> str:
+    """取免登录接口用的匿名 sessionId:随机自造、不经 regLogin,进程内复用。
+
+    Returns:
+        str: 32 位十六进制匿名会话标识。
+    """
+    if not _anon["sid"]:
+        _anon["sid"] = gen_session_id()
+    return _anon["sid"]
+
+
+def do_request(log, path: str, body: dict, need_auth: bool = False) -> dict | None:
+    """通用业务请求:自动带 sessionId + 签名。
+
+    登录态最小化:**默认走免登录**(need_auth=False,用随机未绑定的匿名 sid 签名,不调 regLogin);
+    仅服务端强制要求登录的接口(商家列表 hotRecommend、购买记录 publicity/user/group/pager、拆卡报告 gift/report)才传 need_auth=True。
+
+    Args:
+        log: 日志对象。
+        path (str): 接口路径。
+        body (dict): 业务请求参数(不含 sessionId,内部自动补)。
+        need_auth (bool, optional): 是否需要真实登录会话。False=匿名免登录;True=regLogin 会话。Defaults to False。
+
+    Returns:
+        dict | None: 成功返回响应 JSON(code==1);失败返回 None。
+    """
+    if not need_auth:  # 免登录:匿名 sid,被风控则轮换匿名 sid 下轮再来(始终不登录)
+        sid = get_anon_sid()
+        b = dict(body)
+        b["sessionId"] = sid
+        try:
+            j = _post(log, path, b, sid)
+        except Exception as e:
+            log.warning(f"请求异常 {path}: {e}")
+            return None
+        if j.get("code") == BIZ_OK_CODE:
+            return j
+        log.warning(f"{path} 匿名请求返回 code={j.get('code')},轮换匿名 sid")
+        _anon["sid"] = None
+        return None
+
+    for attempt in range(2):  # 需登录:最多两次(首次 + 会话失效重登后再试)
+        sid = ensure_session(log)
+        if not sid:
+            return None
+        b = dict(body)
+        b["sessionId"] = sid
+        try:
+            j = _post(log, path, b, sid)
+        except Exception as e:
+            log.warning(f"请求异常 {path}: {e}")
+            return None
+        if j.get("code") == BIZ_OK_CODE:
+            return j
+        log.warning(f"{path} 返回 code={j.get('code')},判为会话失效,第 {attempt + 1} 次,清缓存重登")
+        _session["sid"] = None
+    return None
+
+
+if __name__ == "__main__":
+    # 自测:登录 + 在售 + 购买记录
+    logger.remove()
+    logger.add(sys.stderr, level="INFO", format="[{time:HH:mm:ss}] {level} {message}")
+    _log = logger
+
+    _log.info("===== 自测1:脚本自登录 =====")
+    _sid = ensure_session(_log)
+    _log.info(f"拿到 sessionId: {_sid}")
+
+    _log.info("===== 自测2:在售列表 index/top =====")
+    _j = do_request(_log, "/search/app/index/top",
+                    {"currentPage": "1", "limit": "10", "productType": "3", "systemBusinessType": 5})
+    if _j:
+        _d = _j["data"]
+        _log.info(f"code={_j['code']} total={_d.get('totalCount')} 本页={len(_d.get('records', []))}")
+        if _d.get("records"):
+            _r = _d["records"][0]
+            _log.info(
+                f"首条: {_r.get('goodsName')} | {_r.get('corpInfoName')}({_r.get('corpInfoId')}) | 价={_r.get('highestPrice')}")
+
+    _log.info("===== 自测3:购买记录 玩家维度(需登录)=====")
+    _j = do_request(_log, "/order/merchant/app/query/gift/publicity/user/group/pager",
+                    {"currentPage": "1", "giftBusinessName": "", "goodsId": "1660823", "limit": "5"},
+                    need_auth=True)
+    if _j:
+        _d = _j["data"]
+        _log.info(f"code={_j['code']} total={_d.get('totalCount')} 本页={len(_d.get('records', []))}")
+        for _rr in _d.get("records", [])[:3]:
+            _log.info(f"买家={_rr.get('userNick')} 份数={_rr.get('count')} uid={_rr.get('userId')}")

+ 101 - 0
jiwu_spider/jw_detail.py

@@ -0,0 +1,101 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/19
+"""集物星球商品详情补全:拉 merchantGoodsId(免登录) 取详情页字段,供在售/已售两表补全。
+
+  详情字段在列表接口(index/top、corp/history)里没有,只在商品详情里,需逐商品补拉一次。
+  用 detail_fetched 状态位控制每商品只补一次(在售的列表 upsert 不动这些详情列,避免被覆盖)。
+  图片:取 resList 第一张图(商品主图)的相对路径 resAddr,拼 FILE_DOMAIN(逆向 EnvironmentManager 得) 成完整 URL。
+  """
+import time
+
+import jiwu_core as core
+
+IMG_BASE_URL = "https://files.jiwustar.com/"  # 文件/图片域名(EnvironmentManager RELEASE 环境 FILE_DOMAIN)
+
+# 详情补全的列(两表同名同序),值来源见 parse_detail_fields
+DETAIL_COLS = ["img", "goods_type", "price", "original_price", "plan_up_time", "off_shelf_time",
+               "stock", "surplus_stock", "status", "collection_card_name", "gift_way", "random_way",
+               "gift_series"]
+
+
+def fetch_detail(log, goods_id: int) -> dict | None:
+    """拉商品详情 merchantGoodsId(免登录),返回 data 对象。
+
+    Args:
+        log: 日志对象。
+        goods_id (int): 商品 goodsId。
+
+    Returns:
+        dict | None: 详情 data 字典;失败返回 None。
+    """
+    j = core.do_request(log, "/search/app/merchantGoodsId",
+                        {"goodsId": str(goods_id), "systemBusinessType": 5})  # 免登录
+    if not j:
+        return None
+    return j.get("data") or None
+
+
+def parse_detail_fields(data: dict) -> dict:
+    """从详情 data 抽取补全列(含图片拼完整域名;collection/gift/random 在 cardGoods 里)。
+
+    Args:
+        data (dict | None): fetch_detail 返回的详情 data;None 时返回全 None 的列。
+
+    Returns:
+        dict: 键为 DETAIL_COLS 的字典。
+    """
+    if not data:
+        return {c: None for c in DETAIL_COLS}
+    cg = data.get("cardGoods") or {}
+    res_list = data.get("resList") or []
+    addr = res_list[0].get("resAddr") if res_list else None  # resList 第一张=商品主图
+    return {
+        "img": (IMG_BASE_URL + addr) if addr else None,
+        "goods_type": data.get("goodsType"),
+        "price": core.to_yuan(data.get("price")),
+        "original_price": core.to_yuan(data.get("originalPrice")),
+        "plan_up_time": data.get("planUpTime"),
+        "off_shelf_time": data.get("offShelfTime"),
+        "stock": data.get("stock"),
+        "surplus_stock": data.get("surplusStock"),
+        "status": data.get("status"),
+        "collection_card_name": cg.get("collectionCardName"),
+        "gift_way": cg.get("giftWay"),
+        "random_way": cg.get("randomWay"),
+        "gift_series": cg.get("giftSeries") or data.get("specifications"),  # 赠品系列(App"系列"),兜底取顶层 specifications
+    }
+
+
+def enrich_detail(log, pool, table: str, goods_ids: list) -> None:
+    """对表内 detail_fetched=0 的商品补全详情列,成功后置 detail_fetched=1(每商品补一次)。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+        table (str): 目标表名(jw_onsale_product_record / jw_sold_product_record,受控常量)。
+        goods_ids (list): 本轮相关的商品 goods_id 列表。
+    """
+    ids = list({g for g in goods_ids if g})
+    if not ids:
+        return
+    ph = ",".join(["%s"] * len(ids))
+    pending = [r[0] for r in pool.select_all(
+        f"SELECT goods_id FROM {table} WHERE goods_id IN ({ph}) AND detail_fetched=0", tuple(ids))]
+    if not pending:
+        return
+    set_cols = ", ".join(f"`{c}`=%s" for c in DETAIL_COLS)
+    done = 0
+    for gid in pending:
+        data = fetch_detail(log, gid)
+        if not data:  # 取不到详情,不置位、下轮再补
+            continue
+        f = parse_detail_fields(data)
+        pool.update_one(
+            f"UPDATE {table} SET {set_cols}, detail_fetched=1 WHERE goods_id=%s",
+            tuple(f[c] for c in DETAIL_COLS) + (gid,))
+        done += 1
+        time.sleep(0.2)
+    if done:
+        log.info(f"{table} 详情补全 {done} 个商品")

+ 597 - 0
jiwu_spider/jw_onsale_alert.py

@@ -0,0 +1,597 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/19
+"""集物星球在售监控 / 自动上架提醒:轮询全站在售、过滤关注商家(Jake/九叔),三类告警推企微。
+
+  逻辑与参考项目 deca_spider 的 onsale_alert_spider 一致,只把取数层换成集物的接口:
+    - 取数:/search/app/index/top 无按商家过滤参数,需对 productType∈{1,2,3,4} 各翻全部页,
+      合并去重后按 corp_info_id 客户端过滤出关注商家的在售商品(返回 (list, ok),取数不全整轮跳过)。
+    - 三类告警:新品上架(new) / 进度过半(half) / 一车结束(ended)。
+    - 去重靠 jw_onsale_alert_record 的三个标记位 + 进程内内存集合 _seen_onsale:
+        · new / ended:send-then-mark(发送成功才置位,失败下轮补发);
+        · half:先置位再发(容忍偶发漏发,简化逻辑);
+        · _seen_onsale 只让「本进程运行后见过在售」的商品参与结束对账,避免冷启动补发历史老车。
+    - 新品判断用列表自带 soldTime(上架时间):soldTime ≥ 本场窗口起点(最近一个 RUN_START)才算「本场刚上架」
+      (对齐 deca 最新逻辑);更早上架的老货静默建档(new_notified=1)不发消息,防冷启动刷屏。
+    - 结束判定:详情 surplusStock≤0(售罄) 或 已过 offShelfTime(下架/销售结束时间),二者任一即结束(对齐 deca)。
+  """
+import sys
+import time
+import random
+import argparse
+from collections import defaultdict
+from datetime import datetime, timedelta
+
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+from mysql_pool import MySQLConnectionPool
+
+import jiwu_core as core
+import auto_send_wx_msg as wx
+
+# ---------- 业务配置 ----------
+WATCH_CORPS = {100716: "Jake球星卡", 100715: "九叔的喷火龙"}  # 只盯这些商家(corpInfoId→名称)
+PRODUCT_TYPES = ["1", "2", "3", "4"]  # 商品类型:1福袋 2变风盒 3错版卡 4原盒
+SYSTEM_BUSINESS_TYPE = 5              # 业务线:集卡
+PAGE_LIMIT = 20                       # 翻页每页条数
+MAX_PAGES = 200                       # 单类型翻页保护上限
+HALF_THRESHOLD = 0.5                  # 进度过半阈值:已售/总份数 ≥ 0.5
+
+# ---------- 调度配置 ----------
+MIN_INTERVAL_SEC = 60                 # 每轮跑完最小间隔(秒)
+MAX_INTERVAL_SEC = 90                 # 每轮跑完最大间隔(秒),随机打散降风控
+RUN_ALL_DAY = False                   # 对齐 deca:只在 [RUN_START, RUN_END] 直播窗口内轮询(可跨午夜);设 True 则全天
+RUN_START = "20:30"                   # 运行窗口起点,同时是「本场新上架」判定的时间门槛(soldTime≥此才算新品)
+RUN_END = "06:00"                     # 运行窗口终点(跨午夜,2026/08/15 得卡由 03:00 延到 06:00)
+
+# 进程内已见在售集合:只有本进程运行后见过在售的 goods_id 才参与结束对账(重启即清空)
+_seen_onsale = set()
+
+logger.remove()
+logger.add("./logs/onsale_alert_{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")
+
+
+def fmt_money(raw) -> str | None:
+    """把原始金额(×1000000)格式化为「元」字符串:整数去小数,非整去尾零。
+
+    Args:
+        raw (int | float | None): 接口原始金额,÷1000000 为元。
+
+    Returns:
+        str | None: 如 "199"/"12.5";raw 为空返回 None。
+    """
+    if raw is None:
+        return None
+    y = raw / 1000000
+    return str(int(y)) if y == int(y) else f"{y:.2f}".rstrip("0").rstrip(".")
+
+
+def fmt_price(low, high) -> str:
+    """把最低价/最高价拼成展示价格文案:有区间显区间,否则显单价。
+
+    Args:
+        low (int | None): 最低价原始值(×1000000)。
+        high (int | None): 最高价原始值(×1000000)。
+
+    Returns:
+        str: 如 "¥199~299"/"¥199";均空返回 "¥-"。
+    """
+    lm, hm = fmt_money(low), fmt_money(high)
+    if lm and hm and lm != hm:
+        return f"¥{lm}~{hm}"
+    if hm:
+        return f"¥{hm}"
+    if lm:
+        return f"¥{lm}"
+    return "¥-"
+
+
+def parse_dt(v) -> datetime | None:
+    """把接口的上架时间字段解析为 datetime(兼容字符串日期与毫秒/秒级时间戳)。
+
+    Args:
+        v (str | int | None): soldTime 原始值。
+
+    Returns:
+        datetime | None: 解析成功返回 datetime;空值/无法解析返回 None(新品判断按保守处理,视为非新品)。
+    """
+    if not v:
+        return None
+    if isinstance(v, (int, float)):  # 时间戳:毫秒>1e12 转秒
+        ts = v / 1000 if v > 1e12 else v
+        try:
+            return datetime.fromtimestamp(ts)
+        except (ValueError, OSError):
+            return None
+    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d"):
+        try:
+            return datetime.strptime(str(v), fmt)
+        except ValueError:
+            continue
+    return None
+
+
+def parse_onsale(rec: dict) -> dict:
+    """把 index/top 一条在售记录抽取为监控所需字段字典。
+
+    Args:
+        rec (dict): index/top 返回 records 里的一条。
+
+    Returns:
+        dict: 含 goods_id/corp_info_id/corp_info_name/goods_name/价格/库存/规格/上架时间。
+    """
+    return {
+        "goods_id": rec.get("goodsId"),
+        "corp_info_id": rec.get("corpInfoId"),
+        "corp_info_name": rec.get("corpInfoName"),
+        "goods_name": rec.get("goodsName"),
+        "amount": rec.get("amount"),
+        "highest_price": rec.get("highestPrice"),
+        "lowest_price": rec.get("lowestPrice"),
+        "stock_amount": rec.get("stockAmount"),
+        "residue_stock_amount": rec.get("residueStockAmount"),
+        "specification_name": rec.get("specificationName"),
+        "sold_time": rec.get("soldTime"),
+    }
+
+
+def compute_ratio(item: dict) -> float:
+    """计算某在售商品的售出进度比例。
+
+    Args:
+        item (dict): parse_onsale 结果。
+
+    Returns:
+        float: 已售/总份数;总份数缺失或 ≤0 返回 0.0。
+    """
+    total = item.get("stock_amount")
+    residue = item.get("residue_stock_amount")
+    if not isinstance(total, int) or total <= 0 or not isinstance(residue, int):
+        return 0.0
+    sold = total - residue
+    return sold / total if sold > 0 else 0.0
+
+
+def _window_start(now: datetime) -> datetime:
+    """本场监控窗口起点:最近一个已过去的 RUN_START(新上架判定的时间门槛)。
+
+    对齐 deca:当前已过今天 RUN_START 用今天的,否则用昨天的 RUN_START(跨午夜场)。
+
+    Args:
+        now (datetime): 当前时间。
+
+    Returns:
+        datetime: 本场窗口起点时刻。
+    """
+    sh, sm = _parse_hhmm(RUN_START)
+    start = now.replace(hour=sh, minute=sm, second=0, microsecond=0)
+    if now < start:  # 今天 RUN_START 还没到 → 用昨天的
+        start -= timedelta(days=1)
+    return start
+
+
+def is_new_arrival(item: dict, now: datetime) -> bool:
+    """判断某商品是否为「本场新上架的新品」:soldTime ≥ 本场窗口起点(最近的 RUN_START)。
+
+    对齐 deca 最新逻辑:只把「本场监控窗口起点之后上架」的当新品;更早上架的老货静默建档、不提醒;
+    空值/解析失败一律视为非新品(保守,不误报老货)。
+
+    Args:
+        item (dict): parse_onsale 结果。
+        now (datetime): 当前时间。
+
+    Returns:
+        bool: soldTime ≥ 本场窗口起点返回 True;空值/解析失败/老货返回 False。
+    """
+    dt = parse_dt(item.get("sold_time"))
+    if dt is None:
+        return False
+    return dt >= _window_start(now)
+
+
+def fetch_watch_onsale(log) -> tuple[list, bool]:
+    """轮询全站在售(各 productType 翻全部页),过滤出关注商家的在售商品并按 goods_id 去重。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        tuple[list, bool]: (关注商家在售列表, ok)。ok=False 表示取数中途失败、数据不全,本轮应整轮跳过。
+    """
+    watched, seen = [], set()
+    watch_ids = set(WATCH_CORPS.keys())
+    for pt in PRODUCT_TYPES:
+        for page in range(1, MAX_PAGES + 1):
+            j = core.do_request(log, "/search/app/index/top", {
+                "currentPage": str(page), "limit": str(PAGE_LIMIT),
+                "productType": pt, "systemBusinessType": SYSTEM_BUSINESS_TYPE})
+            if not j:  # 请求失败:数据不全,标记本轮无效,避免误判下架/漏报
+                log.warning(f"在售翻页失败 productType={pt} page={page},本轮取数不全")
+                return watched, False
+            recs = (j.get("data") or {}).get("records") or []
+            if not recs:
+                break
+            for r in recs:
+                it = parse_onsale(r)
+                if it["corp_info_id"] in watch_ids and it["goods_id"] not in seen:
+                    seen.add(it["goods_id"])
+                    watched.append(it)
+            if len(recs) < PAGE_LIMIT:  # 末页
+                break
+            time.sleep(0.3)
+    return watched, True
+
+
+def load_existing(log, pool) -> dict:
+    """读取关注商家在监控表里的已建档商品及其三个提醒标记位。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+
+    Returns:
+        dict: {goods_id: {"corp_info_id","corp_info_name","goods_name","new","half","ended"}}。
+    """
+    ids = list(WATCH_CORPS.keys())
+    ph = ",".join(["%s"] * len(ids))
+    rows = pool.select_all(
+        f"SELECT goods_id, corp_info_id, corp_info_name, goods_name, new_notified, half_notified, ended_notified "
+        f"FROM jw_onsale_alert_record WHERE corp_info_id IN ({ph})", tuple(ids))
+    return {r[0]: {"corp_info_id": r[1], "corp_info_name": r[2], "goods_name": r[3],
+                   "new": r[4], "half": r[5], "ended": r[6]} for r in rows}
+
+
+def archive_item(pool, item: dict, new_notified: int) -> None:
+    """把新出现在监控里的商品建档到 jw_onsale_alert_record(已存在则只刷新名称,不动标记位)。
+
+    Args:
+        pool: 数据库连接池。
+        item (dict): parse_onsale 结果。
+        new_notified (int): 建档时的新品提醒标记;1=老货静默建档不发,0=待发新品提醒。
+    """
+    pool.update_one(
+        "INSERT INTO jw_onsale_alert_record (goods_id, corp_info_id, corp_info_name, goods_name, new_notified) "
+        "VALUES (%s,%s,%s,%s,%s) "
+        "ON DUPLICATE KEY UPDATE corp_info_name=VALUES(corp_info_name), goods_name=VALUES(goods_name)",
+        (item["goods_id"], item["corp_info_id"], item["corp_info_name"], item["goods_name"], new_notified))
+
+
+def mark_flag(pool, goods_id: int, column: str) -> None:
+    """把某商品的某个提醒标记位置 1。
+
+    Args:
+        pool: 数据库连接池。
+        goods_id (int): 商品ID。
+        column (str): 标记列名,取值 new_notified / half_notified / ended_notified。
+    """
+    pool.update_one(f"UPDATE jw_onsale_alert_record SET {column}=1 WHERE goods_id=%s", (goods_id,))
+
+
+def confirm_ended(log, goods_id: int) -> dict | None:
+    """二次确认某商品是否真的结束:打详情,售罄(surplusStock≤0) 或 已过下架时间(offShelfTime) 才算结束。
+
+    对齐 deca 最新逻辑:结束 = 剩余库存售罄 OR 当前已过销售结束/下架时间(offShelfTime),二者任一命中即结束;
+    详情取不到 / 两条件都不满足 → 视为本轮抖动缺席,返回 None、下轮再看(不误判下架)。
+
+    Args:
+        log: 日志对象。
+        goods_id (int): 商品ID。
+
+    Returns:
+        dict | None: 确认结束返回详情 data(供拼战报);仍在售/详情取不到返回 None。
+    """
+    j = core.do_request(log, "/search/app/merchantGoodsId",
+                        {"goodsId": str(goods_id), "systemBusinessType": SYSTEM_BUSINESS_TYPE})
+    if not j:
+        return None
+    data = j.get("data") or {}
+    residue = data.get("surplusStock")  # 详情用 surplusStock(剩余库存),非 index/top 的 residueStockAmount
+    if isinstance(residue, int) and residue <= 0:  # 售罄
+        return data
+    end_dt = parse_dt(data.get("offShelfTime"))  # 下架/销售结束时间
+    if end_dt is not None and datetime.now() >= end_dt:  # 已过下架时间
+        return data
+    return None
+
+
+def send_new(log, corp_id: int, items: list) -> bool:
+    """发送某商家的新品上架提醒(一条 markdown,多个新品逐条列出)。
+
+    Args:
+        log: 日志对象。
+        corp_id (int): 商家ID。
+        items (list): 待提醒的新品 item 列表。
+
+    Returns:
+        bool: 发送成功返回 True(供 send-then-mark 置位)。
+    """
+    corp_name = WATCH_CORPS.get(corp_id, items[0].get("corp_info_name") or str(corp_id))
+    lines = []
+    for it in items:
+        price = fmt_price(it.get("lowest_price"), it.get("highest_price"))
+        stock = it.get("stock_amount")
+        residue = it.get("residue_stock_amount")
+        lines.append(f"**{it.get('goods_name')}**\n💰 {price} | 📦 {stock}份 | 🎯 余{residue}/{stock}")
+    title = f"🆕 集物星球 · {corp_name} 新商品上架 {len(items)} 个"
+    return wx.send_wechat_group_msg(log=log, items=lines, title=title) is not None
+
+
+def send_half(log, corp_id: int, items: list) -> bool:
+    """发送某商家的进度过半提醒(一条 markdown,多个过半商品逐条列出)。
+
+    Args:
+        log: 日志对象。
+        corp_id (int): 商家ID。
+        items (list): 元素为 (item, ratio) 的列表。
+
+    Returns:
+        bool: 发送成功返回 True。
+    """
+    corp_name = WATCH_CORPS.get(corp_id, items[0][0].get("corp_info_name") or str(corp_id))
+    lines = []
+    for it, ratio in items:
+        price = fmt_price(it.get("lowest_price"), it.get("highest_price"))
+        stock = it.get("stock_amount")
+        residue = it.get("residue_stock_amount")
+        lines.append(f"**{it.get('goods_name')}**\n💰 {price} | 📈 进度{int(ratio * 100)}% | 🎯 余{residue}/{stock}")
+    title = f"🔥 集物星球 · {corp_name} 拼团进度过半 {len(items)} 个"
+    return wx.send_wechat_group_msg(log=log, items=lines, title=title) is not None
+
+
+def get_buyer_count(log, goods_id: int) -> int:
+    """取某商品的购买人数(赠品公示·玩家维度接口的 totalCount,需登录)。
+
+    Args:
+        log: 日志对象。
+        goods_id (int): 商品ID。
+
+    Returns:
+        int: 购买人数;取不到返回 0。
+    """
+    j = core.do_request(log, "/order/merchant/app/query/gift/publicity/user/group/pager",
+                        {"currentPage": "1", "giftBusinessName": "", "goodsId": str(goods_id),
+                         "limit": "1", "systemBusinessType": SYSTEM_BUSINESS_TYPE}, need_auth=True)
+    if not j:
+        return 0
+    return (j.get("data") or {}).get("totalCount") or 0
+
+
+def send_ended(log, goods_id: int, ex: dict, detail: dict) -> bool:
+    """发送某商品的一车结束战报(标题/价格/售出件数/购买人数)。
+
+    Args:
+        log: 日志对象。
+        goods_id (int): 商品ID。
+        ex (dict): 库内已建档信息(商家名、商品名兜底)。
+        detail (dict): confirm_ended 返回的商品详情 data(详情字段:price/highestPrice/stock)。
+
+    Returns:
+        bool: 发送成功返回 True(供 send-then-mark 置位)。
+    """
+    corp_name = WATCH_CORPS.get(ex.get("corp_info_id"), ex.get("corp_info_name") or "")
+    goods_name = detail.get("goodsName") or ex.get("goods_name")
+    price = fmt_price(detail.get("price"), detail.get("highestPrice"))  # 详情用 price/highestPrice
+    total = detail.get("stock")  # 详情总份数=stock;已售罄即全部售出
+    buyers = get_buyer_count(log, goods_id)  # 玩家维度准确购买人数
+    line = f"**{goods_name}**\n💰 {price} | 🎯 售出 {total} 件\n👥 {buyers} 人购买"
+    title = f"🏁 集物星球 · {corp_name} 一车结束"
+    return wx.send_wechat_group_msg(log=log, items=[line], title=title) is not None
+
+
+def detect_and_report_ended(log, pool, existing: dict, onsale_codes: set) -> None:
+    """结束对账:本进程见过在售、本轮已消失、且未播报过的商品,二次确认后发结束战报。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+        existing (dict): load_existing 结果(本轮读库快照)。
+        onsale_codes (set): 本轮抓到的关注商家在售 goods_id 集合。
+    """
+    candidates = [gid for gid, ex in existing.items()
+                  if gid in _seen_onsale and gid not in onsale_codes and ex["ended"] == 0]
+    for gid in candidates:
+        detail = confirm_ended(log, gid)
+        if detail is None:  # 未确认结束(可能只是本轮抖动缺席),下轮再看
+            continue
+        if send_ended(log, gid, existing[gid], detail):
+            mark_flag(pool, gid, "ended_notified")
+            log.success(f"结束战报已发 goods_id={gid}")
+
+
+def run_once(log, pool) -> None:
+    """跑一轮监控:取数→结束对账→遍历在售判新品/过半→建档并推送。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+    """
+    items, ok = fetch_watch_onsale(log)
+    if not ok:
+        log.warning("本轮取数不完整,跳过判断")
+        return
+    onsale_codes = {it["goods_id"] for it in items}
+    log.info(f"本轮关注商家在售 {len(items)} 个")
+
+    existing = load_existing(log, pool)
+
+    # 1) 先做结束对账(用「旧 existing + 本轮在售」判断谁消失了)
+    detect_and_report_ended(log, pool, existing, onsale_codes)
+
+    # 2) 标记本进程已见过这些在售(此后它们消失才纳入结束对账)
+    _seen_onsale.update(onsale_codes)
+
+    # 3) 遍历在售,收集新品/过半
+    now = datetime.now()
+    new_alerts, half_alerts = [], []
+    for it in items:
+        gid = it["goods_id"]
+        ex = existing.get(gid)
+        if ex is None:  # 监控里首次出现的商品:建档
+            new = is_new_arrival(it, now)
+            archive_item(pool, it, new_notified=0 if new else 1)
+            ex = {"corp_info_id": it["corp_info_id"], "new": 0 if new else 1, "half": 0, "ended": 0}
+            existing[gid] = ex
+            if new:
+                new_alerts.append(it)
+        elif ex["new"] == 0 and ex["ended"] == 0:  # 上轮发送失败的新品,补发
+            new_alerts.append(it)
+        # 过半判断(未过半、未结束才评估;首次达标即发)
+        if ex["half"] == 0 and ex["ended"] == 0:
+            ratio = compute_ratio(it)
+            if ratio >= HALF_THRESHOLD:
+                mark_flag(pool, gid, "half_notified")  # 先置位再发
+                half_alerts.append((it, ratio))
+
+    # 4) 推送:新品按商家分组,send-then-mark(发成功才置 new_notified)
+    if new_alerts:
+        groups = defaultdict(list)
+        for it in new_alerts:
+            groups[it["corp_info_id"]].append(it)
+        for cid, its in groups.items():
+            if send_new(log, cid, its):
+                for it in its:
+                    mark_flag(pool, it["goods_id"], "new_notified")
+        log.info(f"新品上架提醒 {len(new_alerts)} 个")
+
+    # 5) 推送:过半按商家分组(已先置位,直接发)
+    if half_alerts:
+        hgroups = defaultdict(list)
+        for it, ratio in half_alerts:
+            hgroups[it["corp_info_id"]].append((it, ratio))
+        for cid, its in hgroups.items():
+            send_half(log, cid, its)
+        log.info(f"进度过半提醒 {len(half_alerts)} 个")
+
+
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(600), after=core.after_log)
+def main_task(log) -> None:
+    """监控主流程(挂了每 10 分钟重试,最多 100 次):按运行窗口手写轮询循环。
+
+    Args:
+        log: 日志对象。
+
+    Raises:
+        RuntimeError: 数据库连接池异常时抛出以触发重试。
+    """
+    log.info("启动在售监控" + "." * 40)
+    pool = MySQLConnectionPool(log=log)
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        raise RuntimeError("数据库连接池异常")
+    while True:
+        now = datetime.now()
+        if not _in_run_window(now):
+            sleep_s = _seconds_to_next_window(now)
+            log.info(f"当前不在运行窗口,休眠 {sleep_s} 秒到下次窗口")
+            time.sleep(sleep_s)
+            continue
+        try:
+            run_once(log, pool)
+        except Exception as e:
+            log.error(f"监控单轮异常: {e}")
+        gap = random.randint(MIN_INTERVAL_SEC, MAX_INTERVAL_SEC)
+        log.info(f"本轮结束,{gap} 秒后再轮询")
+        time.sleep(gap)
+
+
+def _parse_hhmm(s: str) -> tuple[int, int]:
+    """把 "HH:MM" 解析为 (时, 分)。
+
+    Args:
+        s (str): 形如 "20:30" 的时间串。
+
+    Returns:
+        tuple[int, int]: (小时, 分钟)。
+    """
+    h, m = s.split(":")
+    return int(h), int(m)
+
+
+def _in_run_window(now: datetime) -> bool:
+    """判断当前是否在运行窗口内(支持跨午夜窗口)。
+
+    Args:
+        now (datetime): 当前时间。
+
+    Returns:
+        bool: RUN_ALL_DAY 恒 True;否则按 [RUN_START, RUN_END] 判断。
+    """
+    if RUN_ALL_DAY:
+        return True
+    sh, sm = _parse_hhmm(RUN_START)
+    eh, em = _parse_hhmm(RUN_END)
+    start = now.replace(hour=sh, minute=sm, second=0, microsecond=0)
+    end = now.replace(hour=eh, minute=em, second=0, microsecond=0)
+    if start <= end:  # 同日窗口
+        return start <= now <= end
+    return now >= start or now <= end  # 跨午夜:[start,次日end]
+
+
+def _seconds_to_next_window(now: datetime) -> int:
+    """计算距下一次进入运行窗口还有多少秒。
+
+    Args:
+        now (datetime): 当前时间。
+
+    Returns:
+        int: 休眠秒数(至少 1)。
+    """
+    sh, sm = _parse_hhmm(RUN_START)
+    start = now.replace(hour=sh, minute=sm, second=0, microsecond=0)
+    if now >= start:  # 今天窗口起点已过,等明天
+        start += timedelta(days=1)
+    return max(1, int((start - now).total_seconds()))
+
+
+def _parse_start_time(text: str) -> str:
+    """校验并规范化命令行传入的窗口起点时间,返回 "HH:MM" 字符串。
+
+    Args:
+        text (str): 形如 "20:30" 或 "20:30:00" 的时间串。
+
+    Returns:
+        str: 规范化的 "HH:MM"(丢掉秒,供 _parse_hhmm 使用)。
+
+    Raises:
+        argparse.ArgumentTypeError: 格式非法(非 HH:MM / HH:MM:SS)时抛出。
+    """
+    text = text.strip()
+    for fmt in ("%H:%M:%S", "%H:%M"):
+        try:
+            return datetime.strptime(text, fmt).strftime("%H:%M")
+        except ValueError:
+            continue
+    raise argparse.ArgumentTypeError(f"开始时间格式非法:{text!r},应为 HH:MM 或 HH:MM:SS,如 20:30")
+
+
+def _parse_args() -> argparse.Namespace:
+    """解析命令行参数:可指定运行窗口起点(同时作为「新上架」判定门槛)。
+
+    对齐 deca:位置参数与 --start 等价、位置优先;不传则用默认 RUN_START。
+
+    Returns:
+        argparse.Namespace: 含 start(位置) / start_opt(--start) 两个可选时间(均为规范化 "HH:MM" 或 None)。
+    """
+    parser = argparse.ArgumentParser(
+        description="集物星球在售提醒:可指定运行窗口起点(该时间后的新上架才提醒)")
+    parser.add_argument("start", nargs="?", type=_parse_start_time, default=None,
+                        help="运行窗口起点 HH:MM[:SS],默认 20:30;位置参数写法,如 17:00")
+    parser.add_argument("--start", dest="start_opt", type=_parse_start_time, default=None,
+                        help="运行窗口起点 HH:MM[:SS],与位置参数等价,如 --start 17:00")
+    return parser.parse_args()
+
+
+def schedule_task():
+    """监控入口:直接进入 main_task 的手写轮询循环。"""
+    main_task(log=logger)
+
+
+if __name__ == "__main__":
+    _args = _parse_args()
+    _start = _args.start or _args.start_opt  # 位置参数优先,其次 --start;都未传则保持默认 RUN_START
+    if _start is not None:
+        RUN_START = _start  # 覆盖模块级默认;窗口判定与「新上架」门槛均随之改变(两处均裸读该全局)
+        logger.info(f"运行窗口起点由命令行指定为 {RUN_START}")
+    schedule_task()

+ 307 - 0
jiwu_spider/jw_onsale_report.py

@@ -0,0 +1,307 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/19
+"""集物星球在售日报:只读 jw_onsale_product_record / jw_shop_record / jw_onsale_daily_record,生成 Excel 发企微群。
+
+  逻辑对齐参考项目 deca 的在售报表:只读库不触发抓取(抓取由 jw_onsale_spider 独立定时落库)。
+  每天 09/15/20/01 四档各生成一份带小时的独立文件(每档=当刻实时在售快照,互不覆盖)。
+  Sheet:概览 / 今日新增商家 / 商品明细(今日新上架行淡红高亮)/ 在售趋势(按每日快照差分,最近4天)。
+  金额字段库中已是元(入库时已换算),直接展示。
+  """
+import os
+import sys
+import time
+from datetime import datetime, timedelta
+
+import schedule
+from loguru import logger
+from mysql_pool import MySQLConnectionPool
+
+import auto_send_wx_msg as wx
+import jw_report_excel as xl
+
+PRODUCT_TYPE_NAME = {"1": "福袋", "2": "变风盒", "3": "错版卡", "4": "原盒"}  # 商品类型码→名
+OUT_DIR = "./reports"                 # 报表输出目录(留档不删)
+OUT_PREFIX = "集物星球在售日报"        # 文件名前缀
+SEND_WECHAT = True                    # 是否发企微
+REPORT_TIMES = ("09:00", "15:00", "20:00", "01:00")  # 四档生成时间(上午/下午/晚上/凌晨场),对齐 deca 在售采集
+TREND_DAYS = 4                        # 在售趋势展示天数(今日+前3日)
+SHOP_LIMIT = 100                      # 「其他商家」sheet 存量商家展示上限
+LISTING_HOUR_DAYS = 7                 # 「上架时段分布」统计近 N 天
+
+logger.remove()
+logger.add("./logs/onsale_report_{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")
+
+
+def yuan(raw) -> float | None:
+    """把库中金额(已是元, DECIMAL)转 float 供 Excel。
+
+    Args:
+        raw: 库中金额值(Decimal/数值/None)。
+
+    Returns:
+        float | None: 元;空值返回 None。
+    """
+    return float(raw) if raw is not None else None
+
+
+def fmt_dt(v) -> str:
+    """把时间字段格式化为 "YYYY-MM-DD HH:MM" 字符串。
+
+    Args:
+        v (datetime | str | None): 时间值。
+
+    Returns:
+        str: 格式化字符串;空值返回空串。
+    """
+    if not v:
+        return ""
+    if isinstance(v, datetime):
+        return v.strftime("%Y-%m-%d %H:%M")
+    return str(v)
+
+
+def build_overview(wb, pool) -> None:
+    """写「概览」sheet:商家/在售/今日新增等 KPI 竖排。
+
+    Args:
+        wb: 工作簿。
+        pool: 数据库连接池。
+    """
+    shop_total = pool.select_all("SELECT COUNT(*) FROM jw_shop_record")[0][0]
+    onsale_total = pool.select_all("SELECT COUNT(*) FROM jw_onsale_product_record WHERE is_on_sale=1")[0][0]
+    new_shops = pool.select_all("SELECT COUNT(*) FROM jw_shop_record WHERE DATE(gmt_create_time)=CURDATE()")[0][0]
+    new_goods = pool.select_all(
+        "SELECT COUNT(*) FROM jw_onsale_product_record WHERE is_on_sale=1 AND DATE(sold_time)=CURDATE()")[0][0]
+    sold_total = pool.select_all(  # 在售商品已售份数合计(sold_count=stock-residue),对齐 deca「今日累计已售(份)」
+        "SELECT COALESCE(SUM(sold_count),0) FROM jw_onsale_product_record WHERE is_on_sale=1")[0][0]
+    ws = xl.add_sheet(wb, "概览")
+    xl.write_table(ws, 1, ["指标", "数值"], [
+        ("商家总数", shop_total),
+        ("在售商品总数", onsale_total),
+        ("今日新增商家", new_shops),
+        ("今日新增商品", new_goods),
+        ("今日累计已售(份)", int(sold_total or 0)),
+        ("生成时间", datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
+    ], ["text", "int"], freeze=False)
+
+
+def build_new_shops(wb, pool) -> None:
+    """写「今日新增商家」sheet:当天入库的商家一行一条。
+
+    Args:
+        wb: 工作簿。
+        pool: 数据库连接池。
+    """
+    rows = pool.select_all(
+        "SELECT s.corp_name, s.fans_amount, "
+        "SUM(CASE WHEN p.is_on_sale=1 THEN 1 ELSE 0 END) AS onsale_cnt, "
+        "SUM(CASE WHEN DATE(p.sold_time)=CURDATE() AND p.is_on_sale=1 THEN 1 ELSE 0 END) AS today_new "
+        "FROM jw_shop_record s LEFT JOIN jw_onsale_product_record p ON p.corp_info_id = s.corp_info_id "
+        "WHERE DATE(s.gmt_create_time)=CURDATE() "
+        "GROUP BY s.corp_info_id, s.corp_name, s.fans_amount "
+        "ORDER BY onsale_cnt DESC, s.fans_amount DESC") or []
+    ws = xl.add_sheet(wb, "今日新增商家")
+    xl.write_table(ws, 1, ["商家", "粉丝数", "在售商品数", "今日新上架"],
+                   [(r[0], r[1], int(r[2] or 0), int(r[3] or 0)) for r in rows],
+                   ["text", "int", "int", "int"])
+
+
+def build_other_shops(wb, pool) -> None:
+    """写「其他商家」sheet:非今日新增的存量商家(在售数倒序),一行一条。
+
+    对齐 deca:商家表 LEFT JOIN 在售商品表,取 `gmt_create_time != CURDATE()` 的存量商家,
+    与「今日新增商家」sheet 互补。
+
+    Args:
+        wb: 工作簿。
+        pool: 数据库连接池。
+    """
+    rows = pool.select_all(
+        "SELECT s.corp_name, s.fans_amount, "
+        "SUM(CASE WHEN p.is_on_sale=1 THEN 1 ELSE 0 END) AS onsale_cnt, "
+        "SUM(CASE WHEN DATE(p.sold_time)=CURDATE() AND p.is_on_sale=1 THEN 1 ELSE 0 END) AS today_new "
+        "FROM jw_shop_record s LEFT JOIN jw_onsale_product_record p ON p.corp_info_id = s.corp_info_id "
+        "WHERE DATE(s.gmt_create_time) != CURDATE() "
+        "GROUP BY s.corp_info_id, s.corp_name, s.fans_amount "
+        "ORDER BY onsale_cnt DESC, s.fans_amount DESC LIMIT %s", (SHOP_LIMIT,)) or []
+    ws = xl.add_sheet(wb, "其他商家")
+    xl.write_table(ws, 1, ["商家", "粉丝数", "在售商品数", "今日新上架"],
+                   [(r[0], r[1], int(r[2] or 0), int(r[3] or 0)) for r in rows],
+                   ["text", "int", "int", "int"])
+
+
+def build_products(wb, pool) -> None:
+    """写「商品明细」sheet:全部在售商品,今日新上架行淡红高亮。
+
+    Args:
+        wb: 工作簿。
+        pool: 数据库连接池。
+    """
+    recs = pool.select_all(
+        "SELECT corp_info_name, goods_name, gift_series, product_type, specification_name, sold_time, "
+        "amount, stock_amount, residue_stock_amount, (DATE(sold_time)=CURDATE()) AS is_new "
+        "FROM jw_onsale_product_record WHERE is_on_sale=1 ORDER BY corp_info_name, is_new DESC, goods_id")
+    headers = ["商家", "商品名", "系列", "类型", "规格", "上架时间", "单价(元)",
+               "已售数", "总份数", "进度", "已售总价(元)", "新品"]
+    col_types = ["text", "text", "text", "text", "text", "text", "money",
+                 "int", "int", "pct", "money", "text"]
+    rows, new_flags = [], []
+    for (corp, name, ip, ptype, spec, sold_time, amount, stock, residue, is_new) in recs:
+        sold = (stock - residue) if isinstance(stock, int) and isinstance(residue, int) else None
+        progress = (sold / stock) if isinstance(sold, int) and isinstance(stock, int) and stock > 0 else 0
+        gmv = (sold * float(amount)) if isinstance(sold, int) and amount is not None else None  # amount 已是元(Decimal)
+        rows.append((corp, name, ip, PRODUCT_TYPE_NAME.get(str(ptype), ptype), spec, fmt_dt(sold_time),
+                     yuan(amount), sold, stock, progress, gmv, "🆕" if is_new else ""))
+        new_flags.append(bool(is_new))
+    ws = xl.add_sheet(wb, "商品明细")
+    xl.write_table(ws, 1, headers, rows, col_types, new_flags=new_flags)
+
+
+def fetch_onsale_trend(pool, days: int) -> list:
+    """按每日快照差分出在售趋势(对齐 deca:集合差集算净新增)。
+
+    查最近 days 天(WHERE 多覆盖一天做最早展示日的差分基线)的快照,按 snapshot_date 聚合成
+    商家集合/商品集合;每天净新增 = 当日集合 - 前一日集合(集合差集,非数值相减)。
+
+    Args:
+        pool: 数据库连接池。
+        days (int): 展示天数(今日 + 前 days-1 日)。
+
+    Returns:
+        list: 按日期倒序(最近在前)的 dict 列表,每项含
+            {日期, 在售商家数, 在售拼团数, 新增商家数, 新增拼团数};最早一天无基线时新增为 None。
+    """
+    rows = pool.select_all(
+        "SELECT snapshot_date, corp_info_id, goods_id FROM jw_onsale_daily_record "
+        "WHERE snapshot_date >= CURDATE() - INTERVAL %s DAY", (days,)) or []
+    day_shops, day_goods = {}, {}
+    for snap_date, cid, gid in rows:
+        day_shops.setdefault(snap_date, set()).add(cid)
+        day_goods.setdefault(snap_date, set()).add(gid)
+    dates = sorted(day_shops.keys(), reverse=True)  # 新→旧
+    result = []
+    for snap_date in dates[:days]:
+        prev = snap_date - timedelta(days=1)  # 前一日基线
+        shops, goods = day_shops[snap_date], day_goods[snap_date]
+        if prev in day_shops:
+            new_shops = len(shops - day_shops[prev])  # 净新增商家=当日有、前日无
+            new_goods = len(goods - day_goods[prev])  # 净新增拼团=当日有、前日无
+        else:
+            new_shops = new_goods = None  # 无基线,诚实留空
+        result.append({"日期": str(snap_date), "在售商家数": len(shops), "在售拼团数": len(goods),
+                       "新增商家数": new_shops, "新增拼团数": new_goods})
+    return result
+
+
+def build_trend(wb, trend: list) -> None:
+    """写「在售趋势」sheet:最近 TREND_DAYS 天在售商家/拼团数 + 差分净新增(日期倒序)。
+
+    Args:
+        wb: 工作簿。
+        trend (list): fetch_onsale_trend 结果。
+    """
+    headers = ["日期", "在售商家数", "在售拼团数", "新增商家数(差分)", "新增拼团数(差分)"]
+    col_types = ["text", "int", "int", "int", "int"]
+    data = [(t["日期"], t["在售商家数"], t["在售拼团数"], t["新增商家数"], t["新增拼团数"]) for t in trend]
+    ws = xl.add_sheet(wb, "在售趋势")
+    xl.write_table(ws, 1, headers, data, col_types)
+
+
+def _bar(value: int, vmax: int, width: int = 20) -> str:
+    """按占最大值比例生成 █ 条形迷你图字符串。
+
+    Args:
+        value (int): 当前值。
+        vmax (int): 最大值。
+        width (int, optional): 满格字符数。Defaults to 20。
+
+    Returns:
+        str: █ 组成的条形;value/vmax 为空返回空串。
+    """
+    if not vmax or not value:
+        return ""
+    return "█" * max(1, round(value / vmax * width))
+
+
+def build_listing_hour_dist(wb, pool) -> None:
+    """写「上架时段分布」sheet:近 LISTING_HOUR_DAYS 天按上架小时(0~23)统计上架商品数。
+
+    对齐 deca:按 `HOUR(sold_time)`(上架/开售时间) 分 24 桶,第三列为 █ 条形分布图。
+
+    Args:
+        wb: 工作簿。
+        pool: 数据库连接池。
+    """
+    since = (datetime.now() - timedelta(days=LISTING_HOUR_DAYS)).strftime("%Y-%m-%d")
+    dist = [0] * 24
+    for h, c in pool.select_all(
+            "SELECT HOUR(sold_time) h, COUNT(*) c FROM jw_onsale_product_record "
+            "WHERE sold_time IS NOT NULL AND sold_time >= %s GROUP BY h", (since,)) or []:
+        if h is not None and 0 <= int(h) < 24:
+            dist[int(h)] = int(c)
+    vmax = max(dist) if dist else 0
+    data = [(f"{h:02d}时", dist[h], _bar(dist[h], vmax)) for h in range(24)]
+    ws = xl.add_sheet(wb, "上架时段分布")
+    xl.write_table(ws, 1, ["时段", "上架数", f"分布(近{LISTING_HOUR_DAYS}日累计)"], data,
+                   ["text", "int", "text"])
+
+
+def build_report(pool, out_file: str) -> None:
+    """汇总各 sheet 生成在售日报 Excel(6 sheet,对齐 deca)。
+
+    Args:
+        pool: 数据库连接池。
+        out_file (str): 输出文件路径。
+    """
+    wb = xl.new_workbook()
+    build_overview(wb, pool)                                  # 1 概览
+    build_new_shops(wb, pool)                                 # 2 今日新增商家
+    build_other_shops(wb, pool)                               # 3 其他商家(存量)
+    build_products(wb, pool)                                  # 4 商品明细
+    build_trend(wb, fetch_onsale_trend(pool, TREND_DAYS))     # 5 在售趋势(快照差分)
+    build_listing_hour_dist(wb, pool)                         # 6 上架时段分布
+    xl.save(wb, out_file)
+
+
+def run_once(log) -> str:
+    """生成一次在售日报并发企微(只读库;DB 异常则跳过本轮)。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        str: 生成的 Excel 路径;失败返回空串。
+    """
+    log.info("开始生成在售日报" + "." * 30)
+    pool = MySQLConnectionPool(log=log)
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常,跳过本轮")
+        return ""
+    os.makedirs(OUT_DIR, exist_ok=True)
+    out_file = os.path.abspath(os.path.join(OUT_DIR, f"{OUT_PREFIX}_{datetime.now():%Y%m%d_%H时}.xlsx"))
+    try:
+        build_report(pool, out_file)
+        log.success(f"在售日报已生成: {out_file}")
+    except Exception as e:
+        log.error(f"生成在售日报失败: {e}")
+        return ""
+    if SEND_WECHAT:
+        wx.send_wechat_group_file(log=log, file_path=out_file)
+    return out_file
+
+
+def schedule_task():
+    """定时入口:每天 09:00 / 15:00 / 20:00 / 01:00 各生成并发送一次在售日报(四档,各出一份带小时的文件)。"""
+    # run_once(logger)  # 调试时取消注释立即跑一次
+    for _hhmm in REPORT_TIMES:
+        schedule.every().day.at(_hhmm).do(run_once, logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    schedule_task()

+ 201 - 0
jiwu_spider/jw_onsale_spider.py

@@ -0,0 +1,201 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/19
+"""集物星球在售抓取:免登录遍历各商品类型翻页拉 index/top,落库 jw_onsale_product_record。
+
+  每天 09/15/20/01 四档各跑一次(对齐 deca:上午/下午/晚上/凌晨场),每档同时把当刻在售写入每日快照表
+  jw_onsale_daily_record(商品+日期唯一,同日多档刷成最新值),供在售日报按天差分出「在售趋势」。
+  抓完做下架对账:本轮未出现的商品置 is_on_sale=0。index/top 实测免登录,故 do_request 用默认 need_auth=False。
+  """
+import sys
+import time
+from datetime import date
+
+import schedule
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+from mysql_pool import MySQLConnectionPool
+
+import jiwu_core as core
+import jw_detail
+
+# 商品类型:1福袋 2变风盒 3错版卡 4原盒;均属业务线 5(集卡)
+PRODUCT_TYPES = ["1", "2", "3", "4"]
+SYSTEM_BUSINESS_TYPE = 5
+PAGE_LIMIT = 20
+MAX_PAGES = 200  # 单类型翻页保护上限
+ENRICH_DETAIL = True  # 是否逐商品补拉详情字段(免登录);量大可关
+
+logger.remove()
+logger.add("./logs/onsale_{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")
+
+# 入库列(与 jw_onsale_product_record 对齐)
+_COLS = ["goods_id", "program_id", "goods_name", "product_type", "business_type", "block_type",
+         "goods_ip_id", "goods_ip_name", "random_type", "corp_info_id", "corp_info_name",
+         "amount", "highest_price", "lowest_price", "stock_amount", "residue_stock_amount",
+         "sold_count", "specification_name", "report_status", "live_status", "sold_time",
+         "product_pic", "is_on_sale"]
+
+
+def parse_product(rec: dict) -> dict:
+    """把接口一条在售记录规范化为入库字典。
+
+    Args:
+        rec (dict): index/top 返回 records 里的一条。
+
+    Returns:
+        dict: 键与 jw_onsale_product_record 列对齐的字典。
+    """
+    stock = rec.get("stockAmount")
+    residue = rec.get("residueStockAmount")
+    sold = (stock - residue) if (isinstance(stock, int) and isinstance(residue, int)) else None
+    return {
+        "goods_id": rec.get("goodsId"), "program_id": rec.get("programId"),
+        "goods_name": rec.get("goodsName"), "product_type": rec.get("productType"),
+        "business_type": rec.get("businessType"), "block_type": rec.get("blockType"),
+        "goods_ip_id": rec.get("goodsIPId"), "goods_ip_name": rec.get("goodsIPName"),
+        "random_type": rec.get("randomType"), "corp_info_id": rec.get("corpInfoId"),
+        "corp_info_name": rec.get("corpInfoName"), "amount": core.to_yuan(rec.get("amount")),
+        "highest_price": core.to_yuan(rec.get("highestPrice")), "lowest_price": core.to_yuan(rec.get("lowestPrice")),
+        "stock_amount": stock, "residue_stock_amount": residue, "sold_count": sold,
+        "specification_name": rec.get("specificationName"), "report_status": rec.get("reportStatus"),
+        "live_status": rec.get("liveStatus"), "sold_time": rec.get("soldTime"),
+        "product_pic": rec.get("productPic"), "is_on_sale": 1,
+    }
+
+
+def upsert_products(log, pool, rows: list) -> None:
+    """把在售商品批量 upsert 到库(存在则更新库存/价格等最新状态)。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+        rows (list): parse_product 结果列表。
+    """
+    if not rows:
+        return
+    cols_sql = ",".join(f"`{c}`" for c in _COLS)
+    ph = ",".join(["%s"] * len(_COLS))
+    upd = ",".join(f"`{c}`=VALUES(`{c}`)" for c in _COLS if c != "goods_id")
+    sql = f"INSERT INTO jw_onsale_product_record ({cols_sql}) VALUES ({ph}) ON DUPLICATE KEY UPDATE {upd}"
+    args_list = [tuple(r[c] for c in _COLS) for r in rows]
+    pool.insert_many(query=sql, args_list=args_list)
+    log.info(f"upsert 在售商品 {len(rows)} 条")
+
+
+# 每日快照入库列(与 jw_onsale_daily_record 对齐);唯一键命中(同日多档)时只刷 _SNAP_UPD 里的量价状态
+_SNAP_COLS = ["goods_id", "corp_info_id", "corp_info_name", "snapshot_date", "product_type",
+              "stock_amount", "sold_count", "residue_stock_amount", "live_status", "amount"]
+_SNAP_UPD = ["stock_amount", "sold_count", "residue_stock_amount", "live_status", "amount"]
+
+
+def upsert_daily_snapshot(log, pool, rows: list) -> None:
+    """把本页在售商品写入每日快照表(goods_id+snapshot_date 唯一,当天多档跑刷成最新值)。
+
+    对齐参考项目 deca 的每日快照:唯一键命中(同日多档)时只更新量价状态、保留商品/商家/日期,
+    保证每商品每天一行、恒为当天最后一次采集值,供在售日报按天差分出「在售趋势」。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+        rows (list): parse_product 结果列表(含 amount 等,金额已换算为元)。
+    """
+    if not rows:
+        return
+    today = date.today().isoformat()  # 快照日期;01:00 凌晨场归入新自然日(与 deca 一致)
+    cols_sql = ",".join(f"`{c}`" for c in _SNAP_COLS)
+    ph = ",".join(["%s"] * len(_SNAP_COLS))
+    upd = ",".join(f"`{c}`=VALUES(`{c}`)" for c in _SNAP_UPD)
+    sql = (f"INSERT INTO jw_onsale_daily_record ({cols_sql}) VALUES ({ph}) "
+           f"ON DUPLICATE KEY UPDATE {upd}")
+    args_list = [(r["goods_id"], r["corp_info_id"], r["corp_info_name"], today, r["product_type"],
+                  r["stock_amount"], r["sold_count"], r["residue_stock_amount"],
+                  r["live_status"], r["amount"]) for r in rows]
+    pool.insert_many(query=sql, args_list=args_list)
+
+
+def sweep_offsale(log, pool, seen_ids: set) -> None:
+    """下架对账:库中标记在售、但本轮未出现的商品置 is_on_sale=0。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+        seen_ids (set): 本轮抓到的全部 goods_id。
+    """
+    rows = pool.select_all("SELECT goods_id FROM jw_onsale_product_record WHERE is_on_sale=1")
+    db_ids = {r[0] for r in rows}
+    gone = db_ids - seen_ids
+    for gid in gone:
+        pool.update_one("UPDATE jw_onsale_product_record SET is_on_sale=0 WHERE goods_id=%s", (gid,))
+    if gone:
+        log.info(f"下架对账:{len(gone)} 个商品置为已下架")
+
+
+def fetch_onsale(log, pool) -> None:
+    """免登录遍历各商品类型翻页抓在售并落库,最后做下架对账。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+    """
+    seen_ids = set()
+    for pt in PRODUCT_TYPES:
+        for page in range(1, MAX_PAGES + 1):
+            j = core.do_request(log, "/search/app/index/top", {
+                "currentPage": str(page), "limit": str(PAGE_LIMIT),
+                "productType": pt, "systemBusinessType": SYSTEM_BUSINESS_TYPE})  # 免登录
+            if not j:
+                break
+            recs = (j.get("data") or {}).get("records") or []
+            if not recs:
+                break
+            rows = [parse_product(r) for r in recs]
+            seen_ids.update(r["goods_id"] for r in rows)
+            upsert_products(log, pool, rows)
+            upsert_daily_snapshot(log, pool, rows)  # 同步写每日快照(供在售趋势差分)
+            if len(recs) < PAGE_LIMIT:  # 末页
+                break
+            time.sleep(0.3)
+    sweep_offsale(log, pool, seen_ids)
+    if ENRICH_DETAIL:  # 逐商品补拉详情字段(免登录, detail_fetched 控制每商品补一次)
+        jw_detail.enrich_detail(log, pool, "jw_onsale_product_record", list(seen_ids))
+    log.success(f"本轮在售抓取完成,共 {len(seen_ids)} 个在售商品")
+
+
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=core.after_log)
+def main_task(log) -> None:
+    """在售抓取主流程(挂了每小时重试,最多100次)。
+
+    Args:
+        log: 日志对象。
+
+    Raises:
+        RuntimeError: 数据库连接池异常时抛出以触发重试。
+    """
+    log.info("开始在售抓取" + "." * 40)
+    pool = MySQLConnectionPool(log=log)
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        raise RuntimeError("数据库连接池异常")
+    try:
+        fetch_onsale(log, pool)
+    except Exception as e:
+        log.error(f"在售抓取异常: {e}")
+    finally:
+        log.info("在售抓取结束,等待下一轮" + "." * 20)
+
+
+def schedule_task():
+    """定时入口:每天 09:00 / 15:00 / 20:00 / 01:00 各抓一次在售(对齐 deca 四档:上午/下午/晚上/凌晨场)。"""
+    # main_task(log=logger)   # 调试时取消注释立即跑一次
+    for _hhmm in ("09:00", "15:00", "20:00", "01:00"):
+        schedule.every().day.at(_hhmm).do(main_task, log=logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    schedule_task()

+ 234 - 0
jiwu_spider/jw_report_excel.py

@@ -0,0 +1,234 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/19
+"""集物星球报表 Excel 样式工具库(openpyxl):供在售/已售日报复用统一排版。
+
+  提供分区标题条、表头样式、明细表(斑马纹/新品高亮/金额百分比格式/冻结首行/列宽自适应)、
+  键值竖排(概览/汇总)等写入函数。金额一律以「元」浮点写入单元格并套 #,##0.00 格式,
+  调用方负责把原始金额值 ÷1000000 后传入(1 元 = 1000000)。
+  """
+from openpyxl import Workbook
+from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
+from openpyxl.utils import get_column_letter
+
+# 配色(沿用参考项目 deca 报表风格)
+FILL_SECTION = PatternFill("solid", fgColor="4472C4")    # 分区标题条:蓝底
+FILL_HEADER = PatternFill("solid", fgColor="D9E1F2")     # 表头:浅蓝底
+FILL_ZEBRA = PatternFill("solid", fgColor="F5F8FC")      # 明细隔行斑马
+FILL_SUBTOTAL = PatternFill("solid", fgColor="FCE4D6")   # 汇总数值:浅橙底
+FILL_NEWROW = PatternFill("solid", fgColor="FEF2F2")     # 今日新上架高亮:淡红底
+FONT_SECTION = Font(color="FFFFFF", bold=True, size=12)  # 分区标题:白字加粗
+FONT_HEADER = Font(color="1F3864", bold=True)            # 表头:深蓝字加粗
+FONT_LABEL = Font(color="1F3864", bold=True)             # 键值标签
+_SIDE = Side(style="thin", color="D6DCE5")
+BORDER = Border(left=_SIDE, right=_SIDE, top=_SIDE, bottom=_SIDE)
+
+MONEY_FMT = "#,##0.00"   # 金额(元)
+INT_FMT = "#,##0"        # 计数
+PCT_FMT = "0.0%"         # 进度(传入 0~1 小数)
+
+_ALIGN_L = Alignment(horizontal="left", vertical="center", wrap_text=True)
+_ALIGN_L_NOWRAP = Alignment(horizontal="left", vertical="center")  # 标题/分区条:不换行,长文本自然向右溢出
+_ALIGN_R = Alignment(horizontal="right", vertical="center")
+_ALIGN_C = Alignment(horizontal="center", vertical="center", wrap_text=True)
+
+
+def new_workbook() -> Workbook:
+    """新建一个不含默认 sheet 的工作簿。
+
+    Returns:
+        Workbook: 已删掉默认 Sheet 的空工作簿,后续用 add_sheet 逐个加。
+    """
+    wb = Workbook()
+    wb.remove(wb.active)
+    return wb
+
+
+def add_sheet(wb: Workbook, title: str):
+    """新增一个工作表(表名超长自动截断到 31 字符)。
+
+    Args:
+        wb (Workbook): 目标工作簿。
+        title (str): 工作表名。
+
+    Returns:
+        Worksheet: 新建的工作表。
+    """
+    return wb.create_sheet(title[:31])
+
+
+def _disp_width(text: str) -> int:
+    """按中文占 2、其余占 1 估算字符串显示宽度。
+
+    Args:
+        text (str): 文本。
+
+    Returns:
+        int: 估算显示宽度。
+    """
+    return sum(2 if ord(c) > 127 else 1 for c in str(text))
+
+
+def _autosize(ws, headers: list, rows: list, base_col: int = 1) -> None:
+    """按表头与数据估算并设置各列列宽(clamp 到 [8,60])。
+
+    Args:
+        ws: 工作表。
+        headers (list): 表头列表。
+        rows (list): 数据行(元组列表)。
+        base_col (int): 首列列号(1 起)。Defaults to 1。
+    """
+    for i, h in enumerate(headers):
+        w = _disp_width(h)
+        for r in rows:
+            if i < len(r) and r[i] is not None:
+                w = max(w, _disp_width(r[i]))
+        letter = get_column_letter(base_col + i)
+        cur = ws.column_dimensions[letter].width or 0  # 只增不减:不覆盖调用方预设的更宽列宽
+        ws.column_dimensions[letter].width = max(cur, min(60, max(8, w + 2)))
+
+
+def _apply_fmt(cell, col_type: str) -> None:
+    """按列类型给单元格设数字格式与对齐。
+
+    Args:
+        cell: 单元格。
+        col_type (str): 列类型 money / int / pct / text。
+    """
+    if col_type == "money":
+        cell.number_format = MONEY_FMT
+        cell.alignment = _ALIGN_R
+    elif col_type == "int":
+        cell.number_format = INT_FMT
+        cell.alignment = _ALIGN_R
+    elif col_type == "pct":
+        cell.number_format = PCT_FMT
+        cell.alignment = _ALIGN_R
+    else:
+        cell.alignment = _ALIGN_L
+
+
+def write_section_title(ws, row: int, ncols: int, text: str) -> int:
+    """写一条横跨 ncols 列的分区标题条。
+
+    Args:
+        ws: 工作表。
+        row (int): 起始行(1 起)。
+        ncols (int): 跨列数。
+        text (str): 标题文字。
+
+    Returns:
+        int: 下一可写行号。
+    """
+    # 不用合并单元格:合并区内文字无法溢出、列窄时长标题会被切。改为「给若干列铺底色的空单元格 +
+    # 文字写在 A 列不换行」,让文字自然溢出到后面这些有色空单元格上(永远落在色条内、不被截断)。
+    span = max(1, ncols)  # 铺色跨度=调用方给定列数(同 sheet 内各标题条传同一 ncols→长度统一、严谨)
+    for col in range(1, span + 1):
+        ws.cell(row=row, column=col).fill = FILL_SECTION
+    c = ws.cell(row=row, column=1, value=text)
+    c.font = FONT_SECTION
+    c.alignment = _ALIGN_L_NOWRAP  # 不换行,长文本自然向右溢出到有色空单元格
+    ws.row_dimensions[row].height = 22
+    return row + 1
+
+
+def write_title(ws, row: int, text: str) -> int:
+    """写一条大标题(加粗大字,不填色不合并,自然向右溢出到空列,风格贴近 deca)。
+
+    Args:
+        ws: 工作表。
+        row (int): 行号。
+        text (str): 标题文字。
+
+    Returns:
+        int: 下一可写行号。
+    """
+    c = ws.cell(row=row, column=1, value=text)
+    c.font = Font(color="1F3864", bold=True, size=14)
+    c.alignment = _ALIGN_L_NOWRAP
+    return row + 1
+
+
+def write_kv(ws, row: int, items: list, title: str = None) -> int:
+    """竖排写键值区(指标/数值两列),用于概览、单商家汇总。
+
+    Args:
+        ws: 工作表。
+        row (int): 起始行。
+        items (list): 元素为 (标签, 值, 类型) 的列表;类型取 text/money/int/pct。
+        title (str, optional): 若给出,先写一条分区标题条。Defaults to None。
+
+    Returns:
+        int: 下一可写行号。
+    """
+    if title:
+        row = write_section_title(ws, row, 2, title)
+    for label, value, col_type in items:
+        lc = ws.cell(row=row, column=1, value=label)
+        lc.font = FONT_LABEL              # 标签:深蓝加粗 + 浅蓝底(对齐 deca 汇总块配色)
+        lc.fill = FILL_HEADER
+        lc.alignment = _ALIGN_L_NOWRAP
+        lc.border = BORDER
+        vc = ws.cell(row=row, column=2, value=value)
+        vc.fill = FILL_SUBTOTAL           # 数值:浅橙底(对齐 deca)
+        vc.border = BORDER
+        _apply_fmt(vc, col_type)
+        row += 1
+    ws.column_dimensions["A"].width = max(ws.column_dimensions["A"].width or 8,
+                                          min(40, max(_disp_width(i[0]) for i in items) + 2))
+    ws.column_dimensions["B"].width = max(ws.column_dimensions["B"].width or 8, 20)
+    return row + 1  # 区块后空一行
+
+
+def write_table(ws, row: int, headers: list, rows: list, col_types: list,
+                new_flags: list = None, freeze: bool = True) -> int:
+    """写一张明细表(表头样式 + 斑马纹 + 可选新品高亮 + 数字格式 + 冻结表头 + 列宽自适应)。
+
+    Args:
+        ws: 工作表。
+        row (int): 表头起始行。
+        headers (list): 列标题。
+        rows (list): 数据行,每行元组,元素已是展示单位(金额传元、进度传 0~1 小数)。
+        col_types (list): 与 headers 等长的列类型 text/money/int/pct。
+        new_flags (list, optional): 与 rows 等长的布尔列表,True 的行套新品高亮底色。Defaults to None。
+        freeze (bool, optional): 是否冻结表头行。Defaults to True。
+
+    Returns:
+        int: 下一可写行号。
+    """
+    header_row = row
+    for i, h in enumerate(headers):
+        c = ws.cell(row=header_row, column=1 + i, value=h)
+        c.fill = FILL_HEADER
+        c.font = FONT_HEADER
+        c.alignment = _ALIGN_C
+        c.border = BORDER
+    ws.row_dimensions[header_row].height = 26
+
+    for ri, data in enumerate(rows):
+        r = header_row + 1 + ri
+        is_new = bool(new_flags[ri]) if new_flags else False
+        for i, val in enumerate(data):
+            c = ws.cell(row=r, column=1 + i, value=val)
+            c.border = BORDER
+            _apply_fmt(c, col_types[i] if i < len(col_types) else "text")
+            if is_new:
+                c.fill = FILL_NEWROW
+            elif ri % 2 == 1:
+                c.fill = FILL_ZEBRA
+
+    _autosize(ws, headers, rows)
+    if freeze:
+        ws.freeze_panes = f"A{header_row + 1}"
+    return header_row + 1 + len(rows) + 1  # 表后空一行
+
+
+def save(wb: Workbook, path: str) -> None:
+    """保存工作簿到磁盘。
+
+    Args:
+        wb (Workbook): 工作簿。
+        path (str): 输出文件路径。
+    """
+    wb.save(path)

+ 639 - 0
jiwu_spider/jw_sold_report.py

@@ -0,0 +1,639 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/19
+"""集物星球已售日报:只读 jw_sold_product_record / jw_player_record,生成 Excel 发企微群。
+
+  排版与口径对齐参考项目 deca 的《已售每日统计报告》(8→9 sheet):只读库不触发抓取。
+  业务日窗口:成交完成时间落在 [昨天17:00:00, 今天06:00:00](含两端)算「昨天」的已售(对齐 deca 的
+  completed_at 口径,凌晨仍开播故终点延到 06:00)。成交完成时间取 finish_time(结束),为空退回 soldout_time(售罄)。
+  报告 09:10 跑,已售采集脚本 08:00 先落库(窗口 06:00 关闭后再采)。
+
+  9 个 sheet:平台总览 / 产品系列榜 / 商家GMV榜 / 运营节奏 / Jake明细 / Jake用户排行榜 /
+    九叔明细 / 九叔用户排行榜 / 其他商家。
+  与 deca 差异:集物两重点商家(Jake/九叔)都有真实购买记录,故「参与人数」全用真实去重买家(deca 对非重点
+    商家用中卡近似)、两商家各出一个用户排行榜;集物未采进度轨迹,故明细无「到25/50/75%用时」里程碑列。
+  金额字段库中已是元(入库时已换算),直接展示。
+  """
+import os
+import sys
+import time
+from datetime import date, datetime, timedelta
+
+import schedule
+from loguru import logger
+from mysql_pool import MySQLConnectionPool
+
+import auto_send_wx_msg as wx
+import jw_report_excel as xl
+
+PRODUCT_TYPE_NAME = {"1": "福袋", "2": "变风盒", "3": "错版卡", "4": "原盒"}
+# 重点关注商家:(corpInfoId, 展示名, sheet名前缀),各出「{前缀}明细」+「{前缀}用户排行榜」
+FOCUS_CORPS = [(100716, "Jake球星卡", "Jake"), (100715, "九叔的喷火龙", "九叔")]
+
+# 已售业务日窗口:成交完成时间落在 [昨17:00, 今06:00](含两端);成交完成时间取 finish_time(结束),
+# 为空退回 soldout_time(售罄)。对齐参考项目 deca 的 completed_at 口径。
+WIN_SOLD = ("COALESCE(finish_time, soldout_time) >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR "
+            "AND COALESCE(finish_time, soldout_time) <= CURDATE() + INTERVAL 6 HOUR")
+# 昨日同窗口(用于组齐环比):[前天17:00, 昨天06:00],与 WIN_SOLD 整体平移一天、口径一致
+WIN_SOLD_YDAY = ("COALESCE(finish_time, soldout_time) >= (CURDATE() - INTERVAL 2 DAY) + INTERVAL 17 HOUR "
+                 "AND COALESCE(finish_time, soldout_time) <= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 6 HOUR")
+
+TOP_SERIES = 15                       # 产品系列榜展示上限
+TOP_MERCHANT = 10                     # 商家GMV榜展示上限
+CONC_TOPS = (1, 3, 5, 10)            # GMV集中度档位 TopK
+HOUR_DIST_DAYS = 7                    # 成交时段分布统计近 N 天
+
+OUT_DIR = "./reports"
+OUT_PREFIX = "集物星球已售日报"
+SEND_WECHAT = True
+REPORT_TIME = "09:10"  # 晚于在售日报 10 分钟,避开并发
+
+logger.remove()
+logger.add("./logs/sold_report_{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")
+
+
+def yuan(raw) -> float | None:
+    """把库中金额(已是元, DECIMAL)转 float 供 Excel。
+
+    Args:
+        raw: 库中金额值(Decimal/数值/None)。
+
+    Returns:
+        float | None: 元;空值返回 None。
+    """
+    return float(raw) if raw is not None else None
+
+
+def fmt_dt(v) -> str:
+    """把时间字段格式化为 "YYYY-MM-DD HH:MM"。
+
+    Args:
+        v (datetime | str | None): 时间值。
+
+    Returns:
+        str: 格式化字符串;空值返回空串。
+    """
+    if not v:
+        return ""
+    if isinstance(v, datetime):
+        return v.strftime("%Y-%m-%d %H:%M")
+    return str(v)
+
+
+def _duration(start, end) -> str:
+    """算售卖时长文案(成交时间 - 开售时间),格式 "X小时Y分"(对齐 deca)。
+
+    Args:
+        start (datetime | None): 开售时间。
+        end (datetime | None): 成交时间。
+
+    Returns:
+        str: 形如 "72小时13分";缺失/异常返回空串。
+    """
+    if not (isinstance(start, datetime) and isinstance(end, datetime)):
+        return ""
+    secs = (end - start).total_seconds()
+    if secs < 0:
+        return ""
+    return f"{int(secs // 3600)}小时{int((secs % 3600) // 60)}分"
+
+
+def fmt_pct_change(today: float, yday: float) -> str:
+    """算今日相对昨日同窗口的环比涨跌百分比文案。
+
+    Args:
+        today (float): 今日值。
+        yday (float): 昨日同窗口值。
+
+    Returns:
+        str: 形如 "+12.3%"/"-5.0%";昨日为 0 返回 "—"。
+    """
+    if not yday:
+        return "—"
+    return f"{(today - yday) / yday * 100:+.1f}%"
+
+
+def _bar(value: int, vmax: int, width: int = 20) -> str:
+    """按占最大值比例生成 █ 条形迷你图字符串。
+
+    Args:
+        value (int): 当前值。
+        vmax (int): 最大值。
+        width (int, optional): 满格字符数。Defaults to 20。
+
+    Returns:
+        str: █ 组成的条形;value/vmax 为空返回空串。
+    """
+    if not vmax or not value:
+        return ""
+    return "█" * max(1, round(value / vmax * width))
+
+
+def _line(ws, row: int, text: str) -> int:
+    """在 A 列写一行纯文本(供覆盖检测/漏采明细/脚注这类非表格文案)。
+
+    Args:
+        ws: 工作表。
+        row (int): 行号。
+        text (str): 文本。
+
+    Returns:
+        int: 下一行号。
+    """
+    ws.cell(row=row, column=1, value=text)
+    return row + 1
+
+
+def get_window(pool) -> tuple:
+    """算出已售业务日窗口的起止时刻(供报告展示)。
+
+    Args:
+        pool: 数据库连接池。
+
+    Returns:
+        tuple: (start, end) 两个 "YYYY-MM-DD HH:MM:SS" 字符串,即 [昨17:00, 今06:00]。
+    """
+    row = pool.select_all(
+        "SELECT (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR, CURDATE() + INTERVAL 6 HOUR")[0]
+    return str(row[0]), str(row[1])
+
+
+def _sold_gmv(rec: dict) -> tuple:
+    """由一条已售(成交/组齐)记录算出 (售出份数, GMV元)。
+
+    已售历史 corp/history 里的团都是**成交组齐团**,总份数全部售出,故售出份数 = `stock_amount`
+    (实测全表 `SUM(购买记录 buy_count) == stock_amount`;该接口 `residueStockAmount` 对成交团恒
+    等于总份数、不可用 stock−residue,否则算出售出=0、GMV=0)。GMV = 售出份数 × 单价元。
+
+    Args:
+        rec (dict): fetch_sold_rows 的一条。
+
+    Returns:
+        tuple: (sold_count, gmv_yuan);字段缺失时对应项为 None。
+    """
+    stock = rec.get("stock_amount")
+    amount = rec.get("amount")
+    sold = stock if isinstance(stock, int) else None  # 成交组齐团=全部售出,售出份数=总份数
+    gmv = (sold * float(amount)) if isinstance(sold, int) and amount is not None else None  # amount 已是元
+    return sold, gmv
+
+
+def _agg(rows: list) -> dict:
+    """聚合一批已售记录的核心指标。
+
+    Args:
+        rows (list): fetch_sold_rows 结果的子集。
+
+    Returns:
+        dict: {teams 成团数, merchants 商家数, gmv 总GMV元, avg_unit 均团单价元}。
+    """
+    teams = len(rows)
+    merchants = len({r["corp_info_id"] for r in rows})
+    gmv = sum(g for _, g in map(_sold_gmv, rows) if g) or 0.0
+    units = [float(r["amount"]) for r in rows if r.get("amount") is not None]
+    avg_unit = (sum(units) / len(units)) if units else 0.0
+    return {"teams": teams, "merchants": merchants, "gmv": gmv, "avg_unit": avg_unit}
+
+
+def fetch_sold_rows(pool, win: str) -> list:
+    """取指定业务日窗口内的已售全量(含各字段)。
+
+    Args:
+        pool: 数据库连接池。
+        win (str): WHERE 时间窗条件(WIN_SOLD / WIN_SOLD_YDAY)。
+
+    Returns:
+        list: 每元素为 dict,含商家/商品/价格/库存/时间/buy_fetched 等字段(金额为元)。
+    """
+    recs = pool.select_all(
+        "SELECT corp_info_id, corp_info_name, goods_id, goods_name, goods_ip_name, gift_series, product_type, "
+        "specification_name, amount, highest_price, lowest_price, stock_amount, residue_stock_amount, "
+        "sold_time, soldout_time, finish_time, buy_fetched FROM jw_sold_product_record "
+        f"WHERE {win} ORDER BY corp_info_id, gmt_create_time DESC")
+    keys = ["corp_info_id", "corp_info_name", "goods_id", "goods_name", "goods_ip_name", "gift_series",
+            "product_type", "specification_name", "amount", "highest_price", "lowest_price", "stock_amount",
+            "residue_stock_amount", "sold_time", "soldout_time", "finish_time", "buy_fetched"]
+    return [dict(zip(keys, r)) for r in recs]
+
+
+def fetch_buyers_map(log, pool, goods_ids: list) -> dict:
+    """批量统计指定商品的购买人数(购买记录去重买家数)。
+
+    数据源 jw_player_record(玩家维度,每商品每买家一行,含真实 userId)。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+        goods_ids (list): 商品ID列表。
+
+    Returns:
+        dict: {goods_id: 购买人数};无购买记录的商品不在字典内(取用时默认 0)。
+    """
+    if not goods_ids:
+        return {}
+    ph = ",".join(["%s"] * len(goods_ids))
+    rows = pool.select_all(
+        f"SELECT goods_id, COUNT(DISTINCT user_id) FROM jw_player_record WHERE goods_id IN ({ph}) GROUP BY goods_id",
+        tuple(goods_ids))
+    return {r[0]: int(r[1]) for r in rows}
+
+
+def fetch_corp_distinct_buyers(pool, win: str) -> dict:
+    """按商家统计业务日窗口内的去重买家数(跨该商家全部成交团去重人头,非人次)。
+
+    Args:
+        pool: 数据库连接池。
+        win (str): 时间窗条件(WIN_SOLD)。
+
+    Returns:
+        dict: {corp_info_id: 去重买家数}。
+    """
+    rows = pool.select_all(
+        "SELECT s.corp_info_id, COUNT(DISTINCT p.user_id) "
+        "FROM jw_sold_product_record s JOIN jw_player_record p ON p.goods_id = s.goods_id "
+        f"WHERE {win} GROUP BY s.corp_info_id")
+    return {r[0]: int(r[1]) for r in rows}
+
+
+def fetch_platform_distinct_buyers(pool, win: str) -> int:
+    """统计业务日窗口内全平台去重买家数(跨所有成交团去重人头)。
+
+    Args:
+        pool: 数据库连接池。
+        win (str): 时间窗条件(WIN_SOLD)。
+
+    Returns:
+        int: 全平台去重买家数。
+    """
+    row = pool.select_all(
+        "SELECT COUNT(DISTINCT p.user_id) "
+        "FROM jw_sold_product_record s JOIN jw_player_record p ON p.goods_id = s.goods_id "
+        f"WHERE {win}")
+    return int(row[0][0]) if row and row[0][0] is not None else 0
+
+
+def fetch_completion_hour_dist(pool, days: int) -> list:
+    """按成交完成时间的小时(0~23)统计近 days 天成交团数分布。
+
+    Args:
+        pool: 数据库连接池。
+        days (int): 统计近 N 天。
+
+    Returns:
+        list: 长度 24 的列表,索引=小时,值=该小时成交团数。
+    """
+    since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
+    dist = [0] * 24
+    for h, c in pool.select_all(
+            "SELECT HOUR(COALESCE(finish_time, soldout_time)) h, COUNT(*) c FROM jw_sold_product_record "
+            "WHERE COALESCE(finish_time, soldout_time) >= %s GROUP BY h", (since,)) or []:
+        if h is not None and 0 <= int(h) < 24:
+            dist[int(h)] = int(c)
+    return dist
+
+
+def fetch_user_ranking(pool, corp_id: int) -> list:
+    """取某重点商家在业务日窗口内的用户消费排行(购买记录 join 已售表算金额)。
+
+    Args:
+        pool: 数据库连接池。
+        corp_id (int): 商家 corpInfoId。
+
+    Returns:
+        list: 每元素 (user_id, user_nick, 参与车数, 参与金额元),按金额倒序。
+    """
+    return pool.select_all(
+        "SELECT b.user_id, MAX(b.user_nick) nick, COUNT(DISTINCT b.goods_id) cars, "
+        "SUM(b.buy_count * s.amount) spent "
+        "FROM jw_player_record b JOIN jw_sold_product_record s ON s.goods_id = b.goods_id "
+        f"WHERE s.corp_info_id = %s AND {WIN_SOLD} "
+        "GROUP BY b.user_id ORDER BY spent DESC", (corp_id,)) or []
+
+
+def build_overview(wb, all_rows: list, yday_rows: list, platform_buyers: int, window: tuple) -> None:
+    """写「平台总览」sheet:平台汇总 + 当日组齐环比 + 商家GMV集中度(对齐 deca 排版)。
+
+    Args:
+        wb: 工作簿。
+        all_rows (list): 今日业务日窗口内已售全量。
+        yday_rows (list): 昨日同窗口已售全量(算环比)。
+        platform_buyers (int): 全平台跨团去重买家数(人头,非人次)。
+        window (tuple): 业务日窗口 (start, end)。
+    """
+    t, y = _agg(all_rows), _agg(yday_rows)
+    buyers_total = platform_buyers
+    per_cap = (t["gmv"] / buyers_total) if buyers_total else 0.0
+    ws = xl.add_sheet(wb, "平台总览")
+
+    row = xl.write_title(ws, 1, "集物星球 · 已售每日统计报告")
+    row = _line(ws, row, f"成交时间窗 {window[0]} ~ {window[1]}")
+    row += 1
+    row = xl.write_section_title(ws, row, 4, "平台汇总")
+    row = xl.write_kv(ws, row, [
+        ("商家数", t["merchants"], "int"),
+        ("销售额", round(t["gmv"], 2), "money"),
+        ("成团数", t["teams"], "int"),
+        ("参与人数", buyers_total, "int"),
+        ("均拼单价", round(t["avg_unit"], 2), "money"),
+        ("人均消费", round(per_cap, 2), "money"),
+    ])
+    row += 1
+
+    # 当日组齐环比(数值预格式化成字符串,避免整数被套用金额格式;同列今日/昨日既有金额又有计数)
+    def _n(v, money=False):
+        return f"{v:,.2f}" if money else f"{int(v)}"
+    row = xl.write_section_title(ws, row, 4, "当日组齐环比(vs 昨日同窗口)")
+    comp = [
+        ("组齐GMV", _n(t["gmv"], True), _n(y["gmv"], True), fmt_pct_change(t["gmv"], y["gmv"])),
+        ("成团数", _n(t["teams"]), _n(y["teams"]), fmt_pct_change(t["teams"], y["teams"])),
+        ("活跃商家数", _n(t["merchants"]), _n(y["merchants"]), fmt_pct_change(t["merchants"], y["merchants"])),
+        ("T均单价", _n(t["avg_unit"], True), _n(y["avg_unit"], True), fmt_pct_change(t["avg_unit"], y["avg_unit"])),
+    ]
+    row = xl.write_table(ws, row, ["指标", "今日", "昨日", "环比"], comp, ["text", "text", "text", "text"])
+    row += 1
+
+    # 商家 GMV 集中度 Top1/3/5/10
+    row = xl.write_section_title(ws, row, 4, "商家 GMV 集中度(TopN 占平台组齐总 GMV)")
+    merch_gmv = {}
+    for r in all_rows:
+        _, g = _sold_gmv(r)
+        merch_gmv[r["corp_info_id"]] = merch_gmv.get(r["corp_info_id"], 0.0) + (g or 0)
+    gmvs = sorted(merch_gmv.values(), reverse=True)
+    total = sum(gmvs) or 0
+    conc = [(f"Top{k} 集中度", (sum(gmvs[:k]) / total) if total else 0) for k in CONC_TOPS]
+    xl.write_table(ws, row, ["集中度档位", "占平台GMV"], conc, ["text", "pct"])
+
+
+def build_series(wb, all_rows: list) -> None:
+    """写「产品系列榜」sheet:按 IP 系列(goods_ip_name)汇总 GMV 倒序取 TopN。
+
+    Args:
+        wb: 工作簿。
+        all_rows (list): 今日业务日窗口内已售全量。
+    """
+    agg = {}  # 系列 -> [成团数, GMV]
+    for r in all_rows:
+        _, g = _sold_gmv(r)
+        key = r.get("gift_series") or "(未分类)"
+        a = agg.setdefault(key, [0, 0.0])
+        a[0] += 1
+        a[1] += (g or 0)
+    total = sum(v[1] for v in agg.values()) or 0
+    ranked = sorted(agg.items(), key=lambda kv: kv[1][1], reverse=True)[:TOP_SERIES]
+    data = [(name, cnt, round(g, 2), (g / total if total else 0)) for name, (cnt, g) in ranked]
+    ws = xl.add_sheet(wb, "产品系列榜")
+    ws.column_dimensions["A"].width = 24  # 系列名较长;同时保证 4 列分区标题条罩住整段标题
+    row = xl.write_section_title(ws, 1, 4, f"产品系列销售榜(当日 Top{TOP_SERIES},按 GMV)")
+    xl.write_table(ws, row, ["系列", "成团数", "GMV", "占比"], data, ["text", "int", "money", "pct"])
+
+
+def build_merchant_rank(wb, all_rows: list) -> None:
+    """写「商家GMV榜」sheet:按商家汇总 GMV 倒序取 TopN。
+
+    Args:
+        wb: 工作簿。
+        all_rows (list): 今日业务日窗口内已售全量。
+    """
+    agg = {}  # corp_info_id -> [商家名, 成团数, GMV]
+    for r in all_rows:
+        _, g = _sold_gmv(r)
+        a = agg.setdefault(r["corp_info_id"], [r.get("corp_info_name"), 0, 0.0])
+        a[1] += 1
+        a[2] += (g or 0)
+    total = sum(v[2] for v in agg.values()) or 0
+    ranked = sorted(agg.values(), key=lambda v: v[2], reverse=True)[:TOP_MERCHANT]
+    data = [(name, cnt, round(g, 2), (g / total if total else 0)) for name, cnt, g in ranked]
+    ws = xl.add_sheet(wb, "商家GMV榜")
+    ws.column_dimensions["A"].width = 22  # 商家名较长;同时保证 4 列分区标题条罩住整段标题
+    row = xl.write_section_title(ws, 1, 4, f"商家 GMV 榜(当日组齐口径,前 {TOP_MERCHANT})")
+    xl.write_table(ws, row, ["商家", "成团数", "GMV", "占比"], data, ["text", "int", "money", "pct"])
+
+
+def build_ops(wb, pool, all_rows: list, window: tuple) -> None:
+    """写「运营节奏」sheet:重点商家当日运营快照 + 平台成交时段分布(对齐 deca)。
+
+    Args:
+        wb: 工作簿。
+        pool: 数据库连接池。
+        all_rows (list): 今日业务日窗口内已售全量。
+        window (tuple): 业务日窗口 (start, end),用于判「今日新开团」。
+    """
+    ws = xl.add_sheet(wb, "运营节奏")
+    try:
+        w_start = datetime.strptime(window[0], "%Y-%m-%d %H:%M:%S")
+        w_end = datetime.strptime(window[1], "%Y-%m-%d %H:%M:%S")
+    except (ValueError, TypeError):
+        w_start = w_end = None
+
+    row = xl.write_section_title(ws, 1, 4, "重点商家当日运营快照(新开团 / 已组齐 / 规格)")
+    snap = []
+    for cid, name, _ in FOCUS_CORPS:
+        sub = [r for r in all_rows if r["corp_info_id"] == cid]
+        new_open = sum(1 for r in sub if w_start and isinstance(r.get("sold_time"), datetime)
+                       and w_start <= r["sold_time"] <= w_end)
+        specs = {}
+        for r in sub:
+            s = r.get("specification_name")
+            if s:
+                specs[s] = specs.get(s, 0) + 1
+        spec_txt = " · ".join(f"{k}×{v}" for k, v in sorted(specs.items(), key=lambda x: -x[1])) or "—"
+        snap.append((name, new_open, len(sub), spec_txt))
+    row = xl.write_table(ws, row, ["商家", "今日新开团", "已组齐", "规格分布"], snap,
+                         ["text", "int", "int", "text"])
+    row += 1
+
+    row = xl.write_section_title(ws, row, 4, f"平台成交时段分布(近 {HOUR_DIST_DAYS} 日 24h 累计)")
+    dist = fetch_completion_hour_dist(pool, HOUR_DIST_DAYS)
+    vmax = max(dist) if dist else 0
+    data = [(f"{h:02d}时", dist[h], _bar(dist[h], vmax)) for h in range(24)]
+    xl.write_table(ws, row, ["时段", "成团数", "分布"], data, ["text", "int", "text"])
+
+
+def build_focus_detail(wb, name: str, sheet_name: str, rank_sheet: str, rows: list,
+                       buyers_map: dict, win_text: str, corp_buyers_n: int) -> None:
+    """写某重点商家「明细」sheet:汇总 + 购买记录覆盖检测 + 每条组队明细(对齐 deca)。
+
+    Args:
+        wb: 工作簿。
+        name (str): 商家展示名。
+        sheet_name (str): 本 sheet 名。
+        rank_sheet (str): 对应的用户排行榜 sheet 名(覆盖检测文案里指引)。
+        rows (list): 该商家今日已售记录。
+        buyers_map (dict): 各商品的参与人数映射(供明细列「参与人数(本团)」逐团展示)。
+        win_text (str): 成交时间窗文案 "start ~ end"(写进汇总标题)。
+        corp_buyers_n (int): 该商家跨团去重买家数(人头,供汇总块「参与人数(真实买家)」)。
+    """
+    ws = xl.add_sheet(wb, sheet_name)
+    total_gmv = sum(g for _, g in map(_sold_gmv, rows) if g) or 0.0
+    total_buyers = corp_buyers_n  # 跨团去重人头(汇总口径),明细列的「参与人数(本团)」另用 buyers_map 逐团
+    units = [float(r["amount"]) for r in rows if r.get("amount") is not None]
+    avg_unit = (sum(units) / len(units)) if units else 0.0
+    per_cap = (total_gmv / total_buyers) if total_buyers else 0.0
+
+    row = xl.write_section_title(ws, 1, 12, f"{name} · 汇总(成交时间窗 {win_text})")
+    row = xl.write_kv(ws, row, [
+        ("销售额", round(total_gmv, 2), "money"),
+        ("成团数", len(rows), "int"),
+        ("参与人数(真实买家)", total_buyers, "int"),
+        ("均拼单价", round(avg_unit, 2), "money"),
+        ("人均消费", round(per_cap, 2), "money"),
+    ])
+    row += 1
+
+    # 购买记录覆盖检测:buy_fetched=1 表示该团购买记录已采全
+    fetched = sum(1 for r in rows if r.get("buy_fetched") == 1)
+    missing = [r for r in rows if r.get("buy_fetched") != 1]
+    row = xl.write_section_title(ws, row, 12, "购买记录覆盖检测(成交团 vs 已采购买记录)")
+    row = _line(ws, row, f"成交 {len(rows)} 团 · 采到购买记录 {fetched} 团 · 漏采 {len(missing)} 团"
+                         f"(用户排行见「{rank_sheet}」sheet)")
+    if missing:
+        row = _line(ws, row, "漏采明细(下列团未采到购买记录,未计入用户排行):")
+        for m in missing:
+            row = _line(ws, row, f" - {m.get('goods_id')} {m.get('goods_name') or ''}")
+    row += 1
+
+    row = xl.write_section_title(ws, row, 12, f"每条组队明细(共 {len(rows)} 条,按总金额倒序)")
+    headers = ["序号", "团名(商品标题)", "系列", "类型", "单价", "总份数", "进度%", "总金额",
+               "参与人数(本团)", "开售时间", "成交时间", "售卖时长"]
+    col_types = ["int", "text", "text", "text", "money", "int", "pct", "money",
+                 "int", "text", "text", "text"]
+    detailed = sorted(rows, key=lambda r: (_sold_gmv(r)[1] or 0), reverse=True)
+    data = []
+    for i, r in enumerate(detailed, 1):
+        sold, gmv = _sold_gmv(r)
+        stock = r.get("stock_amount")
+        prog = (sold / stock) if isinstance(sold, int) and isinstance(stock, int) and stock > 0 else 0
+        ct = r.get("finish_time") or r.get("soldout_time")  # 成交时间
+        data.append((i, r.get("goods_name"), r.get("gift_series"),
+                     PRODUCT_TYPE_NAME.get(str(r.get("product_type")), r.get("product_type")),
+                     yuan(r.get("amount")), stock, prog, gmv,
+                     buyers_map.get(r["goods_id"], 0),
+                     fmt_dt(r.get("sold_time")), fmt_dt(ct), _duration(r.get("sold_time"), ct)))
+    xl.write_table(ws, row, headers, data, col_types)
+
+
+def build_user_ranking(wb, name: str, sheet_name: str, corp_id: int, pool) -> None:
+    """写某重点商家「用户排行榜」sheet:买家按消费金额倒序(对齐 deca)。
+
+    Args:
+        wb: 工作簿。
+        name (str): 商家展示名。
+        sheet_name (str): 工作表名。
+        corp_id (int): 商家 corpInfoId。
+        pool: 数据库连接池。
+    """
+    rows = fetch_user_ranking(pool, corp_id)
+    data = []
+    for i, (uid, nick, cars, spent) in enumerate(rows, 1):
+        spent_f = float(spent) if spent is not None else 0.0
+        avg = (spent_f / cars) if cars else 0.0
+        data.append((i, nick or "-", uid, cars, round(spent_f, 2), round(avg, 2)))
+    ws = xl.add_sheet(wb, sheet_name)
+    row = xl.write_section_title(
+        ws, 1, 6, f"用户排行榜 · {name}(共 {len(rows)} 人参与,全部展示,按参与金额倒序)")
+    row = xl.write_table(ws, row, ["排名", "用户昵称", "user_id", "参与车数", "参与金额", "车均消费"],
+                         data, ["int", "text", "text", "int", "money", "money"])
+    _line(ws, row, "注:参与金额 = Σ(购买份数 × 团单价);参与车数 = 参与的不同团数;车均消费 = 参与金额 ÷ 参与车数。")
+
+
+def build_others(wb, rows: list, corp_buyers: dict) -> None:
+    """写「其他商家」sheet:非重点商家按商家汇总(销售额倒序,对齐 deca;人数用真实去重买家)。
+
+    Args:
+        wb: 工作簿。
+        rows (list): 非重点商家的今日已售记录。
+        corp_buyers (dict): {corp_info_id: 跨团去重买家数}(人头)。
+    """
+    agg = {}  # corp_info_id -> [商家名, 成团数, GMV, [单价...]]
+    for r in rows:
+        _, g = _sold_gmv(r)
+        a = agg.setdefault(r["corp_info_id"], [r.get("corp_info_name"), 0, 0.0, []])
+        a[1] += 1
+        a[2] += (g or 0)
+        a[3].append(r.get("amount"))
+    data = []
+    for cid, (name, cnt, g, amts) in agg.items():
+        buyers = corp_buyers.get(cid, 0)  # 跨团去重人头
+        units = [float(a) for a in amts if a is not None]
+        avg = (sum(units) / len(units)) if units else 0.0
+        per = (g / buyers) if buyers else 0.0
+        data.append((name, round(g, 2), cnt, buyers, round(avg, 2), round(per, 2)))
+    data.sort(key=lambda x: x[1], reverse=True)
+    ws = xl.add_sheet(wb, "其他商家")
+    row = xl.write_section_title(ws, 1, 6, f"其他商家汇总(共 {len(data)} 家,按销售额倒序)")
+    xl.write_table(ws, row, ["商家名", "销售额", "成团数", "参与人数", "均拼单价", "人均消费"],
+                   data, ["text", "money", "int", "int", "money", "money"])
+
+
+def build_report(log, pool, out_file: str) -> None:
+    """汇总 9 个 sheet 生成已售日报 Excel(排版对齐 deca)。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+        out_file (str): 输出文件路径。
+    """
+    all_rows = fetch_sold_rows(pool, WIN_SOLD)
+    yday_rows = fetch_sold_rows(pool, WIN_SOLD_YDAY)
+    window = get_window(pool)
+    win_text = f"{window[0]} ~ {window[1]}"  # 明细 sheet 标题复用
+    buyers_map = fetch_buyers_map(log, pool, [r["goods_id"] for r in all_rows])  # 各团去重买家(明细列用)
+    corp_buyers = fetch_corp_distinct_buyers(pool, WIN_SOLD)                     # 各商家跨团去重人头
+    platform_buyers = fetch_platform_distinct_buyers(pool, WIN_SOLD)            # 全平台跨团去重人头
+    focus_ids = {cid for cid, _, _ in FOCUS_CORPS}
+
+    wb = xl.new_workbook()
+    build_overview(wb, all_rows, yday_rows, platform_buyers, window)     # 1 平台总览
+    build_series(wb, all_rows)                                           # 2 产品系列榜
+    build_merchant_rank(wb, all_rows)                                    # 3 商家GMV榜
+    build_ops(wb, pool, all_rows, window)                                # 4 运营节奏
+    for cid, name, prefix in FOCUS_CORPS:                                # 5-8 重点商家明细+用户榜
+        sub = [r for r in all_rows if r["corp_info_id"] == cid]
+        build_focus_detail(wb, name, f"{prefix}明细", f"{prefix}用户排行榜", sub,
+                           buyers_map, win_text, corp_buyers.get(cid, 0))
+        build_user_ranking(wb, name, f"{prefix}用户排行榜", cid, pool)
+    others = [r for r in all_rows if r["corp_info_id"] not in focus_ids]
+    build_others(wb, others, corp_buyers)                               # 9 其他商家
+    xl.save(wb, out_file)
+
+
+def run_once(log) -> str:
+    """生成一次已售日报并发企微(只读库;DB 异常则跳过本轮)。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        str: 生成的 Excel 路径;失败返回空串。
+    """
+    log.info("开始生成已售日报" + "." * 30)
+    pool = MySQLConnectionPool(log=log)
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常,跳过本轮")
+        return ""
+    os.makedirs(OUT_DIR, exist_ok=True)
+    out_file = os.path.abspath(os.path.join(OUT_DIR, f"{OUT_PREFIX}_{date.today():%Y%m%d}.xlsx"))
+    try:
+        build_report(log, pool, out_file)
+        log.success(f"已售日报已生成: {out_file}")
+    except Exception as e:
+        log.error(f"生成已售日报失败: {e}")
+        return ""
+    if SEND_WECHAT:
+        wx.send_wechat_group_file(log=log, file_path=out_file)
+    return out_file
+
+
+def schedule_task():
+    """定时入口:每天 REPORT_TIME 生成并发送已售日报。"""
+    # run_once(logger)  # 调试时取消注释立即跑一次
+    schedule.every().day.at(REPORT_TIME).do(run_once, logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    schedule_task()

+ 396 - 0
jiwu_spider/jw_sold_spider.py

@@ -0,0 +1,396 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/19
+"""集物星球已售抓取:商家列表→逐商家已售 corp/history,并对全站已售商品抓拆卡报告与购买记录。
+
+  接口对应(以抓包为准):
+    - 商家列表 hotRecommend(需登录)→ jw_shop_record
+    - 已售历史 corp/history(免登录)→ jw_sold_product_record(只增)
+    - 拆卡报告 /goods/gift/report/query/search/pager(需登录, sbt=6)→ jw_report_record(只增, giftReportId 去重)
+    - 购买记录 /order/merchant/app/query/gift/publicity/user/group/pager(赠品公示·玩家维度, 需登录)→ jw_player_record
+      每条=一个买家 userId/userNick/picId/count,翻页拿全量;**不 DB 去重**,靠 jw_sold_product_record.buy_fetched
+      状态位控制每商品抓一次(翻页取全→批量入库成功→再置 buy_fetched=1;失败不置位、下轮重试)。
+  拆卡报告「更新不及时」:对结束 REPORT_REFETCH_DAYS 天内(或未结束)的已售商品每轮重查(INSERT IGNORE 自然补齐),
+  老车只补抓从未抓过的。
+  """
+import sys
+import time
+import json
+
+import schedule
+from loguru import logger
+from tenacity import retry, stop_after_attempt, wait_fixed
+from mysql_pool import MySQLConnectionPool
+
+import jiwu_core as core
+import jw_detail
+
+TARGET_CORPS = []  # 空=全站;如 [100716, 100715] 只抓 Jake/九叔
+SYSTEM_BUSINESS_TYPE = 5
+PAGE_LIMIT = 20
+MAX_SHOP_PAGES = 30  # 商家列表翻页上限
+MAX_SOLD_PAGES = 300  # 单商家已售翻页上限
+REPORT_PAGE_LIMIT = 20  # 拆卡报告翻页每页
+MAX_REPORT_PAGES = 50  # 单商品拆卡报告翻页上限
+REPORT_REFETCH_DAYS = 3  # 拆卡报告:结束 N 天内每轮重查(补迟到更新),老车只补抓未抓过的
+BUY_PAGE_LIMIT = 20  # 购买记录翻页每页
+MAX_BUY_PAGES = 50  # 单商品购买记录翻页上限
+
+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")
+
+_SOLD_COLS = ["goods_id", "program_id", "goods_name", "product_type", "business_type",
+              "goods_ip_id", "goods_ip_name", "random_type", "corp_info_id", "corp_info_name",
+              "amount", "highest_price", "lowest_price", "stock_amount", "residue_stock_amount",
+              "specification_name", "report_status", "live_replay_url", "sold_time",
+              "soldout_time", "finish_time"]
+
+
+def parse_shop(rec: dict) -> dict:
+    """把 hotRecommend 一条商家记录规范化为入库字典。
+
+    hotRecommend 商家字段实测:`corpInfoId`(商家id) / `corpInfoName`(名) / `number`(**粉丝数**) /
+    `saleNum`(**在售商品数**)。响应里的 `userId` 是查询者本人账号id(每行恒等)、非商家id,不入库;
+    好评/新品/简介接口体系无此字段,不入库。
+
+    Args:
+        rec (dict): 商家列表返回的一条。
+
+    Returns:
+        dict: 与 jw_shop_record 列对齐的字典(corp_info_id/corp_name/fans_amount/onsale_amount)。
+    """
+    return {
+        "corp_info_id": rec.get("corpInfoId"),
+        "corp_name": rec.get("corpInfoName"),
+        "fans_amount": rec.get("number"),      # number = 粉丝数(实测确认)
+        "onsale_amount": rec.get("saleNum"),   # saleNum = 在售商品数
+    }
+
+
+LIVE_BASE_URL = "https://play.jiwustar.com"  # 直播回放域名(BASE_PULL_STREAM_URL, 逆向 EnvironmentManager RELEASE)
+
+
+def full_live_url(path) -> str | None:
+    """把 liveReplayUrl 相对路径(形如 /live/xxx.mp4)拼成完整直播回放链接。
+
+    接口返回的 liveReplayUrl 只含 `/live/...`,需拼直播域名 https://play.jiwustar.com(路径已带 /live/)。
+
+    Args:
+        path (str | None): 接口返回的 liveReplayUrl。
+
+    Returns:
+        str | None: 完整 URL;空值返回 None;已是 http(s) 开头则原样返回。
+    """
+    if not path:
+        return None
+    if str(path).startswith("http"):
+        return path
+    return LIVE_BASE_URL + (path if str(path).startswith("/") else "/" + path)
+
+
+def parse_sold(rec: dict) -> dict:
+    """把 corp/history 一条已售记录规范化为入库字典。
+
+    Args:
+        rec (dict): 已售历史返回的一条。
+
+    Returns:
+        dict: 与 jw_sold_product_record 列对齐的字典。
+    """
+    return {
+        "goods_id": rec.get("goodsId"), "program_id": rec.get("programId"),
+        "goods_name": rec.get("goodsName"), "product_type": rec.get("productType"),
+        "business_type": rec.get("businessType"), "goods_ip_id": rec.get("goodsIPId"),
+        "goods_ip_name": rec.get("goodsIPName"), "random_type": rec.get("randomType"),
+        "corp_info_id": rec.get("corpInfoId"), "corp_info_name": rec.get("corpInfoName"),
+        "amount": core.to_yuan(rec.get("amount")), "highest_price": core.to_yuan(rec.get("highestPrice")),
+        "lowest_price": core.to_yuan(rec.get("lowestPrice")), "stock_amount": rec.get("stockAmount"),
+        "residue_stock_amount": rec.get("residueStockAmount"),
+        "specification_name": rec.get("specificationName"), "report_status": rec.get("reportStatus"),
+        "live_replay_url": full_live_url(rec.get("liveReplayUrl")), "sold_time": rec.get("soldTime"),
+        "soldout_time": rec.get("soldOutTime"), "finish_time": rec.get("finishTime"),
+    }
+
+
+def get_shops(log, pool) -> list:
+    """抓商家列表 hotRecommend 落库 jw_shop_record,返回 corp_info_id 列表。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+
+    Returns:
+        list: 商家 corp_info_id 列表(受 TARGET_CORPS 过滤)。
+    """
+    corp_ids = []
+    for page in range(1, MAX_SHOP_PAGES + 1):
+        j = core.do_request(log, "/search/app/index/corp/hotRecommend",
+                            {"currentPage": str(page), "limit": str(PAGE_LIMIT), "systemBusinessType": 6},
+                            need_auth=True)  # 商家列表实测需登录
+        if not j:
+            break
+        data = j.get("data") or {}
+        recs = data.get("records") or []
+        if not recs:
+            break
+        shops = [parse_shop(r) for r in recs]
+        for s in shops:
+            pool.update_one(
+                "INSERT INTO jw_shop_record (corp_info_id,corp_name,fans_amount,onsale_amount) "
+                "VALUES (%s,%s,%s,%s) "
+                "ON DUPLICATE KEY UPDATE corp_name=VALUES(corp_name),"
+                "fans_amount=VALUES(fans_amount),onsale_amount=VALUES(onsale_amount)",
+                (s["corp_info_id"], s["corp_name"], s["fans_amount"], s["onsale_amount"]))
+            corp_ids.append(s["corp_info_id"])
+        # 末页判定用响应里的实际每页大小 data.limit:hotRecommend 服务端把每页压到 10 条(≠请求的 PAGE_LIMIT),
+        # 若用 PAGE_LIMIT 判会第 1 页就误判停(之前只翻到 10 个商家的 bug)。实测全量约 82 个商家、9 页。
+        srv_limit = data.get("limit") or PAGE_LIMIT
+        if len(recs) < srv_limit:
+            break
+        time.sleep(0.3)
+    if TARGET_CORPS:
+        corp_ids = [c for c in corp_ids if c in TARGET_CORPS]
+    log.info(f"商家列表入库完成,待抓已售商家 {len(corp_ids)} 家")
+    return corp_ids
+
+
+def fetch_sold_for_corp(log, pool, corp_id: int) -> list:
+    """翻页抓某商家的已售历史,INSERT IGNORE 只增落库。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+        corp_id (int): 商家 corpInfoId。
+
+    Returns:
+        list: 本商家本轮抓到的已售商品 goods_id 列表(供抓拆卡报告/购买记录)。
+    """
+    total, goods_ids = 0, []
+    for page in range(1, MAX_SOLD_PAGES + 1):
+        j = core.do_request(log, "/search/app/corp/history", {
+            "corpInfoId": str(corp_id), "currentPage": str(page),
+            "limit": str(PAGE_LIMIT), "systemBusinessType": SYSTEM_BUSINESS_TYPE})  # 免登录
+        if not j:
+            break
+        recs = (j.get("data") or {}).get("records") or []
+        if not recs:
+            break
+        rows = [parse_sold(r) for r in recs]
+        pool.insert_many(table="jw_sold_product_record", data_list=rows, ignore=True)
+        goods_ids.extend(r["goods_id"] for r in rows if r.get("goods_id"))
+        total += len(rows)
+        if len(recs) < PAGE_LIMIT:
+            break
+        time.sleep(0.3)
+    log.info(f"商家 {corp_id} 已售抓取 {total} 条")
+    return goods_ids
+
+
+# ---------------- 拆卡报告(需登录, sbt=6, INSERT IGNORE 去重, 结束N天内重查) ----------------
+def parse_report(rec: dict, goods_id: int) -> dict:
+    """把拆卡报告一条记录规范化为入库字典(goods_id 由调用方补,响应体不含)。
+
+    Args:
+        rec (dict): gift/report/query/search/pager 返回 records 里的一条。
+        goods_id (int): 所属商品ID(查询参数)。
+
+    Returns:
+        dict: 与 jw_report_record 列对齐的字典。
+    """
+    res = rec.get("resAddrList")
+    return {
+        "gift_report_id": rec.get("giftReportId"), "goods_id": goods_id,
+        "user_id": rec.get("userId"), "username": rec.get("username"),
+        "corp_id": rec.get("corpId"), "corp_name": rec.get("corpName"),
+        "goods_name": rec.get("goodsName"), "serial_item_name": rec.get("serialItemName"),
+        "res_addr_list": json.dumps(res, ensure_ascii=False) if res is not None else None,
+        "winner_status": rec.get("winnerStatus"), "anonymous_status": rec.get("anonymousStatus"),
+        "my_gift_status": rec.get("myGiftStatus"), "report_time": rec.get("createTime"),
+    }
+
+
+def fetch_reports_for_goods(log, pool, goods_id: int) -> None:
+    """翻页抓某商品的拆卡报告(需登录),INSERT IGNORE 按 giftReportId 只增落库。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+        goods_id (int): 商品 goodsId。
+    """
+    total = 0
+    for page in range(1, MAX_REPORT_PAGES + 1):
+        j = core.do_request(log, "/goods/gift/report/query/search/pager", {
+            "currentPage": str(page), "goodsId": str(goods_id), "limit": str(REPORT_PAGE_LIMIT),
+            "systemBusinessType": 6}, need_auth=True)  # 拆卡报告实测需登录, sbt=6
+        if not j:
+            break
+        recs = (j.get("data") or {}).get("records") or []
+        if not recs:
+            break
+        rows = [parse_report(r, goods_id) for r in recs]
+        pool.insert_many(table="jw_report_record", data_list=rows, ignore=True)
+        total += len(rows)
+        if len(recs) < REPORT_PAGE_LIMIT:
+            break
+        time.sleep(0.2)
+    if total:
+        log.info(f"商品 {goods_id} 拆卡报告 {total} 条")
+
+
+def fetch_reports_for_sold(log, pool, goods_ids: list) -> None:
+    """对已售商品抓拆卡报告:结束 REPORT_REFETCH_DAYS 天内(或未结束)每轮重查,老车只补抓从未抓过的。
+
+    拆卡报告有唯一 giftReportId,重查靠 INSERT IGNORE 去重,故重查安全、能补齐迟到的报告。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+        goods_ids (list): 本商家本轮的已售商品 goods_id 列表。
+    """
+    ids = list({g for g in goods_ids if g})
+    if not ids:
+        return
+    ph = ",".join(["%s"] * len(ids))
+    have = {r[0] for r in pool.select_all(
+        f"SELECT DISTINCT goods_id FROM jw_report_record WHERE goods_id IN ({ph})", tuple(ids))}
+    recent = {r[0] for r in pool.select_all(
+        f"SELECT goods_id FROM jw_sold_product_record WHERE goods_id IN ({ph}) "
+        "AND (finish_time IS NULL OR finish_time >= NOW() - INTERVAL %s DAY)",
+        tuple(ids) + (REPORT_REFETCH_DAYS,))}
+    todo = recent | (set(ids) - have)  # 近N天(或未结束)重查 + 从未抓过补一次
+    for gid in todo:
+        try:
+            fetch_reports_for_goods(log, pool, gid)
+        except Exception as e:
+            log.error(f"商品 {gid} 拆卡报告抓取异常: {e}")
+
+
+# ---------------- 购买记录(赠品公示·玩家维度, 需登录, 翻页拿全, buy_fetched 状态位控制抓一次) ----------------
+def parse_buy(rec: dict, goods_id: int) -> dict:
+    """把玩家维度购买记录一条买家规范化为入库字典。
+
+    Args:
+        rec (dict): publicity/user/group/pager 返回 records 里的一条。
+        goods_id (int): 所属商品ID(兜底,响应体一般也带 goodsId)。
+
+    Returns:
+        dict: 与 jw_player_record 列对齐的字典(goods_id/user_id/user_nick/pic_id/buy_count)。
+    """
+    return {
+        "goods_id": rec.get("goodsId") or goods_id, "user_id": rec.get("userId"),
+        "user_nick": rec.get("userNick"), "pic_id": rec.get("picId"),
+        "buy_count": rec.get("count"),
+    }
+
+
+def fetch_buy_for_goods(log, pool, goods_id: int) -> bool:
+    """翻页取全某商品的购买记录(玩家维度,需登录)→ 批量入库(不去重)。
+
+    先把所有页收齐再一次性入库;只要有一页请求失败就判为未取全、返回 False(不入库、不置状态位、下轮重试)。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+        goods_id (int): 商品 goodsId。
+
+    Returns:
+        bool: 取全并入库成功返回 True(供上层置 buy_fetched=1);请求失败返回 False。
+    """
+    rows = []
+    for page in range(1, MAX_BUY_PAGES + 1):
+        j = core.do_request(log, "/order/merchant/app/query/gift/publicity/user/group/pager", {
+            "currentPage": str(page), "giftBusinessName": "", "goodsId": str(goods_id),
+            "limit": str(BUY_PAGE_LIMIT), "systemBusinessType": SYSTEM_BUSINESS_TYPE}, need_auth=True)  # 需登录
+        if not j:
+            return False  # 请求失败=未取全,返回 False 让上层不置状态位、下轮重试
+        recs = (j.get("data") or {}).get("records") or []
+        if not recs:
+            break
+        rows.extend(parse_buy(r, goods_id) for r in recs if r.get("userId") is not None)
+        if len(recs) < BUY_PAGE_LIMIT:
+            break
+        time.sleep(0.2)
+    if rows:
+        pool.insert_many(table="jw_player_record", data_list=rows, ignore=False)  # 不去重,批量保存
+    log.info(f"商品 {goods_id} 购买记录 {len(rows)} 条")
+    return True
+
+
+def fetch_buy_for_pending(log, pool, goods_ids: list) -> None:
+    """对未抓过购买记录(buy_fetched=0)的已售商品抓全入库,成功后置 buy_fetched=1。
+
+    买家列表售罄后即固定,用已售表状态位控制「每个商品抓一次」;取全并入库成功才置位,失败下轮重试。
+
+    Args:
+        log: 日志对象。
+        pool: 数据库连接池。
+        goods_ids (list): 本商家本轮的已售商品 goods_id 列表。
+    """
+    ids = list({g for g in goods_ids if g})
+    if not ids:
+        return
+    ph = ",".join(["%s"] * len(ids))
+    pending = [r[0] for r in pool.select_all(
+        f"SELECT goods_id FROM jw_sold_product_record WHERE goods_id IN ({ph}) AND buy_fetched=0", tuple(ids))]
+    for gid in pending:
+        try:
+            if fetch_buy_for_goods(log, pool, gid):
+                pool.update_one("UPDATE jw_sold_product_record SET buy_fetched=1 WHERE goods_id=%s", (gid,))
+        except Exception as e:
+            log.error(f"商品 {gid} 购买记录抓取异常: {e}")
+
+
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=core.after_log)
+def main_task(log) -> None:
+    """已售抓取主流程(挂了每小时重试):已售历史 + 拆卡报告 + 购买记录。
+
+    Args:
+        log: 日志对象。
+
+    Raises:
+        RuntimeError: 数据库连接池异常时抛出以触发重试。
+    """
+    log.info("开始已售抓取" + "." * 40)
+    pool = MySQLConnectionPool(log=log)
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        raise RuntimeError("数据库连接池异常")
+    try:
+        get_shops(log, pool)  # 先刷新商家表(hotRecommend 每日轮换, INSERT/UPDATE 累积)
+        # 已售对库中【全量商家】循环查询——hotRecommend 每天只返回轮换的一批热门商家,
+        # jw_shop_record 随天数累积覆盖更全;故从表里取全量(而非只取本轮 get_shops 返回的)。
+        corp_ids = [r[0] for r in pool.select_all("SELECT corp_info_id FROM jw_shop_record")]
+        if TARGET_CORPS:  # 如只想抓指定商家(如 Jake/九叔)在此过滤
+            corp_ids = [c for c in corp_ids if c in TARGET_CORPS]
+        log.info(f"待抓已售商家 {len(corp_ids)} 家(取自 jw_shop_record 全量)")
+        for cid in corp_ids:
+            try:
+                goods_ids = fetch_sold_for_corp(log, pool, cid)  # 已售商品(免登录)
+                jw_detail.enrich_detail(log, pool, "jw_sold_product_record", goods_ids)  # 详情补全(免登录)
+                fetch_reports_for_sold(log, pool, goods_ids)  # 拆卡报告(需登录, 按状态重查)
+                fetch_buy_for_pending(log, pool, goods_ids)  # 购买记录(需登录, buy_fetched 状态位控制)
+            except Exception as e:
+                log.error(f"商家 {cid} 已售/拆卡报告/购买记录抓取异常: {e}")
+    except Exception as e:
+        log.error(f"已售抓取异常: {e}")
+    finally:
+        log.info("已售抓取结束,等待下一轮" + "." * 20)
+
+
+def schedule_task():
+    """定时入口:每天 08:00 抓一次已售(含拆卡报告、购买记录)。
+
+    08:00 配合已售日报的业务日窗口 [昨17:00, 今06:00]:窗口 06:00 关闭后再采,
+    报告 09:10 前全站已售数据齐全,不漏窗口尾部(00:30~06:00)结束的团。
+    """
+    # main_task(log=logger)  # 立即跑一次(调试时取消注释)
+    schedule.every().day.at("08:00").do(main_task, log=logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    schedule_task()

+ 671 - 0
jiwu_spider/mysql_pool.py

@@ -0,0 +1,671 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2025/3/25 14:14
+import re
+import pymysql
+import YamlLoader
+from loguru import logger
+from dbutils.pooled_db import PooledDB
+
+# 获取yaml配置
+yaml = YamlLoader.readYaml()
+mysqlYaml = yaml.get("mysql")
+sql_host = mysqlYaml.getValueAsString("host")
+sql_port = mysqlYaml.getValueAsInt("port")
+sql_user = mysqlYaml.getValueAsString("username")
+sql_password = mysqlYaml.getValueAsString("password")
+sql_db = mysqlYaml.getValueAsString("db")
+
+
+class MySQLConnectionPool:
+    """
+    MySQL连接池
+    """
+
+    def __init__(self, mincached=1, maxcached=2, maxconnections=3, log=None):
+        """
+        初始化连接池
+        :param mincached: 初始化时,链接池中至少创建的链接,0表示不创建
+        :param maxcached: 池中空闲连接的最大数目(0 或 None 表示池大小不受限制)
+        :param maxconnections: 允许的最大连接数(0 或 None 表示任意数量的连接)
+        :param log: 自定义日志记录器
+        """
+        # 使用 loguru 的 logger,如果传入了其他 logger,则使用传入的 logger
+        self.log = log or logger
+        self.pool = PooledDB(
+            creator=pymysql,
+            mincached=mincached,
+            maxcached=maxcached,
+            maxconnections=maxconnections,
+            blocking=True,  # 连接池中如果没有可用连接后,是否阻塞等待。True,等待;False,不等待然后报错
+            host=sql_host,
+            port=sql_port,
+            user=sql_user,
+            password=sql_password,
+            database=sql_db,
+            ping=2,  # 每次执行前检查连接有效性,防止使用已断开的连接
+            connect_timeout=5,  # 连接超时时间(秒)
+            # read_timeout=30,  # 读取超时时间(秒)
+            write_timeout=30  # 写入超时时间(秒)
+        )
+
+    # def _execute(self, query, args=None, commit=False):
+    #     """
+    #     执行SQL
+    #     :param query: SQL语句
+    #     :param args: SQL参数
+    #     :param commit: 是否提交事务
+    #     :return: 查询结果
+    #     """
+    #     try:
+    #         with self.pool.connection() as conn:
+    #             with conn.cursor() as cursor:
+    #                 cursor.execute(query, args)
+    #                 if commit:
+    #                     conn.commit()
+    #                 self.log.debug(f"sql _execute, Query: {query}, Rows: {cursor.rowcount}")
+    #                 return cursor
+    #     except Exception as e:
+    #         if commit and conn:
+    #             conn.rollback()
+    #         self.log.exception(f"Error executing query: {e}, Query: {query}, Args: {args}")
+    #         raise e
+
+    def _execute(self, query, args=None, commit=False):
+        """
+        执行SQL(带断连重试)
+        :param query: SQL语句
+        :param args: SQL参数
+        :param commit: 是否提交事务
+        :return: 查询结果
+        """
+        conn = None
+        for attempt in range(2):  # 最多重试1次
+            try:
+                with self.pool.connection() as conn:
+                    with conn.cursor() as cursor:
+                        cursor.execute(query, args)
+                        if commit:
+                            conn.commit()
+                        self.log.debug(f"sql _execute, Query: {query}, Rows: {cursor.rowcount}")
+                        return cursor
+            except pymysql.err.InterfaceError as e:
+                # 连接已断开,重试一次
+                if attempt == 0:
+                    self.log.warning(f"数据库连接断开,正在重试... Error: {e}")
+                    continue
+                self.log.error(f"重试后仍失败: {e}, Query: {query}")
+                raise e
+            except pymysql.err.IntegrityError:
+                # 完整性错误(如重复条目)交由上层处理,避免在此打印完整堆栈污染日志
+                if commit and conn:
+                    try:
+                        conn.rollback()
+                    except Exception:
+                        pass
+                raise
+            except Exception as e:
+                if commit and conn:
+                    try:
+                        conn.rollback()
+                    except Exception:
+                        pass
+                self.log.exception(f"Error executing query: {e}, Query: {query}, Args: {args}")
+                raise e
+
+    def select_one(self, query, args=None):
+        """
+        执行查询,返回单个结果
+        :param query: 查询语句
+        :param args: 查询参数
+        :return: 查询结果
+        """
+        cursor = self._execute(query, args)
+        return cursor.fetchone()
+
+    def select_all(self, query, args=None):
+        """
+        执行查询,返回所有结果
+        :param query: 查询语句
+        :param args: 查询参数
+        :return: 查询结果
+        """
+        cursor = self._execute(query, args)
+        return cursor.fetchall()
+
+    def insert_one(self, query, args):
+        """
+        执行单条插入语句
+        :param query: 插入语句
+        :param args: 插入参数
+        """
+        self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data insert_one 入库中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+        cursor = self._execute(query, args, commit=True)
+        return cursor.lastrowid  # 返回插入的ID
+
+    def insert_all(self, query, args_list):
+        """
+        执行批量插入语句,如果失败则逐条插入
+        :param query: 插入语句
+        :param args_list: 插入参数列表
+        """
+        conn = None
+        cursor = None
+        try:
+            conn = self.pool.connection()
+            cursor = conn.cursor()
+            cursor.executemany(query, args_list)
+            conn.commit()
+            self.log.debug(f"sql insert_all, SQL: {query[:100]}..., Rows: {cursor.rowcount}")
+            self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data insert_all 入库中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+        except pymysql.err.IntegrityError as e:
+            if "Duplicate entry" in str(e):
+                conn.rollback()
+                self.log.warning(f"批量插入遇到重复,开始逐条插入。错误: {e}")
+                rowcount = 0
+                for args in args_list:
+                    try:
+                        self.insert_one(query, args)
+                        rowcount += 1
+                    except pymysql.err.IntegrityError as e2:
+                        if "Duplicate entry" in str(e2):
+                            self.log.debug(f"跳过重复条目: {e2}")
+                        else:
+                            self.log.error(f"插入失败: {e2}")
+                    except Exception as e2:
+                        self.log.error(f"插入失败: {e2}")
+                self.log.info(f"逐条插入完成: {rowcount}/{len(args_list)}条")
+            else:
+                conn.rollback()
+                self.log.exception(f"数据库完整性错误: {e}")
+                raise e
+        except Exception as e:
+            conn.rollback()
+            self.log.exception(f"批量插入失败: {e}")
+            raise e
+        finally:
+            if cursor:
+                cursor.close()
+            if conn:
+                conn.close()
+
+    def insert_one_or_dict(self, table=None, data=None, query=None, args=None, commit=True, ignore=False):
+        """
+        单条插入(支持字典或原始SQL)
+        :param table: 表名(字典插入时必需)
+        :param data: 字典数据 {列名: 值}
+        :param query: 直接SQL语句(与data二选一)
+        :param args: SQL参数(query使用时必需)
+        :param commit: 是否自动提交
+        :param ignore: 是否使用ignore
+        :return: 最后插入ID
+        """
+        if data is not None:
+            if not isinstance(data, dict):
+                raise ValueError("Data must be a dictionary")
+
+            keys = ', '.join([self._safe_identifier(k) for k in data.keys()])
+            values = ', '.join(['%s'] * len(data))
+
+            # 构建 INSERT IGNORE 语句
+            ignore_clause = "IGNORE" if ignore else ""
+            query = f"INSERT {ignore_clause} INTO {self._safe_identifier(table)} ({keys}) VALUES ({values})"
+            args = tuple(data.values())
+        elif query is None:
+            raise ValueError("Either data or query must be provided")
+
+        try:
+            cursor = self._execute(query, args, commit)
+            self.log.info(f"sql insert_one_or_dict, Table: {table}, Rows: {cursor.rowcount}")
+            self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data insert_one_or_dict 入库中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+            return cursor.lastrowid
+        except pymysql.err.IntegrityError as e:
+            if "Duplicate entry" in str(e):
+                # 重复条目用 warning 简短输出,不打印堆栈
+                self.log.warning(f"插入跳过-重复条目 Table: {table}, {e.args[1] if len(e.args) > 1 else e}")
+                return -1  # 返回 -1 表示重复条目被跳过
+            else:
+                self.log.error(f"数据库完整性错误 Table: {table}, Error: {e}")
+                raise
+        except Exception as e:
+            self.log.error(f"insert_one_or_dict 失败 Table: {table}, Error: {e}")
+            raise
+
+    def insert_many(self, table=None, data_list=None, query=None, args_list=None, batch_size=1000, commit=True,
+                    ignore=False):
+        """
+        批量插入(支持字典列表或原始SQL)
+        :param table: 表名(字典插入时必需)
+        :param data_list: 字典列表 [{列名: 值}]
+        :param query: 直接SQL语句(与data_list二选一)
+        :param args_list: SQL参数列表(query使用时必需)
+        :param batch_size: 分批大小
+        :param commit: 是否自动提交
+        :param ignore: 是否使用ignore
+        :return: 影响行数
+        """
+        if data_list is not None:
+            if not data_list or not isinstance(data_list[0], dict):
+                raise ValueError("Data_list must be a non-empty list of dictionaries")
+
+            keys = ', '.join([self._safe_identifier(k) for k in data_list[0].keys()])
+            values = ', '.join(['%s'] * len(data_list[0]))
+
+            # 构建 INSERT IGNORE 语句
+            ignore_clause = "IGNORE" if ignore else ""
+            query = f"INSERT {ignore_clause} INTO {self._safe_identifier(table)} ({keys}) VALUES ({values})"
+            args_list = [tuple(d.values()) for d in data_list]
+        elif query is None:
+            raise ValueError("Either data_list or query must be provided")
+
+        total = 0
+        for i in range(0, len(args_list), batch_size):
+            batch = args_list[i:i + batch_size]
+            try:
+                with self.pool.connection() as conn:
+                    with conn.cursor() as cursor:
+                        cursor.executemany(query, batch)
+                        if commit:
+                            conn.commit()
+                        total += cursor.rowcount
+            except pymysql.err.IntegrityError as e:
+                # 处理唯一索引冲突
+                if "Duplicate entry" in str(e):
+                    if ignore:
+                        # 如果使用了 INSERT IGNORE,理论上不会进这里,但以防万一
+                        self.log.warning(f"批量插入遇到重复条目(ignore模式): {e}")
+                    else:
+                        # 没有使用 IGNORE,降级为逐条插入
+                        self.log.warning(f"批量插入遇到重复条目,开始逐条插入。错误: {e}")
+                        if commit:
+                            conn.rollback()
+                        
+                        rowcount = 0
+                        for j, args in enumerate(batch):
+                            try:
+                                if data_list:
+                                    # 字典模式
+                                    self.insert_one_or_dict(
+                                        table=table,
+                                        data=dict(zip(data_list[0].keys(), args)),
+                                        commit=commit,
+                                        ignore=False  # 单条插入时手动捕获重复
+                                    )
+                                else:
+                                    # 原始SQL模式
+                                    self.insert_one(query, args)
+                                rowcount += 1
+                            except pymysql.err.IntegrityError as e2:
+                                if "Duplicate entry" in str(e2):
+                                    self.log.debug(f"跳过重复条目[{i+j+1}]: {e2}")
+                                else:
+                                    self.log.error(f"插入失败[{i+j+1}]: {e2}")
+                            except Exception as e2:
+                                self.log.error(f"插入失败[{i+j+1}]: {e2}")
+                        total += rowcount
+                        self.log.info(f"批次逐条插入完成: 成功{rowcount}/{len(batch)}条")
+                else:
+                    # 其他完整性错误
+                    self.log.exception(f"数据库完整性错误: {e}")
+                    if commit:
+                        conn.rollback()
+                    raise e
+            except Exception as e:
+                # 其他数据库错误
+                self.log.exception(f"批量插入失败: {e}")
+                if commit:
+                    conn.rollback()
+                raise e
+        if table:
+            self.log.info(f"sql insert_many, Table: {table}, Total Rows: {total}")
+        else:
+            self.log.info(f"sql insert_many, Query: {query}, Total Rows: {total}")
+        return total
+
+    def insert_many_two(self, table=None, data_list=None, query=None, args_list=None, batch_size=1000, commit=True,
+                        ignore=False):
+        """
+        批量插入(支持字典列表或原始SQL) - 备用方法
+        :param table: 表名(字典插入时必需)
+        :param data_list: 字典列表 [{列名: 值}]
+        :param query: 直接SQL语句(与data_list二选一)
+        :param args_list: SQL参数列表(query使用时必需)
+        :param batch_size: 分批大小
+        :param commit: 是否自动提交
+        :param ignore: 是否使用INSERT IGNORE
+        :return: 影响行数
+        """
+        if data_list is not None:
+            if not data_list or not isinstance(data_list[0], dict):
+                raise ValueError("Data_list must be a non-empty list of dictionaries")
+            keys = ', '.join([self._safe_identifier(k) for k in data_list[0].keys()])
+            values = ', '.join(['%s'] * len(data_list[0]))
+            ignore_clause = "IGNORE" if ignore else ""
+            query = f"INSERT {ignore_clause} INTO {self._safe_identifier(table)} ({keys}) VALUES ({values})"
+            args_list = [tuple(d.values()) for d in data_list]
+        elif query is None:
+            raise ValueError("Either data_list or query must be provided")
+    
+        total = 0
+        for i in range(0, len(args_list), batch_size):
+            batch = args_list[i:i + batch_size]
+            try:
+                with self.pool.connection() as conn:
+                    with conn.cursor() as cursor:
+                        cursor.executemany(query, batch)
+                        if commit:
+                            conn.commit()
+                        total += cursor.rowcount
+            except pymysql.err.IntegrityError as e:
+                if "Duplicate entry" in str(e) and not ignore:
+                    self.log.warning(f"批量插入遇到重复,降级为逐条插入: {e}")
+                    if commit:
+                        conn.rollback()
+                    rowcount = 0
+                    for args in batch:
+                        try:
+                            self.insert_one(query, args)
+                            rowcount += 1
+                        except pymysql.err.IntegrityError as e2:
+                            if "Duplicate entry" in str(e2):
+                                self.log.debug(f"跳过重复条目: {e2}")
+                            else:
+                                self.log.error(f"插入失败: {e2}")
+                        except Exception as e2:
+                            self.log.error(f"插入失败: {e2}")
+                    total += rowcount
+                else:
+                    self.log.exception(f"数据库完整性错误: {e}")
+                    if commit:
+                        conn.rollback()
+                    raise e
+            except Exception as e:
+                self.log.exception(f"批量插入失败: {e}")
+                if commit:
+                    conn.rollback()
+                raise e
+        self.log.info(f"sql insert_many_two, Table: {table}, Total Rows: {total}")
+        return total
+
+    def insert_too_many(self, query, args_list, batch_size=1000):
+        """
+        执行批量插入语句,分片提交, 单次插入大于十万+时可用, 如果失败则降级为逐条插入
+        :param query: 插入语句
+        :param args_list: 插入参数列表
+        :param batch_size: 每次插入的条数
+        """
+        self.log.info(f"sql insert_too_many, Query: {query}, Total Rows: {len(args_list)}")
+        for i in range(0, len(args_list), batch_size):
+            batch = args_list[i:i + batch_size]
+            try:
+                with self.pool.connection() as conn:
+                    with conn.cursor() as cursor:
+                        cursor.executemany(query, batch)
+                        conn.commit()
+                        self.log.debug(f"insert_too_many -> Total Rows: {len(batch)}")
+            except Exception as e:
+                self.log.error(f"insert_too_many error. Trying single insert. Error: {e}")
+                # 当前批次降级为单条插入
+                for args in batch:
+                    self.insert_one(query, args)
+
+    def update_one(self, query, args):
+        """
+        执行单条更新语句
+        :param query: 更新语句
+        :param args: 更新参数
+        """
+        self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_one 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+        return self._execute(query, args, commit=True)
+
+    def update_all(self, query, args_list):
+        """
+        执行批量更新语句,如果失败则逐条更新
+        :param query: 更新语句
+        :param args_list: 更新参数列表
+        """
+        conn = None
+        cursor = None
+        try:
+            conn = self.pool.connection()
+            cursor = conn.cursor()
+            cursor.executemany(query, args_list)
+            conn.commit()
+            self.log.debug(f"sql update_all, SQL: {query}, Rows: {len(args_list)}")
+            self.log.info('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>data update_all 更新中>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
+        except Exception as e:
+            conn.rollback()
+            self.log.error(f"Error executing query: {e}")
+            # 如果批量更新失败,则逐条更新
+            rowcount = 0
+            for args in args_list:
+                self.update_one(query, args)
+                rowcount += 1
+            self.log.debug(f'Batch update failed. Updated {rowcount} rows individually.')
+        finally:
+            if cursor:
+                cursor.close()
+            if conn:
+                conn.close()
+
+    def update_one_or_dict(self, table=None, data=None, condition=None, query=None, args=None, commit=True):
+        """
+        单条更新(支持字典或原始SQL)
+        :param table: 表名(字典模式必需)
+        :param data: 字典数据 {列名: 值}(与 query 二选一)
+        :param condition: 更新条件,支持以下格式:
+            - 字典: {"id": 1} → "WHERE id = %s"
+            - 字符串: "id = 1" → "WHERE id = 1"(需自行确保安全)
+            - 元组: ("id = %s", [1]) → "WHERE id = %s"(参数化查询)
+        :param query: 直接SQL语句(与 data 二选一)
+        :param args: SQL参数(query 模式下必需)
+        :param commit: 是否自动提交
+        :return: 影响行数
+        :raises: ValueError 参数校验失败时抛出
+        """
+        # 参数校验
+        if data is not None:
+            if not isinstance(data, dict):
+                raise ValueError("Data must be a dictionary")
+            if table is None:
+                raise ValueError("Table name is required for dictionary update")
+            if condition is None:
+                raise ValueError("Condition is required for dictionary update")
+
+            # 构建 SET 子句
+            set_clause = ", ".join([f"{self._safe_identifier(k)} = %s" for k in data.keys()])
+            set_values = list(data.values())
+
+            # 解析条件
+            condition_clause, condition_args = self._parse_condition(condition)
+            query = f"UPDATE {self._safe_identifier(table)} SET {set_clause} WHERE {condition_clause}"
+            args = set_values + condition_args
+
+        elif query is None:
+            raise ValueError("Either data or query must be provided")
+
+        # 执行更新
+        cursor = self._execute(query, args, commit)
+        # self.log.debug(
+        #     f"Updated table={table}, rows={cursor.rowcount}, query={query[:100]}...",
+        #     extra={"table": table, "rows": cursor.rowcount}
+        # )
+        return cursor.rowcount
+
+    def _parse_condition(self, condition):
+        """
+        解析条件为 (clause, args) 格式
+        :param condition: 字典/字符串/元组
+        :return: (str, list) SQL 子句和参数列表
+        """
+        if isinstance(condition, dict):
+            clause = " AND ".join([f"{self._safe_identifier(k)} = %s" for k in condition.keys()])
+            args = list(condition.values())
+        elif isinstance(condition, str):
+            clause = condition  # 注意:需调用方确保安全
+            args = []
+        elif isinstance(condition, (tuple, list)) and len(condition) == 2:
+            clause, args = condition[0], condition[1]
+            if not isinstance(args, (list, tuple)):
+                args = [args]
+        else:
+            raise ValueError("Condition must be dict/str/(clause, args)")
+        return clause, args
+
+    def update_many(self, table=None, data_list=None, condition_list=None, query=None, args_list=None, batch_size=500,
+                    commit=True):
+        """
+        批量更新(支持字典列表或原始SQL)
+        :param table: 表名(字典插入时必需)
+        :param data_list: 字典列表 [{列名: 值}]
+        :param condition_list: 条件列表(必须为字典,与data_list等长)
+        :param query: 直接SQL语句(与data_list二选一)
+        :param args_list: SQL参数列表(query使用时必需)
+        :param batch_size: 分批大小
+        :param commit: 是否自动提交
+        :return: 影响行数
+        """
+        if data_list is not None:
+            if not data_list or not isinstance(data_list[0], dict):
+                raise ValueError("Data_list must be a non-empty list of dictionaries")
+            if condition_list is None or len(data_list) != len(condition_list):
+                raise ValueError("Condition_list must be provided and match the length of data_list")
+            if not all(isinstance(cond, dict) for cond in condition_list):
+                raise ValueError("All elements in condition_list must be dictionaries")
+
+            # 获取第一个数据项和条件项的键
+            first_data_keys = set(data_list[0].keys())
+            first_cond_keys = set(condition_list[0].keys())
+
+            # 构造基础SQL
+            set_clause = ', '.join([self._safe_identifier(k) + ' = %s' for k in data_list[0].keys()])
+            condition_clause = ' AND '.join([self._safe_identifier(k) + ' = %s' for k in condition_list[0].keys()])
+            base_query = f"UPDATE {self._safe_identifier(table)} SET {set_clause} WHERE {condition_clause}"
+            total = 0
+
+            # 分批次处理
+            for i in range(0, len(data_list), batch_size):
+                batch_data = data_list[i:i + batch_size]
+                batch_conds = condition_list[i:i + batch_size]
+                batch_args = []
+
+                # 检查当前批次的结构是否一致
+                can_batch = True
+                for data, cond in zip(batch_data, batch_conds):
+                    data_keys = set(data.keys())
+                    cond_keys = set(cond.keys())
+                    if data_keys != first_data_keys or cond_keys != first_cond_keys:
+                        can_batch = False
+                        break
+                    batch_args.append(tuple(data.values()) + tuple(cond.values()))
+
+                if not can_batch:
+                    # 结构不一致,转为单条更新
+                    for data, cond in zip(batch_data, batch_conds):
+                        self.update_one_or_dict(table=table, data=data, condition=cond, commit=commit)
+                        total += 1
+                    continue
+
+                # 执行批量更新
+                try:
+                    with self.pool.connection() as conn:
+                        with conn.cursor() as cursor:
+                            cursor.executemany(base_query, batch_args)
+                            if commit:
+                                conn.commit()
+                            total += cursor.rowcount
+                            self.log.debug(f"Batch update succeeded. Rows: {cursor.rowcount}")
+                except Exception as e:
+                    if commit:
+                        conn.rollback()
+                    self.log.error(f"Batch update failed: {e}")
+                    # 降级为单条更新
+                    for args, data, cond in zip(batch_args, batch_data, batch_conds):
+                        try:
+                            self._execute(base_query, args, commit=commit)
+                            total += 1
+                        except Exception as e2:
+                            self.log.error(f"Single update failed: {e2}, Data: {data}, Condition: {cond}")
+            self.log.info(f"Total updated rows: {total}")
+            return total
+        elif query is not None:
+            # 处理原始SQL和参数列表
+            if args_list is None:
+                raise ValueError("args_list must be provided when using query")
+
+            total = 0
+            for i in range(0, len(args_list), batch_size):
+                batch_args = args_list[i:i + batch_size]
+                try:
+                    with self.pool.connection() as conn:
+                        with conn.cursor() as cursor:
+                            cursor.executemany(query, batch_args)
+                            if commit:
+                                conn.commit()
+                            total += cursor.rowcount
+                            self.log.debug(f"Batch update succeeded. Rows: {cursor.rowcount}")
+                except Exception as e:
+                    if commit:
+                        conn.rollback()
+                    self.log.error(f"Batch update failed: {e}")
+                    # 降级为单条更新
+                    for args in batch_args:
+                        try:
+                            self._execute(query, args, commit=commit)
+                            total += 1
+                        except Exception as e2:
+                            self.log.error(f"Single update failed: {e2}, Args: {args}")
+            self.log.info(f"Total updated rows: {total}")
+            return total
+        else:
+            raise ValueError("Either data_list or query must be provided")
+
+    def check_pool_health(self):
+        """
+        检查连接池中有效连接数
+
+        # 使用示例
+        # 配置 MySQL 连接池
+        sql_pool = MySQLConnectionPool(log=log)
+        if not sql_pool.check_pool_health():
+            log.error("数据库连接池异常")
+            raise RuntimeError("数据库连接池异常")
+        """
+        try:
+            with self.pool.connection() as conn:
+                conn.ping(reconnect=True)
+                return True
+        except Exception as e:
+            self.log.error(f"Connection pool health check failed: {e}")
+            return False
+
+    def close(self):
+        """
+        关闭连接池,释放所有连接
+        """
+        try:
+            if hasattr(self, 'pool') and self.pool:
+                self.pool.close()
+                self.log.info("数据库连接池已关闭")
+        except Exception as e:
+            self.log.error(f"关闭连接池失败: {e}")
+
+    @staticmethod
+    def _safe_identifier(name):
+        """SQL标识符安全校验"""
+        if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
+            raise ValueError(f"Invalid SQL identifier: {name}")
+        return name
+
+
+if __name__ == '__main__':
+    sql_pool = MySQLConnectionPool()
+    data_dic = {'card_type_id': 111, 'card_type_name': '补充包 继承的意志【OPC-13】', 'card_type_position': 964,
+                'card_id': 5284, 'card_name': '蒙奇·D·路飞', 'card_number': 'OP13-001', 'card_rarity': 'L',
+                'card_img': 'https://source.windoent.com/OnePiecePc/Picture/1757929283612OP13-001.png',
+                'card_life': '4', 'card_attribute': '打', 'card_power': '5000', 'card_attack': '-',
+                'card_color': '红/绿', 'subscript': 4, 'card_features': '超新星/草帽一伙',
+                'card_text_desc': '【咚!!×1】【对方的攻击时】我方处于活跃状态的咚!!不多于5张的场合,可以将我方任意张数的咚!!转为休息状态。每有1张转为休息状态的咚!!,本次战斗中,此领袖或我方最多1张拥有《草帽一伙》特征的角色力量+2000。',
+                'card_offer_type': '补充包 继承的意志【OPC-13】', 'crawler_language': '简中'}
+    sql_pool.insert_one_or_dict(table="one_piece_record", data=data_dic)

+ 17 - 0
jiwu_spider/requirements.txt

@@ -0,0 +1,17 @@
+# 集物星球爬虫依赖(版本为 2026/08/21 当前环境实测;Python 3.12.10)
+# 安装:pip install -r requirements.txt
+
+# ---- 直接依赖 ----
+curl_cffi==0.15.1b1   # TLS 指纹(impersonate=chrome)过风控,必须
+loguru==0.7.3         # 日志
+tenacity==9.1.4       # 重试
+schedule==1.2.2       # 定时
+requests==2.33.1      # 企微推送等普通请求
+openpyxl==3.1.5       # 生成 Excel 日报
+
+# ---- 公共库 charley-utils 的运行依赖 ----
+# mysql_pool / YamlLoader 需要;charley-utils 本身是本地 editable 安装(pip install -e D:\work\common\charley-utils)、不在 PyPI,
+# 换服务器时需一并部署该目录并 `pip install -e` 安装。
+PyMySQL==1.1.2
+DBUtils==3.1.2
+PyYAML==6.0.3

+ 71 - 0
jiwu_spider/run_all.py

@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/21
+"""集物星球爬虫一键启动:把 5 个常驻任务各自拉成独立子进程并守护(崩溃自动重启)。
+
+  服务器部署只跑这一个文件即可:`python run_all.py`。
+  每个任务是独立子进程(各自维护自己的 schedule 定时与日志、互不干扰),本脚本轮询它们的存活状态,
+  发现哪个退出就隔 RESTART_DELAY 秒重启那个(无人值守长跑用)。
+  想单独调试某个任务时,仍可直接 `python jw_xxx.py` 单跑,不必用本文件。
+  """
+import os
+import sys
+import time
+import subprocess
+
+from loguru import logger
+
+# 待启动的常驻任务:(展示名, 脚本文件)
+TASKS = [
+    ("在售抓取", "jw_onsale_spider.py"),   # 每天 09/15/20/01 四档
+    ("已售抓取", "jw_sold_spider.py"),      # 每天 08:00(含拆卡报告/购买记录)
+    ("在售日报", "jw_onsale_report.py"),    # 每天 09/15/20/01 四档发企微
+    ("已售日报", "jw_sold_report.py"),      # 每天 09:10 发企微
+    ("在售监控", "jw_onsale_alert.py"),     # 常驻轮询、上架/过半/结束提醒
+]
+RESTART_DELAY = 5      # 子进程退出后重启前的等待秒数
+CHECK_INTERVAL = 10    # 存活巡检间隔秒数
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+
+logger.remove()
+# logger.add(sys.stderr, level="INFO", format="[{time:YYYY-MM-DD HH:mm:ss}] {level} {message}")
+logger.add("./logs/run_all_{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 start_one(script: str) -> subprocess.Popen:
+    """用当前 Python 解释器在项目目录下拉起一个任务子进程。
+
+    Args:
+        script (str): 任务脚本文件名(相对项目目录)。
+
+    Returns:
+        subprocess.Popen: 已启动的子进程句柄。
+    """
+    return subprocess.Popen([sys.executable, os.path.join(BASE_DIR, script)], cwd=BASE_DIR)
+
+
+def main() -> None:
+    """启动全部任务并守护:巡检存活,退出的子进程自动重启(无限守护)。"""
+    procs = {name: [script, start_one(script)] for name, script in TASKS}
+    logger.success(f"已启动 {len(procs)} 个任务:{', '.join(procs.keys())}")
+    try:
+        while True:
+            for name, item in procs.items():
+                script, proc = item
+                if proc.poll() is not None:  # 该子进程已退出 → 重启
+                    logger.warning(f"[{name}] {script} 退出(code={proc.returncode}),{RESTART_DELAY}s 后重启")
+                    time.sleep(RESTART_DELAY)
+                    item[1] = start_one(script)
+                    logger.info(f"[{name}] {script} 已重启")
+            time.sleep(CHECK_INTERVAL)
+    except KeyboardInterrupt:  # Ctrl+C:优雅关掉所有子进程
+        logger.info("收到退出信号,正在关闭所有子进程" + "." * 10)
+        for name, (script, proc) in procs.items():
+            proc.terminate()
+        logger.info("已全部关闭")
+
+
+if __name__ == "__main__":
+    main()

+ 198 - 0
jiwu_spider/schema.sql

@@ -0,0 +1,198 @@
+-- 集物星球爬虫建表 DDL
+-- 规范:自增 id 物理主键;业务唯一键单独建 UNIQUE;时间字段固定 gmt_create_time/gmt_modified_time datetime;表名 jw_ 前缀 + _record 后缀。
+-- 金额字段:入库前已 ÷1000000 换算成「元」存储(DECIMAL,两位小数)。实测原始 99000000 → 存 99.00 元(App ¥99.00)。
+SET NAMES utf8mb4;
+
+-- ========== 1. 在售商品(最新状态,index/top 抓取)==========
+CREATE TABLE IF NOT EXISTS `jw_onsale_product_record` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `goods_id` BIGINT NOT NULL COMMENT '商品ID(goodsId)',
+  `program_id` BIGINT DEFAULT NULL COMMENT '节目/团ID(programId)',
+  `goods_name` VARCHAR(500) DEFAULT NULL COMMENT '商品名',
+  `product_type` VARCHAR(8) DEFAULT NULL COMMENT '商品类型码(1福袋2变风3错版卡4原盒)',
+  `business_type` INT DEFAULT NULL COMMENT '业务线businessType',
+  `block_type` INT DEFAULT NULL COMMENT '板块类型blockType',
+  `goods_ip_id` INT DEFAULT NULL COMMENT 'IP分类ID',
+  `goods_ip_name` VARCHAR(64) DEFAULT NULL COMMENT 'IP名(宝可梦/球星卡/海贼王等)',
+  `gift_series` VARCHAR(255) DEFAULT NULL COMMENT '赠品系列(cardGoods.giftSeries,App 赠品系列页的"系列",详情补全)',
+  `random_type` INT DEFAULT NULL COMMENT '玩法类型(randomType)',
+  `corp_info_id` BIGINT DEFAULT NULL COMMENT '商家ID',
+  `corp_info_name` VARCHAR(128) DEFAULT NULL COMMENT '商家名',
+  `amount` DECIMAL(12,2) DEFAULT NULL COMMENT '价格(元),含首购优惠价',
+  `highest_price` DECIMAL(12,2) DEFAULT NULL COMMENT '最高价(元)',
+  `lowest_price` DECIMAL(12,2) DEFAULT NULL COMMENT '最低价(元)',
+  `stock_amount` INT DEFAULT NULL COMMENT '总库存/总份数',
+  `residue_stock_amount` INT DEFAULT NULL COMMENT '剩余库存',
+  `sold_count` INT DEFAULT NULL COMMENT '已售数(stock-residue或字段)',
+  `specification_name` VARCHAR(128) DEFAULT NULL COMMENT '规格(如10盒/3包手雷+2包Hobby)',
+  `report_status` INT DEFAULT NULL COMMENT '拆卡报告状态',
+  `live_status` INT DEFAULT NULL COMMENT '直播状态',
+  `sold_time` DATETIME DEFAULT NULL COMMENT '上架/开售时间',
+  `product_pic` VARCHAR(255) DEFAULT NULL COMMENT '商品图相对路径',
+  `is_on_sale` TINYINT NOT NULL DEFAULT 1 COMMENT '是否在售:1是0已下架',
+  -- 详情页补全字段(来自 merchantGoodsId,由 jw_detail 补拉;detail_fetched 控制每商品补一次)
+  `img` VARCHAR(500) DEFAULT NULL COMMENT '商品图片完整URL(FILE_DOMAIN+resList首图resAddr)',
+  `goods_type` VARCHAR(32) DEFAULT NULL COMMENT '商品类型(goodsType,如MERCHANT_PRODUCT)',
+  `price` DECIMAL(12,2) DEFAULT NULL COMMENT '价格(元)',
+  `original_price` DECIMAL(12,2) DEFAULT NULL COMMENT '原价(元)',
+  `plan_up_time` DATETIME DEFAULT NULL COMMENT '计划上架时间(planUpTime)',
+  `off_shelf_time` DATETIME DEFAULT NULL COMMENT '下架时间(offShelfTime)',
+  `stock` INT DEFAULT NULL COMMENT '详情总库存(stock)',
+  `surplus_stock` INT DEFAULT NULL COMMENT '详情剩余库存(surplusStock)',
+  `status` INT DEFAULT NULL COMMENT '详情状态(status)',
+  `collection_card_name` VARCHAR(255) DEFAULT NULL COMMENT '集卡名称(cardGoods.collectionCardName)',
+  `gift_way` VARCHAR(64) DEFAULT NULL COMMENT '赠卡方式(cardGoods.giftWay)',
+  `random_way` VARCHAR(64) DEFAULT NULL COMMENT '随机方式(cardGoods.randomWay)',
+  `detail_fetched` TINYINT NOT NULL DEFAULT 0 COMMENT '详情字段是否已补:1是0否',
+  `gmt_create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `gmt_modified_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_goods_id` (`goods_id`),
+  KEY `idx_corp` (`corp_info_id`),
+  KEY `idx_onsale` (`is_on_sale`),
+  KEY `idx_detail_fetched` (`detail_fetched`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='集物星球在售商品(最新状态)';
+
+-- ========== 2. 已售商品(只增,corp/history 抓取)==========
+CREATE TABLE IF NOT EXISTS `jw_sold_product_record` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `goods_id` BIGINT NOT NULL COMMENT '商品ID(goodsId)',
+  `program_id` BIGINT DEFAULT NULL COMMENT '节目/团ID',
+  `goods_name` VARCHAR(500) DEFAULT NULL COMMENT '商品名',
+  `product_type` VARCHAR(8) DEFAULT NULL COMMENT '商品类型码',
+  `business_type` INT DEFAULT NULL COMMENT '业务线',
+  `goods_ip_id` INT DEFAULT NULL COMMENT 'IP分类ID',
+  `goods_ip_name` VARCHAR(64) DEFAULT NULL COMMENT 'IP名',
+  `gift_series` VARCHAR(255) DEFAULT NULL COMMENT '赠品系列(cardGoods.giftSeries,App 赠品系列页的"系列",详情补全)',
+  `random_type` INT DEFAULT NULL COMMENT '玩法类型',
+  `corp_info_id` BIGINT DEFAULT NULL COMMENT '商家ID',
+  `corp_info_name` VARCHAR(128) DEFAULT NULL COMMENT '商家名',
+  `amount` DECIMAL(12,2) DEFAULT NULL COMMENT '价格(元)',
+  `highest_price` DECIMAL(12,2) DEFAULT NULL COMMENT '最高价(元)',
+  `lowest_price` DECIMAL(12,2) DEFAULT NULL COMMENT '最低价(元)',
+  `stock_amount` INT DEFAULT NULL COMMENT '总库存/总份数',
+  `residue_stock_amount` INT DEFAULT NULL COMMENT '剩余库存',
+  `specification_name` VARCHAR(128) DEFAULT NULL COMMENT '规格',
+  `report_status` INT DEFAULT NULL COMMENT '拆卡报告状态',
+  `live_replay_url` VARCHAR(255) DEFAULT NULL COMMENT '回放地址',
+  `sold_time` DATETIME DEFAULT NULL COMMENT '开售时间',
+  `soldout_time` DATETIME DEFAULT NULL COMMENT '售罄时间',
+  `finish_time` DATETIME DEFAULT NULL COMMENT '结束时间',
+  `buy_fetched` TINYINT NOT NULL DEFAULT 0 COMMENT '购买记录是否已抓全并入库:1是0否',
+  -- 详情页补全字段(来自 merchantGoodsId,由 jw_detail 补拉;detail_fetched 控制每商品补一次)
+  `img` VARCHAR(500) DEFAULT NULL COMMENT '商品图片完整URL(FILE_DOMAIN+resList首图resAddr)',
+  `goods_type` VARCHAR(32) DEFAULT NULL COMMENT '商品类型(goodsType,如MERCHANT_PRODUCT)',
+  `price` DECIMAL(12,2) DEFAULT NULL COMMENT '价格(元)',
+  `original_price` DECIMAL(12,2) DEFAULT NULL COMMENT '原价(元)',
+  `plan_up_time` DATETIME DEFAULT NULL COMMENT '计划上架时间(planUpTime)',
+  `off_shelf_time` DATETIME DEFAULT NULL COMMENT '下架时间(offShelfTime)',
+  `stock` INT DEFAULT NULL COMMENT '详情总库存(stock)',
+  `surplus_stock` INT DEFAULT NULL COMMENT '详情剩余库存(surplusStock)',
+  `status` INT DEFAULT NULL COMMENT '详情状态(status)',
+  `collection_card_name` VARCHAR(255) DEFAULT NULL COMMENT '集卡名称(cardGoods.collectionCardName)',
+  `gift_way` VARCHAR(64) DEFAULT NULL COMMENT '赠卡方式(cardGoods.giftWay)',
+  `random_way` VARCHAR(64) DEFAULT NULL COMMENT '随机方式(cardGoods.randomWay)',
+  `detail_fetched` TINYINT NOT NULL DEFAULT 0 COMMENT '详情字段是否已补:1是0否',
+  `gmt_create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `gmt_modified_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_goods_id` (`goods_id`),
+  KEY `idx_corp` (`corp_info_id`),
+  KEY `idx_sold_time` (`sold_time`),
+  KEY `idx_buy_fetched` (`buy_fetched`),
+  KEY `idx_detail_fetched` (`detail_fetched`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='集物星球已售商品(只增)';
+
+-- ========== 3. 商家(最新状态,hotRecommend 抓取)==========
+-- hotRecommend 商家字段:corpInfoId/corpInfoName/number(粉丝数)/saleNum(在售商品数)
+-- 注:响应里的 userId 是查询者本人账号id(每行恒等)、非商家id,不存;好评/新品/简介接口体系无此字段,不建列
+CREATE TABLE IF NOT EXISTS `jw_shop_record` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `corp_info_id` BIGINT NOT NULL COMMENT '商家ID(corpInfoId)',
+  `corp_name` VARCHAR(128) DEFAULT NULL COMMENT '商家名(corpInfoName)',
+  `fans_amount` INT DEFAULT NULL COMMENT '粉丝数(number)',
+  `onsale_amount` INT DEFAULT NULL COMMENT '在售商品数(saleNum)',
+  `gmt_create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `gmt_modified_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_corp_info_id` (`corp_info_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='集物星球商家(hotRecommend 热门推荐, 最新状态)';
+
+-- ========== 4. 购买记录(只增,赠品公示·玩家维度 publicity/user/group/pager,每商品每买家一行)==========
+-- 不设唯一键、不 DB 去重:靠 jw_sold_product_record.buy_fetched 状态位控制「每个已售商品抓全一次」
+-- (翻页取全→批量入库成功→再置 buy_fetched=1;失败不置位、下轮重试)。
+CREATE TABLE IF NOT EXISTS `jw_player_record` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `goods_id` BIGINT NOT NULL COMMENT '商品ID',
+  `user_id` BIGINT DEFAULT NULL COMMENT '买家用户ID(userId)',
+  `user_nick` VARCHAR(128) DEFAULT NULL COMMENT '买家昵称(userNick,可能为"匿名购买")',
+  `pic_id` VARCHAR(255) DEFAULT NULL COMMENT '买家头像路径(picId)',
+  `buy_count` INT DEFAULT NULL COMMENT '购买份数(count)',
+  `gmt_create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `gmt_modified_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_goods` (`goods_id`),
+  KEY `idx_user` (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='集物星球购买记录(赠品公示玩家维度,每商品每买家一行,全站已售)';
+
+-- ========== 5. 在售监控提醒状态(盯 jake/九叔,去重用)==========
+CREATE TABLE IF NOT EXISTS `jw_onsale_alert_record` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `goods_id` BIGINT NOT NULL COMMENT '商品ID',
+  `corp_info_id` BIGINT DEFAULT NULL COMMENT '商家ID',
+  `corp_info_name` VARCHAR(128) DEFAULT NULL COMMENT '商家名',
+  `goods_name` VARCHAR(500) DEFAULT NULL COMMENT '商品名',
+  `new_notified` TINYINT NOT NULL DEFAULT 0 COMMENT '新品上架已提醒:1是0否',
+  `half_notified` TINYINT NOT NULL DEFAULT 0 COMMENT '进度过半已提醒:1是0否',
+  `ended_notified` TINYINT NOT NULL DEFAULT 0 COMMENT '结束战报已提醒:1是0否',
+  `gmt_create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `gmt_modified_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_goods_id` (`goods_id`),
+  KEY `idx_corp` (`corp_info_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='集物星球在售监控提醒状态';
+
+-- ========== 6. 拆卡报告(只增,gift/report/query/search/pager 逐条)==========
+CREATE TABLE IF NOT EXISTS `jw_report_record` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `gift_report_id` BIGINT NOT NULL COMMENT '拆卡报告唯一ID(giftReportId,递增,天然去重键)',
+  `goods_id` BIGINT DEFAULT NULL COMMENT '商品ID(来自查询参数,响应体不含)',
+  `user_id` BIGINT DEFAULT NULL COMMENT '用户ID',
+  `username` VARCHAR(128) DEFAULT NULL COMMENT '用户脱敏昵称',
+  `corp_id` BIGINT DEFAULT NULL COMMENT '商家ID',
+  `corp_name` VARCHAR(128) DEFAULT NULL COMMENT '商家名',
+  `goods_name` VARCHAR(500) DEFAULT NULL COMMENT '商品名',
+  `serial_item_name` VARCHAR(255) DEFAULT NULL COMMENT '开出的卡/系列名(serialItemName)',
+  `res_addr_list` TEXT COMMENT '图片路径列表(resAddrList,JSON 串)',
+  `winner_status` INT DEFAULT NULL COMMENT '中奖状态',
+  `anonymous_status` INT DEFAULT NULL COMMENT '匿名状态',
+  `my_gift_status` INT DEFAULT NULL COMMENT '状态(myGiftStatus)',
+  `report_time` DATETIME DEFAULT NULL COMMENT '拆卡时间(createTime)',
+  `gmt_create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `gmt_modified_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_gift_report_id` (`gift_report_id`),
+  KEY `idx_goods` (`goods_id`),
+  KEY `idx_corp` (`corp_id`),
+  KEY `idx_user` (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='集物星球拆卡报告(逐条只增)';
+
+-- ========== 7. 在售每日快照(商品+日期唯一,四档 upsert 保留当天最新值,供在售趋势差分)==========
+CREATE TABLE IF NOT EXISTS `jw_onsale_daily_record` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `goods_id` BIGINT NOT NULL COMMENT '商品ID',
+  `corp_info_id` BIGINT DEFAULT NULL COMMENT '所属商家ID',
+  `corp_info_name` VARCHAR(128) DEFAULT NULL COMMENT '所属商家名',
+  `snapshot_date` DATE NOT NULL COMMENT '快照日期(YYYY-MM-DD)',
+  `product_type` VARCHAR(8) DEFAULT NULL COMMENT '商品类型码(1福袋2变风盒3错版卡4原盒)',
+  `stock_amount` INT DEFAULT NULL COMMENT '当日总份数',
+  `sold_count` INT DEFAULT NULL COMMENT '当日累计已售',
+  `residue_stock_amount` INT DEFAULT NULL COMMENT '当日剩余库存',
+  `live_status` INT DEFAULT NULL COMMENT '当日直播/在售状态',
+  `amount` DECIMAL(12,2) DEFAULT NULL COMMENT '当日单价(元)',
+  `gmt_create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `gmt_modified_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_goods_date` (`goods_id`,`snapshot_date`),
+  KEY `idx_snapshot_date` (`snapshot_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='集物星球在售每日快照(商品+日期唯一,当天多档更新为最新值)';