Browse Source

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 week ago
parent
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`。
 > Python 3.12.10 | 公共库 `charley-utils`(`from mysql_pool import MySQLConnectionPool`)| DB 配置读运行目录 `application.yml`。
 > 深入细节见 `HANDOFF.md` 与 `docs/`;本文只讲「**要启动哪些脚本、各干什么、用哪些表**」。
 > 深入细节见 `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 |
 | # | 脚本 | 干什么 | 启动命令 | 频率 | 用到的表 | 需 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 需) |
 | 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`(默认关) | **是** |
 | 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` | 否 |
 | 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` | 常驻 | — | 否 |
 | 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`。 |
 | `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` 一处**(当前指测试群)。 |
 | `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"` 复用)。 |
 | `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_onsale_product_progress_record` | 在售进度时间序列(60s,变化才写) | #1 | #6 |
 | `deca_buy_record` | 购买记录(去重买家名单) | #1 | #3、#6 |
 | `deca_buy_record` | 购买记录(去重买家名单) | #1 | #3、#6 |
 | `deca_groupbuy_team_record` | 随机团各队明细 | #2、#4 | 报告/统计 |
 | `deca_groupbuy_team_record` | 随机团各队明细 | #2、#4 | 报告/统计 |
@@ -71,7 +78,7 @@
 
 
 1. **运行目录**:常驻脚本务必从项目根目录启动(`buy_record_spider` 会自动 `chdir` 到根),否则 `application.yml` / `token.json` / `./logs` 找不到。
 1. **运行目录**:常驻脚本务必从项目根目录启动(`buy_record_spider` 会自动 `chdir` 到根),否则 `application.yml` / `token.json` / `./logs` 找不到。
 2. **建表**:首次先 `python init_db.py`;历史存量先跑一次 `sold_history_spider.py`。
 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` 一处**。
 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。
 5. **启动顺序建议**:`init_db` →(首次)`sold_history_spider` → 常驻 #1 #2 #3 #4 → 定时 #5 #6 →(会锁屏的机器)#7。
 6. **提醒消息不带商品链接**:分享落地页 `share-detail` 只认带 token 生成的 shareCode,免 token 一律失效;为不引入登录态已去链接、标题改加粗(详见 `docs/优化记录_得卡企微迁移_20260811.md`)。
 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 样本验证)。
 - 业务接口需 `signature` 头:`signature = md5( md5(参数按 key 升序拼接) + currentTime[-6:] + "biu_card_nbclass" )`(逆向自 APK `mf/c0.java`,4 样本验证)。
 - 部分接口另需 `Authorization: Bearer <JWT>`(`access token` 15 分钟过期),走 `token/refresh` 续期规避阿里云验证码风控;`密码登录` 仅兜底。
 - 部分接口另需 `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`。
 - 完整逆向与登录续期说明见 `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\`
 > 归属:`D:\work\2026-08-02(deca_spider)\buy_record_analysis\`
 > 平台:得卡 DECA App(`api.decalive.com`)
 > 平台:得卡 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 登录态 / 签名 / 代理
 ### 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 两种工作模式(切换只改一个变量)
 ### 6.1 两种工作模式(切换只改一个变量)
 
 
 ```python
 ```python
-# 白名单模式:非空 → 只监控这些 code,跳过 on-sale-list
+# 白名单模式:非空 → 只监控这些 code,跳过在售发现(免 token)
 WATCH_CODES = ["GB26080417234"]
 WATCH_CODES = ["GB26080417234"]
 
 
-# 商家模式:WATCH_CODES 为空 → 拉 MERCHANT_ID 的全部在售商品,动态增删
+# 商家模式(默认):WATCH_CODES 为空 → 逐家拉 MERCHANT_IDS 各商家在售(on-sale-list 带 token),动态增删
 # WATCH_CODES = []
 # WATCH_CODES = []
-MERCHANT_ID = "881226408"
+MERCHANT_IDS = ["881226408", "274584650", "538252487", "591544726"]  # 魔都兄弟/卡皇/尼卡/文泰
 ```
 ```
 
 
-- 单商品测试 → 白名单里放 1 个 code
+- 单商品测试 → 白名单里放 1 个 code(免 token)
 - 多商品自选 → 白名单里加多个 code
 - 多商品自选 → 白名单里加多个 code
-- 商家全在售 → 清空 `WATCH_CODES`
+- 商家全在售 → 清空 `WATCH_CODES`(默认,走 `MERCHANT_IDS` 逐家 on-sale-list,**需 token**)
 
 
 ### 6.2 自适应频率
 ### 6.2 自适应频率
 
 
@@ -156,7 +158,7 @@ GLOBAL_MIN_GAP_SEC = 0.2  # 全局相邻两次请求最小间隔(≈每秒 5 
 **前置**:
 **前置**:
 - 项目根有 `application.yml`(数据库配置)。
 - 项目根有 `application.yml`(数据库配置)。
 - MySQL 里 **`deca_buy_record` 表需先建好**:`python init_db.py` 或在客户端跑 `schema.sql` 里那段 DDL(脚本不再自动建表)。
 - 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
 ```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
 from tenacity import retry, stop_after_attempt, wait_fixed
 import deca_sold_core as core
 import deca_sold_core as core
 from mysql_pool import MySQLConnectionPool
 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 在其后,会覆盖回本脚本日志
 # 注:本 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 为空时生效)。
 # 监控商家列表:采这些商家在售商品的购买记录(WATCH_CODES 为空时生效)。
 # 2026/08/24 由单商家扩为多商家并行监控:三家同走「查库拿在售 code → 自适应频率采购买记录」同一套逻辑,
 # 2026/08/24 由单商家扩为多商家并行监控:三家同走「查库拿在售 code → 自适应频率采购买记录」同一套逻辑,
@@ -486,40 +493,75 @@ def snapshot_onsale_progress(log, pool) -> int:
     return len(rows_to_insert)
     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:
     Args:
         log: 日志对象。
         log: 日志对象。
         pool: MySQL 连接池。
         pool: MySQL 连接池。
-        discover (bool): 本轮是否附带商家发现 + 新增补详情(低频触发)。
     """
     """
     try:
     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:
     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)
     # 进度时间序列:紧跟 onsale 表更新之后跑(此时表里就是最新一轮的 sold_count/available_stock)
     try:
     try:
         snapshot_onsale_progress(log, pool)
         snapshot_onsale_progress(log, pool)
     except Exception as e:
     except Exception as e:
         log.error(f"snapshot_onsale_progress error: {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:
 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)
 @retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
 def main_task(log):
 def main_task(log):
-    """常驻主流程:每分钟落全站在售(免 token) + 线程池并发采各监控商家(MERCHANT_IDS)在售商品的购买记录。
+    """常驻主流程:拉监控商家在售(带 token) + 线程池并发采各监控商家(MERCHANT_IDS)在售商品的购买记录(免 token)
 
 
     Args:
     Args:
         log: 日志对象。
         log: 日志对象。
@@ -811,7 +853,7 @@ def main_task(log):
     log.info(f"购买记录+在售常驻采集启动 | {mode} | 并发 {MAX_WORKERS} 线程(全局限速≈{1/GLOBAL_MIN_GAP_SEC:.0f}次/秒) "
     log.info(f"购买记录+在售常驻采集启动 | {mode} | 并发 {MAX_WORKERS} 线程(全局限速≈{1/GLOBAL_MIN_GAP_SEC:.0f}次/秒) "
              f"| 购买记录频率 [{MIN_INTERVAL_SEC}s, {MAX_INTERVAL_SEC}s] "
              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"| 在售发现常规每 {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)
     pool = MySQLConnectionPool(log=log)
     if not pool.check_pool_health():
     if not pool.check_pool_health():
         log.error("数据库连接池异常")
         log.error("数据库连接池异常")
@@ -819,7 +861,6 @@ def main_task(log):
 
 
     monitored: dict = {}                        # {code: {next_run_ts, interval, title, sold_count, card_count}}
     monitored: dict = {}                        # {code: {next_run_ts, interval, title, sold_count, card_count}}
     last_ingest = 0.0                           # 上次「在售落库 + 刷新监控」时刻
     last_ingest = 0.0                           # 上次「在售落库 + 刷新监控」时刻
-    last_discover = 0.0                         # 上次「商家发现 + 补详情」时刻(按 SHOP_DISCOVER_SEC 降频)
 
 
     # 线程池常驻:主线程只负责「落在售/刷新监控 + 派发到点商品」,实际详情采集交由 worker 并发跑。
     # 线程池常驻:主线程只负责「落在售/刷新监控 + 派发到点商品」,实际详情采集交由 worker 并发跑。
     # 并发把串行时被请求延迟浪费的吞吐(仅 ~0.5 次/秒)填到接近全局限速上限(≈5 次/秒),追上快车翻窗节奏、减少漏采。
     # 并发把串行时被请求延迟浪费的吞吐(仅 ~0.5 次/秒)填到接近全局限速上限(≈5 次/秒),追上快车翻窗节奏、减少漏采。
@@ -827,16 +868,13 @@ def main_task(log):
         while True:
         while True:
             now = time.time()
             now = time.time()
 
 
-            # 1) 每(动态)间隔:商家模式先落全站在售(免token)再查库刷新监控;白名单模式只按固定 code 刷新。
+            # 1) 每(动态)间隔:商家模式先拉监控商家在售(带 token)再查库刷新监控;白名单模式只按固定 code 刷新。
             #    快车高发时段(20:00~次日06:00)把发现节奏加密到 FAST_WINDOW_INGEST_SEC,更快把 2~3 分钟即售罄的快车纳入监控。
             #    快车高发时段(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()):
             if now - last_ingest >= _current_ingest_interval(datetime.now()):
                 try:
                 try:
                     if not WATCH_CODES:
                     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)
                     _refresh_monitored(log, pool, monitored, MERCHANT_IDS, now)
                 except Exception as e:
                 except Exception as e:
                     log.error(f"在售落库/刷新监控异常: {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
 USE_PROXY = False     # 代理开关:默认直连,遇 IP 风控再置 True
 TOKEN_FILE = "token.json"       # token 持久化文件(进程重启复用),refreshToken 续期为主、密码登录兜底
 TOKEN_FILE = "token.json"       # token 持久化文件(进程重启复用),refreshToken 续期为主、密码登录兜底
 
 
-# 密码登录兜底凭证(敏感信息,勿外传/勿提交公开仓库;换账号改此三项)
+# 密码登录兜底凭证(默认主号;实际优先取各机 token.json 里的 phone/password/countryCode,
+# 见 load_token/login。敏感信息,勿外传/勿提交公开仓库)
 LOGIN_COUNTRY_CODE = "86"
 LOGIN_COUNTRY_CODE = "86"
 LOGIN_PHONE = "13014617614"
 LOGIN_PHONE = "13014617614"
 LOGIN_PASSWORD = "pass2022"
 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)
 @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 请求(带重试)。
     """通用带签名 POST 请求(带重试)。
 
 
     Args:
     Args:
@@ -148,6 +150,9 @@ def do_request(log, path: str, body: dict, need_auth: bool = False) -> dict | No
         path (str): 接口相对路径(以 / 开头)。
         path (str): 接口相对路径(以 / 开头)。
         body (dict): JSON body,同时用于签名。
         body (dict): JSON body,同时用于签名。
         need_auth (bool, optional): 是否需要携带 Bearer token。Defaults to False。
         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:
     Returns:
         dict | None: 响应 JSON。
         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)}"
         headers["Authorization"] = f"Bearer {ensure_token(log)}"
 
 
     data = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
     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)
     resp = requests.post(BASE + path, headers=headers, data=data, timeout=(5, 30), proxies=proxies)
     if resp.status_code != 200:
     if resp.status_code != 200:
         log.error(f"请求失败 {resp.status_code}: {path}")
         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)
 @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。
     """通用带签名 GET 请求(带重试)。签名参数取 query。
 
 
     Args:
     Args:
@@ -180,6 +187,7 @@ def do_get(log, path: str, params: dict = None, need_auth: bool = False) -> dict
         path (str): 接口相对路径(以 / 开头)。
         path (str): 接口相对路径(以 / 开头)。
         params (dict, optional): URL query 参数(同时用于签名)。Defaults to None。
         params (dict, optional): URL query 参数(同时用于签名)。Defaults to None。
         need_auth (bool, optional): 是否需要携带 Bearer token。Defaults to False。
         need_auth (bool, optional): 是否需要携带 Bearer token。Defaults to False。
+        use_proxy (bool, optional): 是否走代理。None=沿用全局 USE_PROXY;显式 True/False 覆盖。Defaults to None。
 
 
     Returns:
     Returns:
         dict | None: 响应 JSON。
         dict | None: 响应 JSON。
@@ -194,7 +202,7 @@ def do_get(log, path: str, params: dict = None, need_auth: bool = False) -> dict
     if need_auth:
     if need_auth:
         headers["Authorization"] = f"Bearer {ensure_token(log)}"
         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)
     resp = requests.get(BASE + path, headers=headers, params=params, timeout=(5, 30), proxies=proxies)
     if resp.status_code != 200:
     if resp.status_code != 200:
         log.error(f"请求失败 {resp.status_code}: {path}")
         log.error(f"请求失败 {resp.status_code}: {path}")
@@ -276,17 +284,30 @@ def _token_lock(log=None, timeout=30):
 
 
 
 
 def save_token():
 def save_token():
-    """把当前 _TOKEN(access/refresh/exp) 持久化到 TOKEN_FILE。"""
+    """把当前 _TOKEN 持久化到 TOKEN_FILE(access/refresh/exp + 账号凭据 phone/password/countryCode)。
+
+    账号凭据随 token.json 一并落盘,方便人工打开文件直接分辨该程序用的是哪个账号;仅当 _TOKEN
+    里存在这些字段时才写入(避免把默认主号凭据注入到未配置的机器的 token.json)。
+    """
     try:
     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:
         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:
     except Exception as e:
         logger.warning(f"token 持久化失败: {e}")
         logger.warning(f"token 持久化失败: {e}")
 
 
 
 
 def load_token():
 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):
     if not os.path.exists(TOKEN_FILE):
         return
         return
     try:
     try:
@@ -295,6 +316,9 @@ def load_token():
         _TOKEN["access"] = d.get("access")
         _TOKEN["access"] = d.get("access")
         _TOKEN["refresh"] = d.get("refresh")
         _TOKEN["refresh"] = d.get("refresh")
         _TOKEN["exp"] = d.get("exp", 0)
         _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:
     except Exception as e:
         logger.warning(f"token 读取失败: {e}")
         logger.warning(f"token 读取失败: {e}")
 
 
@@ -351,7 +375,12 @@ def login(log) -> bool:
         log.error(f"密码登录已连续失败 {_login_fail_count} 次达上限({MAX_LOGIN_ATTEMPTS}),"
         log.error(f"密码登录已连续失败 {_login_fail_count} 次达上限({MAX_LOGIN_ATTEMPTS}),"
                   "熔断不再登录,请人工检查账号(密码/风控/验证码)")
                   "熔断不再登录,请人工检查账号(密码/风控/验证码)")
         return False
         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:
     try:
         resp = do_request(log, "/api/v1/app/auth/password/login", body, need_auth=False)
         resp = do_request(log, "/api/v1/app/auth/password/login", body, need_auth=False)
         data = (resp or {}).get("data") or {}
         data = (resp or {}).get("data") or {}
@@ -368,7 +397,7 @@ def login(log) -> bool:
         _TOKEN["exp"] = time.time() + expires_in
         _TOKEN["exp"] = time.time() + expires_in
         save_token()
         save_token()
         _login_fail_count = 0
         _login_fail_count = 0
-        log.info(f"密码登录成功,access 有效 {expires_in}s(refreshToken 已更新)")
+        log.info(f"密码登录成功(账号 {phone}),access 有效 {expires_in}s(refreshToken 已更新)")
         return True
         return True
     except Exception as e:
     except Exception as e:
         _login_fail_count += 1
         _login_fail_count += 1

+ 8 - 3
deca_spider/on_sale/README.md

@@ -5,6 +5,10 @@
 > 结论:signature 已 100% 还原(无加壳、纯 Java),登录+续签闭环打通,可长期无人值守。
 > 结论:signature 已 100% 还原(无加壳、纯 Java),登录+续签闭环打通,可长期无人值守。
 > 日期:2026/08/02 | Python 3.12.10
 > 日期: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. 目标信息
 ## 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"}` |
 | 密码登录 | `POST /api/v1/app/auth/password/login` | 否 | `{"countryCode","password","phone"}` |
 | Token 续签 | `POST /api/v1/app/auth/token/refresh` | 否 | `{"refreshToken"}` |
 | Token 续签 | `POST /api/v1/app/auth/token/refresh` | 否 | `{"refreshToken"}` |
 
 
@@ -149,6 +154,6 @@ def make_signature(params: dict, current_time: str) -> str:
 
 
 **登录与 token(避开验证码)**:
 **登录与 token(避开验证码)**:
 - 密码登录 `password/login` 受阿里云验证码2.0 风控,脚本无法自动过验证码;改走 `token/refresh` 续期(**无验证码**)。
 - 密码登录 `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 失效(过期/被踢)时日志会报警,需重新抓包更新。
 - 之后爬虫用它自动续期并滚动写回 `token.json`;refreshToken 失效(过期/被踢)时日志会报警,需重新抓包更新。
 - 商品**上架时间/结束时间**来自详情接口 `POST api/v1/app/groupbuy/detail`(body `{"code":商品code}`,需 token),**仅对今日新增商品拉取**,存入 `deca_product_record` 的 `publish_at`/`sale_start_at`/`sale_end_at`。商家入驻时间接口无,新增按入库时间判定。
 - 商品**上架时间/结束时间**来自详情接口 `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 import Workbook
 from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
 from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
 from openpyxl.utils import get_column_letter
 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.remove()
 logger.add("./logs/onsale_report_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
 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日)
 TREND_DAYS = 4               # 平台在售趋势展示天数(今日 + 前3日)
 LISTING_HOUR_DAYS = 7        # 上架时段分布回看天数(平台 24h 上架节奏)
 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):
 def f(v, default=0.0):
     """把可能为 None/Decimal 的值安全转 float。
     """把可能为 None/Decimal 的值安全转 float。
@@ -163,6 +172,66 @@ def _fmt_series(series_name, title) -> str:
     return (m.group(0) if m else title)[:22]
     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:
 def fetch_data(pool) -> dict:
     """从数据库汇总报表所需数据(KPI + 今日新增商家 + 其他商家 + 各商家在售商品明细)。
     """从数据库汇总报表所需数据(KPI + 今日新增商家 + 其他商家 + 各商家在售商品明细)。
 
 
@@ -681,6 +750,15 @@ def main() -> str:
     if not pool.check_pool_health():
     if not pool.check_pool_health():
         log.error("数据库连接池异常")
         log.error("数据库连接池异常")
         return ""
         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)
     data = fetch_data(pool)
     os.makedirs("reports", exist_ok=True)
     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)
 import deca_wechat                                   # PC 版微信发送(wxauto4)
 from auto_send_wx_msg import send_wechat_group_msg   # 企微机器人发送(备用渠道)
 from auto_send_wx_msg import send_wechat_group_msg   # 企微机器人发送(备用渠道)
 # 复用 daily 的免 token 全站在售拉取(home/search,只拉不落库);alert 独立自采、不依赖 buy_record 落库
 # 复用 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.remove()
 logger.add("./logs/onsale_alert_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
 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]:
 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:
     Args:
         log: 日志对象。
         log: 日志对象。
 
 
     Returns:
     Returns:
         tuple[list, bool]: (本商家在售商品字典列表[parse_product 结果,含 title/unit_price/card_count/
         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,
 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"
+}