jw_sold_spider.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/08/19
  5. """集物星球已售抓取:商家列表→逐商家已售 corp/history,并对全站已售商品抓拆卡报告与购买记录。
  6. 接口对应(以抓包为准):
  7. - 商家列表 hotRecommend(需登录)→ jw_shop_record
  8. - 已售历史 corp/history(免登录)→ jw_sold_product_record(只增)
  9. - 拆卡报告 /goods/gift/report/query/search/pager(需登录, sbt=6)→ jw_report_record(只增, giftReportId 去重)
  10. - 购买记录 /order/merchant/app/query/gift/publicity/user/group/pager(赠品公示·玩家维度, 需登录)→ jw_player_record
  11. 每条=一个买家 userId/userNick/picId/count,翻页拿全量;**不 DB 去重**,靠 jw_sold_product_record.buy_fetched
  12. 状态位控制每商品抓一次(翻页取全→批量入库成功→再置 buy_fetched=1;失败不置位、下轮重试)。
  13. 拆卡报告「更新不及时」:对结束 REPORT_REFETCH_DAYS 天内(或未结束)的已售商品每轮重查(INSERT IGNORE 自然补齐),
  14. 老车只补抓从未抓过的。
  15. """
  16. import sys
  17. import time
  18. import json
  19. import schedule
  20. from loguru import logger
  21. from tenacity import retry, stop_after_attempt, wait_fixed
  22. from mysql_pool import MySQLConnectionPool
  23. import jiwu_core as core
  24. import jw_detail
  25. TARGET_CORPS = [] # 空=全站;如 [100716, 100715] 只抓 Jake/九叔
  26. SYSTEM_BUSINESS_TYPE = 5
  27. PAGE_LIMIT = 20
  28. MAX_SHOP_PAGES = 30 # 商家列表翻页上限
  29. MAX_SOLD_PAGES = 300 # 单商家已售翻页上限
  30. REPORT_PAGE_LIMIT = 20 # 拆卡报告翻页每页
  31. MAX_REPORT_PAGES = 50 # 单商品拆卡报告翻页上限
  32. REPORT_REFETCH_DAYS = 3 # 拆卡报告:结束 N 天内每轮重查(补迟到更新),老车只补抓未抓过的
  33. BUY_PAGE_LIMIT = 20 # 购买记录翻页每页
  34. MAX_BUY_PAGES = 50 # 单商品购买记录翻页上限
  35. logger.remove()
  36. logger.add("./logs/sold_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
  37. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}", level="DEBUG", retention="7 day")
  38. _SOLD_COLS = ["goods_id", "program_id", "goods_name", "product_type", "business_type",
  39. "goods_ip_id", "goods_ip_name", "random_type", "corp_info_id", "corp_info_name",
  40. "amount", "highest_price", "lowest_price", "stock_amount", "residue_stock_amount",
  41. "specification_name", "report_status", "live_replay_url", "sold_time",
  42. "soldout_time", "finish_time"]
  43. def parse_shop(rec: dict) -> dict:
  44. """把 hotRecommend 一条商家记录规范化为入库字典。
  45. hotRecommend 商家字段实测:`corpInfoId`(商家id) / `corpInfoName`(名) / `number`(**粉丝数**) /
  46. `saleNum`(**在售商品数**)。响应里的 `userId` 是查询者本人账号id(每行恒等)、非商家id,不入库;
  47. 好评/新品/简介接口体系无此字段,不入库。
  48. Args:
  49. rec (dict): 商家列表返回的一条。
  50. Returns:
  51. dict: 与 jw_shop_record 列对齐的字典(corp_info_id/corp_name/fans_amount/onsale_amount)。
  52. """
  53. return {
  54. "corp_info_id": rec.get("corpInfoId"),
  55. "corp_name": rec.get("corpInfoName"),
  56. "fans_amount": rec.get("number"), # number = 粉丝数(实测确认)
  57. "onsale_amount": rec.get("saleNum"), # saleNum = 在售商品数
  58. }
  59. LIVE_BASE_URL = "https://play.jiwustar.com" # 直播回放域名(BASE_PULL_STREAM_URL, 逆向 EnvironmentManager RELEASE)
  60. def full_live_url(path) -> str | None:
  61. """把 liveReplayUrl 相对路径(形如 /live/xxx.mp4)拼成完整直播回放链接。
  62. 接口返回的 liveReplayUrl 只含 `/live/...`,需拼直播域名 https://play.jiwustar.com(路径已带 /live/)。
  63. Args:
  64. path (str | None): 接口返回的 liveReplayUrl。
  65. Returns:
  66. str | None: 完整 URL;空值返回 None;已是 http(s) 开头则原样返回。
  67. """
  68. if not path:
  69. return None
  70. if str(path).startswith("http"):
  71. return path
  72. return LIVE_BASE_URL + (path if str(path).startswith("/") else "/" + path)
  73. def parse_sold(rec: dict) -> dict:
  74. """把 corp/history 一条已售记录规范化为入库字典。
  75. Args:
  76. rec (dict): 已售历史返回的一条。
  77. Returns:
  78. dict: 与 jw_sold_product_record 列对齐的字典。
  79. """
  80. return {
  81. "goods_id": rec.get("goodsId"), "program_id": rec.get("programId"),
  82. "goods_name": rec.get("goodsName"), "product_type": rec.get("productType"),
  83. "business_type": rec.get("businessType"), "goods_ip_id": rec.get("goodsIPId"),
  84. "goods_ip_name": rec.get("goodsIPName"), "random_type": rec.get("randomType"),
  85. "corp_info_id": rec.get("corpInfoId"), "corp_info_name": rec.get("corpInfoName"),
  86. "amount": core.to_yuan(rec.get("amount")), "highest_price": core.to_yuan(rec.get("highestPrice")),
  87. "lowest_price": core.to_yuan(rec.get("lowestPrice")), "stock_amount": rec.get("stockAmount"),
  88. "residue_stock_amount": rec.get("residueStockAmount"),
  89. "specification_name": rec.get("specificationName"), "report_status": rec.get("reportStatus"),
  90. "live_replay_url": full_live_url(rec.get("liveReplayUrl")), "sold_time": rec.get("soldTime"),
  91. "soldout_time": rec.get("soldOutTime"), "finish_time": rec.get("finishTime"),
  92. }
  93. def get_shops(log, pool) -> list:
  94. """抓商家列表 hotRecommend 落库 jw_shop_record,返回 corp_info_id 列表。
  95. Args:
  96. log: 日志对象。
  97. pool: 数据库连接池。
  98. Returns:
  99. list: 商家 corp_info_id 列表(受 TARGET_CORPS 过滤)。
  100. """
  101. corp_ids = []
  102. for page in range(1, MAX_SHOP_PAGES + 1):
  103. j = core.do_request(log, "/search/app/index/corp/hotRecommend",
  104. {"currentPage": str(page), "limit": str(PAGE_LIMIT), "systemBusinessType": 6},
  105. need_auth=True) # 商家列表实测需登录
  106. if not j:
  107. break
  108. data = j.get("data") or {}
  109. recs = data.get("records") or []
  110. if not recs:
  111. break
  112. shops = [parse_shop(r) for r in recs]
  113. for s in shops:
  114. pool.update_one(
  115. "INSERT INTO jw_shop_record (corp_info_id,corp_name,fans_amount,onsale_amount) "
  116. "VALUES (%s,%s,%s,%s) "
  117. "ON DUPLICATE KEY UPDATE corp_name=VALUES(corp_name),"
  118. "fans_amount=VALUES(fans_amount),onsale_amount=VALUES(onsale_amount)",
  119. (s["corp_info_id"], s["corp_name"], s["fans_amount"], s["onsale_amount"]))
  120. corp_ids.append(s["corp_info_id"])
  121. # 末页判定用响应里的实际每页大小 data.limit:hotRecommend 服务端把每页压到 10 条(≠请求的 PAGE_LIMIT),
  122. # 若用 PAGE_LIMIT 判会第 1 页就误判停(之前只翻到 10 个商家的 bug)。实测全量约 82 个商家、9 页。
  123. srv_limit = data.get("limit") or PAGE_LIMIT
  124. if len(recs) < srv_limit:
  125. break
  126. time.sleep(0.3)
  127. if TARGET_CORPS:
  128. corp_ids = [c for c in corp_ids if c in TARGET_CORPS]
  129. log.info(f"商家列表入库完成,待抓已售商家 {len(corp_ids)} 家")
  130. return corp_ids
  131. def fetch_sold_for_corp(log, pool, corp_id: int) -> list:
  132. """翻页抓某商家的已售历史,INSERT IGNORE 只增落库。
  133. Args:
  134. log: 日志对象。
  135. pool: 数据库连接池。
  136. corp_id (int): 商家 corpInfoId。
  137. Returns:
  138. list: 本商家本轮抓到的已售商品 goods_id 列表(供抓拆卡报告/购买记录)。
  139. """
  140. total, goods_ids = 0, []
  141. for page in range(1, MAX_SOLD_PAGES + 1):
  142. j = core.do_request(log, "/search/app/corp/history", {
  143. "corpInfoId": str(corp_id), "currentPage": str(page),
  144. "limit": str(PAGE_LIMIT), "systemBusinessType": SYSTEM_BUSINESS_TYPE}) # 免登录
  145. if not j:
  146. break
  147. recs = (j.get("data") or {}).get("records") or []
  148. if not recs:
  149. break
  150. rows = [parse_sold(r) for r in recs]
  151. pool.insert_many(table="jw_sold_product_record", data_list=rows, ignore=True)
  152. goods_ids.extend(r["goods_id"] for r in rows if r.get("goods_id"))
  153. total += len(rows)
  154. if len(recs) < PAGE_LIMIT:
  155. break
  156. time.sleep(0.3)
  157. log.info(f"商家 {corp_id} 已售抓取 {total} 条")
  158. return goods_ids
  159. # ---------------- 拆卡报告(需登录, sbt=6, INSERT IGNORE 去重, 结束N天内重查) ----------------
  160. def parse_report(rec: dict, goods_id: int) -> dict:
  161. """把拆卡报告一条记录规范化为入库字典(goods_id 由调用方补,响应体不含)。
  162. Args:
  163. rec (dict): gift/report/query/search/pager 返回 records 里的一条。
  164. goods_id (int): 所属商品ID(查询参数)。
  165. Returns:
  166. dict: 与 jw_report_record 列对齐的字典。
  167. """
  168. res = rec.get("resAddrList")
  169. return {
  170. "gift_report_id": rec.get("giftReportId"), "goods_id": goods_id,
  171. "user_id": rec.get("userId"), "username": rec.get("username"),
  172. "corp_id": rec.get("corpId"), "corp_name": rec.get("corpName"),
  173. "goods_name": rec.get("goodsName"), "serial_item_name": rec.get("serialItemName"),
  174. "res_addr_list": json.dumps(res, ensure_ascii=False) if res is not None else None,
  175. "winner_status": rec.get("winnerStatus"), "anonymous_status": rec.get("anonymousStatus"),
  176. "my_gift_status": rec.get("myGiftStatus"), "report_time": rec.get("createTime"),
  177. }
  178. def fetch_reports_for_goods(log, pool, goods_id: int) -> None:
  179. """翻页抓某商品的拆卡报告(需登录),INSERT IGNORE 按 giftReportId 只增落库。
  180. Args:
  181. log: 日志对象。
  182. pool: 数据库连接池。
  183. goods_id (int): 商品 goodsId。
  184. """
  185. total = 0
  186. for page in range(1, MAX_REPORT_PAGES + 1):
  187. j = core.do_request(log, "/goods/gift/report/query/search/pager", {
  188. "currentPage": str(page), "goodsId": str(goods_id), "limit": str(REPORT_PAGE_LIMIT),
  189. "systemBusinessType": 6}, need_auth=True) # 拆卡报告实测需登录, sbt=6
  190. if not j:
  191. break
  192. recs = (j.get("data") or {}).get("records") or []
  193. if not recs:
  194. break
  195. rows = [parse_report(r, goods_id) for r in recs]
  196. pool.insert_many(table="jw_report_record", data_list=rows, ignore=True)
  197. total += len(rows)
  198. if len(recs) < REPORT_PAGE_LIMIT:
  199. break
  200. time.sleep(0.2)
  201. if total:
  202. log.info(f"商品 {goods_id} 拆卡报告 {total} 条")
  203. def fetch_reports_for_sold(log, pool, goods_ids: list) -> None:
  204. """对已售商品抓拆卡报告:结束 REPORT_REFETCH_DAYS 天内(或未结束)每轮重查,老车只补抓从未抓过的。
  205. 拆卡报告有唯一 giftReportId,重查靠 INSERT IGNORE 去重,故重查安全、能补齐迟到的报告。
  206. Args:
  207. log: 日志对象。
  208. pool: 数据库连接池。
  209. goods_ids (list): 本商家本轮的已售商品 goods_id 列表。
  210. """
  211. ids = list({g for g in goods_ids if g})
  212. if not ids:
  213. return
  214. ph = ",".join(["%s"] * len(ids))
  215. have = {r[0] for r in pool.select_all(
  216. f"SELECT DISTINCT goods_id FROM jw_report_record WHERE goods_id IN ({ph})", tuple(ids))}
  217. recent = {r[0] for r in pool.select_all(
  218. f"SELECT goods_id FROM jw_sold_product_record WHERE goods_id IN ({ph}) "
  219. "AND (finish_time IS NULL OR finish_time >= NOW() - INTERVAL %s DAY)",
  220. tuple(ids) + (REPORT_REFETCH_DAYS,))}
  221. todo = recent | (set(ids) - have) # 近N天(或未结束)重查 + 从未抓过补一次
  222. for gid in todo:
  223. try:
  224. fetch_reports_for_goods(log, pool, gid)
  225. except Exception as e:
  226. log.error(f"商品 {gid} 拆卡报告抓取异常: {e}")
  227. # ---------------- 购买记录(赠品公示·玩家维度, 需登录, 翻页拿全, buy_fetched 状态位控制抓一次) ----------------
  228. def parse_buy(rec: dict, goods_id: int) -> dict:
  229. """把玩家维度购买记录一条买家规范化为入库字典。
  230. Args:
  231. rec (dict): publicity/user/group/pager 返回 records 里的一条。
  232. goods_id (int): 所属商品ID(兜底,响应体一般也带 goodsId)。
  233. Returns:
  234. dict: 与 jw_player_record 列对齐的字典(goods_id/user_id/user_nick/pic_id/buy_count)。
  235. """
  236. return {
  237. "goods_id": rec.get("goodsId") or goods_id, "user_id": rec.get("userId"),
  238. "user_nick": rec.get("userNick"), "pic_id": rec.get("picId"),
  239. "buy_count": rec.get("count"),
  240. }
  241. def fetch_buy_for_goods(log, pool, goods_id: int) -> bool:
  242. """翻页取全某商品的购买记录(玩家维度,需登录)→ 批量入库(不去重)。
  243. 先把所有页收齐再一次性入库;只要有一页请求失败就判为未取全、返回 False(不入库、不置状态位、下轮重试)。
  244. Args:
  245. log: 日志对象。
  246. pool: 数据库连接池。
  247. goods_id (int): 商品 goodsId。
  248. Returns:
  249. bool: 取全并入库成功返回 True(供上层置 buy_fetched=1);请求失败返回 False。
  250. """
  251. rows = []
  252. for page in range(1, MAX_BUY_PAGES + 1):
  253. j = core.do_request(log, "/order/merchant/app/query/gift/publicity/user/group/pager", {
  254. "currentPage": str(page), "giftBusinessName": "", "goodsId": str(goods_id),
  255. "limit": str(BUY_PAGE_LIMIT), "systemBusinessType": SYSTEM_BUSINESS_TYPE}, need_auth=True) # 需登录
  256. if not j:
  257. return False # 请求失败=未取全,返回 False 让上层不置状态位、下轮重试
  258. recs = (j.get("data") or {}).get("records") or []
  259. if not recs:
  260. break
  261. rows.extend(parse_buy(r, goods_id) for r in recs if r.get("userId") is not None)
  262. if len(recs) < BUY_PAGE_LIMIT:
  263. break
  264. time.sleep(0.2)
  265. if rows:
  266. pool.insert_many(table="jw_player_record", data_list=rows, ignore=False) # 不去重,批量保存
  267. log.info(f"商品 {goods_id} 购买记录 {len(rows)} 条")
  268. return True
  269. def fetch_buy_for_pending(log, pool, goods_ids: list) -> None:
  270. """对未抓过购买记录(buy_fetched=0)的已售商品抓全入库,成功后置 buy_fetched=1。
  271. 买家列表售罄后即固定,用已售表状态位控制「每个商品抓一次」;取全并入库成功才置位,失败下轮重试。
  272. Args:
  273. log: 日志对象。
  274. pool: 数据库连接池。
  275. goods_ids (list): 本商家本轮的已售商品 goods_id 列表。
  276. """
  277. ids = list({g for g in goods_ids if g})
  278. if not ids:
  279. return
  280. ph = ",".join(["%s"] * len(ids))
  281. pending = [r[0] for r in pool.select_all(
  282. f"SELECT goods_id FROM jw_sold_product_record WHERE goods_id IN ({ph}) AND buy_fetched=0", tuple(ids))]
  283. for gid in pending:
  284. try:
  285. if fetch_buy_for_goods(log, pool, gid):
  286. pool.update_one("UPDATE jw_sold_product_record SET buy_fetched=1 WHERE goods_id=%s", (gid,))
  287. except Exception as e:
  288. log.error(f"商品 {gid} 购买记录抓取异常: {e}")
  289. @retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=core.after_log)
  290. def main_task(log) -> None:
  291. """已售抓取主流程(挂了每小时重试):已售历史 + 拆卡报告 + 购买记录。
  292. Args:
  293. log: 日志对象。
  294. Raises:
  295. RuntimeError: 数据库连接池异常时抛出以触发重试。
  296. """
  297. log.info("开始已售抓取" + "." * 40)
  298. pool = MySQLConnectionPool(log=log)
  299. if not pool.check_pool_health():
  300. log.error("数据库连接池异常")
  301. raise RuntimeError("数据库连接池异常")
  302. try:
  303. get_shops(log, pool) # 先刷新商家表(hotRecommend 每日轮换, INSERT/UPDATE 累积)
  304. # 已售对库中【全量商家】循环查询——hotRecommend 每天只返回轮换的一批热门商家,
  305. # jw_shop_record 随天数累积覆盖更全;故从表里取全量(而非只取本轮 get_shops 返回的)。
  306. corp_ids = [r[0] for r in pool.select_all("SELECT corp_info_id FROM jw_shop_record")]
  307. if TARGET_CORPS: # 如只想抓指定商家(如 Jake/九叔)在此过滤
  308. corp_ids = [c for c in corp_ids if c in TARGET_CORPS]
  309. log.info(f"待抓已售商家 {len(corp_ids)} 家(取自 jw_shop_record 全量)")
  310. for cid in corp_ids:
  311. try:
  312. goods_ids = fetch_sold_for_corp(log, pool, cid) # 已售商品(免登录)
  313. jw_detail.enrich_detail(log, pool, "jw_sold_product_record", goods_ids) # 详情补全(免登录)
  314. fetch_reports_for_sold(log, pool, goods_ids) # 拆卡报告(需登录, 按状态重查)
  315. fetch_buy_for_pending(log, pool, goods_ids) # 购买记录(需登录, buy_fetched 状态位控制)
  316. except Exception as e:
  317. log.error(f"商家 {cid} 已售/拆卡报告/购买记录抓取异常: {e}")
  318. except Exception as e:
  319. log.error(f"已售抓取异常: {e}")
  320. finally:
  321. log.info("已售抓取结束,等待下一轮" + "." * 20)
  322. def schedule_task():
  323. """定时入口:每天 08:00 抓一次已售(含拆卡报告、购买记录)。
  324. 08:00 配合已售日报的业务日窗口 [昨17:00, 今06:00]:窗口 06:00 关闭后再采,
  325. 报告 09:10 前全站已售数据齐全,不漏窗口尾部(00:30~06:00)结束的团。
  326. """
  327. # main_task(log=logger) # 立即跑一次(调试时取消注释)
  328. schedule.every().day.at("08:00").do(main_task, log=logger)
  329. while True:
  330. schedule.run_pending()
  331. time.sleep(1)
  332. if __name__ == "__main__":
  333. schedule_task()