deca_team_spider.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/08/11
  5. """得卡 DECA · 随机团 teams 明细高频采集(选队随机 + 剩余随机)。
  6. 背景:
  7. 卡牌拼团分两种"随机团"玩法,同一团会经历两阶段:
  8. · 选队随机(playTypeName 以「选队随机」开头):每支球队一价,team-options 接口(带 token)
  9. 返回逐队 cardCount / availableStock / unitPrice;已售数 = cardCount − availableStock。
  10. · 剩余随机(playTypeName == 剩余随机):卖不动后转成的兜底阶段。团购价变成"剩余池加权
  11. 均价"、买家随机开一张剩余卡。team-options **对剩余随机永远返 29000**、详情 snapshot
  12. 冻结在转换那一刻(每队只有 unitPrice/availableStock,无原始 cardCount)。
  13. 关键结论:**一旦转成剩余随机,原始 cardCount 就再也没接口能补回**。所以在选队阶段必须持续
  14. 抓 team-options 存表(deca_groupbuy_team_record),转剩余后我们才能用「存表的原始总价 − 当前
  15. 剩余货值」算出实时已售额。转剩余前没被我们抓到的老团,team_total_amount 留 NULL、报告端
  16. 标注"原始数据缺失"。
  17. 一轮做什么:
  18. 1) 从 deca_onsale_product_record 拉当前在售·选队随机团 → team-options →
  19. upsert 到 teams 表(data_source=team_options,同 (code, team_id) 覆盖成最新,captured_at
  20. 记本轮时刻) → 算 Σ 单价×(cardCount − availableStock) 写回 team_total_amount。
  21. 2) 从 deca_onsale_product_record 拉当前在售·剩余随机团 → 详情 snapshot 首次存表
  22. (data_source=snapshot;冻结不变,重复见到直接跳过) → 若库里有该团选队阶段的原始
  23. cardCount,则算 Σ 单价×cardCount − detail.unitPrice × detail.availableStock 写回
  24. team_total_amount;否则留 NULL。
  25. 登录态:
  26. - team-options 强制带 token(不带返 10002);
  27. - 详情接口 groupbuy/detail 免登录。
  28. - 与 alert / sold_daily 共用同一份根目录 token.json(HANDOFF 已说明)。
  29. 运行:项目根目录 `python on_sale/deca_team_spider.py`
  30. """
  31. import os
  32. import sys
  33. import time
  34. from datetime import datetime
  35. from decimal import Decimal, ROUND_HALF_UP
  36. import schedule
  37. from loguru import logger
  38. from tenacity import retry, stop_after_attempt, wait_fixed
  39. # 挂靠项目根:复用核心签名/token/请求 + application.yml
  40. _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  41. if _ROOT not in sys.path:
  42. sys.path.insert(0, _ROOT)
  43. # 切到项目根:token.json / application.yml / ./logs 都在根目录,
  44. # 否则从 on_sale/ 目录里启动时 cwd=on_sale/ 找不到根目录的 token.json,team-options(需 token) 会一直失败
  45. os.chdir(_ROOT)
  46. import deca_sold_core as core # noqa: E402
  47. from mysql_pool import MySQLConnectionPool # noqa: E402
  48. # ==================== 配置 ====================
  49. INTERVAL_SEC = 300 # 采集间隔:5 分钟一轮(选队随机变化较快、又不至于压接口)
  50. BETWEEN_ITEM_SEC = 0.3 # 单商品之间的最小间隔,避免瞬时限流
  51. MAX_ITEM_PER_ROUND = 500 # 单轮采集商品数上限(保护;正常远小于此)
  52. ONSALE_TABLE = "deca_onsale_product_record"
  53. TEAM_TABLE = "deca_groupbuy_team_record"
  54. # ==================== 日志 ====================
  55. logger.remove()
  56. logger.add("./logs/team_spider_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
  57. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
  58. level="DEBUG", retention="7 day")
  59. # logger.add(sys.stderr, level="INFO",
  60. # format="[{time:HH:mm:ss}] {level} {message}")
  61. def after_log(retry_state):
  62. """tenacity 重试回调,业务函数首参约定为 log。
  63. Args:
  64. retry_state: tenacity 传入的 RetryCallState,含调用参数与结果。
  65. """
  66. log = retry_state.args[0] if retry_state.args else logger
  67. if retry_state.outcome.failed:
  68. log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
  69. else:
  70. log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
  71. def _round2(x) -> Decimal | None:
  72. """Decimal 化并四舍五入到 2 位小数。
  73. Args:
  74. x: 数值(int/float/str/Decimal),None/异常直接返回 None。
  75. Returns:
  76. Decimal | None: 保留 2 位;None 表示不可算。
  77. """
  78. if x is None:
  79. return None
  80. try:
  81. return Decimal(str(x)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
  82. except Exception:
  83. return None
  84. def fetch_onsale_random(log, pool) -> tuple[list, list]:
  85. """从 deca_onsale_product_record 查当前在售·选队随机 / 剩余随机团 code 列表。
  86. Args:
  87. log: 日志对象。
  88. pool: MySQL 连接池。
  89. Returns:
  90. tuple[list[str], list[str]]: (选队随机 codes, 剩余随机 codes)。
  91. """
  92. rows = pool.select_all(
  93. f"SELECT product_code, play_type_name FROM {ONSALE_TABLE} "
  94. f"WHERE is_on_sale=1 AND play_type_name IS NOT NULL "
  95. f"LIMIT {MAX_ITEM_PER_ROUND}") or []
  96. xd, sy = [], []
  97. for code, ptn in rows:
  98. if not ptn:
  99. continue
  100. if "选队随机" in ptn:
  101. xd.append(code)
  102. elif "剩余随机" in ptn:
  103. sy.append(code)
  104. log.info(f"[发现] 在售·选队随机 {len(xd)} / 剩余随机 {len(sy)}")
  105. return xd, sy
  106. @retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
  107. def fetch_team_options(log, code: str) -> list | None:
  108. """打 team-options 接口拿一个团的逐队 teams(带 token)。
  109. Args:
  110. log: 日志对象。
  111. code (str): 商品编码。
  112. Returns:
  113. list | None: teams 列表;接口报 29000(已转剩余随机)或失败时返回 None(None 是业务态,
  114. 不视为需要重试的错误——只有网络级异常才会被 tenacity 拦下重试)。
  115. """
  116. r = core.do_request(log, "/api/v1/app/groupbuy/team-options", {"code": code}, need_auth=True)
  117. if not r or r.get("code") != 0:
  118. return None # 29000 = 商家开启剩余随机中,正常业务态
  119. return (r.get("data") or {}).get("list") or []
  120. @retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
  121. def fetch_detail(log, code: str) -> dict | None:
  122. """打 detail 接口拿详情(免 token)。
  123. Args:
  124. log: 日志对象。
  125. code (str): 商品编码。
  126. Returns:
  127. dict | None: data 字典;失败返回 None。
  128. """
  129. d = core.do_request(log, "/api/v1/app/groupbuy/detail", {"code": code}, need_auth=False)
  130. return (d or {}).get("data") or None
  131. def upsert_team_row(pool, code: str, ptn: str, source: str, team: dict,
  132. captured_at: str, snap_total: int | None = None) -> None:
  133. """把一条 team 明细 upsert 进 deca_groupbuy_team_record。
  134. 唯一键 (product_code, team_id, data_source) 冲突时按最新覆盖——
  135. 这就把「在售团每轮覆盖成最新」自动做掉了。
  136. Args:
  137. pool: MySQL 连接池。
  138. code (str): 商品编码。
  139. ptn (str): 采集时的 playTypeName。
  140. source (str): 数据来源 'team_options' / 'snapshot'。
  141. team (dict): 单条 team 数据(team-options 项 或 snapshot.teams 项)。
  142. captured_at (str): 采集时刻 YYYY-MM-DD HH:MM:SS。
  143. snap_total (int | None, optional): 剩余随机 snapshot.totalQuantity。Defaults to None。
  144. """
  145. tid = team.get("teamId")
  146. if tid is None:
  147. return
  148. cc = team.get("cardCount") # 选队阶段有;剩余快照无 → NULL
  149. av = team.get("availableStock")
  150. up = _round2(team.get("unitPrice"))
  151. sold = (cc - av) if (cc is not None and av is not None) else None
  152. pool._execute(
  153. f"INSERT INTO {TEAM_TABLE} "
  154. f"(product_code, play_type_name, data_source, team_id, team_name_en, team_name_zh, "
  155. f" team_logo_image_url, unit_price, card_count, available_stock, sold_count, "
  156. f" snapshot_total_quantity, captured_at) "
  157. f"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
  158. f"ON DUPLICATE KEY UPDATE "
  159. f" play_type_name=VALUES(play_type_name), team_name_en=VALUES(team_name_en), "
  160. f" team_name_zh=VALUES(team_name_zh), team_logo_image_url=VALUES(team_logo_image_url), "
  161. f" unit_price=VALUES(unit_price), card_count=VALUES(card_count), "
  162. f" available_stock=VALUES(available_stock), sold_count=VALUES(sold_count), "
  163. f" snapshot_total_quantity=VALUES(snapshot_total_quantity), "
  164. f" captured_at=VALUES(captured_at)",
  165. (code, ptn, source, tid, team.get("teamNameEn"), team.get("teamNameZh"),
  166. team.get("teamLogoImageUrl"), up, cc, av, sold, snap_total, captured_at),
  167. commit=True)
  168. def has_snapshot(pool, code: str) -> bool:
  169. """判断某团是否已存过剩余随机 snapshot(冻结值,只需存一次)。
  170. Args:
  171. pool: MySQL 连接池。
  172. code (str): 商品编码。
  173. Returns:
  174. bool: True 已存过、可跳过;False 未存。
  175. """
  176. row = pool.select_one(
  177. f"SELECT 1 FROM {TEAM_TABLE} WHERE product_code=%s AND data_source='snapshot' LIMIT 1",
  178. (code,))
  179. return bool(row)
  180. def get_original_total(pool, code: str) -> Decimal | None:
  181. """从 teams 表查某团选队阶段抓过的「原始总价」= Σ 单价×cardCount。
  182. Args:
  183. pool: MySQL 连接池。
  184. code (str): 商品编码。
  185. Returns:
  186. Decimal | None: 原始总价;缺 team-options 记录时返回 None。
  187. """
  188. row = pool.select_one(
  189. f"SELECT SUM(unit_price * card_count) FROM {TEAM_TABLE} "
  190. f"WHERE product_code=%s AND data_source='team_options' AND card_count IS NOT NULL",
  191. (code,))
  192. if not row or row[0] is None:
  193. return None
  194. return _round2(row[0])
  195. def update_team_total(pool, code: str, amount: Decimal | None) -> None:
  196. """把算好的团总价写回 deca_onsale_product_record.team_total_amount。
  197. Args:
  198. pool: MySQL 连接池。
  199. code (str): 商品编码。
  200. amount (Decimal | None): 总价;None 会显式清空(表示"数据缺失")。
  201. """
  202. pool.update_one(
  203. f"UPDATE {ONSALE_TABLE} SET team_total_amount=%s WHERE product_code=%s",
  204. (amount, code))
  205. def process_xuandui(log, pool, code: str, captured_at: str) -> bool:
  206. """处理一个在售·选队随机团:抓 team-options → 存表 → 算总价 → 回写商品。
  207. Args:
  208. log: 日志对象。
  209. pool: MySQL 连接池。
  210. code (str): 商品编码。
  211. captured_at (str): 本轮采集时刻。
  212. Returns:
  213. bool: True 成功;False 接口不可用(如刚转成剩余随机)。
  214. """
  215. teams = fetch_team_options(log, code)
  216. if not teams:
  217. return False # 可能这一瞬间刚转成剩余随机,本轮跳过;下轮以剩余随机身份进另一分支
  218. total = Decimal("0")
  219. ptn = "选队随机" # 精确 name 已在商品行 play_type_name;此处只作 teams 表内的存档
  220. for t in teams:
  221. upsert_team_row(pool, code, ptn, "team_options", t, captured_at)
  222. cc = t.get("cardCount") or 0
  223. av = t.get("availableStock") or 0
  224. up = Decimal(str(t.get("unitPrice") or "0"))
  225. total += up * (cc - av)
  226. update_team_total(pool, code, _round2(total))
  227. return True
  228. def process_shengyu(log, pool, code: str, captured_at: str) -> bool:
  229. """处理一个在售·剩余随机团:首次存 snapshot;每轮从 detail 取实时剩余算总价回写。
  230. 公式:team_total_amount = 原始总价(存表 Σ 单价×cardCount) − detail.unitPrice × detail.availableStock。
  231. 若库里没有该团选队阶段的 cardCount 记录,team_total_amount 留 NULL 表示"数据缺失"。
  232. Args:
  233. log: 日志对象。
  234. pool: MySQL 连接池。
  235. code (str): 商品编码。
  236. captured_at (str): 本轮采集时刻(仅首次存 snapshot 时用)。
  237. Returns:
  238. bool: True 处理成功;False 详情拉失败。
  239. """
  240. dd = fetch_detail(log, code)
  241. if not dd:
  242. return False
  243. # 1) 首次存 snapshot(冻结不变,只存一次)
  244. if not has_snapshot(pool, code):
  245. snap = dd.get("remainingRandomTeamSnapshot") or {}
  246. for t in (snap.get("teams") or []):
  247. upsert_team_row(pool, code, "剩余随机", "snapshot", t, captured_at,
  248. snap_total=snap.get("totalQuantity"))
  249. # 2) 每轮重算实时总价
  250. original = get_original_total(pool, code)
  251. if original is None:
  252. # 转剩余随机前没被我们抓到过 team-options → 拿不到原始总价,显式留 NULL
  253. update_team_total(pool, code, None)
  254. log.warning(f"[数据缺失] {code} 无选队阶段 team-options 记录,team_total_amount=NULL")
  255. return True
  256. up_now = Decimal(str(dd.get("unitPrice") or "0"))
  257. av_now = int(dd.get("availableStock") or 0)
  258. remaining_value = up_now * av_now
  259. total = original - remaining_value
  260. if total < 0:
  261. # 边界:原始总价是"名义价",理论上不会 <0;出现即为数据异常,记日志、置 None
  262. log.warning(f"[异常] {code} original={original} - remaining={remaining_value} <0,置 NULL")
  263. update_team_total(pool, code, None)
  264. else:
  265. update_team_total(pool, code, _round2(total))
  266. return True
  267. @retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
  268. def main_task(log):
  269. """采集主函数:一轮遍历在售随机团、更新 teams 表与 team_total_amount。
  270. 挂了每小时重试(无人值守);单轮内各商品独立 try/except,单个失败不拖垮整轮。
  271. Args:
  272. log: 日志对象。
  273. Raises:
  274. RuntimeError: 数据库连接池异常时抛出以触发重试。
  275. """
  276. log.info(f"开始运行 {sys._getframe().f_code.co_name}" + "." * 40)
  277. pool = MySQLConnectionPool(log=log)
  278. if not pool.check_pool_health():
  279. log.error("数据库连接池异常")
  280. raise RuntimeError("数据库连接池异常")
  281. try:
  282. xd_codes, sy_codes = fetch_onsale_random(log, pool)
  283. captured_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  284. # 选队随机:每轮全量刷新 team-options
  285. ok_xd = fail_xd = 0
  286. for c in xd_codes:
  287. try:
  288. if process_xuandui(log, pool, c, captured_at):
  289. ok_xd += 1
  290. else:
  291. fail_xd += 1
  292. except Exception as e:
  293. fail_xd += 1
  294. log.error(f"[选队随机·失败] {c}: {e}")
  295. time.sleep(BETWEEN_ITEM_SEC)
  296. log.info(f"[选队随机] 处理完成: 成功 {ok_xd} 失败 {fail_xd}")
  297. # 剩余随机:每轮从 detail 取实时剩余、更新总价
  298. ok_sy = fail_sy = 0
  299. for c in sy_codes:
  300. try:
  301. if process_shengyu(log, pool, c, captured_at):
  302. ok_sy += 1
  303. else:
  304. fail_sy += 1
  305. except Exception as e:
  306. fail_sy += 1
  307. log.error(f"[剩余随机·失败] {c}: {e}")
  308. time.sleep(BETWEEN_ITEM_SEC)
  309. log.info(f"[剩余随机] 处理完成: 成功 {ok_sy} 失败 {fail_sy}")
  310. except Exception as e:
  311. log.error(f"{sys._getframe().f_code.co_name} error: {e}")
  312. finally:
  313. log.info(f"{sys._getframe().f_code.co_name} 运行结束,等待下一轮" + "." * 20)
  314. def schedule_task():
  315. """定时入口:每 INTERVAL_SEC 秒跑一次 main_task。启动时立即跑一次。"""
  316. main_task(log=logger) # 启动即刻跑一轮,避免等 5 分钟才开始
  317. schedule.every(INTERVAL_SEC).seconds.do(main_task, log=logger)
  318. while True:
  319. schedule.run_pending()
  320. time.sleep(1)
  321. if __name__ == "__main__":
  322. schedule_task()