|
|
@@ -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)
|