|
|
@@ -0,0 +1,764 @@
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+# Author : Charley
|
|
|
+# Python : 3.12.10
|
|
|
+# Date : 2026/07/06
|
|
|
+"""潮谷街(吃谷商城)每日增量采集爬虫。
|
|
|
+
|
|
|
+三级采集管道(结构对齐 kaji 的 kj_daily_spider):
|
|
|
+ 1. 商户发现:翻页遍历「shop 列表页」group_buying_products(product_types/category_id 模式),
|
|
|
+ 从每个商品里提取 merchant_id / merchant_name,写入 cgj_shop_record。
|
|
|
+ 2. 商品采集:遍历库内每个商户的「历史成交」group_buying_products(merchant_id + status 模式),
|
|
|
+ 逐商品补 product/info 详情后写入 cgj_product_record。
|
|
|
+ 3. 玩家采集:遍历 cgj_product_record 中未采集过玩家的商品,抓 announcement_by_user
|
|
|
+ 的买家名单写入 cgj_player_record。
|
|
|
+
|
|
|
+鉴权:潮谷街这几个接口 access-token 为空、无签名/加密,属公开(游客)接口,
|
|
|
+只需带固定的 devicecode / version 等请求头即可,适合长期无人值守。
|
|
|
+
|
|
|
+翻页约定(三个列表接口统一):请求体/参数带 cursor(首页为空串),响应返回
|
|
|
+ data.list(本页数据)、data.cursor(下一页游标)、data.done(true=到底)。
|
|
|
+ 循环条件:done 为 True 或 cursor 为空即停止。
|
|
|
+
|
|
|
+【字段解析】save_shops / build_product / save_players 里的 `for item in items:` 循环中,
|
|
|
+"""
|
|
|
+import sys
|
|
|
+import time
|
|
|
+from datetime import datetime
|
|
|
+
|
|
|
+import requests
|
|
|
+import schedule
|
|
|
+from loguru import logger
|
|
|
+from tenacity import retry, stop_after_attempt, wait_fixed
|
|
|
+from mysql_pool import MySQLConnectionPool
|
|
|
+
|
|
|
+logger.remove()
|
|
|
+logger.add("./logs/{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
|
|
|
+ format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
|
|
|
+ level="DEBUG", retention="7 day")
|
|
|
+
|
|
|
+# ==================== 基础配置 ====================
|
|
|
+# 业务域名(来源:抓包,四个接口都走这个域)
|
|
|
+BASE = "https://5fdc92.chaogujieapp.com"
|
|
|
+
|
|
|
+PAGE_SIZE = 20 # 列表接口每页条数(抓包实测 20)
|
|
|
+PLAYER_PAGE_SIZE = 20 # 玩家名单每页条数(抓包实测 20)
|
|
|
+# MAX_PAGES = 500 # 单个列表翻页保护上限,防异常时无限翻页
|
|
|
+# 增量早停阈值:某商户历史成交连续这么多页「全是已入库商品(0 新)」即停止翻页。
|
|
|
+# 用「连续多页」而非「单页」,兼容历史成交列表里新商品跨页穿插的情况;调大更稳、调小更省。
|
|
|
+STOP_AFTER_DUPE_PAGES = 2
|
|
|
+MAX_PAGES = 5 # 单个列表翻页保护上限,防异常时无限翻页 每日任务时设置更小限制
|
|
|
+
|
|
|
+# 「历史成交」过滤值(来源:抓包固定 status=1796)。九尾等仅有在售无历史成交的商户返回 0 条,符合预期。
|
|
|
+HISTORY_STATUS = 1796
|
|
|
+
|
|
|
+# 设备码 / App 版本(来源:抓包,公开接口无需登录 token)
|
|
|
+DEVICE_CODE = "8420C51411ECF16C802600868F2D214A"
|
|
|
+APP_VERSION = "1.0.15"
|
|
|
+
|
|
|
+# 是否为每个商品补拉 product/info 详情(拿系列/规格/简介/视频/上下架时间等)。
|
|
|
+# 关闭可大幅减少请求量,但详情维度字段会缺失。
|
|
|
+FETCH_DETAIL = True
|
|
|
+# 是否为每个商品补拉直播回放(live/user_live/detail,room_id 取自详情的 live_room_id)。
|
|
|
+# 依赖 FETCH_DETAIL=True——要先有详情才能拿到 live_room_id。
|
|
|
+FETCH_REPLAY = True
|
|
|
+# 是否使用代理。潮谷街实测可直连;若遇 IP 风控再置 True 并配置 get_proxys。
|
|
|
+USE_PROXY = False
|
|
|
+
|
|
|
+# 固定 UA(来源:抓包,潮谷街为 Flutter WebView)
|
|
|
+UA = ("Mozilla/5.0 (Linux; Android 11; Pixel 5 Build/RQ3A.211001.001; wv) "
|
|
|
+ "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/148.0.7778.120 "
|
|
|
+ "Mobile Safari/537.36")
|
|
|
+
|
|
|
+# 基础请求头(来源:抓包)。POST 的 content-type 由 requests 的 json= 自动补,不放这里。
|
|
|
+BASE_HEADERS = {
|
|
|
+ "user-agent": UA,
|
|
|
+ "accept-encoding": "gzip",
|
|
|
+ "channel-version": APP_VERSION, # 抓包 channel-version
|
|
|
+ "access-token": "", # 抓包为空:公开/游客接口,无需登录
|
|
|
+ "source-id": "1", # 抓包固定
|
|
|
+ "language": "zh", # 抓包固定
|
|
|
+ "version": APP_VERSION, # 抓包 App 版本
|
|
|
+ "devicecode": DEVICE_CODE, # 抓包设备码
|
|
|
+ "platform-id": "1", # 抓包固定(1=Android)
|
|
|
+ "channel-name": "huawei", # 抓包渠道
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+def after_log(retry_state):
|
|
|
+ """tenacity 重试回调,记录每次尝试的结果。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ retry_state: tenacity 传入的 RetryCallState 对象,含调用参数与结果。
|
|
|
+ """
|
|
|
+ # 约定业务函数首个位置参数为 log;取不到时回退全局 logger
|
|
|
+ if retry_state.args and len(retry_state.args) > 0:
|
|
|
+ log = retry_state.args[0]
|
|
|
+ else:
|
|
|
+ log = logger
|
|
|
+
|
|
|
+ if retry_state.outcome.failed:
|
|
|
+ log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
|
|
|
+ else:
|
|
|
+ log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
|
|
|
+
|
|
|
+
|
|
|
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
|
|
|
+def get_proxys(log):
|
|
|
+ """获取隧道代理配置(默认不启用,见 USE_PROXY)。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: 日志对象。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ dict: requests 可用的 proxies 字典。
|
|
|
+
|
|
|
+ Raises:
|
|
|
+ Exception: 组装代理配置异常时向上抛出以触发重试。
|
|
|
+ """
|
|
|
+ tunnel = "x371.kdltps.com:15818"
|
|
|
+ kdl_username = "t13753103189895"
|
|
|
+ kdl_password = "o0yefv6z"
|
|
|
+ try:
|
|
|
+ proxies = {
|
|
|
+ "http": "http://%(user)s:%(pwd)s@%(proxy)s/" % {"user": kdl_username, "pwd": kdl_password, "proxy": tunnel},
|
|
|
+ "https": "http://%(user)s:%(pwd)s@%(proxy)s/" % {"user": kdl_username, "pwd": kdl_password,
|
|
|
+ "proxy": tunnel},
|
|
|
+ }
|
|
|
+ return proxies
|
|
|
+ except Exception as e:
|
|
|
+ log.error(f"Error getting proxy: {e}")
|
|
|
+ raise e
|
|
|
+
|
|
|
+
|
|
|
+def ts_to_dt(ts) -> str | None:
|
|
|
+ """将秒级 Unix 时间戳转成 'YYYY-MM-DD HH:MM:SS' 字符串。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ ts (int | str | None): 秒级时间戳;为 None / 0 / 空时返回 None。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ str | None: 格式化时间字符串;无有效时间戳时返回 None。
|
|
|
+ """
|
|
|
+ if not ts:
|
|
|
+ return None
|
|
|
+ try:
|
|
|
+ return datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M:%S")
|
|
|
+ except (ValueError, TypeError, OSError):
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+@retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
|
|
|
+def cgj_request(log, path: str, method: str = "GET",
|
|
|
+ params: dict = None, json_body: dict = None,
|
|
|
+ extra_headers: dict = None) -> dict | None:
|
|
|
+ """潮谷街通用请求函数(带重试)。
|
|
|
+
|
|
|
+ 潮谷街接口无签名/加密,仅需固定请求头,故本函数只负责拼 URL、发请求、判状态码。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: 日志对象。
|
|
|
+ path (str): 接口相对路径(以 / 开头,不含域名),如 "/api/home/group_buying_products"。
|
|
|
+ method (str, optional): 请求方法,"GET" 或 "POST"。Defaults to "GET"。
|
|
|
+ params (dict, optional): URL query 参数。Defaults to None。
|
|
|
+ json_body (dict, optional): POST 的 JSON body。Defaults to None。
|
|
|
+ extra_headers (dict, optional): 追加 / 覆盖的请求头。Defaults to None。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ dict | None: 响应 JSON;非 200 时抛异常触发重试。
|
|
|
+
|
|
|
+ Raises:
|
|
|
+ RuntimeError: HTTP 状态码非 200 时抛出。
|
|
|
+ """
|
|
|
+ url = f"{BASE}{path}"
|
|
|
+ req_headers = BASE_HEADERS.copy()
|
|
|
+ if extra_headers:
|
|
|
+ req_headers.update(extra_headers)
|
|
|
+
|
|
|
+ proxies = get_proxys(log) if USE_PROXY else None
|
|
|
+ if method.upper() == "POST":
|
|
|
+ resp = requests.post(url, headers=req_headers, json=json_body, params=params, timeout=(5, 30), proxies=proxies)
|
|
|
+ else:
|
|
|
+ resp = requests.get(url, headers=req_headers, params=params, timeout=(5, 30), proxies=proxies)
|
|
|
+
|
|
|
+ if resp.status_code != 200:
|
|
|
+ log.error(f"请求失败 {resp.status_code}: {url}")
|
|
|
+ raise RuntimeError(f"HTTP {resp.status_code}")
|
|
|
+ return resp.json()
|
|
|
+
|
|
|
+
|
|
|
+def get_group_buying_page(log, cursor: str = "", merchant_id=None) -> dict | None:
|
|
|
+ """获取 group_buying_products 一页(发现模式 / 商户历史成交模式二合一)。
|
|
|
+
|
|
|
+ 该接口一站两用:
|
|
|
+ - 不传 merchant_id:全局「shop 列表页」,用 product_types/category_id 过滤,用于商户发现。
|
|
|
+ - 传 merchant_id:某商户「历史成交」,用 status=HISTORY_STATUS 过滤。
|
|
|
+ 两种模式响应结构一致(data.list / data.cursor / data.done)。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: 日志对象。
|
|
|
+ cursor (str, optional): 翻页游标,首页传空串。Defaults to ""。
|
|
|
+ merchant_id (int | str | None, optional): 商户 id;None=商户发现模式。Defaults to None。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ dict | None: 响应 JSON;失败时由 cgj_request 抛异常触发重试。
|
|
|
+ """
|
|
|
+ if merchant_id is not None:
|
|
|
+ body = {
|
|
|
+ "cursor": cursor,
|
|
|
+ "size": PAGE_SIZE,
|
|
|
+ "image_size": "68",
|
|
|
+ "status": HISTORY_STATUS,
|
|
|
+ "merchant_id": merchant_id,
|
|
|
+ }
|
|
|
+ else:
|
|
|
+ body = {
|
|
|
+ "cursor": cursor,
|
|
|
+ "size": PAGE_SIZE,
|
|
|
+ "image_size": "178",
|
|
|
+ "product_types": [1],
|
|
|
+ "category_id": 1,
|
|
|
+ }
|
|
|
+ return cgj_request(log, "/api/home/group_buying_products", method="POST", json_body=body)
|
|
|
+
|
|
|
+
|
|
|
+# ==================== 一、商户发现(shop 列表页) ====================
|
|
|
+def save_shops(log, items: list, sql_pool, seen: set) -> int:
|
|
|
+ """从 shop 列表页的商品项提取商户并写入 cgj_shop_record(存在则更新店名并复活)。
|
|
|
+
|
|
|
+ ------------------------------------------------------------------
|
|
|
+ 商户维度目前只取驱动管道必需的 merchant_id / merchant_name,
|
|
|
+ 如需更多商户字段(如 merchant_image_url),在下面 for 循环里往 data_dict 加即可,
|
|
|
+ 并同步 schema.sql 的 cgj_shop_record 列与下方 upsert 的 SQL。
|
|
|
+ ------------------------------------------------------------------
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: 日志对象。
|
|
|
+ items (list[dict]): group_buying_products 返回的 data.list,每项含 merchant_id / merchant_name。
|
|
|
+ sql_pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
+ seen (set): 跨页去重的 merchant_id 集合,避免重复写库。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ int: 本次实际写库的商户数(已去重)。
|
|
|
+ """
|
|
|
+ info_list = []
|
|
|
+ for item in items:
|
|
|
+ merchant_id = item.get("merchant_id") # 商户 id(管道必需)
|
|
|
+ merchant_name = item.get("merchant_name") # 商户名(管道必需)
|
|
|
+
|
|
|
+ data_dict = {"shop_id": merchant_id, "shop_name": merchant_name}
|
|
|
+
|
|
|
+ if not merchant_id or merchant_id in seen:
|
|
|
+ log.info(f"商户 {merchant_id} 已存在 seen,跳过")
|
|
|
+ continue
|
|
|
+ seen.add(merchant_id)
|
|
|
+ info_list.append(data_dict)
|
|
|
+
|
|
|
+ if not info_list:
|
|
|
+ log.info("本页无新商户,不写库")
|
|
|
+ return 0
|
|
|
+
|
|
|
+ # 存在则更新店名并把 is_deleted 刷回 0:能出现在列表 = 商户在营业(曾注销的在此复活)
|
|
|
+ sql = ("INSERT INTO cgj_shop_record (shop_id, shop_name) VALUES (%s, %s) "
|
|
|
+ "ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), is_deleted = 0")
|
|
|
+ args_list = [(d["shop_id"], d["shop_name"]) for d in info_list]
|
|
|
+ # print(args_list)
|
|
|
+ sql_pool.insert_many(query=sql, args_list=args_list)
|
|
|
+ return len(info_list)
|
|
|
+
|
|
|
+
|
|
|
+def get_shop_list(log, sql_pool) -> int:
|
|
|
+ """翻页遍历 shop 列表页,发现并写入全部商户。
|
|
|
+
|
|
|
+ 翻页靠响应的 done / cursor 判断,merchant_id 唯一键去重兜底。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: 日志对象。
|
|
|
+ sql_pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ int: 去重后发现的商户总数。
|
|
|
+ """
|
|
|
+ seen = set()
|
|
|
+ cursor = ""
|
|
|
+ page = 0
|
|
|
+
|
|
|
+ while page < MAX_PAGES:
|
|
|
+ try:
|
|
|
+ resp = get_group_buying_page(log, cursor, merchant_id=None)
|
|
|
+ except Exception as e:
|
|
|
+ log.error(f"shop 列表页 cursor={cursor!r} 请求失败: {e}")
|
|
|
+ break
|
|
|
+ if not resp or resp.get("code") != 0:
|
|
|
+ log.info(f"shop 列表页返回异常: {resp.get('msg') if resp else None}")
|
|
|
+ break
|
|
|
+
|
|
|
+ data = resp.get("data") or {}
|
|
|
+ items = data.get("list") or []
|
|
|
+ if not items:
|
|
|
+ log.info(f"shop 列表页第 {page + 1} 页无数据,停止翻页")
|
|
|
+ break
|
|
|
+
|
|
|
+ save_shops(log, items, sql_pool, seen)
|
|
|
+ log.info(f"shop 列表页第 {page + 1} 页完成,本页 {len(items)} 条,累计商户 {len(seen)}")
|
|
|
+
|
|
|
+ if data.get("done") or not data.get("cursor"):
|
|
|
+ break
|
|
|
+ cursor = data["cursor"]
|
|
|
+ page += 1
|
|
|
+
|
|
|
+ return len(seen)
|
|
|
+
|
|
|
+
|
|
|
+# ==================== 二、商品采集(商户历史成交 + 详情) ====================
|
|
|
+def get_product_detail(log, product_id) -> dict:
|
|
|
+ """获取商品详情 product/info,返回 data 节点(含 product_info / merchant_info 等)。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: 日志对象。
|
|
|
+ product_id (int | str): 商品 id。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ dict: 详情 data 字典;无数据时返回空字典。
|
|
|
+ """
|
|
|
+ resp = cgj_request(log, "/api/product/info", method="GET", params={"id": product_id})
|
|
|
+ return (resp or {}).get("data") or {}
|
|
|
+
|
|
|
+
|
|
|
+def get_live_detail(log, room_id: str) -> dict:
|
|
|
+ """获取直播回放详情 live/user_live/detail,返回 data 节点。
|
|
|
+
|
|
|
+ room_id 取自商品详情的 data.live_room_id。回放视频地址在 data.offline_address_info[].url
|
|
|
+ (腾讯云 vod,name="默认"),另带 status / start_time / end_time / duration 等直播信息。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: 日志对象。
|
|
|
+ room_id (str): 直播间 id(商品详情的 live_room_id)。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ dict: 回放详情 data 字典;room_id 为空或无数据时返回空字典。
|
|
|
+ """
|
|
|
+ if not room_id:
|
|
|
+ return {}
|
|
|
+ resp = cgj_request(log, "/api/live/user_live/detail", method="POST", json_body={"room_id": room_id})
|
|
|
+ return (resp or {}).get("data") or {}
|
|
|
+
|
|
|
+
|
|
|
+def build_product(item: dict, detail: dict, shop_id, shop_name: str, live_detail: dict = None) -> dict:
|
|
|
+ """把历史成交列表项 + 商品详情组装成 cgj_product_record 一行。
|
|
|
+
|
|
|
+ ------------------------------------------------------------------
|
|
|
+ 列表项字段(item.*)来自 group_buying_products;详情字段(detail.*)来自 product/info。
|
|
|
+ ------------------------------------------------------------------
|
|
|
+
|
|
|
+ Args:
|
|
|
+ item (dict): 历史成交列表里的商品项(group_buying_products data.list 项)。
|
|
|
+ detail (dict): product/info 的 data 节点;FETCH_DETAIL 关闭或失败时为空字典。
|
|
|
+ shop_id (int | str): 商户 id。
|
|
|
+ shop_name (str): 商户名。
|
|
|
+ live_detail (dict, optional): live/user_live/detail 的 data 节点(回放信息);
|
|
|
+ FETCH_REPLAY 关闭 / 无 live_room_id / 失败时为空字典。Defaults to None。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ dict: 与 cgj_product_record 列对应的数据字典。
|
|
|
+ """
|
|
|
+ # 详情里的嵌套节点(按需取用)
|
|
|
+ product_info = detail.get("product_info") or {}
|
|
|
+ basic_info = product_info.get("basic_info") or {}
|
|
|
+ # print(product_info)
|
|
|
+ # print('-------------------')
|
|
|
+ # print(basic_info)
|
|
|
+
|
|
|
+ # 直播回放(待确认):回放地址在 offline_address_info[].url(腾讯云 vod),取第一个(name="默认")
|
|
|
+ live_detail = live_detail or {}
|
|
|
+ offline_list = live_detail.get("offline_address_info") or []
|
|
|
+ replay_url = offline_list[0].get("url") if offline_list else None
|
|
|
+
|
|
|
+ row = {
|
|
|
+ # ---- 管道必需(勿删)----
|
|
|
+ "pid": item.get("id"), # 商品 id
|
|
|
+ "shop_id": shop_id, # 商户 id
|
|
|
+ "shop_name": shop_name, # 商户名
|
|
|
+
|
|
|
+ # ---- 列表项字段(item.*,待确认)----
|
|
|
+ "title": item.get("name"), # 商品名
|
|
|
+ "price": item.get("price"), # 价格
|
|
|
+ "imgs": item.get("image_url"), # 主图
|
|
|
+ "stock_quantity": item.get("stock_quantity"), # 库存
|
|
|
+ "sold_quantity": item.get("sold_quantity"), # 已售
|
|
|
+ "remaining_quantity": item.get("remaining_quantity"), # 剩余
|
|
|
+ "card_edition_name": item.get("card_edition_name"), # 卡版名
|
|
|
+
|
|
|
+ # ---- 详情字段(detail.*,待确认;FETCH_DETAIL 关闭时为空)----
|
|
|
+ "specs": basic_info.get("specs"), # 规格
|
|
|
+ "status": product_info.get("status"), # 商品状态
|
|
|
+ "listing_beg_time": product_info.get("listing_beg_time"), # 上架时间(详情直接给文本)
|
|
|
+ "listing_end_time": product_info.get("listing_end_time"), # 下架时间(详情文本)
|
|
|
+
|
|
|
+ # ---- 直播回放字段(来自 live/user_live/detail;FETCH_REPLAY 关闭时为空)----
|
|
|
+ "live_room_id": detail.get("live_room_id"), # 直播间 id(补采回放用,勿删)
|
|
|
+ "replay_url": replay_url, # 回放视频地址(offline_address_info[0].url,多个只取下标0)
|
|
|
+ "live_start_time": live_detail.get("start_time"), # 开播时间
|
|
|
+ "live_end_time": live_detail.get("end_time"), # 结束时间
|
|
|
+ }
|
|
|
+ return row
|
|
|
+
|
|
|
+
|
|
|
+def filter_new_pids(shop_id, page_pids: list, sql_pool) -> set:
|
|
|
+ """在「本页」的 pid 里查出哪些是新的(库中不存在),只查这一页、不整表拉取。
|
|
|
+
|
|
|
+ 为什么按页查而非一次性把该商户全部 pid 拉成集合:某些商户历史成交可能几十万条,
|
|
|
+ 整表拉取会 SELECT 出几十万行并在内存堆一个几十 MB 的集合,每轮每店都来一遍不划算。
|
|
|
+ 改为每页用 `pid IN (...)` 批量查已存在的(凭 pid 唯一索引很快),内存只占一页的量,
|
|
|
+ 商户体量再大也不受影响;增量本就只翻头几页(连续旧页即早停),每店只几次小查询。
|
|
|
+
|
|
|
+ 历史成交列表顺序不稳定、新商品会跨页穿插,所以判重用「集合」而非「单个停止线精确匹配」,
|
|
|
+ 再配合调用方的「连续 STOP_AFTER_DUPE_PAGES 页全旧」早停。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ shop_id (int | str): 商户 id。
|
|
|
+ page_pids (list): 本页商品 pid 列表(原始类型,可能为 int)。
|
|
|
+ sql_pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ set[int]: 本页中「库里还没有」的 pid 集合;本页为空时返回空集合。
|
|
|
+ pid 库内与 API 均为 int,直接按 int 比较(索引最优)。
|
|
|
+ """
|
|
|
+ pids = [int(p) for p in page_pids if p is not None]
|
|
|
+ if not pids:
|
|
|
+ return set()
|
|
|
+ placeholders = ",".join(["%s"] * len(pids))
|
|
|
+ rows = sql_pool.select_all(
|
|
|
+ f"SELECT pid FROM cgj_product_record WHERE shop_id = %s AND pid IN ({placeholders})",
|
|
|
+ (shop_id, *pids),
|
|
|
+ )
|
|
|
+ existing = {r[0] for r in rows} if rows else set()
|
|
|
+ return {p for p in pids if p not in existing}
|
|
|
+
|
|
|
+
|
|
|
+def get_sold_list(log, shop_id, shop_name: str, sql_pool, incremental: bool = True) -> int:
|
|
|
+ """遍历某商户历史成交翻页,逐商品补详情后写入 cgj_product_record。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: 日志对象。
|
|
|
+ shop_id (int | str): 商户 id。
|
|
|
+ shop_name (str): 商户名。
|
|
|
+ sql_pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
+ incremental (bool, optional): True(daily) 碰到最新已采 pid 早停,只采新商品;
|
|
|
+ False(history) 全量深翻所有页。Defaults to True。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ int: 本商户写入的商品数。
|
|
|
+ """
|
|
|
+ cursor = ""
|
|
|
+ page = 0
|
|
|
+ saved = 0
|
|
|
+ dupe_pages = 0 # 连续「全是已入库商品(0 新)」的页数,达到阈值即增量早停
|
|
|
+
|
|
|
+ while page < MAX_PAGES:
|
|
|
+ try:
|
|
|
+ resp = get_group_buying_page(log, cursor, merchant_id=shop_id)
|
|
|
+ # print(resp)
|
|
|
+ except Exception as e:
|
|
|
+ log.error(f"商户 {shop_id} 历史成交 cursor={cursor!r} 请求失败: {e}")
|
|
|
+ break
|
|
|
+
|
|
|
+ if not resp or resp.get("code") != 0:
|
|
|
+ log.info(f"商户 {shop_id} 历史成交返回异常: {resp.get('msg') if resp else None}")
|
|
|
+ break
|
|
|
+
|
|
|
+ data = resp.get("data") or {}
|
|
|
+ items = data.get("list") or []
|
|
|
+ if not items:
|
|
|
+ log.info(f"商户 {shop_id} 历史成交第 {page + 1} 页无数据,停止翻页")
|
|
|
+ break
|
|
|
+
|
|
|
+ # 增量判重:只查本页 pid 里哪些是新的(不整表拉取,适配商户体量增长)
|
|
|
+ new_pids = filter_new_pids(shop_id, [it.get("id") for it in items], sql_pool) if incremental else None
|
|
|
+
|
|
|
+ batch = []
|
|
|
+ new_on_page = 0 # 本页「新商品」数(用于连续全旧页早停判断)
|
|
|
+ for item in items:
|
|
|
+ pid = item.get("id")
|
|
|
+ # 已入库的商品直接跳过,连详情/回放都不再请求(省大量无用请求)
|
|
|
+ if incremental and int(pid) not in new_pids:
|
|
|
+ continue
|
|
|
+ new_on_page += 1
|
|
|
+
|
|
|
+ detail = {}
|
|
|
+ if FETCH_DETAIL:
|
|
|
+ try:
|
|
|
+ detail = get_product_detail(log, pid)
|
|
|
+ except Exception as e:
|
|
|
+ log.error(f"商品 {pid} 详情请求失败: {e}")
|
|
|
+
|
|
|
+ # 直播回放:room_id 取自详情的 live_room_id,再单独请求 live/user_live/detail
|
|
|
+ live_detail = {}
|
|
|
+ if FETCH_REPLAY and detail:
|
|
|
+ room_id = detail.get("live_room_id")
|
|
|
+ if room_id:
|
|
|
+ try:
|
|
|
+ live_detail = get_live_detail(log, room_id)
|
|
|
+ except Exception as e:
|
|
|
+ log.error(f"商品 {pid} 回放详情请求失败: {e}")
|
|
|
+
|
|
|
+ row = build_product(item, detail, shop_id, shop_name, live_detail)
|
|
|
+ if row:
|
|
|
+ batch.append(row)
|
|
|
+
|
|
|
+ if batch:
|
|
|
+ # 已存在(pid 唯一)则跳过:历史成交为终态,无需覆盖
|
|
|
+ sql_pool.insert_many(table="cgj_product_record", data_list=batch, ignore=True)
|
|
|
+ saved += len(batch)
|
|
|
+
|
|
|
+ log.info(f"商户 {shop_id} 历史成交第 {page + 1} 页完成,本页 {len(items)} 商品,新增 {new_on_page} 个")
|
|
|
+
|
|
|
+ # 增量早停:连续 STOP_AFTER_DUPE_PAGES 页都没有新商品 → 已翻到旧数据区,停止翻页
|
|
|
+ if incremental:
|
|
|
+ if new_on_page == 0:
|
|
|
+ dupe_pages += 1
|
|
|
+ if dupe_pages >= STOP_AFTER_DUPE_PAGES:
|
|
|
+ log.info(f"商户 {shop_id} 连续 {dupe_pages} 页无新商品,增量早停")
|
|
|
+ break
|
|
|
+ else:
|
|
|
+ dupe_pages = 0
|
|
|
+
|
|
|
+ if data.get("done") or not data.get("cursor"):
|
|
|
+ break
|
|
|
+ cursor = data["cursor"]
|
|
|
+ page += 1
|
|
|
+
|
|
|
+ return saved
|
|
|
+
|
|
|
+
|
|
|
+# ==================== 三、玩家采集(买家名单) ====================
|
|
|
+def get_player_page(log, product_id, cursor: str = "") -> dict | None:
|
|
|
+ """获取某商品玩家(买家)名单的一页 announcement_by_user。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: 日志对象。
|
|
|
+ product_id (int | str): 商品 id。
|
|
|
+ cursor (str, optional): 翻页游标,首页传空串。Defaults to ""。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ dict | None: 响应 JSON(含 data.list / cursor / done);失败由 cgj_request 抛异常触发重试。
|
|
|
+ """
|
|
|
+ return cgj_request(
|
|
|
+ log, "/api/product/announcement_by_user", method="GET",
|
|
|
+ params={"id": product_id, "cursor": cursor, "size": PLAYER_PAGE_SIZE},
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def save_players(product_id, items: list, sql_pool) -> int:
|
|
|
+ """解析玩家(买家)名单并写入 cgj_player_record。
|
|
|
+
|
|
|
+ ------------------------------------------------------------------
|
|
|
+ 并同步 schema.sql 的 cgj_player_record 列。
|
|
|
+ ------------------------------------------------------------------
|
|
|
+
|
|
|
+ Args:
|
|
|
+ product_id (int | str): 商品 id(写入 pid 列)。
|
|
|
+ items (list[dict]): announcement_by_user 的 data.list,每项含 view_key/total/buyer_name/buyer_image_url。
|
|
|
+ sql_pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ int: 本次写入的玩家记录数。
|
|
|
+ """
|
|
|
+ info_list = []
|
|
|
+ for item in items:
|
|
|
+ # print(item)
|
|
|
+ info_list.append({
|
|
|
+ "pid": product_id, # 商品 id(管道必需,item 里没有)
|
|
|
+ "view_key": item.get("view_key"), # 唯一标识
|
|
|
+ "total": item.get("total"), # 份数
|
|
|
+ "buyer_name": item.get("buyer_name") # 买家昵称(脱敏)
|
|
|
+ })
|
|
|
+ # print(info_list)
|
|
|
+
|
|
|
+ if info_list:
|
|
|
+ sql_pool.insert_many(table="cgj_player_record", data_list=info_list, ignore=True)
|
|
|
+ return len(info_list)
|
|
|
+
|
|
|
+
|
|
|
+def get_player_list(log, product_id, sql_pool) -> bool:
|
|
|
+ """遍历某商品玩家名单翻页并写库。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: 日志对象。
|
|
|
+ product_id (int | str): 商品 id。
|
|
|
+ sql_pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ bool: True 表示抓到玩家数据,False 表示无数据。
|
|
|
+ """
|
|
|
+ cursor = ""
|
|
|
+ page = 0
|
|
|
+ has_data = False
|
|
|
+
|
|
|
+ while page < MAX_PAGES:
|
|
|
+ try:
|
|
|
+ resp = get_player_page(log, product_id, cursor)
|
|
|
+ except Exception as e:
|
|
|
+ log.error(f"商品 {product_id} 玩家名单 cursor={cursor!r} 请求失败: {e}")
|
|
|
+ break
|
|
|
+
|
|
|
+ if not resp or resp.get("code") != 0:
|
|
|
+ log.info(f"商品 {product_id} 暂无玩家: {resp.get('msg') if resp else None}")
|
|
|
+ break
|
|
|
+
|
|
|
+ data = resp.get("data") or {}
|
|
|
+ items = data.get("list") or []
|
|
|
+ if not items:
|
|
|
+ log.info(f"商品 {product_id} 玩家名单翻页完成")
|
|
|
+ break
|
|
|
+
|
|
|
+ has_data = True
|
|
|
+ save_players(product_id, items, sql_pool)
|
|
|
+
|
|
|
+ if data.get("done") or not data.get("cursor"):
|
|
|
+ log.info(f"商品 {product_id} 玩家名单翻页完成")
|
|
|
+ break
|
|
|
+ cursor = data["cursor"]
|
|
|
+ page += 1
|
|
|
+
|
|
|
+ return has_data
|
|
|
+
|
|
|
+
|
|
|
+# ==================== 四、回放补采 ====================
|
|
|
+def refill_replays(log, sql_pool) -> int:
|
|
|
+ """补采回放地址:对已采玩家(player_state=1)但 replay_url 仍空的商品,
|
|
|
+ 重取 live_room_id → live/user_live/detail → offline_address_info[0].url → 更新 replay_url。
|
|
|
+
|
|
|
+ 覆盖场景:首次入库时直播还没结束 / 回放 VOD 还没生成,offline_address_info 为空、
|
|
|
+ replay_url 拿不到;直播结束、回放就绪后由本函数补回。库里已存 live_room_id 则直接用,
|
|
|
+ 没有(如首采详情失败)则回退重取商品详情拿 room_id。replay_url 仍空则留到下一轮再试。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: 日志对象。
|
|
|
+ sql_pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ int: 本轮成功补采的回放数。
|
|
|
+ """
|
|
|
+ rows = sql_pool.select_all(
|
|
|
+ "SELECT pid, live_room_id FROM cgj_product_record WHERE replay_url IS NULL AND player_state = 1"
|
|
|
+ )
|
|
|
+ rows = rows or []
|
|
|
+ log.info(f"待补回放商品 {len(rows)} 个")
|
|
|
+ filled = 0
|
|
|
+ for pid, room_id in rows:
|
|
|
+ try:
|
|
|
+ # 库里没存 room_id(首采详情失败等)→ 回退重取详情
|
|
|
+ if not room_id:
|
|
|
+ detail = get_product_detail(log, pid)
|
|
|
+ room_id = detail.get("live_room_id")
|
|
|
+ if not room_id:
|
|
|
+ continue
|
|
|
+
|
|
|
+ live_detail = get_live_detail(log, room_id)
|
|
|
+ offline_list = live_detail.get("offline_address_info") or []
|
|
|
+ url = offline_list[0].get("url") if offline_list else None
|
|
|
+ live_start_time = live_detail.get("start_time") # 开播时间
|
|
|
+ live_end_time = live_detail.get("end_time") # 结束时间
|
|
|
+ if url:
|
|
|
+ sql_pool.update_one(
|
|
|
+ "UPDATE cgj_product_record SET replay_url = %s, live_start_time = %s, live_end_time = %s WHERE pid = %s",
|
|
|
+ (url, live_start_time, live_end_time, pid),
|
|
|
+ )
|
|
|
+ filled += 1
|
|
|
+ log.info(f"商品 {pid} 回放已补采 → {url}")
|
|
|
+ except Exception as e:
|
|
|
+ log.error(f"商品 {pid} 回放补采失败: {e}")
|
|
|
+ return filled
|
|
|
+
|
|
|
+
|
|
|
+# ==================== 主流程 ====================
|
|
|
+@retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
|
|
|
+def cgj_main(log):
|
|
|
+ """潮谷街每日采集主函数:商户发现 → 商品采集(+详情) → 玩家采集。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: 日志对象。
|
|
|
+
|
|
|
+ Raises:
|
|
|
+ RuntimeError: 数据库连接池异常时抛出以触发重试。
|
|
|
+ """
|
|
|
+ log.info(f"开始运行 {sys._getframe().f_code.co_name} 潮谷街采集任务" + "." * 40)
|
|
|
+
|
|
|
+ sql_pool = MySQLConnectionPool(log=log)
|
|
|
+ if not sql_pool.check_pool_health():
|
|
|
+ log.error("数据库连接池异常")
|
|
|
+ raise RuntimeError("数据库连接池异常")
|
|
|
+
|
|
|
+ try:
|
|
|
+ # 1) 商户发现
|
|
|
+ try:
|
|
|
+ n = get_shop_list(log, sql_pool)
|
|
|
+ log.info(f"商户发现完成,去重商户 {n} 个")
|
|
|
+ except Exception as e:
|
|
|
+ log.error(f"get_shop_list error: {e}")
|
|
|
+
|
|
|
+ time.sleep(5)
|
|
|
+
|
|
|
+ # 2) 商品采集:遍历库内所有商户的历史成交
|
|
|
+ try:
|
|
|
+ shop_rows = sql_pool.select_all("SELECT shop_id, shop_name FROM cgj_shop_record WHERE is_deleted = 0")
|
|
|
+ log.info(f"待采集商户 {len(shop_rows)} 个")
|
|
|
+ for shop_id, shop_name in shop_rows:
|
|
|
+ try:
|
|
|
+ cnt = get_sold_list(log, shop_id, shop_name, sql_pool)
|
|
|
+ log.info(f"商户 {shop_id} {shop_name} 商品采集完成,写入 {cnt} 个")
|
|
|
+ except Exception as e:
|
|
|
+ log.error(f"get_sold_list error(商户 {shop_id}): {e}")
|
|
|
+ except Exception as e:
|
|
|
+ log.error(f"iterate_shop_list error: {e}")
|
|
|
+
|
|
|
+ time.sleep(5)
|
|
|
+
|
|
|
+ # 3) 玩家采集:遍历尚未成功采集玩家的商品
|
|
|
+ try:
|
|
|
+ prod_rows = sql_pool.select_all("SELECT pid FROM cgj_product_record WHERE player_state != 1")
|
|
|
+ pids = [row[0] for row in prod_rows] if prod_rows else []
|
|
|
+ log.info(f"待采集玩家的商品 {len(pids)} 个")
|
|
|
+ for pid in pids:
|
|
|
+ try:
|
|
|
+ # 先置 1 表示开始采集
|
|
|
+ sql_pool.update_one("UPDATE cgj_product_record SET player_state = 1 WHERE pid = %s", (pid,))
|
|
|
+ has_data = get_player_list(log, pid, sql_pool)
|
|
|
+ if not has_data:
|
|
|
+ # 无玩家置 2,下轮仍会重试
|
|
|
+ sql_pool.update_one("UPDATE cgj_product_record SET player_state = 2 WHERE pid = %s", (pid,))
|
|
|
+ except Exception as pid_error:
|
|
|
+ log.error(f"商品 {pid} 玩家采集失败: {pid_error}")
|
|
|
+ try:
|
|
|
+ sql_pool.update_one("UPDATE cgj_product_record SET player_state = 3 WHERE pid = %s", (pid,))
|
|
|
+ except Exception as update_error:
|
|
|
+ log.error(f"更新商品 {pid} 状态失败: {update_error}")
|
|
|
+ except Exception as e:
|
|
|
+ log.error(f"iterate_player_list error: {e}")
|
|
|
+
|
|
|
+ # 4) 回放补采:玩家采集之后,对已采玩家(player_state=1)但 replay_url 仍空的商品重取回放
|
|
|
+ try:
|
|
|
+ n = refill_replays(log, sql_pool)
|
|
|
+ log.info(f"回放补采完成,本轮 {n} 条")
|
|
|
+ except Exception as e:
|
|
|
+ log.error(f"refill_replays error: {e}")
|
|
|
+ except Exception as e:
|
|
|
+ log.error(f"{sys._getframe().f_code.co_name} error: {e}")
|
|
|
+ finally:
|
|
|
+ log.info(f"潮谷街采集 {sys._getframe().f_code.co_name} 运行结束,等待下一轮" + "." * 20)
|
|
|
+
|
|
|
+
|
|
|
+def schedule_task():
|
|
|
+ """定时任务入口:每天 00:01 运行一次 cgj_main。"""
|
|
|
+ # 立即运行一次(调试时取消注释)
|
|
|
+ # cgj_main(log=logger)
|
|
|
+
|
|
|
+ schedule.every().day.at("00:01").do(cgj_main, log=logger)
|
|
|
+ while True:
|
|
|
+ schedule.run_pending()
|
|
|
+ time.sleep(1)
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ # cgj_main(logger)
|
|
|
+ schedule_task()
|