deca_sold_spider.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/08/03
  5. """得卡 DECA 商家历史成交采集爬虫(独立任务)。
  6. 抓取每个商家的「历史成交」groupbuy/merchant/sold-list,INSERT IGNORE 写入 deca_sold_record。
  7. 历史成交为终态、只增不改,用 product_code 唯一键去重增量采集。
  8. deca_sold_record 预留 report_state / replay_state,供后续通过 product_code / live_id
  9. 采集「拆卡报告」与「视频回放」。
  10. 鉴权:本接口需 Authorization Bearer(需 token),复用 deca_daily_spider 的登录/续期逻辑
  11. (优先 refreshToken 续期,避开阿里云验证码)。签名规则同样复用。
  12. """
  13. import sys
  14. import time
  15. import schedule
  16. from loguru import logger
  17. from tenacity import retry, stop_after_attempt, wait_fixed
  18. from mysql_pool import MySQLConnectionPool
  19. import deca_daily_spider as deca # 复用 do_request / ensure_token / make_signature / after_log
  20. # 覆盖 deca 导入时装的 logger handler,本任务日志独立到 sold_*.log
  21. logger.remove()
  22. logger.add("./logs/sold_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
  23. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
  24. level="DEBUG", retention="7 day")
  25. PAGE_SIZE = 20 # 历史成交每页条数(抓包实测 20)
  26. MAX_PAGES = 200 # 单商家历史成交翻页保护上限
  27. def parse_sold(item: dict) -> dict | None:
  28. """把历史成交列表项解析成 deca_sold_record 一行。
  29. Args:
  30. item (dict): sold-list 的 data.list 项(结构同在售商品列表)。
  31. Returns:
  32. dict | None: 与 deca_sold_record 列对应的数据字典;无 code 时返回 None。
  33. """
  34. code = item.get("code")
  35. if not code:
  36. return None
  37. m = item.get("merchant") or {}
  38. return {
  39. "product_code": code,
  40. "merchant_user_id": str(m.get("merchantUserID")) if m.get("merchantUserID") else None,
  41. "merchant_name": m.get("merchantName"),
  42. "title": item.get("title"),
  43. "card_product_title": item.get("cardProductTitle"),
  44. "cover_image_url": item.get("coverImageUrl"),
  45. "unit_price": item.get("unitPrice"),
  46. "min_unit_price": item.get("minUnitPrice"),
  47. "max_unit_price": item.get("maxUnitPrice"),
  48. "card_count": item.get("cardCount"),
  49. "sold_count": item.get("soldCount"),
  50. "available_stock": item.get("availableStock"),
  51. "groupbuy_status": item.get("groupbuyStatus"),
  52. "groupbuy_status_name": item.get("groupbuyStatusName"),
  53. "play_type": item.get("playType"),
  54. "live_id": item.get("liveId"),
  55. "completed_at": item.get("completedAt") or None, # 成交完成时间
  56. "publicity_at": item.get("publicityAt") or None,
  57. }
  58. def get_sold_list(log, merchant_user_id: str, pool) -> int:
  59. """翻页拉取某商家历史成交,INSERT IGNORE 写入 deca_sold_record。
  60. 历史成交为终态,用 product_code 唯一键去重,只增不改。
  61. Args:
  62. log: 日志对象。
  63. merchant_user_id (str): 商家用户 ID。
  64. pool (MySQLConnectionPool): MySQL 连接池。
  65. Returns:
  66. int: 本商家写入的历史成交商品数(已去重)。
  67. """
  68. page = 1
  69. saved = 0
  70. total = None
  71. while page <= MAX_PAGES:
  72. body = {"merchantUserId": merchant_user_id, "page": page, "pageSize": PAGE_SIZE}
  73. try:
  74. resp = deca.do_request(log, "/api/v1/app/groupbuy/merchant/sold-list", body, need_auth=True)
  75. except Exception as e:
  76. log.error(f"商家 {merchant_user_id} 历史成交第 {page} 页请求失败: {e}")
  77. break
  78. if not resp or resp.get("code") != 0:
  79. log.info(f"商家 {merchant_user_id} 历史成交返回异常: {resp.get('msg') if resp else None}")
  80. break
  81. data = resp.get("data") or {}
  82. if total is None:
  83. total = data.get("total")
  84. items = data.get("list") or []
  85. if not items:
  86. break
  87. rows = [r for r in (parse_sold(it) for it in items) if r]
  88. if rows:
  89. pool.insert_many(table="deca_sold_record", data_list=rows, ignore=True)
  90. saved += len(rows)
  91. # 翻页终止:已覆盖 total,或本页不足一页
  92. if total is not None and page * PAGE_SIZE >= total:
  93. break
  94. if len(items) < PAGE_SIZE:
  95. break
  96. page += 1
  97. time.sleep(0.2)
  98. if saved:
  99. log.info(f"商家 {merchant_user_id} 历史成交入库 {saved} 个(total={total})")
  100. return saved
  101. def fill_sold_details(log, pool) -> int:
  102. """给未补详情(publish_at 为空)的历史成交商品补上架/开售/结束时间。
  103. 调 groupbuy/detail 取 publishAt/saleStartAt/saleEndAt。历史成交为终态,
  104. 每个商品补一次即可(publish_at 非空后不再重复拉)。
  105. Args:
  106. log: 日志对象。
  107. pool (MySQLConnectionPool): MySQL 连接池。
  108. Returns:
  109. int: 本轮成功补详情的商品数。
  110. """
  111. rows = pool.select_all(
  112. "SELECT product_code FROM deca_sold_record WHERE publish_at IS NULL") or []
  113. log.info(f"待补详情历史成交商品 {len(rows)} 个")
  114. filled = 0
  115. for (code,) in rows:
  116. try:
  117. d = deca.get_product_detail(log, code)
  118. if not d:
  119. continue
  120. pool.update_one(
  121. "UPDATE deca_sold_record SET publish_at=%s, sale_start_at=%s, sale_end_at=%s "
  122. "WHERE product_code=%s",
  123. (d.get("publishAt") or None, d.get("saleStartAt") or None,
  124. d.get("saleEndAt") or None, code))
  125. filled += 1
  126. except Exception as e:
  127. log.error(f"历史成交商品 {code} 补详情失败: {e}")
  128. time.sleep(0.3) # 轻微限速
  129. return filled
  130. @retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=deca.after_log)
  131. def main_task(log):
  132. """遍历库内所有商家,采集历史成交写入 deca_sold_record。
  133. 商家名单取自 deca_shop_record(由 deca_daily_spider 维护)。
  134. Args:
  135. log: 日志对象。
  136. Raises:
  137. RuntimeError: 数据库连接池异常时抛出以触发重试。
  138. """
  139. log.info(f"开始运行 {sys._getframe().f_code.co_name} 商家历史成交采集" + "." * 40)
  140. pool = MySQLConnectionPool(log=log)
  141. if not pool.check_pool_health():
  142. log.error("数据库连接池异常")
  143. raise RuntimeError("数据库连接池异常")
  144. try:
  145. deca.ensure_token(log) # 预取 token(后续请求自动续期)
  146. rows = pool.select_all("SELECT merchant_user_id FROM deca_shop_record")
  147. uids = [r[0] for r in rows] if rows else []
  148. log.info(f"待采集商家 {len(uids)} 个")
  149. for uid in uids:
  150. try:
  151. get_sold_list(log, uid, pool)
  152. except Exception as e:
  153. log.error(f"get_sold_list error(商家 {uid}): {e}")
  154. time.sleep(0.3) # 轻微限速
  155. # 补详情:给未补(publish_at 为空)的历史成交商品拉 groupbuy/detail 的上架/开售/结束时间
  156. try:
  157. n = fill_sold_details(log, pool)
  158. log.info(f"历史成交补详情完成,本轮 {n} 个")
  159. except Exception as e:
  160. log.error(f"fill_sold_details error: {e}")
  161. except Exception as e:
  162. log.error(f"{sys._getframe().f_code.co_name} error: {e}")
  163. finally:
  164. log.info(f"商家历史成交采集 {sys._getframe().f_code.co_name} 运行结束" + "." * 20)
  165. def schedule_task():
  166. """定时任务入口:每天 03:00 采集一次商家历史成交。"""
  167. main_task(log=logger) # 立即跑一次(调试时取消注释)
  168. schedule.every().day.at("03:00").do(main_task, log=logger)
  169. while True:
  170. schedule.run_pending()
  171. time.sleep(1)
  172. if __name__ == "__main__":
  173. schedule_task()