Prechádzať zdrojové kódy

feat(core): 支持逐商家带 token 拉取在售列表并调整相关逻辑

- 2026/09/05 起全站免 token 翻页失效,新增对 on-sale-list 接口的带 token 直连请求支持
- buy_record_spider 改用逐商家带 token 调用 on-sale-list 分页拉取在售商品,替代原免 token 全站拉取
- 取消 buy_record_spider 中全站下架对账,由专用脚本 deca_on_sale_report 负责全站带 token 对账
- ingest_onsale 函数改为先按监控商家列表分页拉在售商品再统一刷新监控,不再做全站发现和补详情
- on_sale_report 新增 full_onsale_sweep 函数,实现发报告前全站逐商家带 token 拉取在售数据并做下架对账
- onsale_alert_spider 改为逐商家带 token 调用 on-sale-list 采集,替换原免 token 全站拉取方式
- deca_sold_core do_request/do_get 新增 use_proxy 参数,支持根据请求性质选择是否走代理
- deca_sold_core 的 token.json 持久化和读取支持保存和载入手机号码、密码和国家码,方便多机账号隔离登录
- 登录流程改为优先使用 token.json 中的账号凭据兜底密码登录,实现一机一账号部署
- 主流程 main_task 变更说明:购买记录继续免 token,监控在售列表改为带 token 分商家接口拉取并走直连,保证账号和 IP 稳定
- README 文档大量更新,说明接口权限变更、一机多账号部署方案,强调 on-sale-list 接口为在售全量唯一入口
- 代理策略调整:详情接口走代理切换 IP,带 token 拉商家在售列表走直连固定 IP 防止关联风控
- 日志记录和异常捕获逻辑调整,保障关键流程异常时不会拖垮整体运行,提高稳定性
charley 1 týždeň pred
rodič
commit
bad34d628d

+ 19 - 11
deca_spider/README.md

@@ -5,6 +5,13 @@
 > Python 3.12.10 | 公共库 `charley-utils`(`from mysql_pool import MySQLConnectionPool`)| DB 配置读运行目录 `application.yml`。
 > 深入细节见 `HANDOFF.md` 与 `docs/`;本文只讲「**要启动哪些脚本、各干什么、用哪些表**」。
 
+> **⚠️ 2026/09/05 重大变更**:站方把**免 token 的全站在售接口 `home/search` 砍成首屏推荐(只返前 20 条、不可翻页)**,`exhibition/list` 商家列表也砍成首屏 30。原「空 query 翻页拿全量在售」失效 →
+> 在售全量只能靠**逐商家 `on-sale-list`(需 token)**枚举。据此改为**一机一账号**部署(天然一机一 IP,降低账号关联封号/顶号):
+> - 电脑 A(已售报告 #4 + 随机团 #2)= 现有主号,不动;
+> - 服务器 B(购买记录 #1)= 账号 `18040545025`:4 家监控走 on-sale-list 带 token,购买记录仍 detail 免 token;
+> - 电脑 C(提醒 #3 + 在售报告 #5)= 账号 `18086100242`:提醒采 1 家、报告发报告前全站对账 161 家(均带 token)。
+> 每台 `token.json` 填 `{"phone","password","countryCode"}` 即可(首次自动登录补 access/refresh,也便于人工分辨该机用哪个账号)。详见 [memory] 与下文第七节。
+
 ---
 
 ## 一、需要常驻启动的脚本(生产环境要跑的)
@@ -13,11 +20,11 @@
 
 | # | 脚本 | 干什么 | 启动命令 | 频率 | 用到的表 | 需 token |
 |---|---|---|---|---|---|---|
-| 1 ★ | `buy_record_analysis/buy_record_spider.py` | **在售数据主采集 + 购买记录**:①每分钟免 token 拉全站在售落库 + 每 60s 记进度快照;②采本商家购买记录(自适应频率、售罄/结束自动停采) | `python buy_record_analysis/buy_record_spider.py` | 常驻循环(自适应) | 写 `deca_onsale_shop_record`、`deca_onsale_product_record`、`deca_onsale_product_daily_record`、`deca_onsale_product_progress_record`、`deca_buy_record` |  |
+| 1 ★ | `buy_record_analysis/buy_record_spider.py` | **在售数据主采集 + 购买记录**:①每轮拉 4 家监控商家(`MERCHANT_IDS`)`on-sale-list`(**带 token 走直连**)落库 + 记进度快照;②采本商家购买记录(`detail` 免 token、自适应频率、售罄/结束自动停采)。**(2026/09/05 起①由全站 home/search 免 token 改为逐 4 家 on-sale-list 带 token;不再做全站下架对账)** | `python buy_record_analysis/buy_record_spider.py` | 常驻循环(自适应) | 写 `deca_onsale_shop_record`、`deca_onsale_product_record`、`deca_onsale_product_daily_record`、`deca_onsale_product_progress_record`、`deca_buy_record` | **是**(在售列表;购买记录免) |
 | 2 ★ | `deca_team_spider.py` | **随机团总价采集**:选队随机抓 team-options 存队伍明细+算总价;剩余随机存 snapshot 实时算。转成剩余随机后 team-options 永久失效,故必须常驻不断采 | `python deca_team_spider.py`(2026/08/20 由 on_sale/ 迁回根目录) | 常驻,每 5 分钟一轮 | 写 `deca_groupbuy_team_record`;更新 `deca_onsale_product_record.team_total_amount` | 部分(选队 team-options 需) |
-| 3 | `onsale_alert_spider.py` | **在售提醒(企微)**:监控 881226408,按上架时间提醒「窗口起点后新上架」+ 进度过半 + 一车结束战报 | `python onsale_alert_spider.py [HH:MM]`(默认 20:30,可传如 `17:00`) | 常驻,默认 20:30~次日 06:00 轮询(起点可传参) | 读写 `deca_onsale_alert_record`;读 `deca_buy_record`(结束战报的参与人数) |  |
+| 3 | `onsale_alert_spider.py` | **在售提醒(企微)**:监控 881226408,按上架时间提醒「窗口起点后新上架」+ 进度过半 + 一车结束战报。**(2026/09/05 起自采从全站 home/search 免 token 改为本商家 `on-sale-list` 带 token 直连;详情仍免 token)** | `python onsale_alert_spider.py [HH:MM]`(默认 20:30,可传如 `17:00`) | 常驻,默认 20:30~次日 06:00 轮询(起点可传参) | 读写 `deca_onsale_alert_record`;读 `deca_buy_record`(结束战报的参与人数) | **是**(在售列表;详情免) |
 | 4 | `sold_daily_spider.py` | **已售拼团每日增量**:商家→已售→详情→拆卡报告+回放(连续无新页早停) | `python sold_daily_spider.py` | 常驻定时(每天 **08:00**,代码实际值;docstring 写的 03:00 已过时) | 写 `deca_shop_record`、`deca_product_record`、`deca_report_record`、`deca_groupbuy_team_record`、`deca_kami_record`(默认关) | **是** |
-| 5 | `on_sale/deca_on_sale_report.py` | **在售每日报告(企微 Excel)**:查库生成多 Sheet Excel 发群 | `python on_sale/deca_on_sale_report.py loop` | 定时 09:00/15:00/20:00/01:00 | 读 `deca_onsale_shop_record`、`deca_onsale_product_record`、`deca_onsale_product_daily_record` |  |
+| 5 | `on_sale/deca_on_sale_report.py` | **在售每日报告(企微 Excel)**:**发报告前先全站对账**(遍历库内 161 家 `on-sale-list` 带 token 全量落库 + `is_on_sale` 下架对账 + `fill_product_details` 回填 publish_at/规格),再查库生成多 Sheet Excel 发群 | `python on_sale/deca_on_sale_report.py loop` | 定时 09:00/15:00/20:00/01:00 | 写/读 `deca_onsale_shop_record`、`deca_onsale_product_record`、`deca_onsale_product_daily_record` | **是**(全站对账) |
 | 6 | `stats/daily_report.py` | **已售每日报告(企微 Excel)**:成交时间窗 [昨天17点,今天3点] 的平台/商家汇总+明细 | `python stats/daily_report.py` | 定时(每天 09:10) | 读 `deca_product_record`、`deca_report_record`、`deca_buy_record`、`deca_onsale_product_progress_record` | 否 |
 | 7 | `keep_awake.py` | **防 Windows 自动锁屏/息屏**,保证机器与上述常驻脚本可用(挡不住手动 Win+L / 域策略强制锁屏) | `python keep_awake.py` | 常驻 | — | 否 |
 
@@ -42,7 +49,7 @@
 |---|---|
 | `deca_sold_core.py` | **核心库**:签名 / token 续期 / 带重试请求 / 已售采集管道 / 随机团总价计算。被各已售脚本 `import deca_sold_core as core`。 |
 | `auto_send_wx_msg.py` | **企微群机器人发送(主发送渠道,只此一份)**:`send_wechat_group_msg()` 发 text/markdown_v2、`send_wechat_group_file()` 发文件(Excel)。`on_sale/`、`stats/` 脚本用 `sys.path` 引导 import 根目录这份 —— **换群只改本文件 `WEBHOOK_URL` 一处**(当前指测试群)。 |
-| `on_sale/deca_on_sale_daily_spider.py` | 原「在售每日采集」,**已退休不单独常驻**;其免 token 抓取函数(`fetch_all_onsale`/`get_shop_list` 等)被 #1、#3 复用。 |
+| `on_sale/deca_on_sale_daily_spider.py` | 原「在售每日采集」,**已退休不单独常驻**;其纯解析/落库函数(`parse_product`/`save_products`/`fill_product_details`/`_sweep_offsale`)被 #1、#3、#5 复用。**注:其 `fetch_all_onsale`(home/search 免 token 全站)2026/09/05 起已被站方砍成首屏 20,不再用于全量采集。** |
 | `deca_wechat.py`(及 `on_sale/`、`stats/` 副本) | PC 版微信(wxauto4)发送,**2026/08/11 起停用**、保留可回退(提醒脚本切 `SEND_CHANNEL="pc"` 复用)。 |
 
 ---
@@ -51,9 +58,9 @@
 
 | 表 | 说明 | 写入方 | 读取方 |
 |---|---|---|---|
-| `deca_onsale_shop_record` | 在售商家 | #1 | #5 |
-| `deca_onsale_product_record` | 在售商品(最新状态,含 `team_total_amount`) | #1、#2(改总价) | #2、#5 |
-| `deca_onsale_product_daily_record` | 在售商品每日售卖快照 | #1 | #5 |
+| `deca_onsale_shop_record` | 在售商家 | ~~#1~~(**商家发现 2026/09/05 已停**:`exhibition/list` 被砍成首屏 30 且需 token;表现为历史存量 161 家,新商家不再自动入库) | #5 |
+| `deca_onsale_product_record` | 在售商品(最新状态,含 `team_total_amount`) | #1(仅 4 家监控商家)、#5(全站对账 161 家)、#2(改总价) | #2、#5 |
+| `deca_onsale_product_daily_record` | 在售商品每日售卖快照 | #1、#5 | #5 |
 | `deca_onsale_product_progress_record` | 在售进度时间序列(60s,变化才写) | #1 | #6 |
 | `deca_buy_record` | 购买记录(去重买家名单) | #1 | #3、#6 |
 | `deca_groupbuy_team_record` | 随机团各队明细 | #2、#4 | 报告/统计 |
@@ -71,7 +78,7 @@
 
 1. **运行目录**:常驻脚本务必从项目根目录启动(`buy_record_spider` 会自动 `chdir` 到根),否则 `application.yml` / `token.json` / `./logs` 找不到。
 2. **建表**:首次先 `python init_db.py`;历史存量先跑一次 `sold_history_spider.py`。
-3. **token.json**:#4(已售)与 #2(随机团选队)需登录态;失效时跑 `get_token.py` 刷新(**root 直读本地**,替代旧 mitmproxy 代理抓包;脚本认「最新登录」账号,多账号切换时先在 App 切到目标账号再跑)。#1/#3 全链路免 token
+3. **token.json(一机一账号)**:**2026/09/05 起 #1/#3/#5 的在售采集也需 token**(免 token 全站接口被砍)。三台机各放一份 `token.json`,内含**本机账号**的 `phone`/`password`/`countryCode`(首次只填这三项,`deca_sold_core` 会自动密码登录补 `access`/`refresh`;缺失时回落硬编码主号)——这样各用各号、不互相顶号,打开文件即可分辨该机用哪个账号。旧 `get_token.py`(root 直读本地 App)仍可用于电脑 A 的主号。**带 token 请求走直连、免 token 详情走隧道换 IP**(`do_request(use_proxy=...)` 按调用区分)
 4. **企微群**:提醒与报告都发 `auto_send_wx_msg.WEBHOOK_URL`(当前=测试群);换正式群**只改根目录 `auto_send_wx_msg.py` 一处**。
 5. **启动顺序建议**:`init_db` →(首次)`sold_history_spider` → 常驻 #1 #2 #3 #4 → 定时 #5 #6 →(会锁屏的机器)#7。
 6. **提醒消息不带商品链接**:分享落地页 `share-detail` 只认带 token 生成的 shareCode,免 token 一律失效;为不引入登录态已去链接、标题改加粗(详见 `docs/优化记录_得卡企微迁移_20260811.md`)。
@@ -94,9 +101,10 @@
 
 - 业务接口需 `signature` 头:`signature = md5( md5(参数按 key 升序拼接) + currentTime[-6:] + "biu_card_nbclass" )`(逆向自 APK `mf/c0.java`,4 样本验证)。
 - 部分接口另需 `Authorization: Bearer <JWT>`(`access token` 15 分钟过期),走 `token/refresh` 续期规避阿里云验证码风控;`密码登录` 仅兜底。
-- 免 token 接口:全站在售 `home/search`、商品详情 `groupbuy/detail`、分享校验 `groupbuy/share-detail`。
-- 需 token 接口:已售 `sold-list`、卡密 `card/list`、拆卡报告 `open-card-report`、回放 `live/replay/detail`、随机团 `team-options`。
-- **token.json 来源**:设备已 root,`get_token.py` 直读 App 的 Jetpack DataStore(`files/datastore/deka_settings.preferences_pb`,token 存在顶层 key `switch_accounts` 的 JSON 里,账号字段 `uid`)解析当前登录账号的 `accessToken`/`refreshToken` 覆盖写;只需种一次,之后由 `deca_sold_core.ensure_token` 靠 `refreshToken` 自续期。已弃用 mitmproxy 代理抓包(`capture_token.py`/`run_capture_token.py` 已删)。
+- 免 token 接口:商品详情 `groupbuy/detail`(含购买记录 `purchaseRecords`,高频轮询命脉)、分享校验 `groupbuy/share-detail`。
+- 需 token 接口:**逐商家在售 `groupbuy/merchant/on-sale-list`(2026/09/05 起在售全量唯一入口)**、商家列表 `merchant/exhibition/list`、已售 `sold-list`、卡密 `card/list`、拆卡报告 `open-card-report`、回放 `live/replay/detail`、随机团 `team-options`。
+- **2026/09/05 站方改版**:`home/search`(全站在售)与 `exhibition/list`(商家列表)都退化成「首屏推荐」——`home/search` 只返前 20、`page≥2` 空、`pageSize>20` 报 `10001`;`exhibition/list` 只返前 30。故全站免 token 翻页失效,在售/商家枚举改走带 token 的 `on-sale-list` + 库内已存商家 ID。
+- **token.json 账号凭据**:现支持在 `token.json` 存 `phone`/`password`/`countryCode`(`deca_sold_core.load_token/save_token/login`),密码登录兜底优先用本机所配账号;缺失回落硬编码主号。旧 `get_token.py` 直读 App DataStore(`deka_settings.preferences_pb` 的 `switch_accounts`)仍可用于主号种子。
 - 完整逆向与登录续期说明见 `on_sale/README.md`。
 
 ---

+ 16 - 14
deca_spider/buy_record_analysis/README.md

@@ -2,8 +2,10 @@
 
 > 归属:`D:\work\2026-08-02(deca_spider)\buy_record_analysis\`
 > 平台:得卡 DECA App(`api.decalive.com`)
-> 最后更新:2026/08/06
-> 依赖父项目:签名/Token/请求/代理层复用根目录 `deca_sold_core.py`;数据库配置读根目录 `application.yml`;Token 持久化在根目录 `token.json`。
+> 最后更新:2026/09/05
+> 依赖父项目:签名/Token/请求/代理层复用根目录 `deca_sold_core.py`;数据库配置读运行目录 `application.yml`;Token 持久化在运行目录 `token.json`。
+
+> **⚠️ 2026/09/05 变更**:站方把免 token 的全站在售 `home/search` 砍成首屏 20、翻页失效;本脚本在售发现从「全站 home/search 免 token」改为**逐 4 家监控商家 `MERCHANT_IDS` → `on-sale-list`(需 token,走直连)**,购买记录仍走 `groupbuy/detail` 免 token。因只采 4 家、**不再做全站下架对账**(`_sweep_offsale` 只能在全站集合上做)。本脚本(服务器 B)现需 `token.json` 配本机账号(`18040545025`);全站在售报告的对账已移交电脑 C 的 `deca_on_sale_report`。详见根 `README.md` 与项目记忆。
 
 ---
 
@@ -37,14 +39,14 @@
 
 ### 2.2 登录态 / 签名 / 代理
 
-**关键:详情接口 `groupbuy/detail` 无需登录**(实测不带 token 也返回 code=0 + 10 条),所以**白名单模式全程免登录**。只有**商家模式**要拉的在售列表 `on-sale-list` 需要 token(不带会 `code=10002 未登录`),故仅该模式启动时 `ensure_token`
+**关键:详情接口 `groupbuy/detail` 无需登录**(实测不带 token 也返回 code=0 + 10 条),所以**购买记录采集全程免登录**。而**在售列表 `on-sale-list` 需要 token**(不带会 `code=10002 未登录`)——**2026/09/05 起商家模式默认对 `MERCHANT_IDS`(4 家)逐家拉 on-sale-list 带 token 发现在售**(原全站 `home/search` 免 token 路径已被站方砍掉)
 
-| 接口 | 用途 | 是否需要 token |
-|---|---|---|
-| `groupbuy/detail` | 拉购买记录(两种模式都用) | ❌ 免登录 |
-| `groupbuy/merchant/on-sale-list` | 商家模式拉在售商品列表 | ✅ 需要 |
+| 接口 | 用途 | 是否需要 token | 代理 |
+|---|---|---|---|
+| `groupbuy/detail` | 拉购买记录(两种模式都用) | ❌ 免登录 | 走快代理隧道换 IP(`USE_PROXY=True`) |
+| `groupbuy/merchant/on-sale-list` | 商家模式逐 4 家拉在售 | ✅ 需要 | **走直连**(`do_request(use_proxy=False)`,保持账号↔IP 稳定,防关联封号) |
 
-签名细节见根目录 `README.md`。本项目通过 `deca_sold_core` 复用(`core.USE_PROXY=True` 开代理;`throttled_do_request` 里 detail 传 `need_auth=False`、on-sale-list 传 `need_auth=True`)。
+签名细节见根目录 `README.md`。本项目通过 `deca_sold_core` 复用(`core.USE_PROXY=True` 详情走隧道;`core.do_request(...need_auth=True, use_proxy=False)` 让 on-sale-list 带 token 走直连)。
 
 ---
 
@@ -119,17 +121,17 @@
 ### 6.1 两种工作模式(切换只改一个变量)
 
 ```python
-# 白名单模式:非空 → 只监控这些 code,跳过 on-sale-list
+# 白名单模式:非空 → 只监控这些 code,跳过在售发现(免 token)
 WATCH_CODES = ["GB26080417234"]
 
-# 商家模式:WATCH_CODES 为空 → 拉 MERCHANT_ID 的全部在售商品,动态增删
+# 商家模式(默认):WATCH_CODES 为空 → 逐家拉 MERCHANT_IDS 各商家在售(on-sale-list 带 token),动态增删
 # WATCH_CODES = []
-MERCHANT_ID = "881226408"
+MERCHANT_IDS = ["881226408", "274584650", "538252487", "591544726"]  # 魔都兄弟/卡皇/尼卡/文泰
 ```
 
-- 单商品测试 → 白名单里放 1 个 code
+- 单商品测试 → 白名单里放 1 个 code(免 token)
 - 多商品自选 → 白名单里加多个 code
-- 商家全在售 → 清空 `WATCH_CODES`
+- 商家全在售 → 清空 `WATCH_CODES`(默认,走 `MERCHANT_IDS` 逐家 on-sale-list,**需 token**)
 
 ### 6.2 自适应频率
 
@@ -156,7 +158,7 @@ GLOBAL_MIN_GAP_SEC = 0.2  # 全局相邻两次请求最小间隔(≈每秒 5 
 **前置**:
 - 项目根有 `application.yml`(数据库配置)。
 - MySQL 里 **`deca_buy_record` 表需先建好**:`python init_db.py` 或在客户端跑 `schema.sql` 里那段 DDL(脚本不再自动建表)。
-- 白名单模式**不需要** `token.json`;商家模式(`WATCH_CODES=[]`)才需要根目录有有效 `token.json`
+- 白名单模式**不需要** `token.json`;商家模式(`WATCH_CODES=[]`,默认)**需要**运行目录有配好本机账号的 `token.json`(2026/09/05 起在售发现改带 token)。本机账号见根 `README.md` 部署表(服务器 B = `18040545025`)
 
 ```bash
 # 前台跑(关终端就停,适合调试)

+ 71 - 33
deca_spider/buy_record_analysis/buy_record_spider.py

@@ -62,12 +62,19 @@ from loguru import logger
 from tenacity import retry, stop_after_attempt, wait_fixed
 import deca_sold_core as core
 from mysql_pool import MySQLConnectionPool
-# 复用 daily 的免 token 采集/落库(home/search 全站在售 → deca_onsale_* 三张表);daily 文件保留、不再单独常驻
+# 复用 daily 的商品解析/落库(parse_product/save_products,纯解析+DB,无网络);商品在售数据改由本脚本
+# 逐监控商家 on-sale-list(带 token)采集(2026/09/05:站方把免 token 的 home/search 砍成首屏 20,
+# 全站免 token 翻页失效,故监控侧改为只按 MERCHANT_IDS 逐商家带 token 拉在售,请求量小、且不漏本商家的车)。
 # 注:本 import 会触发 daily 模块级 logger 配置,但下方 buy_record 的 logger.remove/add 在其后,会覆盖回本脚本日志
-from on_sale.deca_on_sale_daily_spider import get_shop_list, get_onsale_products, fill_product_details
+from on_sale.deca_on_sale_daily_spider import parse_product, save_products
 
 # ==================== 配置 ====================
-core.USE_PROXY = True               # 详情接口(groupbuy/detail)走快代理隧道;全链路免 token
+core.USE_PROXY = True               # 免 token 的详情接口(groupbuy/detail)走快代理隧道换 IP,防高频轮询触发 IP 风控
+# 在售列表接口:只对 MERCHANT_IDS 逐商家拉在售(需 token),走直连固定 IP(不走隧道换 IP)——
+# 让本机账号↔IP 稳定,降低账号被风控关联概率;免 token 的详情仍走隧道(详情不带登录态,不牵连账号)。
+ON_SALE_PATH = "/api/v1/app/groupbuy/merchant/on-sale-list"
+ON_SALE_PAGE_SIZE = 20              # on-sale-list 每页条数(站方硬限 ≤20)
+ON_SALE_MAX_PAGES = 20             # 单商家在售翻页上限(防异常翻页失控;20 页=400 个已远超单商家在售量)
 
 # 监控商家列表:采这些商家在售商品的购买记录(WATCH_CODES 为空时生效)。
 # 2026/08/24 由单商家扩为多商家并行监控:三家同走「查库拿在售 code → 自适应频率采购买记录」同一套逻辑,
@@ -486,40 +493,75 @@ def snapshot_onsale_progress(log, pool) -> int:
     return len(rows_to_insert)
 
 
-def ingest_onsale(log, pool, discover: bool):
-    """复用 daily 逻辑免 token 采集全站在售并落三张表
+def get_monitored_onsale(log, pool, merchant_ids: list) -> int:
+    """逐监控商家拉在售商品并落库(带 token 走直连)
 
-    每轮都拉全站在售 get_onsale_products(写 deca_onsale_product_record + 每日快照 + 下架对账);
-    紧跟一步 snapshot_onsale_progress 记录本轮进度变化(分钟级时间序列,供后续统计)。
-    discover=True 时额外做商家发现 get_shop_list 与今日新增商品补详情 fill_product_details——
-    这两步变化慢,按 SHOP_DISCOVER_SEC 降频触发。各步独立 try/except,单步失败不拖垮整轮。
+    2026/09/05 起站方把免 token 的全站 home/search 砍成首屏 20、全站免 token 翻页失效;监控侧只关心
+    MERCHANT_IDS 这几家,故改为逐商家调 on-sale-list(需 token)分页拉取,parse_product 解析后
+    save_products upsert(is_on_sale=1 自愈)。请求量小(几家×每页 20),token 走直连保持账号↔IP 稳定。
+    注意:本函数只对指定商家落库,**不做全站下架对账**——_sweep_offsale 只能在全站全量集合上做,
+    在此做会把非监控商家的在售商品误置为下架。
+
+    Args:
+        log: 日志对象。
+        pool: MySQL 连接池。
+        merchant_ids (list[str]): 监控商家 user_id 列表。
+
+    Returns:
+        int: 本轮写入/更新的商品数(各商家合计)。
+    """
+    saved = 0
+    for mid in merchant_ids:
+        page = 1
+        total = None
+        while page <= ON_SALE_MAX_PAGES:
+            body = {"merchantUserId": mid, "page": page, "pageSize": ON_SALE_PAGE_SIZE}
+            try:
+                resp = core.do_request(log, ON_SALE_PATH, body, need_auth=True, use_proxy=False)
+            except Exception as e:
+                log.error(f"商家 {mid} 在售第 {page} 页请求失败: {e}")
+                break
+            if not resp or resp.get("code") != 0:
+                log.info(f"商家 {mid} 在售返回异常: {resp.get('msg') if resp else None}")
+                break
+            data = resp.get("data") or {}
+            if total is None:
+                total = data.get("total")
+            items = data.get("list") or []
+            if not items:
+                break
+            rows = [r for r in (parse_product(it) for it in items) if r]
+            saved += save_products(log, rows, pool)
+            if total is not None and page * ON_SALE_PAGE_SIZE >= total:  # 已采满 total
+                break
+            if len(items) < ON_SALE_PAGE_SIZE:                          # 本页不足一页即末页
+                break
+            page += 1
+    return saved
+
+
+def ingest_onsale(log, pool):
+    """采监控商家在售落库 + 记录进度时间序列(供刷新监控队列 + 进度统计)。
+
+    每轮拉 MERCHANT_IDS 各商家在售(带 token,get_monitored_onsale)写 deca_onsale_product_record;
+    紧跟 snapshot_onsale_progress 记录本轮进度变化(分钟级时间序列)。各步独立 try/except,单步失败不拖垮整轮。
+    注:全站商家发现 / 今日新增补详情 / 全站下架对账等「全市场」职责已移交电脑 C 的在售报告(发报告前全量对账),
+    本脚本(服务器 B)只负责监控商家的采集,不再碰全站。
 
     Args:
         log: 日志对象。
         pool: MySQL 连接池。
-        discover (bool): 本轮是否附带商家发现 + 新增补详情(低频触发)。
     """
     try:
-        n = get_onsale_products(log, pool)
-        log.info(f"[在售落库] 全站在售写入/更新 {n} 个")
+        n = get_monitored_onsale(log, pool, MERCHANT_IDS)
+        log.info(f"[在售落库] 监控 {len(MERCHANT_IDS)} 家在售写入/更新 {n} 个")
     except Exception as e:
-        log.error(f"get_onsale_products error: {e}")
+        log.error(f"get_monitored_onsale error: {e}")
     # 进度时间序列:紧跟 onsale 表更新之后跑(此时表里就是最新一轮的 sold_count/available_stock)
     try:
         snapshot_onsale_progress(log, pool)
     except Exception as e:
         log.error(f"snapshot_onsale_progress error: {e}")
-    if discover:
-        try:
-            n = get_shop_list(log, pool)
-            log.info(f"[商家发现] 去重商家 {n} 个")
-        except Exception as e:
-            log.error(f"get_shop_list error: {e}")
-        try:
-            n = fill_product_details(log, pool)
-            log.info(f"[补详情] 今日新增补详情 {n} 个")
-        except Exception as e:
-            log.error(f"fill_product_details error: {e}")
 
 
 def fetch_on_sale_products(log, merchant_id: str, pool) -> list:
@@ -799,7 +841,7 @@ def _poll_worker(log, pool, monitored: dict, code: str):
 
 @retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
 def main_task(log):
-    """常驻主流程:每分钟落全站在售(免 token) + 线程池并发采各监控商家(MERCHANT_IDS)在售商品的购买记录。
+    """常驻主流程:拉监控商家在售(带 token) + 线程池并发采各监控商家(MERCHANT_IDS)在售商品的购买记录(免 token)
 
     Args:
         log: 日志对象。
@@ -811,7 +853,7 @@ def main_task(log):
     log.info(f"购买记录+在售常驻采集启动 | {mode} | 并发 {MAX_WORKERS} 线程(全局限速≈{1/GLOBAL_MIN_GAP_SEC:.0f}次/秒) "
              f"| 购买记录频率 [{MIN_INTERVAL_SEC}s, {MAX_INTERVAL_SEC}s] "
              f"| 在售发现常规每 {ONSALE_INGEST_SEC}s、快车时段({FAST_WINDOW_START:%H:%M}~次日{FAST_WINDOW_END:%H:%M})每 {FAST_WINDOW_INGEST_SEC}s "
-             f"| 全链路免 token | 详情代理={core.USE_PROXY}")
+             f"| 购买记录免 token;在售列表带 token 走直连 | 详情代理={core.USE_PROXY}")
     pool = MySQLConnectionPool(log=log)
     if not pool.check_pool_health():
         log.error("数据库连接池异常")
@@ -819,7 +861,6 @@ def main_task(log):
 
     monitored: dict = {}                        # {code: {next_run_ts, interval, title, sold_count, card_count}}
     last_ingest = 0.0                           # 上次「在售落库 + 刷新监控」时刻
-    last_discover = 0.0                         # 上次「商家发现 + 补详情」时刻(按 SHOP_DISCOVER_SEC 降频)
 
     # 线程池常驻:主线程只负责「落在售/刷新监控 + 派发到点商品」,实际详情采集交由 worker 并发跑。
     # 并发把串行时被请求延迟浪费的吞吐(仅 ~0.5 次/秒)填到接近全局限速上限(≈5 次/秒),追上快车翻窗节奏、减少漏采。
@@ -827,16 +868,13 @@ def main_task(log):
         while True:
             now = time.time()
 
-            # 1) 每(动态)间隔:商家模式先落全站在售(免token)再查库刷新监控;白名单模式只按固定 code 刷新。
+            # 1) 每(动态)间隔:商家模式先拉监控商家在售(带 token)再查库刷新监控;白名单模式只按固定 code 刷新。
             #    快车高发时段(20:00~次日06:00)把发现节奏加密到 FAST_WINDOW_INGEST_SEC,更快把 2~3 分钟即售罄的快车纳入监控。
-            #    本步在主线程串行做(期间 worker 仍在并发采集),频率低(20~60s 一次),不拖累采集吞吐。
+            #    本步在主线程串行做(期间 worker 仍在并发采集),频率低(10~60s 一次),不拖累采集吞吐。
             if now - last_ingest >= _current_ingest_interval(datetime.now()):
                 try:
                     if not WATCH_CODES:
-                        discover = (now - last_discover >= SHOP_DISCOVER_SEC)
-                        ingest_onsale(log, pool, discover)
-                        if discover:
-                            last_discover = now
+                        ingest_onsale(log, pool)
                     _refresh_monitored(log, pool, monitored, MERCHANT_IDS, now)
                 except Exception as e:
                     log.error(f"在售落库/刷新监控异常: {e}")

+ 40 - 11
deca_spider/deca_sold_core.py

@@ -36,7 +36,8 @@ SALT = "biu_card_nbclass"          # 签名固定 salt(逆向自 mf/c0.java 
 USE_PROXY = False     # 代理开关:默认直连,遇 IP 风控再置 True
 TOKEN_FILE = "token.json"       # token 持久化文件(进程重启复用),refreshToken 续期为主、密码登录兜底
 
-# 密码登录兜底凭证(敏感信息,勿外传/勿提交公开仓库;换账号改此三项)
+# 密码登录兜底凭证(默认主号;实际优先取各机 token.json 里的 phone/password/countryCode,
+# 见 load_token/login。敏感信息,勿外传/勿提交公开仓库)
 LOGIN_COUNTRY_CODE = "86"
 LOGIN_PHONE = "13014617614"
 LOGIN_PASSWORD = "pass2022"
@@ -140,7 +141,8 @@ def make_signature(params: dict, current_time: str) -> str:
 
 
 @retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
-def do_request(log, path: str, body: dict, need_auth: bool = False) -> dict | None:
+def do_request(log, path: str, body: dict, need_auth: bool = False,
+               use_proxy: bool = None) -> dict | None:
     """通用带签名 POST 请求(带重试)。
 
     Args:
@@ -148,6 +150,9 @@ def do_request(log, path: str, body: dict, need_auth: bool = False) -> dict | No
         path (str): 接口相对路径(以 / 开头)。
         body (dict): JSON body,同时用于签名。
         need_auth (bool, optional): 是否需要携带 Bearer token。Defaults to False。
+        use_proxy (bool, optional): 是否走代理。None=沿用全局 USE_PROXY;显式 True/False 覆盖。
+            用于「带 token 的请求走直连(False)、免 token 高频请求走隧道(True)」按调用区分,
+            避免一个账号在轮换 IP 间跳动而被风控关联。Defaults to None。
 
     Returns:
         dict | None: 响应 JSON。
@@ -163,7 +168,8 @@ def do_request(log, path: str, body: dict, need_auth: bool = False) -> dict | No
         headers["Authorization"] = f"Bearer {ensure_token(log)}"
 
     data = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
-    proxies = get_proxys(log) if USE_PROXY else None
+    _use_proxy = USE_PROXY if use_proxy is None else use_proxy  # 按调用覆盖全局代理开关
+    proxies = get_proxys(log) if _use_proxy else None
     resp = requests.post(BASE + path, headers=headers, data=data, timeout=(5, 30), proxies=proxies)
     if resp.status_code != 200:
         log.error(f"请求失败 {resp.status_code}: {path}")
@@ -172,7 +178,8 @@ def do_request(log, path: str, body: dict, need_auth: bool = False) -> dict | No
 
 
 @retry(stop=stop_after_attempt(5), wait=wait_fixed(2), after=after_log)
-def do_get(log, path: str, params: dict = None, need_auth: bool = False) -> dict | None:
+def do_get(log, path: str, params: dict = None, need_auth: bool = False,
+           use_proxy: bool = None) -> dict | None:
     """通用带签名 GET 请求(带重试)。签名参数取 query。
 
     Args:
@@ -180,6 +187,7 @@ def do_get(log, path: str, params: dict = None, need_auth: bool = False) -> dict
         path (str): 接口相对路径(以 / 开头)。
         params (dict, optional): URL query 参数(同时用于签名)。Defaults to None。
         need_auth (bool, optional): 是否需要携带 Bearer token。Defaults to False。
+        use_proxy (bool, optional): 是否走代理。None=沿用全局 USE_PROXY;显式 True/False 覆盖。Defaults to None。
 
     Returns:
         dict | None: 响应 JSON。
@@ -194,7 +202,7 @@ def do_get(log, path: str, params: dict = None, need_auth: bool = False) -> dict
     if need_auth:
         headers["Authorization"] = f"Bearer {ensure_token(log)}"
 
-    proxies = get_proxys(log) if USE_PROXY else None
+    proxies = get_proxys(log) if (USE_PROXY if use_proxy is None else use_proxy) else None
     resp = requests.get(BASE + path, headers=headers, params=params, timeout=(5, 30), proxies=proxies)
     if resp.status_code != 200:
         log.error(f"请求失败 {resp.status_code}: {path}")
@@ -276,17 +284,30 @@ def _token_lock(log=None, timeout=30):
 
 
 def save_token():
-    """把当前 _TOKEN(access/refresh/exp) 持久化到 TOKEN_FILE。"""
+    """把当前 _TOKEN 持久化到 TOKEN_FILE(access/refresh/exp + 账号凭据 phone/password/countryCode)。
+
+    账号凭据随 token.json 一并落盘,方便人工打开文件直接分辨该程序用的是哪个账号;仅当 _TOKEN
+    里存在这些字段时才写入(避免把默认主号凭据注入到未配置的机器的 token.json)。
+    """
     try:
+        out = {"phone": _TOKEN.get("phone"), "password": _TOKEN.get("password"),
+               "countryCode": _TOKEN.get("countryCode"),
+               "access": _TOKEN.get("access"), "refresh": _TOKEN.get("refresh"),
+               "exp": _TOKEN.get("exp", 0)}
+        # 未配置账号凭据的机器(如沿用默认主号)不写空的 phone/password,保持 token.json 干净
+        out = {k: v for k, v in out.items() if not (k in ("phone", "password", "countryCode") and v is None)}
         with open(TOKEN_FILE, "w", encoding="utf-8") as f:
-            json.dump({"access": _TOKEN.get("access"), "refresh": _TOKEN.get("refresh"),
-                       "exp": _TOKEN.get("exp", 0)}, f)
+            json.dump(out, f, ensure_ascii=False, indent=2)
     except Exception as e:
         logger.warning(f"token 持久化失败: {e}")
 
 
 def load_token():
-    """从 TOKEN_FILE 恢复 _TOKEN;文件不存在或损坏时静默跳过。"""
+    """从 TOKEN_FILE 恢复 _TOKEN(access/refresh/exp + 账号凭据);文件不存在或损坏时静默跳过。
+
+    若 token.json 内含 phone/password/countryCode,则一并载入 _TOKEN,供密码登录兜底按「本机所配
+    账号」登录——从而三台机器各用各的账号、互不顶号(凭据缺失时 login 回落到模块默认主号)。
+    """
     if not os.path.exists(TOKEN_FILE):
         return
     try:
@@ -295,6 +316,9 @@ def load_token():
         _TOKEN["access"] = d.get("access")
         _TOKEN["refresh"] = d.get("refresh")
         _TOKEN["exp"] = d.get("exp", 0)
+        for k in ("phone", "password", "countryCode"):  # 账号凭据:存在才载入,供 login 兜底用本机账号
+            if d.get(k):
+                _TOKEN[k] = d[k]
     except Exception as e:
         logger.warning(f"token 读取失败: {e}")
 
@@ -351,7 +375,12 @@ def login(log) -> bool:
         log.error(f"密码登录已连续失败 {_login_fail_count} 次达上限({MAX_LOGIN_ATTEMPTS}),"
                   "熔断不再登录,请人工检查账号(密码/风控/验证码)")
         return False
-    body = {"countryCode": LOGIN_COUNTRY_CODE, "password": LOGIN_PASSWORD, "phone": LOGIN_PHONE}
+    # 账号凭据优先取 token.json 里所配(load_token 载入到 _TOKEN),缺失才回落模块默认主号——
+    # 从而三台机器各用各的账号登录兜底、不互相顶号
+    phone = _TOKEN.get("phone") or LOGIN_PHONE
+    password = _TOKEN.get("password") or LOGIN_PASSWORD
+    country_code = _TOKEN.get("countryCode") or LOGIN_COUNTRY_CODE
+    body = {"countryCode": country_code, "password": password, "phone": phone}
     try:
         resp = do_request(log, "/api/v1/app/auth/password/login", body, need_auth=False)
         data = (resp or {}).get("data") or {}
@@ -368,7 +397,7 @@ def login(log) -> bool:
         _TOKEN["exp"] = time.time() + expires_in
         save_token()
         _login_fail_count = 0
-        log.info(f"密码登录成功,access 有效 {expires_in}s(refreshToken 已更新)")
+        log.info(f"密码登录成功(账号 {phone}),access 有效 {expires_in}s(refreshToken 已更新)")
         return True
     except Exception as e:
         _login_fail_count += 1

+ 8 - 3
deca_spider/on_sale/README.md

@@ -5,6 +5,10 @@
 > 结论:signature 已 100% 还原(无加壳、纯 Java),登录+续签闭环打通,可长期无人值守。
 > 日期:2026/08/02 | Python 3.12.10
 
+> **⚠️ 2026/09/05 接口变更**:站方把**免 token 的全站在售 `home/search` 砍成「首屏推荐」**(只返前 20 条、`page≥2` 空、`pageSize>20` 报 `10001`),`merchant/exhibition/list` 商家列表也**改为需 token 且只返首屏 30 家**。
+> 影响:原「空 query 翻页拿全量在售」失效,**在售全量只能靠逐商家 `groupbuy/merchant/on-sale-list`(需 token)枚举**(配合库内已存商家 ID);`groupbuy/detail` 详情仍免 token。
+> 据此改为**一机一账号**部署:购买记录(服务器 B,账号 `18040545025`)、提醒+在售报告(电脑 C,账号 `18086100242`)、已售+随机团(电脑 A,主号不动)。`token.json` 现支持内嵌 `phone`/`password`/`countryCode`,密码登录兜底按本机账号走、互不顶号。详见根 `README.md` 第一节与项目记忆。
+
 ---
 
 ## 1. 目标信息
@@ -21,8 +25,9 @@
 
 | 用途 | 方法 & 路径 | 是否需登录 | 请求体 |
 |---|---|---|---|
-| 商家展位列表 | `POST /api/v1/app/merchant/exhibition/list` | 否 | `{"page":N}` |
-| 在售商品列表 | `POST /api/v1/app/groupbuy/merchant/on-sale-list` | **是** | `{"merchantUserId","page","pageSize":20}` |
+| 商家展位列表 | `POST /api/v1/app/merchant/exhibition/list` | **是**(2026/09/05 起需 token,且只返首屏 30 家) | `{"page":N}` |
+| 在售商品列表 | `POST /api/v1/app/groupbuy/merchant/on-sale-list` | **是**(在售全量唯一入口) | `{"merchantUserId","page","pageSize":20}` |
+| 全站在售(首屏) | `POST /api/v1/app/home/search` | 否(**2026/09/05 起被砍成首屏 20、不可翻页**,已弃用于全量采集) | `{"query":"","saleStatus":0,"page","pageSize":20,...}` |
 | 密码登录 | `POST /api/v1/app/auth/password/login` | 否 | `{"countryCode","password","phone"}` |
 | Token 续签 | `POST /api/v1/app/auth/token/refresh` | 否 | `{"refreshToken"}` |
 
@@ -149,6 +154,6 @@ def make_signature(params: dict, current_time: str) -> str:
 
 **登录与 token(避开验证码)**:
 - 密码登录 `password/login` 受阿里云验证码2.0 风控,脚本无法自动过验证码;改走 `token/refresh` 续期(**无验证码**)。
-- 首次:在 APP 正常登录后抓包,把 `refreshToken` 粘入项目根目录 `token.json` 的 `refresh` 字段:`{"access":null,"refresh":"<粘这里>","exp":0}`
+- 首次:在 APP 正常登录后抓包,把 `refreshToken` 粘入运行目录 `token.json`。**2026/09/05 起推荐直接写本机账号凭据**,程序首次会自动密码登录补 `access`/`refresh`:`{"phone":"18040545025","password":"147258369@ww","countryCode":"86"}`(`deca_sold_core.load_token/save_token/login` 会读取并保留这些字段,兜底登录按本机账号走、便于人工分辨该机用哪个号)。旧格式 `{"access":null,"refresh":"<粘这里>","exp":0}` 仍兼容
 - 之后爬虫用它自动续期并滚动写回 `token.json`;refreshToken 失效(过期/被踢)时日志会报警,需重新抓包更新。
 - 商品**上架时间/结束时间**来自详情接口 `POST api/v1/app/groupbuy/detail`(body `{"code":商品code}`,需 token),**仅对今日新增商品拉取**,存入 `deca_product_record` 的 `publish_at`/`sale_start_at`/`sale_end_at`。商家入驻时间接口无,新增按入库时间判定。

+ 78 - 0
deca_spider/on_sale/deca_on_sale_report.py

@@ -37,6 +37,10 @@ from mysql_pool import MySQLConnectionPool
 from openpyxl import Workbook
 from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
 from openpyxl.utils import get_column_letter
+# 2026/09/05:站方把免 token 的全站 home/search 砍成首屏 20,全站免 token 翻页失效,报告改为「发报告前
+# 逐商家 on-sale-list(带 token) 全站对账」拿准在售全量;复用 daily 的解析/落库/下架对账 + core 的带锁 token。
+import deca_sold_core as core
+from deca_on_sale_daily_spider import parse_product, save_products, _sweep_offsale, fill_product_details
 
 logger.remove()
 logger.add("./logs/onsale_report_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
@@ -67,6 +71,11 @@ SHOP_LIMIT = 100              # 商家表(今日新增/其他)最多显示的行
 TREND_DAYS = 4               # 平台在售趋势展示天数(今日 + 前3日)
 LISTING_HOUR_DAYS = 7        # 上架时段分布回看天数(平台 24h 上架节奏)
 
+# ---- 发报告前全站在售对账(2026/09/05) 配置 ----
+ON_SALE_PATH = "/api/v1/app/groupbuy/merchant/on-sale-list"  # 逐商家在售(需 token)
+ONSALE_PAGE_SIZE = 20        # on-sale-list 每页条数(站方硬限 ≤20)
+ONSALE_MAX_PAGES = 30        # 单商家在售翻页上限(防异常翻页失控)
+
 
 def f(v, default=0.0):
     """把可能为 None/Decimal 的值安全转 float。
@@ -163,6 +172,66 @@ def _fmt_series(series_name, title) -> str:
     return (m.group(0) if m else title)[:22]
 
 
+def full_onsale_sweep(log, pool) -> int:
+    """发报告前全站在售对账:遍历库内所有商家,逐家 on-sale-list(带 token) 全量落库 + is_on_sale 下架对账。
+
+    2026/09/05:站方把免 token 的全站 home/search 砍成首屏 20、全站免 token 翻页失效;报告改为发报告前主动扫全站——
+    按 deca_onsale_shop_record 里的商家逐家拉在售(需 token,走直连保持账号↔IP 稳定),parse_product 解析后
+    save_products upsert(is_on_sale=1 自愈),全部采完后用在售 code 并集 _sweep_offsale 把不在售的置 is_on_sale=0,
+    使概览「在售商品总数」及各表口径准确。
+
+    安全:只要有商家本轮抓取失败(网络/风控中断),即跳过下架对账——避免把该商家在售商品误判为下架。
+
+    Args:
+        log: 日志对象。
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        int: 本轮写入/更新的商品数(各商家合计)。
+    """
+    mids = [r[0] for r in (pool.select_all(
+        "SELECT merchant_user_id FROM deca_onsale_shop_record") or []) if r[0]]
+    all_codes = set()
+    saved = 0
+    failed = 0
+    for mid in mids:
+        page = 1
+        total = None
+        merchant_ok = False
+        while page <= ONSALE_MAX_PAGES:
+            body = {"merchantUserId": mid, "page": page, "pageSize": ONSALE_PAGE_SIZE}
+            try:
+                resp = core.do_request(log, ON_SALE_PATH, body, need_auth=True, use_proxy=False)
+            except Exception as e:
+                log.error(f"商家 {mid} 在售第 {page} 页请求失败: {e}")
+                break
+            if not resp or resp.get("code") != 0:
+                log.info(f"商家 {mid} 在售返回异常: {resp.get('msg') if resp else None}")
+                break
+            data = resp.get("data") or {}
+            if total is None:
+                total = data.get("total")
+            items = data.get("list") or []
+            rows = [r for r in (parse_product(it) for it in items) if r]
+            saved += save_products(log, rows, pool)
+            all_codes.update(r["product_code"] for r in rows)
+            # 本页为空、已采满 total、或不足一页 → 该商家翻到底
+            if not items or (total is not None and page * ONSALE_PAGE_SIZE >= total) or len(items) < ONSALE_PAGE_SIZE:
+                merchant_ok = True
+                break
+            page += 1
+        if not merchant_ok:
+            failed += 1
+    log.info(f"[全站对账] 遍历 {len(mids)} 家,写入/更新 {saved} 个,在售并集 {len(all_codes)} 个,失败 {failed} 家")
+    # 有商家抓取失败则跳过下架对账,避免把失败商家的在售品误置下架
+    if all_codes and failed == 0:
+        n = _sweep_offsale(log, pool, all_codes)
+        log.info(f"[全站对账] 下架对账完成,本轮置下架 {n} 个")
+    else:
+        log.warning(f"[全站对账] 跳过下架对账(失败 {failed} 家 / 在售集合 {len(all_codes)} 空),避免误判下架")
+    return saved
+
+
 def fetch_data(pool) -> dict:
     """从数据库汇总报表所需数据(KPI + 今日新增商家 + 其他商家 + 各商家在售商品明细)。
 
@@ -681,6 +750,15 @@ def main() -> str:
     if not pool.check_pool_health():
         log.error("数据库连接池异常")
         return ""
+    # 发报告前先全站对账:逐商家 on-sale-list(带 token) 落库 + is_on_sale 下架对账,保证在售口径准确
+    # (免 token 的全站 home/search 已被站方砍成首屏 20,不再能翻页拿全量)
+    try:
+        full_onsale_sweep(log, pool)
+        # 回填 publish_at/规格/系列/模式(详情接口免 token)——on-sale-list 不含这些字段,
+        # 而报告「今日新增/新品/明细列」依赖它们;只补 publish_at 或 spec_name 为空的商品,已补过的不重复拉
+        fill_product_details(log, pool)
+    except Exception as e:
+        log.error(f"全站对账/补详情异常(本轮沿用库内现有在售数据继续出报告): {e}")
     data = fetch_data(pool)
     os.makedirs("reports", exist_ok=True)
 

+ 33 - 11
deca_spider/onsale_alert_spider.py

@@ -41,7 +41,7 @@ import deca_sold_core as core
 import deca_wechat                                   # PC 版微信发送(wxauto4)
 from auto_send_wx_msg import send_wechat_group_msg   # 企微机器人发送(备用渠道)
 # 复用 daily 的免 token 全站在售拉取(home/search,只拉不落库);alert 独立自采、不依赖 buy_record 落库
-from on_sale.deca_on_sale_daily_spider import fetch_all_onsale
+from on_sale.deca_on_sale_daily_spider import parse_product
 
 logger.remove()
 logger.add("./logs/onsale_alert_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
@@ -284,24 +284,46 @@ def _progress(sold, card) -> float:
 
 
 def fetch_onsale(log) -> tuple[list, bool]:
-    """免 token 抓全站在售、筛出 MERCHANT_ID 的在售商品(alert 独立自采,不依赖 buy_record 落库)。
+    """带 token 抓 MERCHANT_ID 一家的在售商品(逐页 on-sale-list,走直连)。
 
-    复用 daily 的 fetch_all_onsale(home/search 免 token、只拉不落库)拿全站在售,
-    再按 merchant_user_id 筛出本商家、按 product_code 去重。全链路免 token。
+    2026/09/05:站方把免 token 的全站 home/search 砍成首屏 20、全站免 token 翻页失效,可能刷不到本商家的车;
+    故改为直接调本商家 on-sale-list(需 token)分页拉取,parse_product 解析后按 product_code 去重返回。
+    token 走直连保持账号↔IP 稳定;详情接口仍免 token。
 
     Args:
         log: 日志对象。
 
     Returns:
         tuple[list, bool]: (本商家在售商品字典列表[parse_product 结果,含 title/unit_price/card_count/
-            sold_count/available_stock/merchant_name 等], 全站是否正常翻到底)。
-            bool=False 表示全站抓取中途异常/未取全,调用方据此放弃本轮下架/结束对账,避免误判。
+            sold_count/available_stock/merchant_name 等], 是否正常翻到底)。
+            bool=False 表示中途异常/未取全,调用方据此放弃本轮下架/结束对账,避免误判。
     """
-    all_products, ok = fetch_all_onsale(log)
-    rows = {p["product_code"]: p for p in all_products
-            if p.get("merchant_user_id") == MERCHANT_ID}
-    log.info(f"商家 {MERCHANT_ID} 当前在售商品 {len(rows)} 个(全站 {len(all_products)} 筛出,免 token,ok={ok})")
-    return list(rows.values()), ok
+    rows = []
+    page = 1
+    total = None
+    ok = False
+    while page <= 20:
+        body = {"merchantUserId": MERCHANT_ID, "page": page, "pageSize": 20}
+        try:
+            resp = core.do_request(log, ON_SALE_PATH, body, need_auth=True, use_proxy=False)
+        except Exception as e:
+            log.error(f"商家 {MERCHANT_ID} 在售第 {page} 页请求失败: {e}")
+            break
+        if not resp or resp.get("code") != 0:
+            log.info(f"商家 {MERCHANT_ID} 在售返回异常: {resp.get('msg') if resp else None}")
+            break
+        data = resp.get("data") or {}
+        if total is None:
+            total = data.get("total")
+        items = data.get("list") or []
+        rows.extend(r for r in (parse_product(it) for it in items) if r)
+        if (total is not None and page * 20 >= total) or len(items) < 20:  # 采满 total 或末页
+            ok = True
+            break
+        page += 1
+    uniq = {p["product_code"]: p for p in rows}  # 按 code 去重
+    log.info(f"商家 {MERCHANT_ID} 当前在售商品 {len(uniq)} 个(on-sale-list 带 token,ok={ok})")
+    return list(uniq.values()), ok
 
 
 def _insert_alert(pool, r: dict, progress_pct: float, new_notified: int,

+ 5 - 1
deca_spider/token.json

@@ -1 +1,5 @@
-{"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiI1NDIwOTg5MzQiLCJwbHQiOjEsInV0IjoxLCJwb3J0YWwiOiJhcHAiLCJleHAiOjE3ODcwMTIxMDAsImlhdCI6MTc4NzAxMTIwMH0.cPBmW56f_qgqZVdyMg47WBEbSwZ7Q2GYETU6-fMsl44", "refresh": "8a8c9a4360e8d843b7805c2eb6a3efea2e61c92e386e890a86ecd936aed6c445", "exp": 1787012100.789561}
+{
+  "phone": "18040545025",
+  "password": "147258369@ww",
+  "countryCode": "86"
+}