deca_team_spider.py 16 KB

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