scheduler.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. """统一定时任务调度入口。"""
  2. from __future__ import annotations
  3. import argparse
  4. import json
  5. import sys
  6. import time
  7. from datetime import date, datetime, timedelta
  8. from pathlib import Path
  9. from typing import Any, Callable
  10. PROJECT_ROOT = Path(__file__).resolve().parents[1]
  11. if str(PROJECT_ROOT) not in sys.path:
  12. sys.path.insert(0, str(PROJECT_ROOT))
  13. from app.hot_content.client import JsonApiClient
  14. from app.hot_content.decode_result_service import run_once as run_decode_result_once
  15. from app.hot_content.config import load_flow_config
  16. from app.hot_content.postprocess_service import run_once as run_postprocess_once
  17. from app.hot_content.repository import HotContentRepository
  18. from app.hot_content.service import run_once
  19. from app.hot_content.timezone import SHANGHAI_TZ
  20. from app.hot_content.types import FlowConfig
  21. from app.hot_content.wxindex_words import run_wxindex_words_daily_job
  22. from app.hot_content.wxindex_heat_pattern import run_wxindex_heat_pattern_daily_job
  23. from app.festival_demand.config import load_festival_demand_config
  24. from app.festival_demand.repository import FestivalDemandRepository
  25. from app.festival_demand.service import run_festival_demand_daily_job, print_festival_demand_summary
  26. from app.festival_demand.types import FestivalDemandConfig
  27. from app.gap_script_demand.config import load_gap_script_demand_config
  28. from app.gap_script_demand.repository import GapScriptDemandRepository
  29. from app.gap_script_demand.service import (
  30. print_gap_script_demand_summary,
  31. run_gap_script_demand_daily_job,
  32. )
  33. from app.gap_script_demand.types import GapScriptDemandConfig
  34. # 当天失败后自动重试间隔(秒)
  35. SAME_DAY_RETRY_INTERVAL_SECONDS = 10 * 60
  36. def _import_blocking_scheduler() -> Any:
  37. try:
  38. from apscheduler.schedulers.blocking import BlockingScheduler
  39. except ImportError as exc:
  40. raise RuntimeError("缺少依赖:请先执行 pip install -r requirements.txt") from exc
  41. return BlockingScheduler
  42. def run_hot_content_job(config: FlowConfig) -> None:
  43. try:
  44. summary = run_once(config)
  45. print(json.dumps(summary, ensure_ascii=False, indent=2))
  46. except Exception as exc:
  47. print(f"hot content flow failed: {exc}", file=sys.stderr)
  48. def run_decode_result_job(config: FlowConfig) -> None:
  49. """解构结果拉取 -> 后处理 -> 写入 ODPS 需求表。"""
  50. summary: dict[str, Any] = {}
  51. try:
  52. summary["decode_result"] = run_decode_result_once(config)
  53. except Exception as exc:
  54. summary["decode_result_error"] = str(exc)
  55. print(f"decode result flow failed: {exc}", file=sys.stderr)
  56. try:
  57. summary["postprocess"] = run_postprocess_once(config)
  58. except Exception as exc:
  59. summary["postprocess_error"] = str(exc)
  60. print(f"postprocess flow failed: {exc}", file=sys.stderr)
  61. print(json.dumps(summary, ensure_ascii=False, indent=2))
  62. def run_postprocess_job(config: FlowConfig) -> None:
  63. try:
  64. summary = run_postprocess_once(config)
  65. print(json.dumps({"job": "postprocess", "summary": summary}, ensure_ascii=False, indent=2))
  66. except Exception as exc:
  67. print(f"postprocess flow failed: {exc}", file=sys.stderr)
  68. def run_wxindex_words_refresh_job(config: FlowConfig) -> None:
  69. repository = HotContentRepository(config.mysql)
  70. api_client = JsonApiClient(
  71. timeout_seconds=config.request_timeout_seconds,
  72. verify_ssl=config.https_verify_ssl,
  73. )
  74. try:
  75. summary = run_wxindex_words_daily_job(
  76. repository,
  77. api_client,
  78. config.wxindex_api_url,
  79. )
  80. print(
  81. json.dumps(
  82. {"job": "wxindex_words_refresh", "summary": summary},
  83. ensure_ascii=False,
  84. indent=2,
  85. )
  86. )
  87. except Exception as exc:
  88. print(f"wxindex words refresh failed: {exc}", file=sys.stderr)
  89. finally:
  90. repository.close()
  91. def run_wxindex_heat_pattern_job(config: FlowConfig) -> None:
  92. repository = HotContentRepository(config.mysql)
  93. api_client = JsonApiClient(
  94. timeout_seconds=config.request_timeout_seconds,
  95. verify_ssl=config.https_verify_ssl,
  96. )
  97. try:
  98. summary = run_wxindex_heat_pattern_daily_job(
  99. repository,
  100. config=config,
  101. api_client=api_client,
  102. )
  103. print(
  104. json.dumps(
  105. {"job": "wxindex_heat_pattern", "summary": summary},
  106. ensure_ascii=False,
  107. indent=2,
  108. )
  109. )
  110. except Exception as exc:
  111. print(f"wxindex heat pattern failed: {exc}", file=sys.stderr)
  112. finally:
  113. repository.close()
  114. def _today_shanghai() -> date:
  115. return datetime.now(SHANGHAI_TZ).date()
  116. def _is_write_success(summary: dict[str, Any]) -> bool:
  117. """MySQL 已写入,或分区已有数据(视为当天写入目标已达成)。"""
  118. skip_reason = summary.get("skip_reason")
  119. if skip_reason == "partition_data_exists":
  120. return True
  121. mysql_save = summary.get("mysql_save") or {}
  122. if mysql_save.get("skipped") and mysql_save.get("skip_reason") == "partition_data_exists":
  123. return True
  124. if mysql_save and not mysql_save.get("skipped"):
  125. return True
  126. return False
  127. def _festival_demand_needs_retry(summary: dict[str, Any]) -> bool:
  128. """节日需求:异常外的可恢复失败(如源数据未就绪)需要重试。"""
  129. if _is_write_success(summary):
  130. return False
  131. skip_reason = summary.get("skip_reason")
  132. # 当天无活跃节日 / 测试跳过 / 流程跑完但无需落库 → 视为当天完成,不再重试
  133. if skip_reason in {"no_active_festivals", "skip_odps"}:
  134. return False
  135. if skip_reason == "no_demand_names":
  136. return True
  137. # 匹配完成但无生成需求(无需写入)
  138. if not skip_reason and not (summary.get("mysql_save") or {}):
  139. return False
  140. return True
  141. def _gap_script_demand_needs_retry(summary: dict[str, Any]) -> bool:
  142. """脚本主驱:源数据未就绪需重试;无匹配项则当天完成。"""
  143. if _is_write_success(summary):
  144. return False
  145. skip_reason = summary.get("skip_reason")
  146. if skip_reason == "no_matched_demands":
  147. return False
  148. if skip_reason == "no_demand_names":
  149. return True
  150. if not skip_reason and not (summary.get("mysql_save") or {}):
  151. return False
  152. return True
  153. def _run_with_same_day_retry(
  154. *,
  155. job_name: str,
  156. query_date: date,
  157. run_once_fn: Callable[[], dict[str, Any]],
  158. needs_retry: Callable[[dict[str, Any]], bool],
  159. print_summary: Callable[[dict[str, Any]], None],
  160. interval_seconds: int = SAME_DAY_RETRY_INTERVAL_SECONDS,
  161. ) -> None:
  162. """当天失败后每隔 interval_seconds 重试,直到写入成功或跨日停止。"""
  163. attempt = 0
  164. while True:
  165. attempt += 1
  166. try:
  167. summary = run_once_fn()
  168. print_summary(summary)
  169. print(
  170. json.dumps(
  171. {
  172. "job": job_name,
  173. "attempt": attempt,
  174. "query_date": query_date.isoformat(),
  175. "summary": summary,
  176. },
  177. ensure_ascii=False,
  178. indent=2,
  179. )
  180. )
  181. if not needs_retry(summary):
  182. if _is_write_success(summary):
  183. print(
  184. f"{job_name}: write success on attempt={attempt} "
  185. f"query_date={query_date.isoformat()}",
  186. flush=True,
  187. )
  188. else:
  189. print(
  190. f"{job_name}: finished without retry "
  191. f"attempt={attempt} skip_reason={summary.get('skip_reason')!r} "
  192. f"query_date={query_date.isoformat()}",
  193. flush=True,
  194. )
  195. return
  196. retry_reason = summary.get("skip_reason") or "incomplete"
  197. print(
  198. f"{job_name}: not ready ({retry_reason}), "
  199. f"retry in {interval_seconds}s "
  200. f"(attempt={attempt}, query_date={query_date.isoformat()})",
  201. flush=True,
  202. )
  203. except Exception as exc:
  204. print(f"{job_name} failed: {exc}", file=sys.stderr)
  205. print(
  206. f"{job_name}: retry in {interval_seconds}s "
  207. f"(attempt={attempt}, query_date={query_date.isoformat()})",
  208. flush=True,
  209. )
  210. if _today_shanghai() != query_date:
  211. print(
  212. f"{job_name}: stop retry, day changed "
  213. f"(query_date={query_date.isoformat()})",
  214. flush=True,
  215. )
  216. return
  217. time.sleep(max(interval_seconds, 1))
  218. if _today_shanghai() != query_date:
  219. print(
  220. f"{job_name}: stop retry after sleep, day changed "
  221. f"(query_date={query_date.isoformat()})",
  222. flush=True,
  223. )
  224. return
  225. def run_festival_demand_job(config: FestivalDemandConfig) -> None:
  226. query_date = _today_shanghai()
  227. _run_with_same_day_retry(
  228. job_name="festival_demand",
  229. query_date=query_date,
  230. run_once_fn=lambda: run_festival_demand_daily_job(config, query_date=query_date),
  231. needs_retry=_festival_demand_needs_retry,
  232. print_summary=print_festival_demand_summary,
  233. )
  234. def run_gap_script_demand_job(config: GapScriptDemandConfig) -> None:
  235. query_date = _today_shanghai()
  236. _run_with_same_day_retry(
  237. job_name="gap_script_demand",
  238. query_date=query_date,
  239. run_once_fn=lambda: run_gap_script_demand_daily_job(config, query_date=query_date),
  240. needs_retry=_gap_script_demand_needs_retry,
  241. print_summary=print_gap_script_demand_summary,
  242. )
  243. def register_hot_content_job(scheduler: Any, config: FlowConfig) -> None:
  244. scheduler.add_job(
  245. run_hot_content_job,
  246. trigger="cron",
  247. hour=config.hot_flow_cron_hours,
  248. minute=config.hot_flow_cron_minute,
  249. timezone=SHANGHAI_TZ,
  250. args=[config],
  251. id="hot_content_flow",
  252. name="热点内容抓取搜索解构流程",
  253. replace_existing=True,
  254. coalesce=True,
  255. max_instances=1,
  256. )
  257. def register_decode_result_job(scheduler: Any, config: FlowConfig) -> None:
  258. interval_seconds = max(config.decode_result_interval_seconds, 60)
  259. scheduler.add_job(
  260. run_decode_result_job,
  261. trigger="interval",
  262. seconds=interval_seconds,
  263. args=[config],
  264. id="decode_result_flow",
  265. name="解构结果拉取、后处理与 ODPS 需求表写入",
  266. replace_existing=True,
  267. coalesce=True,
  268. max_instances=1,
  269. next_run_time=datetime.now(SHANGHAI_TZ) + timedelta(seconds=interval_seconds),
  270. )
  271. def register_wxindex_words_refresh_job(scheduler: Any, config: FlowConfig) -> None:
  272. scheduler.add_job(
  273. run_wxindex_words_refresh_job,
  274. trigger="cron",
  275. hour=config.wxindex_words_cron_hours,
  276. minute=config.wxindex_words_cron_minute,
  277. timezone=SHANGHAI_TZ,
  278. args=[config],
  279. id="wxindex_words_refresh",
  280. name="微信指数词汇总表补全缺失日期并清理低均值词",
  281. replace_existing=True,
  282. coalesce=True,
  283. max_instances=1,
  284. )
  285. def register_wxindex_heat_pattern_job(scheduler: Any, config: FlowConfig) -> None:
  286. scheduler.add_job(
  287. run_wxindex_heat_pattern_job,
  288. trigger="cron",
  289. hour=config.wxindex_heat_pattern_cron_hours,
  290. minute=config.wxindex_heat_pattern_cron_minute,
  291. timezone=SHANGHAI_TZ,
  292. args=[config],
  293. id="wxindex_heat_pattern",
  294. name="微信指数热度模式分析(持续高热/上涨/暴涨)",
  295. replace_existing=True,
  296. coalesce=True,
  297. max_instances=1,
  298. )
  299. def _has_today_output_data(*, strategy: str, repository_cls: type[Any]) -> bool:
  300. """判断当天输出 strategy 分区是否已有成功写入数据。"""
  301. partition_dt = _today_shanghai().strftime("%Y%m%d")
  302. flow_config = load_flow_config()
  303. repository = repository_cls(flow_config.mysql)
  304. try:
  305. return bool(
  306. repository.has_partition_data(
  307. strategy=strategy,
  308. partition_dt=partition_dt,
  309. )
  310. )
  311. finally:
  312. repository.close()
  313. def _should_run_immediately_on_startup(
  314. *,
  315. strategy: str,
  316. repository_cls: type[Any],
  317. job_name: str,
  318. ) -> bool:
  319. """启动时判断:当天尚未成功写入则立刻执行,不只依赖 cron。"""
  320. try:
  321. already_done = _has_today_output_data(
  322. strategy=strategy,
  323. repository_cls=repository_cls,
  324. )
  325. except Exception as exc:
  326. print(
  327. f"{job_name}: startup check failed ({exc}), "
  328. "will run immediately",
  329. flush=True,
  330. )
  331. return True
  332. if already_done:
  333. print(
  334. f"{job_name}: today already succeeded "
  335. f"(strategy={strategy}, date={_today_shanghai().isoformat()}), "
  336. "skip startup run",
  337. flush=True,
  338. )
  339. return False
  340. print(
  341. f"{job_name}: today not succeeded yet "
  342. f"(strategy={strategy}, date={_today_shanghai().isoformat()}), "
  343. "schedule startup run now",
  344. flush=True,
  345. )
  346. return True
  347. def register_festival_demand_job(scheduler: Any, config: FestivalDemandConfig) -> None:
  348. job_kwargs: dict[str, Any] = {
  349. "trigger": "cron",
  350. "hour": config.cron_hours,
  351. "minute": config.cron_minute,
  352. "timezone": SHANGHAI_TZ,
  353. "args": [config],
  354. "id": "festival_demand",
  355. "name": "节日需求:活跃节日检测 + ODPS 需求词匹配",
  356. "replace_existing": True,
  357. "coalesce": True,
  358. "max_instances": 1,
  359. }
  360. if _should_run_immediately_on_startup(
  361. strategy=config.output_strategy,
  362. repository_cls=FestivalDemandRepository,
  363. job_name="festival_demand",
  364. ):
  365. job_kwargs["next_run_time"] = datetime.now(SHANGHAI_TZ)
  366. scheduler.add_job(run_festival_demand_job, **job_kwargs)
  367. def register_gap_script_demand_job(scheduler: Any, config: GapScriptDemandConfig) -> None:
  368. job_kwargs: dict[str, Any] = {
  369. "trigger": "cron",
  370. "hour": config.cron_hours,
  371. "minute": config.cron_minute,
  372. "timezone": SHANGHAI_TZ,
  373. "args": [config],
  374. "id": "gap_script_demand",
  375. "name": "当下供需gap:脚本主驱画面改造筛选",
  376. "replace_existing": True,
  377. "coalesce": True,
  378. "max_instances": 1,
  379. }
  380. if _should_run_immediately_on_startup(
  381. strategy=config.output_strategy,
  382. repository_cls=GapScriptDemandRepository,
  383. job_name="gap_script_demand",
  384. ):
  385. job_kwargs["next_run_time"] = datetime.now(SHANGHAI_TZ)
  386. scheduler.add_job(run_gap_script_demand_job, **job_kwargs)
  387. def start_scheduler() -> None:
  388. BlockingScheduler = _import_blocking_scheduler()
  389. scheduler = BlockingScheduler(timezone=SHANGHAI_TZ)
  390. config = load_flow_config()
  391. festival_config = load_festival_demand_config()
  392. gap_script_config = load_gap_script_demand_config()
  393. register_hot_content_job(scheduler, config)
  394. register_decode_result_job(scheduler, config)
  395. register_wxindex_words_refresh_job(scheduler, config)
  396. register_wxindex_heat_pattern_job(scheduler, config)
  397. register_festival_demand_job(scheduler, festival_config)
  398. register_gap_script_demand_job(scheduler, gap_script_config)
  399. print(
  400. "scheduler started, timezone=Asia/Shanghai, "
  401. "jobs=['hot_content_flow', 'decode_result_flow', 'wxindex_words_refresh', "
  402. "'wxindex_heat_pattern', 'festival_demand', 'gap_script_demand'], "
  403. f"hot_cron={config.hot_flow_cron_hours}:{config.hot_flow_cron_minute:02d}, "
  404. f"decode_result_interval={config.decode_result_interval_seconds}s, "
  405. f"wxindex_words_cron={config.wxindex_words_cron_hours}:{config.wxindex_words_cron_minute:02d}, "
  406. f"wxindex_heat_pattern_cron="
  407. f"{config.wxindex_heat_pattern_cron_hours}:{config.wxindex_heat_pattern_cron_minute:02d}, "
  408. f"festival_demand_cron={festival_config.cron_hours}:{festival_config.cron_minute:02d}, "
  409. f"gap_script_demand_cron={gap_script_config.cron_hours}:{gap_script_config.cron_minute:02d}, "
  410. f"same_day_retry_interval={SAME_DAY_RETRY_INTERVAL_SECONDS}s, "
  411. "startup_check=festival_demand+gap_script_demand"
  412. )
  413. scheduler.start()
  414. def parse_args() -> argparse.Namespace:
  415. parser = argparse.ArgumentParser(description="统一定时任务调度入口")
  416. parser.add_argument("--once", action="store_true", help="执行一次,不启动调度器")
  417. parser.add_argument(
  418. "--job",
  419. choices=(
  420. "all",
  421. "hot-content",
  422. "decode-result",
  423. "postprocess",
  424. "wxindex-refresh",
  425. "wxindex-heat-pattern",
  426. "festival-demand",
  427. "gap-script-demand",
  428. ),
  429. default="all",
  430. help="--once 时选择执行哪个任务",
  431. )
  432. return parser.parse_args()
  433. def main() -> None:
  434. args = parse_args()
  435. if args.once:
  436. config = load_flow_config()
  437. if args.job in {"all", "hot-content"}:
  438. summary = run_once(config)
  439. print(
  440. json.dumps(
  441. {"job": "hot_content_flow", "summary": summary},
  442. ensure_ascii=False,
  443. indent=2,
  444. )
  445. )
  446. if args.job in {"all", "decode-result"}:
  447. summary: dict[str, Any] = {}
  448. try:
  449. summary["decode_result"] = run_decode_result_once(config)
  450. except Exception as exc:
  451. summary["decode_result_error"] = str(exc)
  452. try:
  453. summary["postprocess"] = run_postprocess_once(config)
  454. except Exception as exc:
  455. summary["postprocess_error"] = str(exc)
  456. print(
  457. json.dumps(
  458. {"job": "decode_result_flow", "summary": summary},
  459. ensure_ascii=False,
  460. indent=2,
  461. )
  462. )
  463. if args.job in {"postprocess"}:
  464. summary = run_postprocess_once(config)
  465. print(
  466. json.dumps(
  467. {"job": "postprocess", "summary": summary},
  468. ensure_ascii=False,
  469. indent=2,
  470. )
  471. )
  472. if args.job in {"wxindex-refresh"}:
  473. run_wxindex_words_refresh_job(config)
  474. if args.job in {"wxindex-heat-pattern"}:
  475. run_wxindex_heat_pattern_job(config)
  476. if args.job in {"festival-demand"}:
  477. festival_config = load_festival_demand_config()
  478. run_festival_demand_job(festival_config)
  479. if args.job in {"gap-script-demand"}:
  480. gap_script_config = load_gap_script_demand_config()
  481. run_gap_script_demand_job(gap_script_config)
  482. return
  483. start_scheduler()
  484. if __name__ == "__main__":
  485. main()