start_cxx_spider.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.13.17
  4. # Date : 2026/1/29 16:31
  5. """cxx 爬虫与报表模块的统一定时启动入口。
  6. 2026/11/04 变更:纳入购买订单采集(super_vault_order_spider)与超新星已售每日报告
  7. (stats/super_vault_nova_daily_report),并改为「启动时固定工作目录」,使其可在
  8. Linux 服务器上以 systemd / nohup 等任意方式拉起而不依赖调用方的 cwd。
  9. 单进程 + 线程模型:schedule 主循环只负责计时,每个任务在独立线程里跑,
  10. 互不阻塞;同名任务加锁,上一轮没跑完时本轮跳过,避免并发写同一批数据。
  11. """
  12. import os
  13. import sys
  14. import time
  15. import threading
  16. # ---- 路径与工作目录(必须在导入本项目模块之前完成)----
  17. # mysql_pool 在导入期就按 cwd 相对路径读 application.yml,而服务器上由 systemd/cron
  18. # 拉起时 cwd 往往不是项目目录,故先切到本文件所在目录(项目根),让相对路径始终可用。
  19. # 公共模块 mysql_pool/YamlLoader 与配置 application.yml 都只在根目录留一份,子目录不放副本。
  20. BASE_DIR = os.path.dirname(os.path.abspath(__file__))
  21. STATS_DIR = os.path.join(BASE_DIR, "stats")
  22. os.chdir(BASE_DIR)
  23. # 以「python start_cxx_spider.py」方式启动时,sys.path[0] 已是 BASE_DIR;
  24. # stats 是普通子目录(非包),须显式加入才能直接导入其中的报表模块。
  25. for _p in (BASE_DIR, STATS_DIR):
  26. if _p not in sys.path:
  27. sys.path.insert(0, _p)
  28. # ---- 日志:全项目唯一的 sink,所有模块的输出都汇总到 logs/{日期}.log ----
  29. # 各业务模块只在自己作为 __main__ 独立运行时才配 sink,被 import 时不碰全局 logger,
  30. # 故这里在 import 之前还是之后配置都一样,不再像从前那样靠 import 顺序决定日志去向。
  31. #
  32. # 日志目录优先取环境变量 CXX_LOG_DIR,缺省回落到项目根 logs/:
  33. # 服务器的日志采集只读固定目录(如容器内 /app/logs),而项目代码未必就放在该目录的上一级,
  34. # 故目录由部署环境指定、代码不写死;文件名固定 {time:YYYYMMDD}.log 以匹配采集端的格式要求。
  35. # 部署示例:CXX_LOG_DIR=/app/logs python start_cxx_spider.py
  36. import schedule
  37. from loguru import logger
  38. LOG_DIR = os.environ.get("CXX_LOG_DIR") or os.path.join(BASE_DIR, "logs")
  39. os.makedirs(LOG_DIR, exist_ok=True) # 提前建目录:目录不存在或无权限时在此直接报错,而非等到首条日志才暴露
  40. logger.remove()
  41. logger.add(os.path.join(LOG_DIR, "{time:YYYYMMDD}.log"), encoding='utf-8', rotation="00:00",
  42. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
  43. level="DEBUG", retention="7 day")
  44. from super_vault_daily_spider import cxx_daily_main
  45. from super_vault_on_sale_spider import cxx_sale_main
  46. from super_vault_order_spider import cxx_order_main
  47. from super_vault_nova_daily_report import run_once as cxx_nova_report_once
  48. # 任务互斥锁:key=任务名,防同一任务上下两轮重叠执行
  49. # (订单全量回填可能跑数小时,02:00 那轮没跑完时 08:00 不能再起一轮去「删旧重灌」同一批 pid)
  50. _JOB_LOCKS = {}
  51. _LOCKS_GUARD = threading.Lock()
  52. def _get_job_lock(job_name: str) -> threading.Lock:
  53. """取(或懒创建)指定任务名的互斥锁。
  54. Args:
  55. job_name (str): 任务名,由 run_threaded 传入。
  56. Returns:
  57. threading.Lock: 该任务名对应的锁对象,同名任务复用同一把。
  58. """
  59. with _LOCKS_GUARD:
  60. if job_name not in _JOB_LOCKS:
  61. _JOB_LOCKS[job_name] = threading.Lock()
  62. return _JOB_LOCKS[job_name]
  63. def _job_wrapper(job_name: str, job_func, args: tuple, kwargs: dict):
  64. """线程内执行任务:加锁防重入,并兜底捕获异常避免线程静默死掉。
  65. Args:
  66. job_name (str): 任务名,用于日志与锁。
  67. job_func: 要运行的目标函数。
  68. args (tuple): 传给目标函数的位置参数。
  69. kwargs (dict): 传给目标函数的关键字参数。
  70. Returns:
  71. None: 无返回;执行结果与异常均写入日志。
  72. """
  73. lock = _get_job_lock(job_name)
  74. # 非阻塞抢锁:抢不到说明上一轮还在跑,本轮直接跳过(而不是排队堆积)
  75. if not lock.acquire(blocking=False):
  76. logger.warning(f"任务 {job_name} 上一轮仍在运行,本轮跳过")
  77. return
  78. try:
  79. logger.info(f"任务 {job_name} 开始")
  80. job_func(*args, **kwargs)
  81. logger.info(f"任务 {job_name} 结束")
  82. except Exception as e:
  83. # 线程里抛出的异常不会被主循环捕获,必须在此落日志,否则无人值守时无从排查
  84. logger.exception(f"任务 {job_name} 异常退出: {e}")
  85. finally:
  86. lock.release()
  87. def run_threaded(job_func, *args, job_name: str = None, **kwargs):
  88. """在新线程中运行给定的函数,并传递参数。
  89. Args:
  90. job_func: 要运行的目标函数。
  91. *args: 传给目标函数的位置参数。
  92. job_name (str, optional): 任务名,用于日志与防重入锁。Defaults to None(取函数名)。
  93. **kwargs: 传给目标函数的关键字参数。
  94. Returns:
  95. None: 无返回;线程以守护模式启动,主进程退出时一并结束。
  96. """
  97. name = job_name or getattr(job_func, "__name__", "job")
  98. job_thread = threading.Thread(target=_job_wrapper, args=(name, job_func, args, kwargs),
  99. name=name, daemon=True)
  100. job_thread.start()
  101. def schedule_task():
  102. """爬虫与报表模块的统一定时入口。
  103. 排期(服务器时区须为 Asia/Shanghai,schedule 按本地时间触发):
  104. 启动即跑 在售商品采集(cxx_sale_main)
  105. 每小时 在售商品采集(cxx_sale_main)
  106. 07:01 每日商品采集(cxx_daily_main)
  107. 02:00 购买订单采集(cxx_order_main),增量
  108. 08:00 购买订单采集(cxx_order_main),补抓 02:00 失败/漏抓及新售罄的团
  109. 09:30 超新星已售每日报告(统计前一天成交,生成 xlsx 并发企微群)
  110. Returns:
  111. None: 不返回,常驻循环直到进程被终止。
  112. """
  113. # 立即运行一次任务
  114. # run_threaded(cxx_daily_main, log=logger)
  115. # time.sleep(5)
  116. run_threaded(cxx_sale_main, log=logger)
  117. # 设置定时任务
  118. schedule.every().day.at("07:01").do(run_threaded, cxx_daily_main, log=logger)
  119. schedule.every().hour.do(run_threaded, cxx_sale_main, log=logger)
  120. # 购买订单采集:两轮均为增量,靠 order_state 只补未完成的团,重复跑安全
  121. # log 以位置参数传入——该函数挂了 @retry(after=after_log),after_log 靠 args[0] 取日志对象
  122. schedule.every().day.at("02:00").do(run_threaded, cxx_order_main, logger)
  123. schedule.every().day.at("08:00").do(run_threaded, cxx_order_main, logger)
  124. # 已售每日报告:排在订单采集(08:00 那轮)之后,保证统计到的是当日补全后的数据
  125. schedule.every().day.at("09:30").do(run_threaded, cxx_nova_report_once, log=logger,
  126. job_name="cxx_nova_daily_report")
  127. while True:
  128. schedule.run_pending()
  129. time.sleep(1)
  130. if __name__ == '__main__':
  131. schedule_task()