#!/usr/bin/env bash # ============================================================= # 作者:Junhui Luo / 2025-07-14 # 每分钟由系统 cron 触发一次,内部解析任务表达式决定是否执行 # ============================================================= ###################### 全局配置 ###################### SCRIPT_DIR="/root/luojunhui/LongArticlesJob" # 工作目录 LOG_DIR="${SCRIPT_DIR}/logs" # 日志根目录 PYTHON_SCRIPT="long_articles_job.py" # 统一入口脚本 CONDA_SH="/root/miniconda3/etc/profile.d/conda.sh" CONDA_ENV="tasks" LOG_RETENTION_DAYS=7 # 日志保存天数 LOG_MAX_MB=100 # 单文件最大 MB,超过清空 # 失败告警(自行实现:邮件、钉钉、Prometheus Pushgateway…) on_failure(){ local task=$1 local now=$(date '+%F %T') local timestamp=$(($(date +%s%N)/1000000)) local url="https://open.feishu.cn/open-apis/bot/v2/hook/223b3d72-f2e8-40e0-9b53-6956e0ae7158" local content="定时任务失败:${task}\n时间:${now}" curl --request POST "${url}" \ --header 'Content-Type: application/json' \ --data-row "{\"msg_type\":\"interactive\",\"card\":{\"content\":\"${content}\"}}" \ >/dev/null 2>&1 } ###################### 任务定义 ###################### # 语法: "分 时 日 月 周|任务名|日志模板" # 支持 *、*/n、a-b、a,b,c 以及它们组合 TASKS=( "0 3 * * *|run_sph_video_crawler|${LOG_DIR}/run_sph_video_crawler/%Y-%m-%d.log" "0 6 * * *|run_piaoquan_video_crawler|${LOG_DIR}/run_piaoquan_video_crawler/%Y-%m-%d.log" "10 6 * * *|run_sohu_video_crawler|${LOG_DIR}/run_sohu_video_crawler/%Y-%m-%d.log" "20 11 * * *|top_article_generalize|${LOG_DIR}/top_article_generalize/%Y-%m-%d.log" "0 15 * * *|run_sph_video_crawler|${LOG_DIR}/run_sph_video_crawler/%Y-%m-%d.log" # 示例:每分钟执行 # "* * * * *|heartbeat|${LOG_DIR}/heartbeat/%Y-%m-%d.log" ) ###################### 工具函数 ###################### log(){ printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "$2"; } cron_field_match(){ # 参数:字段 当前值 local field=$1 now=$2 [[ $field == "*" ]] && return 0 IFS=',' read -ra parts <<< "$field" for p in "${parts[@]}"; do if [[ $p == "*/"* ]]; then # 步进 */n local step=${p#*/} (( now % step == 0 )) && return 0 elif [[ $p == *-* ]]; then # 范围 a-b local start=${p%-*} end=${p#*-} (( now >= start && now <= end )) && return 0 elif (( now == p )); then # 单值 return 0 fi done return 1 } cron_match(){ # 参数:完整表达式 read -r m h dom mon dow <<< "$1" local n_m=$(date +%-M) n_h=$(date +%-H) n_dom=$(date +%-d) \ n_mon=$(date +%-m) n_dow=$(date +%-u) # 1(周一)…7(周日) cron_field_match "$m" "$n_m" && \ cron_field_match "$h" "$n_h" && \ cron_field_match "$dom" "$n_dom" && \ cron_field_match "$mon" "$n_mon" && \ cron_field_match "$dow" "$n_dow" } start_task(){ # 参数:任务名 日志文件 local name=$1 logfile=$2 mkdir -p "$(dirname "$logfile")"; touch "$logfile" # 若已在运行则跳过 pgrep -f "python3 $PYTHON_SCRIPT --task_name $name" >/dev/null && { log INFO "任务 $name 已在运行" | tee -a "$logfile"; return; } ( # 子 shell 中运行,便于 flock 持锁 exec >>"$logfile" 2>&1 log INFO "启动任务 $name" cd "$SCRIPT_DIR" || { log ERROR "进入 $SCRIPT_DIR 失败"; exit 1; } [[ -f $CONDA_SH ]] && { source "$CONDA_SH"; conda activate "$CONDA_ENV"; } nohup python3 "$PYTHON_SCRIPT" --task_name "$name" & sleep 1 pgrep -f "python3 $PYTHON_SCRIPT --task_name $name" \ && log SUCCESS "任务 $name 启动成功" \ || { log ERROR "任务 $name 启动失败"; on_failure "$name"; } ) & } cleanup_logs(){ # 过期删除 find "$LOG_DIR" -type f -name '*.log' -mtime +"$LOG_RETENTION_DAYS" -delete # 超大清空 find "$LOG_DIR" -type f -name '*.log' -size +"${LOG_MAX_MB}M" -exec \ sh -c 'cat /dev/null > "$1"' sh {} \; } ###################### 主流程 ###################### ( # 全局锁,阻止同脚本并行 exec 200>"$0.lock" flock -n 200 || exit 0 mkdir -p "$LOG_DIR" MAIN_LOG=$(printf '%s/scheduler_%(%Y-%m-%d)T.log' "$LOG_DIR" -1) exec >>"$MAIN_LOG" 2>&1 log INFO "====== 调度开始 ======" for task in "${TASKS[@]}"; do IFS='|' read -r cron_expr name log_tpl <<< "$task" cron_match "$cron_expr" || continue logfile=$(date +"$log_tpl") # 渲染 %Y-%m-%d start_task "$name" "$logfile" done cleanup_logs log INFO "====== 调度结束 ======" ) exit 0