start_cxx_spider.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  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. # backtrace=True 保留完整调用栈便于定位;diagnose=False 关掉 loguru 对 traceback 的变量值标注——
  42. # 它会把各帧局部变量的值逐行打印出来,连库失败时会把数据库密码写进日志并被日志采集送往中心平台。
  43. logger.add(os.path.join(LOG_DIR, "{time:YYYYMMDD}.log"), encoding='utf-8', rotation="00:00",
  44. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
  45. level="DEBUG", retention="7 day", backtrace=True, diagnose=False)
  46. from super_vault_daily_spider import cxx_daily_main
  47. from super_vault_on_sale_spider import cxx_sale_main
  48. from super_vault_order_spider import cxx_order_main
  49. from super_vault_nova_daily_report import run_once as cxx_nova_report_once
  50. # 任务互斥锁:key=任务名,防同一任务上下两轮重叠执行
  51. # (订单全量回填可能跑数小时,02:00 那轮没跑完时 08:00 不能再起一轮去「删旧重灌」同一批 pid)
  52. _JOB_LOCKS = {}
  53. _LOCKS_GUARD = threading.Lock()
  54. def _get_job_lock(job_name: str) -> threading.Lock:
  55. """取(或懒创建)指定任务名的互斥锁。
  56. Args:
  57. job_name (str): 任务名,由 run_threaded 传入。
  58. Returns:
  59. threading.Lock: 该任务名对应的锁对象,同名任务复用同一把。
  60. """
  61. with _LOCKS_GUARD:
  62. if job_name not in _JOB_LOCKS:
  63. _JOB_LOCKS[job_name] = threading.Lock()
  64. return _JOB_LOCKS[job_name]
  65. def _job_wrapper(job_name: str, job_func, args: tuple, kwargs: dict):
  66. """线程内执行任务:加锁防重入,并兜底捕获异常避免线程静默死掉。
  67. Args:
  68. job_name (str): 任务名,用于日志与锁。
  69. job_func: 要运行的目标函数。
  70. args (tuple): 传给目标函数的位置参数。
  71. kwargs (dict): 传给目标函数的关键字参数。
  72. Returns:
  73. None: 无返回;执行结果与异常均写入日志。
  74. """
  75. lock = _get_job_lock(job_name)
  76. # 非阻塞抢锁:抢不到说明上一轮还在跑,本轮直接跳过(而不是排队堆积)
  77. if not lock.acquire(blocking=False):
  78. logger.warning(f"任务 {job_name} 上一轮仍在运行,本轮跳过")
  79. return
  80. try:
  81. logger.info(f"任务 {job_name} 开始")
  82. job_func(*args, **kwargs)
  83. logger.info(f"任务 {job_name} 结束")
  84. except Exception as e:
  85. # 线程里抛出的异常不会被主循环捕获,必须在此落日志,否则无人值守时无从排查
  86. logger.exception(f"任务 {job_name} 异常退出: {e}")
  87. finally:
  88. lock.release()
  89. def run_threaded(job_func, *args, job_name: str = None, **kwargs):
  90. """在新线程中运行给定的函数,并传递参数。
  91. Args:
  92. job_func: 要运行的目标函数。
  93. *args: 传给目标函数的位置参数。
  94. job_name (str, optional): 任务名,用于日志与防重入锁。Defaults to None(取函数名)。
  95. **kwargs: 传给目标函数的关键字参数。
  96. Returns:
  97. None: 无返回;线程以守护模式启动,主进程退出时一并结束。
  98. """
  99. name = job_name or getattr(job_func, "__name__", "job")
  100. job_thread = threading.Thread(target=_job_wrapper, args=(name, job_func, args, kwargs),
  101. name=name, daemon=True)
  102. job_thread.start()
  103. def schedule_task():
  104. """爬虫与报表模块的统一定时入口。
  105. 排期(服务器时区须为 Asia/Shanghai,schedule 按本地时间触发):
  106. 启动即跑 在售商品采集(cxx_sale_main)
  107. 每小时 在售商品采集(cxx_sale_main)
  108. 07:01 每日商品采集(cxx_daily_main)
  109. 02:00 购买订单采集(cxx_order_main),增量
  110. 08:00 购买订单采集(cxx_order_main),补抓 02:00 失败/漏抓及新售罄的团
  111. 09:30 超新星已售每日报告(统计前一天成交,生成 xlsx 并发企微群)
  112. Returns:
  113. None: 不返回,常驻循环直到进程被终止。
  114. """
  115. # 立即运行一次任务
  116. # run_threaded(cxx_daily_main, log=logger)
  117. # time.sleep(5)
  118. run_threaded(cxx_sale_main, log=logger)
  119. # 设置定时任务
  120. schedule.every().day.at("07:01").do(run_threaded, cxx_daily_main, log=logger)
  121. schedule.every().hour.do(run_threaded, cxx_sale_main, log=logger)
  122. # 购买订单采集:两轮均为增量,靠 order_state 只补未完成的团,重复跑安全
  123. # log 以位置参数传入——该函数挂了 @retry(after=after_log),after_log 靠 args[0] 取日志对象
  124. schedule.every().day.at("02:00").do(run_threaded, cxx_order_main, logger)
  125. schedule.every().day.at("08:00").do(run_threaded, cxx_order_main, logger)
  126. # 已售每日报告:排在订单采集(08:00 那轮)之后,保证统计到的是当日补全后的数据
  127. schedule.every().day.at("09:30").do(run_threaded, cxx_nova_report_once, log=logger,
  128. job_name="cxx_nova_daily_report")
  129. while True:
  130. schedule.run_pending()
  131. time.sleep(1)
  132. if __name__ == '__main__':
  133. schedule_task()