super_vault_order_spider.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/08/21
  5. """超级仓库 SuperVault · 购买订单(真实买家)爬虫。
  6. 背景:
  7. 已售统计报告 stats/super_vault_his_report.py 的「参与人数」原本用拆卡报告
  8. super_vault_report_record.user_name 去重近似——那只是「中卡用户」,偏低、也不是真实买家。
  9. order/simple/list 接口按「每笔购买订单」返回买家(username/avatar) + 下单时间 + 份数,
  10. 用它可统计每个拼团商品的「真实购买人数」,补上报告缺的真实买家维度。
  11. 接口探测结论(2026/08/21 实测):
  12. - URL:POST https://cxx.cardsvault.net/app/order/simple/list,body {"tid","pageNum","pageSize"}。
  13. - 免登录:不带 Authorization 也返回 200,故本脚本不带任何 token(登录态最小化)。
  14. - pageSize 被服务端锁死为 9:请求 100/200 均只回 9 条,无法加大。
  15. - 深翻页可用:可一路翻到末页(超尾页会被钳到最后一页),故能事后抓已售商品的全部订单。
  16. - tid == pid(与拆卡报告口径一致,即 super_vault_product_record.pid)。
  17. 去重/幂等策略(重要):
  18. 源接口无订单号,且 (user_name, order_time, count) 在源头就不唯一(热门团存在「多个匿名同秒各买
  19. 1 份」等真实但同键的不同订单,实测大团约 0.1%)。故不建唯一键、不靠该组合去重——那会误删真实订单。
  20. 改为按 pid「删旧 + 整体重灌」:每次抓完一个商品的全部订单后,先删该 pid 旧行再批量写入,
  21. 幂等且不丢任何一笔。表上仅留 idx_pid 普通索引。
  22. 真实购买人数口径(留给报告层,本爬虫只如实存每一笔):
  23. 登录用户头像路径内嵌 uid(/avatar/{uid}/avatar_pic/...),可精确去重且识别复购;
  24. 默认头像用户无 uid 但 user_name 为系统稳定 handle,也可按 user_name 去重;
  25. 唯独「匿名」天生无法区分(多个匿名同名同默认头像)。参考统计 SQL:
  26. SELECT
  27. COUNT(*) AS 订单笔数,
  28. SUM(`count`) AS 总份数,
  29. COUNT(DISTINCT COALESCE(uid, user_name)) AS 去重身份数,
  30. SUM(is_anonymous) AS 匿名笔数,
  31. -- 真实购买人数 ≈ 非匿名去重身份 + 匿名笔数(每笔按1人,上界近似)
  32. COUNT(DISTINCT CASE WHEN is_anonymous=0 THEN COALESCE(uid, user_name) END)
  33. + SUM(is_anonymous) AS 真实购买人数近似
  34. FROM super_vault_order_record WHERE pid = %s;
  35. 运行方式:
  36. 从项目根目录运行 python super_vault_order_spider.py(读根目录 application.yml)。
  37. 首次为全量回填(984 个已售商品,数小时),可断点续跑:每个商品抓完置 order_state=1,
  38. 重跑只补 order_state 未完成(NULL/0/3)的商品;之后每天增量抓新增已售商品,成本很低。
  39. """
  40. import os
  41. import re
  42. import sys
  43. import time
  44. import math
  45. # 必须在 import mysql_pool 之前切到本文件所在目录(即项目根目录):mysql_pool 在「导入期」
  46. # 就按 cwd 相对路径读 application.yml(读不到会直接 UnboundLocalError)。
  47. # 入口脚本已切过,这里是幂等的;单独跑本文件时则靠这行兜住任意启动目录。
  48. ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
  49. os.chdir(ROOT_DIR)
  50. import requests
  51. import schedule
  52. from loguru import logger
  53. from tenacity import retry, stop_after_attempt, wait_fixed
  54. from mysql_pool import MySQLConnectionPool
  55. # 日志统一写 logs/{日期}.log,sink 由入口脚本配置(见文件末尾 __main__)。
  56. # 模块级绝不配置 sink:被 start_cxx_spider 等入口 import 时若在此 logger.remove()/add(),
  57. # 会摘掉入口已装好的 sink(或反被后续 import 的模块摘掉),日志去向变成由 import 顺序决定。
  58. #
  59. # 日志目录优先取环境变量 CXX_LOG_DIR,缺省回落到项目根 logs/:
  60. # 服务器的日志采集只读固定目录(如容器内 /app/logs),而项目代码未必就放在该目录的上一级,
  61. # 故目录由部署环境指定、代码不写死;文件名固定 {time:YYYYMMDD}.log 以匹配采集端的格式要求。
  62. LOG_DIR = os.environ.get("CXX_LOG_DIR") or os.path.join(ROOT_DIR, "logs")
  63. LOG_PATH = os.path.join(LOG_DIR, "{time:YYYYMMDD}.log")
  64. # 接口地址
  65. ORDER_LIST_URL = "https://cxx.cardsvault.net/app/order/simple/list"
  66. # 请求头:免登录,故不带 Authorization(登录态最小化)
  67. HEADERS = {
  68. "User-Agent": "okhttp/4.9.0",
  69. "CXX-APP-API-VERSION": "V2", # 必须添加
  70. "Content-Type": "application/json; charset=UTF-8",
  71. }
  72. PAGE_SIZE = 9 # 服务端锁死为 9,请求更大值无效,不要改
  73. USE_PROXY = False # 代理开关:默认直连,遇 IP 风控再置 True
  74. PAGE_SLEEP = 0.2 # 每页请求间隔(秒),限速避免触发风控
  75. PRODUCT_SLEEP = 0.5 # 每个商品之间间隔(秒)
  76. MAX_PRODUCTS_PER_RUN = 0 # 单轮最多处理的商品数,0=不限(断点续跑靠 order_state,全量可放心跑满)
  77. # order_state 状态含义(写入 super_vault_product_record.order_state)
  78. # NULL = 从未抓取
  79. # 0(ACTIVE) = 已抓但商品仍在售(未售罄),订单还会增长,下轮继续重灌刷新
  80. # 1(DONE) = 已售罄/冻结,抓完不再重抓
  81. # 2(EMPTY) = 无购买记录
  82. # 3(FAIL) = 抓取失败/中断,下轮重试
  83. STATE_ACTIVE = 0 # 在售未售罄,已抓但待刷新
  84. STATE_DONE = 1 # 售罄冻结,已抓完
  85. STATE_EMPTY = 2 # 无购买记录(total=0)
  86. STATE_FAIL = 3 # 抓取失败/中断
  87. # 从头像 url 提取用户 uid:形如 .../avatar/1444/avatar_pic/xxx.jpg → 1444;默认头像无此段
  88. UID_PATTERN = re.compile(r"/avatar/(\d+)/")
  89. def after_log(retry_state):
  90. """tenacity 重试回调,记录每次尝试的结果。
  91. Args:
  92. retry_state: tenacity 传入的 RetryCallState,其 args[0] 约定为日志对象。
  93. """
  94. log = retry_state.args[0] if retry_state.args else logger
  95. if retry_state.outcome.failed:
  96. log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
  97. else:
  98. log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
  99. @retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
  100. def get_proxys(log):
  101. """获取隧道代理配置。
  102. Args:
  103. log: 日志对象。
  104. Returns:
  105. dict: requests 可用的 proxies 字典。
  106. Raises:
  107. Exception: 组装代理配置异常时向上抛出以触发重试。
  108. """
  109. tunnel = "x371.kdltps.com:15818"
  110. kdl_username = "t13753103189895"
  111. kdl_password = "o0yefv6z"
  112. try:
  113. proxy = f"http://{kdl_username}:{kdl_password}@{tunnel}/"
  114. return {"http": proxy, "https": proxy}
  115. except Exception as e:
  116. log.error(f"Error getting proxy: {e}")
  117. raise e
  118. @retry(stop=stop_after_attempt(5), wait=wait_fixed(1), after=after_log)
  119. def get_order_single_page(log, pid, page_num):
  120. """获取某拼团商品单页的购买订单数据(带重试)。
  121. Args:
  122. log: 日志对象。
  123. pid (int): 拼团商品 id(接口里的 tid)。
  124. page_num (int): 页码,从 1 开始。
  125. Returns:
  126. dict | None: {"total": int, "current_page": int, "items": list};业务码非 200 时返回 None。
  127. Raises:
  128. requests.HTTPError: HTTP 状态码非 2xx 时抛出以触发重试。
  129. """
  130. data = {"tid": str(pid), "pageNum": page_num, "pageSize": PAGE_SIZE}
  131. proxies = get_proxys(log) if USE_PROXY else None
  132. response = requests.post(ORDER_LIST_URL, headers=HEADERS, json=data, timeout=(5, 30), proxies=proxies)
  133. response.raise_for_status()
  134. result = response.json()
  135. if result.get("status") == 200:
  136. d = result.get("data", {}) or {}
  137. total = d.get("total", 0)
  138. current_page = d.get("pageNum", page_num)
  139. items = d.get("data", []) or []
  140. log.debug(f"pid={pid} 第 {current_page} 页 / 共 {total} 条,本页 {len(items)} 条")
  141. return {"total": total, "current_page": current_page, "items": items}
  142. log.error(f"pid={pid} 第 {page_num} 页 API 返回错误: {result.get('msg', '未知错误')}")
  143. return None
  144. def parse_order_items(pid, items):
  145. """把接口返回的订单列表解析成待入库的 dict 列表。
  146. Args:
  147. pid (int): 拼团商品 id,写入每行。
  148. items (list[dict]): 接口 data.data,每元素含 userInfo/createTime/count。
  149. Returns:
  150. list[dict]: 每元素为 {pid, user_name, uid, is_anonymous, avatar, count, order_time}。
  151. """
  152. parsed = []
  153. for item in items:
  154. user_info = item.get("userInfo", {}) or {}
  155. user_name = user_info.get("username")
  156. avatar = user_info.get("avatar")
  157. # 从头像路径提取 uid(登录用户才有;默认头像/匿名为空)
  158. m = UID_PATTERN.search(avatar) if avatar else None
  159. uid = int(m.group(1)) if m else None
  160. parsed.append({
  161. "pid": pid,
  162. "user_name": user_name,
  163. "uid": uid,
  164. "is_anonymous": 1 if user_name == "匿名" else 0, # 匿名天生无法区分,单独标记供报告层处理
  165. "avatar": avatar,
  166. "count": item.get("count"), # 该笔购买份数
  167. "order_time": item.get("createTime"), # 下单时间
  168. })
  169. return parsed
  170. def crawl_product_orders(log, sql_pool, pid, frozen=True):
  171. """抓取单个拼团商品的全部购买订单并入库,返回落库条数。
  172. 策略「攒齐全部页 → 删旧 → 整体重灌」:先逐页翻完 ceil(total/9) 页、把所有订单攒进内存,
  173. 全部成功后再 DELETE 该 pid 旧行 + 批量插入。这样:
  174. - 天然幂等:重跑=整体替换,不会累加重复;
  175. - 不误删真实订单:不靠 (user,time,count) 唯一键去重,匿名同秒/同份数的不同订单都完整保留;
  176. - 失败安全:任一页多次重试仍失败即抛出,此时既未删也未插,旧数据/状态原样保留,交上层记 STATE_FAIL。
  177. 源接口按 createTime 倒序,对已售罄(冻结)商品列表稳定,故一次翻页即为精确全量快照。
  178. 终态标记:售罄(frozen=True)商品订单已冻结,标 STATE_DONE 不再重抓;仍在售(frozen=False)的商品
  179. 订单会继续增长,标 STATE_ACTIVE 让下轮重灌刷新,直到售罄后被标 DONE。
  180. Args:
  181. log: 日志对象。
  182. sql_pool (MySQLConnectionPool): 数据库连接池。
  183. pid (int): 拼团商品 id。
  184. frozen (bool, optional): 该商品是否已售罄冻结。True 标 DONE,False 标 ACTIVE 待刷新。Defaults to True。
  185. Returns:
  186. int: 落库的订单条数;无购买记录返回 0。
  187. Raises:
  188. RuntimeError: 某页多次重试后仍返回空时抛出,触发上层记 STATE_FAIL。
  189. """
  190. # 第 1 页:拿 total 与本页数据
  191. first = get_order_single_page(log, pid, 1)
  192. if not first:
  193. raise RuntimeError(f"pid={pid} 第 1 页返回空")
  194. total = first["total"]
  195. if total == 0:
  196. # 售罄却无记录→标 EMPTY 不再抓;在售无记录→标 ACTIVE 待其产生订单
  197. empty_state = STATE_EMPTY if frozen else STATE_ACTIVE
  198. log.info(f"pid={pid} 无购买记录,置 order_state={empty_state}")
  199. sql_pool.update_one_or_dict(
  200. table="super_vault_product_record",
  201. data={"order_state": empty_state},
  202. condition={"pid": pid})
  203. return 0
  204. total_pages = math.ceil(total / PAGE_SIZE)
  205. log.info(f"pid={pid} 共 {total} 笔订单,{total_pages} 页,frozen={frozen},开始抓取")
  206. # 攒齐所有页(先全部成功,再动数据库)
  207. all_items = parse_order_items(pid, first["items"])
  208. for page_num in range(2, total_pages + 1):
  209. time.sleep(PAGE_SLEEP)
  210. page = get_order_single_page(log, pid, page_num)
  211. if not page:
  212. raise RuntimeError(f"pid={pid} 第 {page_num} 页返回空")
  213. all_items.extend(parse_order_items(pid, page["items"]))
  214. # 全部页成功 → 删旧 + 整体重灌 + 标记状态(售罄=DONE 不再抓;在售=ACTIVE 待刷新)
  215. final_state = STATE_DONE if frozen else STATE_ACTIVE
  216. sql_pool.update_one_or_dict(
  217. query="DELETE FROM super_vault_order_record WHERE pid = %s",
  218. args=(pid,))
  219. if all_items:
  220. sql_pool.insert_many(table="super_vault_order_record", data_list=all_items)
  221. sql_pool.update_one_or_dict(
  222. table="super_vault_product_record",
  223. data={"order_state": final_state},
  224. condition={"pid": pid})
  225. log.info(f"pid={pid} 抓取完成,落库 {len(all_items)} 笔,order_state={final_state}")
  226. return len(all_items)
  227. def get_pending_products(log, sql_pool, limit=0):
  228. """取待抓订单的已售商品列表(口径与已售报告一致),含「是否售罄冻结」标记。
  229. 已售 = sold_count>0 且 status NOT IN(0,1,2,3);只挑 order_state 未完成的
  230. (NULL/0=未抓或在售待刷新、3=失败重试),跳过 1=已抓完(售罄冻结)与 2=无记录;
  231. 按成交时间倒序,新团优先补。frozen = sold_count>=count(售罄),决定抓完后标 DONE 还是 ACTIVE。
  232. Args:
  233. log: 日志对象。
  234. sql_pool (MySQLConnectionPool): 数据库连接池。
  235. limit (int, optional): 最多返回的商品数,0 表示不限。Defaults to 0。
  236. Returns:
  237. list[tuple[int, bool]]: 每元素为 (pid, frozen),frozen 表示是否已售罄冻结。
  238. """
  239. sql = (
  240. "SELECT pid, (count > 0 AND sold_count >= count) AS frozen FROM super_vault_product_record "
  241. "WHERE sold_count > 0 AND status NOT IN (0, 1, 2, 3) "
  242. "AND (order_state IS NULL OR order_state NOT IN (1, 2)) "
  243. "ORDER BY COALESCE(completion_time, sold_time, sell_time) DESC")
  244. if limit and limit > 0:
  245. sql += f" LIMIT {int(limit)}"
  246. rows = sql_pool.select_all(sql) or []
  247. products = [(r[0], bool(r[1])) for r in rows]
  248. log.info(f"待抓订单的已售商品 {len(products)} 个")
  249. return products
  250. @retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
  251. def cxx_order_main(log, limit=0):
  252. """购买订单采集主函数(可断点续跑)。
  253. Args:
  254. log: 日志对象。
  255. limit (int, optional): 单轮最多处理的商品数,0=不限。Defaults to 0。
  256. Raises:
  257. RuntimeError: 数据库连接池异常时抛出以触发重试。
  258. """
  259. log.info(f"开始运行 {sys._getframe().f_code.co_name} 购买订单采集" + "." * 40)
  260. sql_pool = MySQLConnectionPool(log=log)
  261. if not sql_pool.check_pool_health():
  262. log.error("数据库连接池异常")
  263. raise RuntimeError("数据库连接池异常")
  264. try:
  265. run_limit = limit or MAX_PRODUCTS_PER_RUN
  266. products = get_pending_products(log, sql_pool, run_limit)
  267. done, empty, fail = 0, 0, 0
  268. for idx, (pid, frozen) in enumerate(products, 1):
  269. log.info(f"[{idx}/{len(products)}] 处理 pid={pid} (frozen={frozen})")
  270. try:
  271. fetched = crawl_product_orders(log, sql_pool, pid, frozen)
  272. if fetched == 0:
  273. empty += 1
  274. else:
  275. done += 1
  276. except Exception as e:
  277. # 单个商品失败不拖垮整轮:记 order_state=3,下轮重试
  278. log.error(f"pid={pid} 抓取失败: {e}")
  279. fail += 1
  280. sql_pool.update_one_or_dict(
  281. table="super_vault_product_record",
  282. data={"order_state": STATE_FAIL},
  283. condition={"pid": pid})
  284. time.sleep(PRODUCT_SLEEP)
  285. log.info(f"本轮结束:成功 {done},无记录 {empty},失败 {fail},共处理 {len(products)}")
  286. except Exception as e:
  287. log.error(f"{sys._getframe().f_code.co_name} error: {e}")
  288. finally:
  289. log.info(f"{sys._getframe().f_code.co_name} 运行结束,等待下一轮" + "." * 20)
  290. def schedule_task():
  291. """定时入口:启动即增量抓一次,并每天 02:00、08:00 各跑一次(08:00 为保险补跑)。
  292. 三次触发均为增量:靠 order_state 只补未完成(NULL/0/3)的团,已抓完(1)/无记录(2)跳过;
  293. 抓取按 pid「删旧重灌」天然幂等,schedule 单线程顺序执行不会并发重入,故重复跑安全、开销小。
  294. 08:00 一轮用于补 02:00 失败(3)/漏抓的团、重刷在售(0)团,以及 02:00~08:00 间新售罄的团。
  295. """
  296. # cxx_order_main(logger) # 启动即先增量跑一次,不必等到 02:00
  297. schedule.every().day.at("02:00").do(cxx_order_main, logger)
  298. schedule.every().day.at("08:00").do(cxx_order_main, logger) # 保险:再补一轮
  299. while True:
  300. schedule.run_pending()
  301. time.sleep(1)
  302. if __name__ == "__main__":
  303. # 独立运行才配置日志 sink:按天切分、保留 7 天,与入口脚本同格式同去向(根目录 logs/)
  304. logger.remove()
  305. # diagnose=False:关掉 traceback 的变量值标注,避免连库失败时把数据库密码写进日志
  306. logger.add(LOG_PATH, encoding='utf-8', rotation="00:00",
  307. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
  308. level="DEBUG", retention="7 day", backtrace=True, diagnose=False)
  309. # 全量回填(数小时,断点续跑):直接 cxx_order_main(logger)
  310. # 小样本验证:cxx_order_main(logger, limit=5)
  311. # cxx_order_main(logger)
  312. schedule_task()