deca_team_spider.py 17 KB

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