start_cxx_spider.py 6.6 KB

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