scp_spider.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/07/15
  5. """
  6. SCP Auctions (catalogs.scpauctions.com) 每周增量爬虫(周调度)
  7. 逻辑(两阶段):
  8. 1. GET /auctions/past 只解析首页(最新的拍卖会排在首页顶部)
  9. 2. 查库 select distinct event_id from scp_auction,得到已爬过的场次
  10. 3. 差集 = 新增拍卖会
  11. 4. 没有新增 → 本轮无数据可抓,仍补跑一次阶段二兜底后结束
  12. 5. 阶段一 列表:对每个新增拍卖会入库 scp_auction,再翻页抓 lot 列表入库 scp_lot(state 默认 0)
  13. 6. 阶段二 详情:扫库 scp_lot 中 state != 1 的记录 → 逐条进详情页抓字段写回
  14. 说明:SCP 的拍卖会一旦结束即定型,新场次会出现在 /auctions/past 首页顶部;
  15. 因此增量只需盯首页,用 event_id 差集识别新增场次即可(老场次早已在库)。
  16. """
  17. import sys
  18. import time
  19. import random
  20. import schedule
  21. from curl_cffi import requests
  22. from loguru import logger
  23. from tenacity import retry, stop_after_attempt, wait_fixed
  24. from mysql_pool import MySQLConnectionPool
  25. from scp_core import (
  26. client_identifier_list,
  27. crawl_one_auction,
  28. get_auction_list,
  29. save_auction,
  30. update_details_for_pending,
  31. after_log,
  32. )
  33. logger.remove()
  34. logger.add("./logs/{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
  35. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
  36. level="DEBUG", retention="7 day")
  37. def get_existing_event_ids(log, sql_pool):
  38. """查库返回已爬过的 event_id 集合。
  39. Args:
  40. log: logger 对象。
  41. sql_pool: MySQL 连接池;为 None 时返回空集合(视作库内无任何场次)。
  42. Returns:
  43. set[str]: 已存在的 event_id 字符串集合。
  44. """
  45. if sql_pool is None:
  46. log.warning("sql_pool 为 None,视为库内无任何场次(将全量重抓首页)")
  47. return set()
  48. rows = sql_pool.select_all("select distinct event_id from scp_auction_record")
  49. keys = {str(r[0]) for r in rows} if rows else set()
  50. log.info(f"库中已存在 {len(keys)} 个 event_id")
  51. return keys
  52. def diff_new_auctions(log, recent_auctions, existing_keys):
  53. """从首页拍卖会中筛出库里没有的新增场次。
  54. Args:
  55. log: logger 对象。
  56. recent_auctions (list[dict]): get_auction_list(only_first_page=True) 的返回。
  57. existing_keys (set[str]): 已存在的 event_id 集合。
  58. Returns:
  59. list[dict]: 待抓取的新增拍卖会列表。
  60. """
  61. new_list = [a for a in recent_auctions if a["event_id"] not in existing_keys]
  62. log.info(f"新增待抓取拍卖会数: {len(new_list)} -> {[a['event_id'] for a in new_list]}")
  63. return new_list
  64. def run_incremental(log, sql_pool):
  65. """增量抓取主流程(阶段一)。
  66. Args:
  67. log: logger 对象。
  68. sql_pool: MySQL 连接池;为 None 时不入库,仅在内存收集并 print 样本。
  69. Returns:
  70. None: 无返回值。
  71. """
  72. impersonate = random.choice(client_identifier_list)
  73. with requests.Session() as session:
  74. try:
  75. recent = get_auction_list(log, session, impersonate, only_first_page=True)
  76. except Exception as e:
  77. log.error(f"获取首页拍卖会列表失败: {e}")
  78. return
  79. existing_keys = get_existing_event_ids(log, sql_pool)
  80. new_auctions = diff_new_auctions(log, recent, existing_keys)
  81. if not new_auctions:
  82. log.info("本轮无新增拍卖会,跳过 list 抓取")
  83. return
  84. for idx, auc in enumerate(new_auctions, 1):
  85. log.info(f"========== [{idx}/{len(new_auctions)}] 新增拍卖会 {auc['event_id']} ({auc['auction_name']}) ==========")
  86. try:
  87. save_auction(log, sql_pool, auc)
  88. crawl_one_auction(log, sql_pool, session, impersonate, auc)
  89. except Exception as e:
  90. log.error(f"拍卖会 {auc['event_id']} 抓取异常: {e}")
  91. continue
  92. @retry(stop=stop_after_attempt(100), wait=wait_fixed(3600), after=after_log)
  93. def scp_main(log):
  94. """周调度主函数:增量 list + 补详情。
  95. Args:
  96. log: logger 对象。
  97. Returns:
  98. None: 无返回值。
  99. """
  100. log.info(f"开始运行 {sys._getframe().f_code.co_name} 增量爬虫任务 ...")
  101. sql_pool = MySQLConnectionPool(log=log)
  102. if not sql_pool:
  103. log.error("MySQL数据库连接失败")
  104. raise Exception("MySQL数据库连接失败")
  105. try:
  106. # 阶段一:抓新增拍卖会的 lot 列表入库
  107. try:
  108. run_incremental(log, sql_pool)
  109. except Exception as e:
  110. log.error(f"增量抓取失败: {e}")
  111. # 阶段二:扫库 state != 1 的 lot 补抓详情(含上一轮失败/中断的兜底)
  112. try:
  113. update_details_for_pending(log, sql_pool)
  114. except Exception as e:
  115. log.error(f"详情补抓失败: {e}")
  116. except Exception as e:
  117. log.error(f"{sys._getframe().f_code.co_name} error: {e}")
  118. finally:
  119. log.info(f"爬虫程序 {sys._getframe().f_code.co_name} 运行结束,等待下一轮采集 ...")
  120. def schedule_task():
  121. """启动调度器:先即时跑一次,之后每周一 05:00 跑一次增量。
  122. Returns:
  123. None: 常驻循环,无返回值。
  124. """
  125. scp_main(log=logger)
  126. schedule.every().monday.at("05:00").do(scp_main, log=logger)
  127. while True:
  128. schedule.run_pending()
  129. time.sleep(1)
  130. if __name__ == "__main__":
  131. # 测试时直接跑一次
  132. # scp_main(log=logger)
  133. # 上生产再切回 schedule
  134. schedule_task()