Procházet zdrojové kódy

fix(spider): 修复板块失效并改用「全部」接口发现店铺

- 停用旧板块发现 get_board_shop_list,改用不需 token 的公开「全部」接口覆盖全部在售店铺
- 修正请求 version 头,从旧版 1.9.9.82537 改为新版 1.3.09,避免返回空数据
- 新增获取「全部」板块单页活动接口并解析店铺信息,插入数据库时使用 INSERT IGNORE
- 采用分页遍历「全部」接口,空页或不足整页时停止翻页,最多爬取 100 页
- 修复商品列表翻页逻辑,去除错误 total 初始值判断,改为空页或不足整页时停止
- 调整定时任务启动逻辑,暂时注释立即运行调用,保留每日定时执行任务
charley před 2 dny
rodič
revize
21337248ae
1 změnil soubory, kde provedl 144 přidání a 23 odebrání
  1. 144 23
      zc_spider/zc_new_daily_spider.py

+ 144 - 23
zc_spider/zc_new_daily_spider.py

@@ -10,7 +10,8 @@ import user_agent
 from loguru import logger
 from crypto_utils import CryptoHelper
 from mysql_pool import MySQLConnectionPool
-from zc_board_shop_spider import get_board_shop_list
+# 2026/08/06 停用板块发现:get_board_shop_list 的旧 cateId 已随版本升级失效,
+# 改用「全部」接口(get_all_shop_list)覆盖全部在售店铺,故移除该导入(文件保留备用)。
 from tenacity import retry, stop_after_attempt, wait_fixed
 
 logger.remove()
@@ -30,7 +31,10 @@ headers = {
     "channelNo": "88888888",
     "pageSize": str(PAGE_SIZE),
     # "pageNum": 1,
-    "version": "1.9.9.82537"
+    # 2026/08/06 版本失效修复:服务端升级后按 version 头做灰度校验,旧值 1.9.9.82537
+    # 会被静默降级为返回空 rows(HTTP 200 不报错、数据悄悄变空)。改为抓包实测的当前
+    # 真实版本 1.3.09 后 getActList / getActEndList 均恢复正常返回。
+    "version": "1.3.09"
 }
 
 
@@ -132,6 +136,124 @@ def get_shop_single_page(log, page_num, page_size=PAGE_SIZE):
     return resp
 
 
+# 「全部」板块的分类ID。来源:抓包数据.txt 里「全部」两条 getActList 请求体(AES 解密后得到),
+# 2026/08/06 版本升级后该值为 "0,6,3"。此接口为公开接口,不校验 Authorization,无需 token。
+ALL_SHOP_CATE_ID = "0,6,3"
+
+
+def get_all_shop_single_page(log, page_num, page_size=PAGE_SIZE):
+    """获取「全部」板块的活动列表单页(用于发现全部在售店铺)。
+
+    走 getActList「全部」板块(cateId=ALL_SHOP_CATE_ID),从每条活动里解析出
+    merNo / merName,覆盖当前所有有进行中活动的店铺。为公开接口,无需 token。
+
+    Args:
+        log: 日志对象,从调用方透传。
+        page_num (int): 页码,从 1 开始。
+        page_size (int, optional): 每页条数。Defaults to PAGE_SIZE。
+
+    Returns:
+        dict | None: 解密后的响应字典(含 rows);请求失败时返回 None。
+    """
+    log.debug(f"Getting all-shop list, page: {page_num}")
+    url = f"{BASE_URL}/zc-api/act/actProduct/getActList"
+    # 请求体字段与抓包解密结果一一对应,仅 cateId 固定为「全部」板块:
+    #   finish / loading -> 前端分页状态位,抓包固定 false / true,照搬即可
+    #   actStatus=1      -> 只取进行中的活动
+    request_data = {
+        'cateId': ALL_SHOP_CATE_ID,
+        'pageSize': page_size,
+        'pageNum': page_num,
+        'finish': False,
+        'loading': True,
+        'actStatus': 1
+    }
+    try:
+        resp = make_encrypted_post_request(log, url, request_data, extra_headers={"pageNum": str(page_num)})
+    except Exception as e:
+        log.error(f"Error getting all-shop list, page {page_num}: {e}")
+        resp = None
+    return resp
+
+
+def parse_all_shop_data(log, items, sql_pool, seen):
+    """解析「全部」接口活动里的店铺并写库(INSERT IGNORE,不覆盖存量)。
+
+    「全部」接口只返回 merNo / merName,拿不到 sold_number(销量) / fans(粉丝)。因此对
+    新发现的店铺 sold_number 写死哨兵值 1,让其能被每日任务「WHERE sold_number != 0」
+    选中进入商品采集管道;用 INSERT IGNORE 跳过已存在的店铺,避免覆盖 get_shop_list
+    (getMerMyList)回填的真实 sold_number / fans。
+
+    Args:
+        log: 日志对象,从调用方透传。
+        items (list[dict]): getActList「全部」板块返回的活动列表(每条含 merNo / merName)。
+        sql_pool (MySQLConnectionPool): MySQL 连接池。
+        seen (set): 跨页去重的 shop_id 集合,避免同一店铺重复写库。
+
+    Returns:
+        int: 本次实际写入的店铺数量(已去重)。
+    """
+    info_list = []
+    for item in items:
+        shop_id = item.get('merNo')
+        shop_name = item.get('merName')
+        # 同一店铺会出现在多条活动里,跨页去重以减少无谓写库
+        if shop_id in seen:
+            continue
+        seen.add(shop_id)
+        # sold_number 写死哨兵值 1:仅作「需采集」标记(板块拿不到真实销量),配合 INSERT IGNORE
+        # 只对新插入店铺生效,已存在店铺整行被跳过;后续 get_shop_list 会回填真实值
+        info_list.append({'shop_id': shop_id, 'shop_name': shop_name, 'sold_number': 1})
+
+    if not info_list:
+        return 0
+    sql_pool.insert_many(table='zc_shop_record', data_list=info_list, ignore=True)
+    return len(info_list)
+
+
+def get_all_shop_list(log, sql_pool):
+    """「全部」接口店铺发现入口:翻页遍历全部在售店铺并写库。
+
+    店铺主来源,替代原来覆盖不全的 getMerMyList「我的商户列表」。翻页采用
+    「空页或不足整页即停」的可靠判断,受 MAX 页数保护。
+
+    Args:
+        log: 日志对象,从调用方透传。
+        sql_pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        int: 去重后合计写入的店铺数量。
+    """
+    log.info("开始「全部」接口店铺发现......")
+    page_num = 1
+    max_pages = 100
+    seen = set()
+
+    while page_num <= max_pages:
+        result = get_all_shop_single_page(log, page_num, PAGE_SIZE)
+        if result is None:
+            log.error(f"「全部」第 {page_num} 页请求失败,停止翻页")
+            break
+
+        data_list = result.get('rows', [])
+        if len(data_list) == 0:
+            log.info(f"「全部」第 {page_num} 页无数据,停止翻页")
+            break
+
+        parse_all_shop_data(log, data_list, sql_pool, seen)
+        log.info(f"「全部」第 {page_num} 页完成,本页活动数: {len(data_list)},累计去重店铺: {len(seen)}")
+
+        # 不足整页说明已到末页,停止翻页
+        if len(data_list) < PAGE_SIZE:
+            log.info(f"「全部」第 {page_num} 页不足整页,已到末页,停止翻页")
+            break
+
+        page_num += 1
+
+    log.info(f"「全部」接口店铺发现结束,去重店铺数: {len(seen)}")
+    return len(seen)
+
+
 def get_sold_single_page(log, mer_no, page_num):
     """
     获取商品列表(支持翻页)
@@ -338,35 +460,32 @@ def get_shop_list(log, sql_pool):
     :param sql_pool: MySQL连接池
     """
     page_num = 1
-    total = 0
+    max_pages = 100
 
-    while page_num <= 100:
+    # 2026/08/06 翻页 bug 修复:原逻辑 total 初值为 0,却用「total is None」判断是否取总数,
+    # 导致 total 永远停在 0,停止条件「(page_num-1)*PAGE_SIZE >= total」在第 1 页跑完后即
+    # 0 >= 0 成立、立刻 break,只能采到第 1 页 10 个店铺。改为「空页 / 不足整页即停」的可靠判断。
+    while page_num <= max_pages:
         result = get_shop_single_page(log, page_num, PAGE_SIZE)
-        # print(result)
         if result is None:
             log.error(f"第 {page_num} 页请求失败,停止翻页")
             break
 
         data_list = result.get('rows', [])
-        parse_shop_data(log, data_list, sql_pool)
-
-        # 获取总条数(第一页时获取)
-        if total is None and 'total' in result:
-            total = result['total']
-            log.info(f"总记录数: {total}")
-
-        # 检查是否有数据
+        # 空页直接停止,避免用空列表调用 insert_many 触发 ValueError
         if len(data_list) == 0:
             log.info(f"第 {page_num} 页无数据,停止翻页")
             break
 
-        # 根据total判断是否超出范围
-        if total is not None and (page_num - 1) * PAGE_SIZE >= total:
-            log.info(f"已遍历完所有数据,停止翻页")
-            break
-
+        # getMerMyList 含真实 sold_number / fans,走 ON DUPLICATE KEY UPDATE 回填覆盖哨兵值
+        parse_shop_data(log, data_list, sql_pool)
         log.info(f"第 {page_num} 页查询完成,本页条数: {len(data_list)}")
 
+        # 不足整页说明已到末页,停止翻页
+        if len(data_list) < PAGE_SIZE:
+            log.info(f"第 {page_num} 页不足整页,已到末页,停止翻页")
+            break
+
         page_num += 1
 
 
@@ -476,13 +595,15 @@ def zc_main(log):
         # player test
         # has_data = get_player_list(log, 1800, token, sql_pool)
 
-        # 获取首页板块商户列表
+        # 店铺主来源:「全部」接口发现全部在售店铺(替代覆盖不全的 getMerMyList)
+        # 2026/08/06 停用板块发现 get_board_shop_list:其 5 个旧 cateId 已随版本升级失效,
+        # 且「全部」接口(cateId=0,6,3)已覆盖所有在售店铺,板块发现冗余。
         try:
-            get_board_shop_list(log, sql_pool)
+            get_all_shop_list(log, sql_pool)
         except Exception as e:
-            log.error(f'get_board_shop_list error: {e}')
+            log.error(f'get_all_shop_list error: {e}')
 
-        # 获取shop data
+        # 补充:getMerMyList 回填「我的店铺」真实 sold_number / fans(「全部」接口拿不到这两个字段)
         try:
             get_shop_list(log, sql_pool)
         except Exception as e:
@@ -551,7 +672,7 @@ def schedule_task():
     爬虫模块 定时任务 的启动文件
     """
     # 立即运行一次任务
-    zc_main(log=logger)
+    # zc_main(log=logger)
 
     # 设置定时任务
     schedule.every().day.at("00:01").do(zc_main, log=logger)