cgj_daily_spider.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/07/06
  5. """潮谷街(吃谷商城)每日增量采集爬虫。
  6. 三级采集管道(结构对齐 kaji 的 kj_daily_spider):
  7. 1. 商户发现:翻页遍历「shop 列表页」group_buying_products(product_types/category_id 模式),
  8. 从每个商品里提取 merchant_id / merchant_name,写入 cgj_shop_record。
  9. 2. 商品采集:遍历库内每个商户的「历史成交」group_buying_products(merchant_id + status 模式),
  10. 逐商品补 product/info 详情后写入 cgj_product_record。
  11. 3. 玩家采集:遍历 cgj_product_record 中未采集过玩家的商品,抓 announcement_by_user
  12. 的买家名单写入 cgj_player_record。
  13. 鉴权:潮谷街这几个接口 access-token 为空、无签名/加密,属公开(游客)接口,
  14. 只需带固定的 devicecode / version 等请求头即可,适合长期无人值守。
  15. 翻页约定(三个列表接口统一):请求体/参数带 cursor(首页为空串),响应返回
  16. data.list(本页数据)、data.cursor(下一页游标)、data.done(true=到底)。
  17. 循环条件:done 为 True 或 cursor 为空即停止。
  18. 【字段解析】save_shops / build_product / save_players 里的 `for item in items:` 循环中,
  19. """
  20. import sys
  21. import time
  22. from datetime import datetime
  23. import requests
  24. import schedule
  25. from loguru import logger
  26. from tenacity import retry, stop_after_attempt, wait_fixed
  27. from mysql_pool import MySQLConnectionPool
  28. logger.remove()
  29. logger.add("./logs/{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
  30. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
  31. level="DEBUG", retention="7 day")
  32. # ==================== 基础配置 ====================
  33. # 业务域名(来源:抓包,四个接口都走这个域)
  34. BASE = "https://5fdc92.chaogujieapp.com"
  35. PAGE_SIZE = 20 # 列表接口每页条数(抓包实测 20)
  36. PLAYER_PAGE_SIZE = 20 # 玩家名单每页条数(抓包实测 20)
  37. # MAX_PAGES = 500 # 单个列表翻页保护上限,防异常时无限翻页
  38. # 增量早停阈值:某商户历史成交连续这么多页「全是已入库商品(0 新)」即停止翻页。
  39. # 用「连续多页」而非「单页」,兼容历史成交列表里新商品跨页穿插的情况;调大更稳、调小更省。
  40. STOP_AFTER_DUPE_PAGES = 2
  41. MAX_PAGES = 5 # 单个列表翻页保护上限,防异常时无限翻页 每日任务时设置更小限制
  42. # 「历史成交」过滤值(来源:抓包固定 status=1796)。九尾等仅有在售无历史成交的商户返回 0 条,符合预期。
  43. HISTORY_STATUS = 1796
  44. # 设备码 / App 版本(来源:抓包,公开接口无需登录 token)
  45. DEVICE_CODE = "8420C51411ECF16C802600868F2D214A"
  46. APP_VERSION = "1.0.15"
  47. # 是否为每个商品补拉 product/info 详情(拿系列/规格/简介/视频/上下架时间等)。
  48. # 关闭可大幅减少请求量,但详情维度字段会缺失。
  49. FETCH_DETAIL = True
  50. # 是否为每个商品补拉直播回放(live/user_live/detail,room_id 取自详情的 live_room_id)。
  51. # 依赖 FETCH_DETAIL=True——要先有详情才能拿到 live_room_id。
  52. FETCH_REPLAY = True
  53. # 是否使用代理。潮谷街实测可直连;若遇 IP 风控再置 True 并配置 get_proxys。
  54. USE_PROXY = False
  55. # 固定 UA(来源:抓包,潮谷街为 Flutter WebView)
  56. UA = ("Mozilla/5.0 (Linux; Android 11; Pixel 5 Build/RQ3A.211001.001; wv) "
  57. "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/148.0.7778.120 "
  58. "Mobile Safari/537.36")
  59. # 基础请求头(来源:抓包)。POST 的 content-type 由 requests 的 json= 自动补,不放这里。
  60. BASE_HEADERS = {
  61. "user-agent": UA,
  62. "accept-encoding": "gzip",
  63. "channel-version": APP_VERSION, # 抓包 channel-version
  64. "access-token": "", # 抓包为空:公开/游客接口,无需登录
  65. "source-id": "1", # 抓包固定
  66. "language": "zh", # 抓包固定
  67. "version": APP_VERSION, # 抓包 App 版本
  68. "devicecode": DEVICE_CODE, # 抓包设备码
  69. "platform-id": "1", # 抓包固定(1=Android)
  70. "channel-name": "huawei", # 抓包渠道
  71. }
  72. def after_log(retry_state):
  73. """tenacity 重试回调,记录每次尝试的结果。
  74. Args:
  75. retry_state: tenacity 传入的 RetryCallState 对象,含调用参数与结果。
  76. """
  77. # 约定业务函数首个位置参数为 log;取不到时回退全局 logger
  78. if retry_state.args and len(retry_state.args) > 0:
  79. log = retry_state.args[0]
  80. else:
  81. log = logger
  82. if retry_state.outcome.failed:
  83. log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
  84. else:
  85. log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
  86. @retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
  87. def get_proxys(log):
  88. """获取隧道代理配置(默认不启用,见 USE_PROXY)。
  89. Args:
  90. log: 日志对象。
  91. Returns:
  92. dict: requests 可用的 proxies 字典。
  93. Raises:
  94. Exception: 组装代理配置异常时向上抛出以触发重试。
  95. """
  96. tunnel = "x371.kdltps.com:15818"
  97. kdl_username = "t13753103189895"
  98. kdl_password = "o0yefv6z"
  99. try:
  100. proxies = {
  101. "http": "http://%(user)s:%(pwd)s@%(proxy)s/" % {"user": kdl_username, "pwd": kdl_password, "proxy": tunnel},
  102. "https": "http://%(user)s:%(pwd)s@%(proxy)s/" % {"user": kdl_username, "pwd": kdl_password,
  103. "proxy": tunnel},
  104. }
  105. return proxies
  106. except Exception as e:
  107. log.error(f"Error getting proxy: {e}")
  108. raise e
  109. def ts_to_dt(ts) -> str | None:
  110. """将秒级 Unix 时间戳转成 'YYYY-MM-DD HH:MM:SS' 字符串。
  111. Args:
  112. ts (int | str | None): 秒级时间戳;为 None / 0 / 空时返回 None。
  113. Returns:
  114. str | None: 格式化时间字符串;无有效时间戳时返回 None。
  115. """
  116. if not ts:
  117. return None
  118. try:
  119. return datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M:%S")
  120. except (ValueError, TypeError, OSError):
  121. return None
  122. @retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
  123. def cgj_request(log, path: str, method: str = "GET",
  124. params: dict = None, json_body: dict = None,
  125. extra_headers: dict = None) -> dict | None:
  126. """潮谷街通用请求函数(带重试)。
  127. 潮谷街接口无签名/加密,仅需固定请求头,故本函数只负责拼 URL、发请求、判状态码。
  128. Args:
  129. log: 日志对象。
  130. path (str): 接口相对路径(以 / 开头,不含域名),如 "/api/home/group_buying_products"。
  131. method (str, optional): 请求方法,"GET" 或 "POST"。Defaults to "GET"。
  132. params (dict, optional): URL query 参数。Defaults to None。
  133. json_body (dict, optional): POST 的 JSON body。Defaults to None。
  134. extra_headers (dict, optional): 追加 / 覆盖的请求头。Defaults to None。
  135. Returns:
  136. dict | None: 响应 JSON;非 200 时抛异常触发重试。
  137. Raises:
  138. RuntimeError: HTTP 状态码非 200 时抛出。
  139. """
  140. url = f"{BASE}{path}"
  141. req_headers = BASE_HEADERS.copy()
  142. if extra_headers:
  143. req_headers.update(extra_headers)
  144. proxies = get_proxys(log) if USE_PROXY else None
  145. if method.upper() == "POST":
  146. resp = requests.post(url, headers=req_headers, json=json_body, params=params, timeout=(5, 30), proxies=proxies)
  147. else:
  148. resp = requests.get(url, headers=req_headers, params=params, timeout=(5, 30), proxies=proxies)
  149. if resp.status_code != 200:
  150. log.error(f"请求失败 {resp.status_code}: {url}")
  151. raise RuntimeError(f"HTTP {resp.status_code}")
  152. return resp.json()
  153. def get_group_buying_page(log, cursor: str = "", merchant_id=None) -> dict | None:
  154. """获取 group_buying_products 一页(发现模式 / 商户历史成交模式二合一)。
  155. 该接口一站两用:
  156. - 不传 merchant_id:全局「shop 列表页」,用 product_types/category_id 过滤,用于商户发现。
  157. - 传 merchant_id:某商户「历史成交」,用 status=HISTORY_STATUS 过滤。
  158. 两种模式响应结构一致(data.list / data.cursor / data.done)。
  159. Args:
  160. log: 日志对象。
  161. cursor (str, optional): 翻页游标,首页传空串。Defaults to ""。
  162. merchant_id (int | str | None, optional): 商户 id;None=商户发现模式。Defaults to None。
  163. Returns:
  164. dict | None: 响应 JSON;失败时由 cgj_request 抛异常触发重试。
  165. """
  166. if merchant_id is not None:
  167. body = {
  168. "cursor": cursor,
  169. "size": PAGE_SIZE,
  170. "image_size": "68",
  171. "status": HISTORY_STATUS,
  172. "merchant_id": merchant_id,
  173. }
  174. else:
  175. body = {
  176. "cursor": cursor,
  177. "size": PAGE_SIZE,
  178. "image_size": "178",
  179. "product_types": [1],
  180. "category_id": 1,
  181. }
  182. return cgj_request(log, "/api/home/group_buying_products", method="POST", json_body=body)
  183. # ==================== 一、商户发现(shop 列表页) ====================
  184. def save_shops(log, items: list, sql_pool, seen: set) -> int:
  185. """从 shop 列表页的商品项提取商户并写入 cgj_shop_record(存在则更新店名并复活)。
  186. ------------------------------------------------------------------
  187. 商户维度目前只取驱动管道必需的 merchant_id / merchant_name,
  188. 如需更多商户字段(如 merchant_image_url),在下面 for 循环里往 data_dict 加即可,
  189. 并同步 schema.sql 的 cgj_shop_record 列与下方 upsert 的 SQL。
  190. ------------------------------------------------------------------
  191. Args:
  192. log: 日志对象。
  193. items (list[dict]): group_buying_products 返回的 data.list,每项含 merchant_id / merchant_name。
  194. sql_pool (MySQLConnectionPool): MySQL 连接池。
  195. seen (set): 跨页去重的 merchant_id 集合,避免重复写库。
  196. Returns:
  197. int: 本次实际写库的商户数(已去重)。
  198. """
  199. info_list = []
  200. for item in items:
  201. merchant_id = item.get("merchant_id") # 商户 id(管道必需)
  202. merchant_name = item.get("merchant_name") # 商户名(管道必需)
  203. data_dict = {"shop_id": merchant_id, "shop_name": merchant_name}
  204. if not merchant_id or merchant_id in seen:
  205. log.info(f"商户 {merchant_id} 已存在 seen,跳过")
  206. continue
  207. seen.add(merchant_id)
  208. info_list.append(data_dict)
  209. if not info_list:
  210. log.info("本页无新商户,不写库")
  211. return 0
  212. # 存在则更新店名并把 is_deleted 刷回 0:能出现在列表 = 商户在营业(曾注销的在此复活)
  213. sql = ("INSERT INTO cgj_shop_record (shop_id, shop_name) VALUES (%s, %s) "
  214. "ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), is_deleted = 0")
  215. args_list = [(d["shop_id"], d["shop_name"]) for d in info_list]
  216. # print(args_list)
  217. sql_pool.insert_many(query=sql, args_list=args_list)
  218. return len(info_list)
  219. def get_shop_list(log, sql_pool) -> int:
  220. """翻页遍历 shop 列表页,发现并写入全部商户。
  221. 翻页靠响应的 done / cursor 判断,merchant_id 唯一键去重兜底。
  222. Args:
  223. log: 日志对象。
  224. sql_pool (MySQLConnectionPool): MySQL 连接池。
  225. Returns:
  226. int: 去重后发现的商户总数。
  227. """
  228. seen = set()
  229. cursor = ""
  230. page = 0
  231. while page < MAX_PAGES:
  232. try:
  233. resp = get_group_buying_page(log, cursor, merchant_id=None)
  234. except Exception as e:
  235. log.error(f"shop 列表页 cursor={cursor!r} 请求失败: {e}")
  236. break
  237. if not resp or resp.get("code") != 0:
  238. log.info(f"shop 列表页返回异常: {resp.get('msg') if resp else None}")
  239. break
  240. data = resp.get("data") or {}
  241. items = data.get("list") or []
  242. if not items:
  243. log.info(f"shop 列表页第 {page + 1} 页无数据,停止翻页")
  244. break
  245. save_shops(log, items, sql_pool, seen)
  246. log.info(f"shop 列表页第 {page + 1} 页完成,本页 {len(items)} 条,累计商户 {len(seen)}")
  247. if data.get("done") or not data.get("cursor"):
  248. break
  249. cursor = data["cursor"]
  250. page += 1
  251. return len(seen)
  252. # ==================== 二、商品采集(商户历史成交 + 详情) ====================
  253. def get_product_detail(log, product_id) -> dict:
  254. """获取商品详情 product/info,返回 data 节点(含 product_info / merchant_info 等)。
  255. Args:
  256. log: 日志对象。
  257. product_id (int | str): 商品 id。
  258. Returns:
  259. dict: 详情 data 字典;无数据时返回空字典。
  260. """
  261. resp = cgj_request(log, "/api/product/info", method="GET", params={"id": product_id})
  262. return (resp or {}).get("data") or {}
  263. def get_live_detail(log, room_id: str) -> dict:
  264. """获取直播回放详情 live/user_live/detail,返回 data 节点。
  265. room_id 取自商品详情的 data.live_room_id。回放视频地址在 data.offline_address_info[].url
  266. (腾讯云 vod,name="默认"),另带 status / start_time / end_time / duration 等直播信息。
  267. Args:
  268. log: 日志对象。
  269. room_id (str): 直播间 id(商品详情的 live_room_id)。
  270. Returns:
  271. dict: 回放详情 data 字典;room_id 为空或无数据时返回空字典。
  272. """
  273. if not room_id:
  274. return {}
  275. resp = cgj_request(log, "/api/live/user_live/detail", method="POST", json_body={"room_id": room_id})
  276. return (resp or {}).get("data") or {}
  277. def build_product(item: dict, detail: dict, shop_id, shop_name: str, live_detail: dict = None) -> dict:
  278. """把历史成交列表项 + 商品详情组装成 cgj_product_record 一行。
  279. ------------------------------------------------------------------
  280. 列表项字段(item.*)来自 group_buying_products;详情字段(detail.*)来自 product/info。
  281. ------------------------------------------------------------------
  282. Args:
  283. item (dict): 历史成交列表里的商品项(group_buying_products data.list 项)。
  284. detail (dict): product/info 的 data 节点;FETCH_DETAIL 关闭或失败时为空字典。
  285. shop_id (int | str): 商户 id。
  286. shop_name (str): 商户名。
  287. live_detail (dict, optional): live/user_live/detail 的 data 节点(回放信息);
  288. FETCH_REPLAY 关闭 / 无 live_room_id / 失败时为空字典。Defaults to None。
  289. Returns:
  290. dict: 与 cgj_product_record 列对应的数据字典。
  291. """
  292. # 详情里的嵌套节点(按需取用)
  293. product_info = detail.get("product_info") or {}
  294. basic_info = product_info.get("basic_info") or {}
  295. # print(product_info)
  296. # print('-------------------')
  297. # print(basic_info)
  298. # 直播回放(待确认):回放地址在 offline_address_info[].url(腾讯云 vod),取第一个(name="默认")
  299. live_detail = live_detail or {}
  300. offline_list = live_detail.get("offline_address_info") or []
  301. replay_url = offline_list[0].get("url") if offline_list else None
  302. row = {
  303. # ---- 管道必需(勿删)----
  304. "pid": item.get("id"), # 商品 id
  305. "shop_id": shop_id, # 商户 id
  306. "shop_name": shop_name, # 商户名
  307. # ---- 列表项字段(item.*,待确认)----
  308. "title": item.get("name"), # 商品名
  309. "price": item.get("price"), # 价格
  310. "imgs": item.get("image_url"), # 主图
  311. "stock_quantity": item.get("stock_quantity"), # 库存
  312. "sold_quantity": item.get("sold_quantity"), # 已售
  313. "remaining_quantity": item.get("remaining_quantity"), # 剩余
  314. "card_edition_name": item.get("card_edition_name"), # 卡版名
  315. # ---- 详情字段(detail.*,待确认;FETCH_DETAIL 关闭时为空)----
  316. "specs": basic_info.get("specs"), # 规格
  317. "status": product_info.get("status"), # 商品状态
  318. "listing_beg_time": product_info.get("listing_beg_time"), # 上架时间(详情直接给文本)
  319. "listing_end_time": product_info.get("listing_end_time"), # 下架时间(详情文本)
  320. # ---- 直播回放字段(来自 live/user_live/detail;FETCH_REPLAY 关闭时为空)----
  321. "live_room_id": detail.get("live_room_id"), # 直播间 id(补采回放用,勿删)
  322. "replay_url": replay_url, # 回放视频地址(offline_address_info[0].url,多个只取下标0)
  323. "live_start_time": live_detail.get("start_time"), # 开播时间
  324. "live_end_time": live_detail.get("end_time"), # 结束时间
  325. }
  326. return row
  327. def filter_new_pids(shop_id, page_pids: list, sql_pool) -> set:
  328. """在「本页」的 pid 里查出哪些是新的(库中不存在),只查这一页、不整表拉取。
  329. 为什么按页查而非一次性把该商户全部 pid 拉成集合:某些商户历史成交可能几十万条,
  330. 整表拉取会 SELECT 出几十万行并在内存堆一个几十 MB 的集合,每轮每店都来一遍不划算。
  331. 改为每页用 `pid IN (...)` 批量查已存在的(凭 pid 唯一索引很快),内存只占一页的量,
  332. 商户体量再大也不受影响;增量本就只翻头几页(连续旧页即早停),每店只几次小查询。
  333. 历史成交列表顺序不稳定、新商品会跨页穿插,所以判重用「集合」而非「单个停止线精确匹配」,
  334. 再配合调用方的「连续 STOP_AFTER_DUPE_PAGES 页全旧」早停。
  335. Args:
  336. shop_id (int | str): 商户 id。
  337. page_pids (list): 本页商品 pid 列表(原始类型,可能为 int)。
  338. sql_pool (MySQLConnectionPool): MySQL 连接池。
  339. Returns:
  340. set[int]: 本页中「库里还没有」的 pid 集合;本页为空时返回空集合。
  341. pid 库内与 API 均为 int,直接按 int 比较(索引最优)。
  342. """
  343. pids = [int(p) for p in page_pids if p is not None]
  344. if not pids:
  345. return set()
  346. placeholders = ",".join(["%s"] * len(pids))
  347. rows = sql_pool.select_all(
  348. f"SELECT pid FROM cgj_product_record WHERE shop_id = %s AND pid IN ({placeholders})",
  349. (shop_id, *pids),
  350. )
  351. existing = {r[0] for r in rows} if rows else set()
  352. return {p for p in pids if p not in existing}
  353. def get_sold_list(log, shop_id, shop_name: str, sql_pool, incremental: bool = True) -> int:
  354. """遍历某商户历史成交翻页,逐商品补详情后写入 cgj_product_record。
  355. Args:
  356. log: 日志对象。
  357. shop_id (int | str): 商户 id。
  358. shop_name (str): 商户名。
  359. sql_pool (MySQLConnectionPool): MySQL 连接池。
  360. incremental (bool, optional): True(daily) 碰到最新已采 pid 早停,只采新商品;
  361. False(history) 全量深翻所有页。Defaults to True。
  362. Returns:
  363. int: 本商户写入的商品数。
  364. """
  365. cursor = ""
  366. page = 0
  367. saved = 0
  368. dupe_pages = 0 # 连续「全是已入库商品(0 新)」的页数,达到阈值即增量早停
  369. while page < MAX_PAGES:
  370. try:
  371. resp = get_group_buying_page(log, cursor, merchant_id=shop_id)
  372. # print(resp)
  373. except Exception as e:
  374. log.error(f"商户 {shop_id} 历史成交 cursor={cursor!r} 请求失败: {e}")
  375. break
  376. if not resp or resp.get("code") != 0:
  377. log.info(f"商户 {shop_id} 历史成交返回异常: {resp.get('msg') if resp else None}")
  378. break
  379. data = resp.get("data") or {}
  380. items = data.get("list") or []
  381. if not items:
  382. log.info(f"商户 {shop_id} 历史成交第 {page + 1} 页无数据,停止翻页")
  383. break
  384. # 增量判重:只查本页 pid 里哪些是新的(不整表拉取,适配商户体量增长)
  385. new_pids = filter_new_pids(shop_id, [it.get("id") for it in items], sql_pool) if incremental else None
  386. batch = []
  387. new_on_page = 0 # 本页「新商品」数(用于连续全旧页早停判断)
  388. for item in items:
  389. pid = item.get("id")
  390. # 已入库的商品直接跳过,连详情/回放都不再请求(省大量无用请求)
  391. if incremental and int(pid) not in new_pids:
  392. continue
  393. new_on_page += 1
  394. detail = {}
  395. if FETCH_DETAIL:
  396. try:
  397. detail = get_product_detail(log, pid)
  398. except Exception as e:
  399. log.error(f"商品 {pid} 详情请求失败: {e}")
  400. # 直播回放:room_id 取自详情的 live_room_id,再单独请求 live/user_live/detail
  401. live_detail = {}
  402. if FETCH_REPLAY and detail:
  403. room_id = detail.get("live_room_id")
  404. if room_id:
  405. try:
  406. live_detail = get_live_detail(log, room_id)
  407. except Exception as e:
  408. log.error(f"商品 {pid} 回放详情请求失败: {e}")
  409. row = build_product(item, detail, shop_id, shop_name, live_detail)
  410. if row:
  411. batch.append(row)
  412. if batch:
  413. # 已存在(pid 唯一)则跳过:历史成交为终态,无需覆盖
  414. sql_pool.insert_many(table="cgj_product_record", data_list=batch, ignore=True)
  415. saved += len(batch)
  416. log.info(f"商户 {shop_id} 历史成交第 {page + 1} 页完成,本页 {len(items)} 商品,新增 {new_on_page} 个")
  417. # 增量早停:连续 STOP_AFTER_DUPE_PAGES 页都没有新商品 → 已翻到旧数据区,停止翻页
  418. if incremental:
  419. if new_on_page == 0:
  420. dupe_pages += 1
  421. if dupe_pages >= STOP_AFTER_DUPE_PAGES:
  422. log.info(f"商户 {shop_id} 连续 {dupe_pages} 页无新商品,增量早停")
  423. break
  424. else:
  425. dupe_pages = 0
  426. if data.get("done") or not data.get("cursor"):
  427. break
  428. cursor = data["cursor"]
  429. page += 1
  430. return saved
  431. # ==================== 三、玩家采集(买家名单) ====================
  432. def get_player_page(log, product_id, cursor: str = "") -> dict | None:
  433. """获取某商品玩家(买家)名单的一页 announcement_by_user。
  434. Args:
  435. log: 日志对象。
  436. product_id (int | str): 商品 id。
  437. cursor (str, optional): 翻页游标,首页传空串。Defaults to ""。
  438. Returns:
  439. dict | None: 响应 JSON(含 data.list / cursor / done);失败由 cgj_request 抛异常触发重试。
  440. """
  441. return cgj_request(
  442. log, "/api/product/announcement_by_user", method="GET",
  443. params={"id": product_id, "cursor": cursor, "size": PLAYER_PAGE_SIZE},
  444. )
  445. def save_players(product_id, items: list, sql_pool) -> int:
  446. """解析玩家(买家)名单并写入 cgj_player_record。
  447. ------------------------------------------------------------------
  448. 并同步 schema.sql 的 cgj_player_record 列。
  449. ------------------------------------------------------------------
  450. Args:
  451. product_id (int | str): 商品 id(写入 pid 列)。
  452. items (list[dict]): announcement_by_user 的 data.list,每项含 view_key/total/buyer_name/buyer_image_url。
  453. sql_pool (MySQLConnectionPool): MySQL 连接池。
  454. Returns:
  455. int: 本次写入的玩家记录数。
  456. """
  457. info_list = []
  458. for item in items:
  459. # print(item)
  460. info_list.append({
  461. "pid": product_id, # 商品 id(管道必需,item 里没有)
  462. "view_key": item.get("view_key"), # 唯一标识
  463. "total": item.get("total"), # 份数
  464. "buyer_name": item.get("buyer_name") # 买家昵称(脱敏)
  465. })
  466. # print(info_list)
  467. if info_list:
  468. sql_pool.insert_many(table="cgj_player_record", data_list=info_list, ignore=True)
  469. return len(info_list)
  470. def get_player_list(log, product_id, sql_pool) -> bool:
  471. """遍历某商品玩家名单翻页并写库。
  472. Args:
  473. log: 日志对象。
  474. product_id (int | str): 商品 id。
  475. sql_pool (MySQLConnectionPool): MySQL 连接池。
  476. Returns:
  477. bool: True 表示抓到玩家数据,False 表示无数据。
  478. """
  479. cursor = ""
  480. page = 0
  481. has_data = False
  482. while page < MAX_PAGES:
  483. try:
  484. resp = get_player_page(log, product_id, cursor)
  485. except Exception as e:
  486. log.error(f"商品 {product_id} 玩家名单 cursor={cursor!r} 请求失败: {e}")
  487. break
  488. if not resp or resp.get("code") != 0:
  489. log.info(f"商品 {product_id} 暂无玩家: {resp.get('msg') if resp else None}")
  490. break
  491. data = resp.get("data") or {}
  492. items = data.get("list") or []
  493. if not items:
  494. log.info(f"商品 {product_id} 玩家名单翻页完成")
  495. break
  496. has_data = True
  497. save_players(product_id, items, sql_pool)
  498. if data.get("done") or not data.get("cursor"):
  499. log.info(f"商品 {product_id} 玩家名单翻页完成")
  500. break
  501. cursor = data["cursor"]
  502. page += 1
  503. return has_data
  504. # ==================== 四、回放补采 ====================
  505. def refill_replays(log, sql_pool) -> int:
  506. """补采回放地址:对已采玩家(player_state=1)但 replay_url 仍空的商品,
  507. 重取 live_room_id → live/user_live/detail → offline_address_info[0].url → 更新 replay_url。
  508. 覆盖场景:首次入库时直播还没结束 / 回放 VOD 还没生成,offline_address_info 为空、
  509. replay_url 拿不到;直播结束、回放就绪后由本函数补回。库里已存 live_room_id 则直接用,
  510. 没有(如首采详情失败)则回退重取商品详情拿 room_id。replay_url 仍空则留到下一轮再试。
  511. Args:
  512. log: 日志对象。
  513. sql_pool (MySQLConnectionPool): MySQL 连接池。
  514. Returns:
  515. int: 本轮成功补采的回放数。
  516. """
  517. rows = sql_pool.select_all(
  518. "SELECT pid, live_room_id FROM cgj_product_record WHERE replay_url IS NULL AND player_state = 1"
  519. )
  520. rows = rows or []
  521. log.info(f"待补回放商品 {len(rows)} 个")
  522. filled = 0
  523. for pid, room_id in rows:
  524. try:
  525. # 库里没存 room_id(首采详情失败等)→ 回退重取详情
  526. if not room_id:
  527. detail = get_product_detail(log, pid)
  528. room_id = detail.get("live_room_id")
  529. if not room_id:
  530. continue
  531. live_detail = get_live_detail(log, room_id)
  532. offline_list = live_detail.get("offline_address_info") or []
  533. url = offline_list[0].get("url") if offline_list else None
  534. live_start_time = live_detail.get("start_time") # 开播时间
  535. live_end_time = live_detail.get("end_time") # 结束时间
  536. if url:
  537. sql_pool.update_one(
  538. "UPDATE cgj_product_record SET replay_url = %s, live_start_time = %s, live_end_time = %s WHERE pid = %s",
  539. (url, live_start_time, live_end_time, pid),
  540. )
  541. filled += 1
  542. log.info(f"商品 {pid} 回放已补采 → {url}")
  543. except Exception as e:
  544. log.error(f"商品 {pid} 回放补采失败: {e}")
  545. return filled
  546. # ==================== 主流程 ====================
  547. @retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
  548. def cgj_main(log):
  549. """潮谷街每日采集主函数:商户发现 → 商品采集(+详情) → 玩家采集。
  550. Args:
  551. log: 日志对象。
  552. Raises:
  553. RuntimeError: 数据库连接池异常时抛出以触发重试。
  554. """
  555. log.info(f"开始运行 {sys._getframe().f_code.co_name} 潮谷街采集任务" + "." * 40)
  556. sql_pool = MySQLConnectionPool(log=log)
  557. if not sql_pool.check_pool_health():
  558. log.error("数据库连接池异常")
  559. raise RuntimeError("数据库连接池异常")
  560. try:
  561. # 1) 商户发现
  562. try:
  563. n = get_shop_list(log, sql_pool)
  564. log.info(f"商户发现完成,去重商户 {n} 个")
  565. except Exception as e:
  566. log.error(f"get_shop_list error: {e}")
  567. time.sleep(5)
  568. # 2) 商品采集:遍历库内所有商户的历史成交
  569. try:
  570. shop_rows = sql_pool.select_all("SELECT shop_id, shop_name FROM cgj_shop_record WHERE is_deleted = 0")
  571. log.info(f"待采集商户 {len(shop_rows)} 个")
  572. for shop_id, shop_name in shop_rows:
  573. try:
  574. cnt = get_sold_list(log, shop_id, shop_name, sql_pool)
  575. log.info(f"商户 {shop_id} {shop_name} 商品采集完成,写入 {cnt} 个")
  576. except Exception as e:
  577. log.error(f"get_sold_list error(商户 {shop_id}): {e}")
  578. except Exception as e:
  579. log.error(f"iterate_shop_list error: {e}")
  580. time.sleep(5)
  581. # 3) 玩家采集:遍历尚未成功采集玩家的商品
  582. try:
  583. prod_rows = sql_pool.select_all("SELECT pid FROM cgj_product_record WHERE player_state != 1")
  584. pids = [row[0] for row in prod_rows] if prod_rows else []
  585. log.info(f"待采集玩家的商品 {len(pids)} 个")
  586. for pid in pids:
  587. try:
  588. # 先置 1 表示开始采集
  589. sql_pool.update_one("UPDATE cgj_product_record SET player_state = 1 WHERE pid = %s", (pid,))
  590. has_data = get_player_list(log, pid, sql_pool)
  591. if not has_data:
  592. # 无玩家置 2,下轮仍会重试
  593. sql_pool.update_one("UPDATE cgj_product_record SET player_state = 2 WHERE pid = %s", (pid,))
  594. except Exception as pid_error:
  595. log.error(f"商品 {pid} 玩家采集失败: {pid_error}")
  596. try:
  597. sql_pool.update_one("UPDATE cgj_product_record SET player_state = 3 WHERE pid = %s", (pid,))
  598. except Exception as update_error:
  599. log.error(f"更新商品 {pid} 状态失败: {update_error}")
  600. except Exception as e:
  601. log.error(f"iterate_player_list error: {e}")
  602. # 4) 回放补采:玩家采集之后,对已采玩家(player_state=1)但 replay_url 仍空的商品重取回放
  603. try:
  604. n = refill_replays(log, sql_pool)
  605. log.info(f"回放补采完成,本轮 {n} 条")
  606. except Exception as e:
  607. log.error(f"refill_replays error: {e}")
  608. except Exception as e:
  609. log.error(f"{sys._getframe().f_code.co_name} error: {e}")
  610. finally:
  611. log.info(f"潮谷街采集 {sys._getframe().f_code.co_name} 运行结束,等待下一轮" + "." * 20)
  612. def schedule_task():
  613. """定时任务入口:每天 00:01 运行一次 cgj_main。"""
  614. # 立即运行一次(调试时取消注释)
  615. # cgj_main(log=logger)
  616. schedule.every().day.at("00:01").do(cgj_main, log=logger)
  617. while True:
  618. schedule.run_pending()
  619. time.sleep(1)
  620. if __name__ == "__main__":
  621. # cgj_main(logger)
  622. schedule_task()