Commit 3ac999b0 authored by 郭阔's avatar 郭阔

feat: platform abstraction layer (auto-detect darwin/linux)

- bin/platform.sh: single source of truth for platform differences —
  OS detection (NH_OS override for tests), PATH bootstrap without
  shadowing caller-injected commands, platform default paths, scheduler
  abstraction (systemd timer + systemctl <-> launchd plist + launchctl
  with named entry scripts), file-level disable on darwin (survives
  reboot, one-click recoverable), sed -i / GNU-sed shims, root check
  only on linux, conf loading (EnvironmentFile equivalent)
- bin/schedule.py: plist parse/write + auto-detect by filename
  (timer/plist/.disabled); schedule sources listing subcommand
- harvest/continue/daily-digest: drop hardcoded nvm//home/yichu paths,
  self-bootstrap via platform.sh, harvest now loads conf itself
  (run.sh wrapper no longer required)
- add-source/modify-source: fully cross-platform schedule management
  (plist generation + launchctl on darwin, timer + systemctl on linux)
- status.sh: cross-platform overview/detail/journal (launchd log on darwin)
- deploy/migrate: linux-only guard with pointer to setup.sh
- tests: env-synced platform simulation (NH_OS), fake launchctl suite,
  darwin plist/disable/enable roundtrip tests; new disable contract
  (unload + preserve times) replaces clear-times; 37 tests green
parent ed91787c
#!/bin/bash #!/bin/bash
# add-source.sh — 新增一个抓取栏目(timer + 可选 conf),自动错开时间 # add-source.sh — 新增一个抓取栏目(timer + 可选 conf),自动错开时间
# #
# 用法: # 用法(macOS 用户态直接运行;Linux 写 /etc 需 sudo):
# sudo ./bin/add-source.sh <source-name> [选项] # ./bin/add-source.sh <source-name> [选项]
# #
# 选项: # 选项:
# --time HH:MM[,HH:MM] 运行时间,如 09:30(不指定则自动错开:现有最晚时间 +15 分钟) # --time HH:MM[,HH:MM] 运行时间,如 09:30(不指定则自动错开:现有最晚时间 +15 分钟)
...@@ -22,16 +22,19 @@ ...@@ -22,16 +22,19 @@
# sudo ./bin/add-source.sh nikkei-asia # 自动错开到下一个空档 # sudo ./bin/add-source.sh nikkei-asia # 自动错开到下一个空档
# #
# 环境变量(可覆盖默认路径): # 环境变量(可覆盖默认路径):
# SYSTEMD_DIR systemd unit 目录(默认 /etc/systemd/system) # NH_SCHEDULE_DIR 调度目录(linux: /etc/systemd/system;darwin: ~/Library/LaunchAgents)
# CONF_DIR 栏目配置目录(默认 /etc/news-harvester) # CONF_DIR 栏目配置目录(linux: /etc/news-harvester;darwin: $WORK_DIR/etc/news-harvester)
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SCHEDULE_HELPER="$SCRIPT_DIR/schedule.py" # shellcheck disable=SC1091
source "$SCRIPT_DIR/platform.sh"
SCHEDULE_HELPER="$NH_SCHEDULE_HELPER"
SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}" WORK_DIR="$(nh_default_work_dir)"
CONF_DIR="${CONF_DIR:-/etc/news-harvester}" CONF_DIR="$(nh_default_conf_dir "$WORK_DIR")"
LOG_DIR="${LOG_DIR:-$WORK_DIR/logs}"
SOURCE="" SOURCE=""
TIME="" TIME=""
...@@ -88,11 +91,13 @@ confirm() { ...@@ -88,11 +91,13 @@ confirm() {
# 避免和 digest 冲突 # 避免和 digest 冲突
[ "$SOURCE" = "digest" ] && err "栏目名不能用 digest(与汇总服务冲突)" [ "$SOURCE" = "digest" ] && err "栏目名不能用 digest(与汇总服务冲突)"
[ "$(id -u)" -eq 0 ] || err "请用 sudo 运行" nh_require_root || exit 1
# 检查依赖:service 模板 + harvest.sh # 检查依赖:linux 需要 service 模板;harvest.sh 属于本仓库
[ -f "$SYSTEMD_DIR/news-harvester@.service" ] || err "未找到 $SYSTEMD_DIR/news-harvester@.service,请先运行 migrate.sh 安装" if nh_is_linux; then
[ -f "/home/yichu/Documents/News-Harvester/bin/harvest.sh" ] || warn "harvest.sh 不在默认位置,确认已安装(可忽略若路径不同)" [ -f "$(nh_schedule_dir)/news-harvester@.service" ] || err "未找到 $(nh_schedule_dir)/news-harvester@.service,请先运行 migrate.sh 安装"
fi
[ -f "$NH_BIN_DIR/harvest.sh" ] || warn "harvest.sh 不在预期位置: $NH_BIN_DIR"
# 校验时间格式 # 校验时间格式
validate_count() { validate_count() {
...@@ -108,16 +113,15 @@ validate_mos() { ...@@ -108,16 +113,15 @@ validate_mos() {
[ "$FALLBACK_PROVIDER" = "default" ] && FALLBACK_PROVIDER="" [ "$FALLBACK_PROVIDER" = "default" ] && FALLBACK_PROVIDER=""
# --- 检查是否已存在 --- # --- 检查是否已存在 ---
TIMER_PATH="$SYSTEMD_DIR/news-harvester@${SOURCE}.timer" TIMER_PATH="$(nh_schedule_file "$SOURCE")"
CONF_PATH="$CONF_DIR/${SOURCE}.conf" CONF_PATH="$CONF_DIR/${SOURCE}.conf"
if [ -f "$TIMER_PATH" ] && [ "$FORCE" = "0" ]; then if nh_schedule_exists "$SOURCE" && [ "$FORCE" = "0" ]; then
err "timer 已存在: $TIMER_PATH\n 如需覆盖,加 --force;如需改时间,用:\n sudo systemctl edit --full news-harvester@${SOURCE}.timer" err "调度已存在: $TIMER_PATH\n 如需覆盖,加 --force;如需改时间,用:\n sudo $0 $SOURCE --time HH:MM"
fi fi
# --- 自动推算时间 --- # --- 自动推算时间 ---
if [ -z "$TIME" ]; then if [ -z "$TIME" ]; then
TIME=$(python3 "$SCHEDULE_HELPER" suggest "$SYSTEMD_DIR") TIME=$(python3 "$SCHEDULE_HELPER" suggest "$(nh_schedule_dir)")
AUTO_TIME=1 AUTO_TIME=1
else else
TIME=$(python3 "$SCHEDULE_HELPER" normalize "$TIME") || exit 1 TIME=$(python3 "$SCHEDULE_HELPER" normalize "$TIME") || exit 1
...@@ -128,7 +132,7 @@ fi ...@@ -128,7 +132,7 @@ fi
echo "=== 即将创建栏目: $SOURCE ===" echo "=== 即将创建栏目: $SOURCE ==="
echo " 运行时间: $TIME" echo " 运行时间: $TIME"
[ "$AUTO_TIME" = "1" ] && echo " (自动推算:现有最晚栏目 +15 分钟)" [ "$AUTO_TIME" = "1" ] && echo " (自动推算:现有最晚栏目 +15 分钟)"
echo " timer 文件: $TIMER_PATH" echo " 调度文件: $TIMER_PATH"
if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ] || [ -n "$PROVIDER" ] || [ -n "$FALLBACK_PROVIDER" ]; then if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ] || [ -n "$PROVIDER" ] || [ -n "$FALLBACK_PROVIDER" ]; then
echo " conf 文件: $CONF_PATH" echo " conf 文件: $CONF_PATH"
[ -n "$COUNT" ] && echo " COUNT=$COUNT" [ -n "$COUNT" ] && echo " COUNT=$COUNT"
...@@ -143,29 +147,15 @@ fi ...@@ -143,29 +147,15 @@ fi
echo "" echo ""
confirm "确认创建?" || { echo "已取消。"; exit 0; } confirm "确认创建?" || { echo "已取消。"; exit 0; }
# --- 写 timer --- # --- 写调度(linux: timer 文件;darwin: plist + 入口脚本) ---
mkdir -p "$SYSTEMD_DIR" nh_schedule_write "$SOURCE" "$TIME"
cat > "$TIMER_PATH" <<EOF
[Unit]
Description=Run News Harvester ($SOURCE) daily at $TIME
[Timer]
$(python3 "$SCHEDULE_HELPER" calendar "$TIME")
Persistent=true
AccuracySec=1min
Unit=news-harvester@${SOURCE}.service
[Install]
WantedBy=timers.target
EOF
python3 "$SCHEDULE_HELPER" update "$TIMER_PATH" "$TIME"
ok "已创建 $TIMER_PATH" ok "已创建 $TIMER_PATH"
# --- 写 conf(仅当有覆盖项) --- # --- 写 conf(仅当有覆盖项) ---
if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ] || [ -n "$PROVIDER" ] || [ -n "$FALLBACK_PROVIDER" ]; then if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ] || [ -n "$PROVIDER" ] || [ -n "$FALLBACK_PROVIDER" ]; then
mkdir -p "$CONF_DIR" mkdir -p "$CONF_DIR"
{ {
echo "# /etc/news-harvester/${SOURCE}.conf — 由 add-source.sh 生成 $(date '+%Y-%m-%d %H:%M:%S')" echo "# ${CONF_PATH} — 由 add-source.sh 生成 $(date '+%Y-%m-%d %H:%M:%S')"
echo "# 仅写需要覆盖的字段,其余继承 default.conf" echo "# 仅写需要覆盖的字段,其余继承 default.conf"
echo "" echo ""
[ -n "$COUNT" ] && echo "COUNT=$COUNT" [ -n "$COUNT" ] && echo "COUNT=$COUNT"
...@@ -178,13 +168,10 @@ if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ] || [ -n "$P ...@@ -178,13 +168,10 @@ if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ] || [ -n "$P
ok "已创建 $CONF_PATH" ok "已创建 $CONF_PATH"
fi fi
# --- daemon-reload + enable --- # --- 加载调度 ---
systemctl daemon-reload
ok "systemd 已重新加载"
if [ "$NO_ENABLE" = "0" ]; then if [ "$NO_ENABLE" = "0" ]; then
systemctl enable --now "news-harvester@${SOURCE}.timer" nh_schedule_enable "$SOURCE"
ok "已启用并启动 news-harvester@${SOURCE}.timer" ok "已启用调度: $SOURCE($TIME)"
fi fi
echo "" echo ""
...@@ -193,10 +180,12 @@ ok "栏目 $SOURCE 添加完成!" ...@@ -193,10 +180,12 @@ ok "栏目 $SOURCE 添加完成!"
echo "========================================" echo "========================================"
echo "" echo ""
echo "后续操作:" echo "后续操作:"
echo " 查看定时器: systemctl list-timers news-harvester@${SOURCE}.timer" echo " 手动测试: $NH_BIN_DIR/harvest.sh $SOURCE 1 (只采 1 条验证)"
echo " 手动测试: sudo systemctl start news-harvester@${SOURCE}.service" echo " 查看运行结果: cat $LOG_DIR/${SOURCE}.last.log"
echo " 实时日志: journalctl -u news-harvester@${SOURCE}.service -f" if nh_is_darwin; then
echo " 改时间: sudo systemctl edit --full news-harvester@${SOURCE}.timer (改后 daemon-reload)" echo " 查看调度: launchctl list | grep newsharvester"
echo " 查看运行结果: cat ~/Documents/News-Harvester/logs/${SOURCE}.last.log" else
echo " 查看定时器: systemctl list-timers news-harvester@${SOURCE}.timer"
fi
echo "" echo ""
echo "提示:此栏目今天的抓取记录会自动被每日汇总(digest)纳入分析。" echo "提示:此栏目今天的抓取记录会自动被每日汇总(digest)纳入分析。"
...@@ -27,11 +27,11 @@ ...@@ -27,11 +27,11 @@
# WORK_DIR 项目工作目录(默认 /home/yichu/Documents/News-Harvester) # WORK_DIR 项目工作目录(默认 /home/yichu/Documents/News-Harvester)
# LOG_DIR 日志目录(默认 <WORK_DIR>/logs) # LOG_DIR 日志目录(默认 <WORK_DIR>/logs)
# --- 设置 PATH 和 nvm(等价于 .zshrc 中的配置) --- # --- 平台层:自动识别 darwin/linux,自举 PATH,解析默认路径 ---
# Web 面板通过 sudo -u yichu 调用时不加载 .zshrc,需显式设置 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export NVM_DIR="/home/yichu/.nvm" # shellcheck disable=SC1091
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" source "$SCRIPT_DIR/platform.sh"
export PATH="/home/yichu/.local/bin:/home/yichu/.nvm/versions/node/v24.14.0/bin:$PATH" nh_bootstrap_env
SOURCE="" SOURCE=""
MODE="resume" MODE="resume"
...@@ -82,13 +82,10 @@ case "$MODE" in ...@@ -82,13 +82,10 @@ case "$MODE" in
resume|repair) ;; resume|repair) ;;
*) err "--mode 只能是 resume 或 repair: $MODE";; *) err "--mode 只能是 resume 或 repair: $MODE";;
esac esac
# --- 加载配置(同 harvest.sh 的 EnvironmentFile 层级) --- # --- 加载配置(同 harvest.sh 的层级:default.conf → <source>.conf 覆盖) ---
# Web 面板直接调用时不走 systemd,EnvironmentFile 不会自动加载,需手动 source WORK_DIR="$(nh_default_work_dir)"
CONF_DIR="${CONF_DIR:-/etc/news-harvester}" CONF_DIR="$(nh_default_conf_dir "$WORK_DIR")"
# 先加载 default.conf,再加载 source.conf(后者覆盖前者) nh_load_conf "$SOURCE"
for _conf in "$CONF_DIR/default.conf" "$CONF_DIR/${SOURCE}.conf"; do
[ -f "$_conf" ] && source "$_conf"
done
MAIL_TO="${MAIL_TO:-}" MAIL_TO="${MAIL_TO:-}"
MAIL_ON_SUCCESS="${MAIL_ON_SUCCESS:-1}" MAIL_ON_SUCCESS="${MAIL_ON_SUCCESS:-1}"
...@@ -96,7 +93,6 @@ ALERT_COST_OVER="${ALERT_COST_OVER:-}" ...@@ -96,7 +93,6 @@ ALERT_COST_OVER="${ALERT_COST_OVER:-}"
MAX_BUDGET_USD="${MAX_BUDGET_USD:-15}" MAX_BUDGET_USD="${MAX_BUDGET_USD:-15}"
PROVIDER="${PROVIDER:-}" PROVIDER="${PROVIDER:-}"
FALLBACK_PROVIDER="${FALLBACK_PROVIDER:-}" FALLBACK_PROVIDER="${FALLBACK_PROVIDER:-}"
WORK_DIR="${WORK_DIR:-/home/yichu/Documents/News-Harvester}"
LOG_DIR="${LOG_DIR:-$WORK_DIR/logs}" LOG_DIR="${LOG_DIR:-$WORK_DIR/logs}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
...@@ -115,7 +111,7 @@ mkdir -p "$LOG_DIR" ...@@ -115,7 +111,7 @@ mkdir -p "$LOG_DIR"
LATEST_JSON=$(ls -t "$LOG_DIR"/${SOURCE}.*.json 2>/dev/null | head -1) LATEST_JSON=$(ls -t "$LOG_DIR"/${SOURCE}.*.json 2>/dev/null | head -1)
if [ -z "$LATEST_JSON" ]; then if [ -z "$LATEST_JSON" ]; then
err "找不到 ${SOURCE} 的 JSON 日志文件,无法获取 session_id\n 请先正常运行一次采集: sudo systemctl start news-harvester@${SOURCE}.service" err "找不到 ${SOURCE} 的 JSON 日志文件,无法获取 session_id\n 请先正常运行一次采集: nh_service_trigger ${SOURCE}(或面板手动触发)"
fi fi
if ! command -v jq >/dev/null 2>&1; then if ! command -v jq >/dev/null 2>&1; then
......
...@@ -22,28 +22,25 @@ ...@@ -22,28 +22,25 @@
# DIGEST_BUDGET_USD 汇总分析 claude 预算上限(默认 2) # DIGEST_BUDGET_USD 汇总分析 claude 预算上限(默认 2)
# COST_ALERT_THRESHOLD 单源成本告警阈值(美元,默认 1) # COST_ALERT_THRESHOLD 单源成本告警阈值(美元,默认 1)
# --- 设置 PATH 和 nvm(等价于 .zshrc 中的配置) --- # --- 平台层:自动识别 darwin/linux,自举 PATH,解析默认路径 ---
# systemd service 和 web 面板(sudo -u yichu)都是非交互 shell,不加载 .zshrc SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export NVM_DIR="/home/yichu/.nvm" # shellcheck disable=SC1091
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" source "$SCRIPT_DIR/platform.sh"
export PATH="/home/yichu/.local/bin:/home/yichu/.nvm/versions/node/v24.14.0/bin:$PATH" nh_bootstrap_env
# --- 手动加载配置(Web 面板直接调用时不走 systemd EnvironmentFile) --- # --- 配置加载(systemd 调用时 EnvironmentFile 已注入,再 source 一次是幂等的) ---
# systemd 调用时 EnvironmentFile 已加载,再 source 一次是幂等的 WORK_DIR="$(nh_default_work_dir)"
CONF_DIR="${CONF_DIR:-/etc/news-harvester}" CONF_DIR="$(nh_default_conf_dir "$WORK_DIR")"
[ -f "$CONF_DIR/default.conf" ] && source "$CONF_DIR/default.conf" nh_load_conf digest
MAIL_TO="${MAIL_TO:-}" MAIL_TO="${MAIL_TO:-}"
DIGEST_MAIL_ALWAYS="${DIGEST_MAIL_ALWAYS:-1}" DIGEST_MAIL_ALWAYS="${DIGEST_MAIL_ALWAYS:-1}"
PROVIDER="${PROVIDER:-}" PROVIDER="${PROVIDER:-}"
WORK_DIR="${WORK_DIR:-/home/yichu/Documents/News-Harvester}"
LOG_DIR="${LOG_DIR:-$WORK_DIR/logs}" LOG_DIR="${LOG_DIR:-$WORK_DIR/logs}"
DIGEST_RETAIN_DAYS="${DIGEST_RETAIN_DAYS:-30}" DIGEST_RETAIN_DAYS="${DIGEST_RETAIN_DAYS:-30}"
DIGEST_BUDGET_USD="${DIGEST_BUDGET_USD:-2}" DIGEST_BUDGET_USD="${DIGEST_BUDGET_USD:-2}"
COST_ALERT_THRESHOLD="${COST_ALERT_THRESHOLD:-1}" COST_ALERT_THRESHOLD="${COST_ALERT_THRESHOLD:-1}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# --- 加载 provider 配置,构造 --settings 参数 --- # --- 加载 provider 配置,构造 --settings 参数 ---
source "$SCRIPT_DIR/provider.sh" source "$SCRIPT_DIR/provider.sh"
build_claude_settings_args "$PROVIDER" build_claude_settings_args "$PROVIDER"
...@@ -129,7 +126,7 @@ if [ "$SOURCE_COUNT" -eq 0 ]; then ...@@ -129,7 +126,7 @@ if [ "$SOURCE_COUNT" -eq 0 ]; then
echo "[digest] 今天 ($TODAY_HUMAN) 没有抓取记录。" | tee "$DIGEST_MD" echo "[digest] 今天 ($TODAY_HUMAN) 没有抓取记录。" | tee "$DIGEST_MD"
if [ -n "$MAIL_TO" ] && [ "$DIGEST_MAIL_ALWAYS" = "1" ]; then if [ -n "$MAIL_TO" ] && [ "$DIGEST_MAIL_ALWAYS" = "1" ]; then
BODY='<p>今天没有发现任何抓取记录。</p><p>可能所有 timer 未触发或脚本未落盘 JSON。</p>' BODY='<p>今天没有发现任何抓取记录。</p><p>可能所有 timer 未触发或脚本未落盘 JSON。</p>'
BODY+='<p>请检查:<br><code>systemctl list-timers "news-harvester@*"</code><br><code>journalctl -u "news-harvester@*" --since today</code></p>' BODY+='<p>请检查调度是否加载(linux: <code>systemctl list-timers "news-harvester@*"</code>;macOS: <code>launchctl list | grep newsharvester</code>)与运行日志 <code>logs/launchd-*.log</code></p>'
BODY=$(wrap_html "$BODY" "⚠️ 无抓取记录" "$TODAY_HUMAN" "alert") BODY=$(wrap_html "$BODY" "⚠️ 无抓取记录" "$TODAY_HUMAN" "alert")
printf '%s' "$BODY" | "$SCRIPT_DIR/notify-mail.sh" "$MAIL_TO" "[News Harvester] [ALERT] 无抓取记录: $TODAY_HUMAN" html \ printf '%s' "$BODY" | "$SCRIPT_DIR/notify-mail.sh" "$MAIL_TO" "[News Harvester] [ALERT] 无抓取记录: $TODAY_HUMAN" html \
2>> "$DIGEST_RAW" || true 2>> "$DIGEST_RAW" || true
......
...@@ -26,6 +26,12 @@ ...@@ -26,6 +26,12 @@
set -euo pipefail set -euo pipefail
# 仅支持 Linux(systemd 服务器工具);macOS 用 bin/setup.sh 自举
if [ "$(uname -s)" != "Linux" ]; then
echo "❌ 一键部署到 Linux 服务器 仅支持 Linux(systemd)。macOS 请运行: ./bin/setup.sh" >&2
exit 1
fi
SYNC_SYSTEMD=1 SYNC_SYSTEMD=1
RESTART_WEB=1 RESTART_WEB=1
SYNC_PROVIDERS=1 SYNC_PROVIDERS=1
......
...@@ -20,27 +20,30 @@ ...@@ -20,27 +20,30 @@
# WORK_DIR 项目工作目录(默认 /home/yichu/Documents/News-Harvester) # WORK_DIR 项目工作目录(默认 /home/yichu/Documents/News-Harvester)
# LOG_DIR 日志目录(默认 <WORK_DIR>/logs) # LOG_DIR 日志目录(默认 <WORK_DIR>/logs)
# --- 设置 PATH 和 nvm(等价于 .zshrc 中的配置) --- # --- 平台层:自动识别 darwin/linux,自举 PATH,解析默认路径 ---
# systemd service (User=yichu) 非交互 shell 不加载 .zshrc,需显式设置 # systemd/launchd 的非交互 shell 都不加载 rc,PATH 统一在 platform.sh 声明
export NVM_DIR="/home/yichu/.nvm" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" # shellcheck disable=SC1091
export PATH="/home/yichu/.local/bin:/home/yichu/.nvm/versions/node/v24.14.0/bin:$PATH" source "$SCRIPT_DIR/platform.sh"
nh_bootstrap_env
SOURCE="${1:?用法: harvest.sh <source-name> [count]}" SOURCE="${1:?用法: harvest.sh <source-name> [count]}"
[[ "$SOURCE" =~ ^[a-zA-Z0-9_-]+$ ]] || { echo "栏目名格式错误" >&2; exit 1; } [[ "$SOURCE" =~ ^[a-zA-Z0-9_-]+$ ]] || { echo "栏目名格式错误" >&2; exit 1; }
WORK_DIR="$(nh_default_work_dir)"
CONF_DIR="$(nh_default_conf_dir "$WORK_DIR")"
LOG_DIR="${LOG_DIR:-$WORK_DIR/logs}"
# 配置注入(linux 由 systemd EnvironmentFile 注入,此处加载为幂等补充)
nh_load_conf "$SOURCE"
COUNT="${2:-${COUNT:-20}}" COUNT="${2:-${COUNT:-20}}"
MAIL_TO="${MAIL_TO:-}" MAIL_TO="${MAIL_TO:-}"
MAIL_ON_SUCCESS="${MAIL_ON_SUCCESS:-1}" MAIL_ON_SUCCESS="${MAIL_ON_SUCCESS:-1}"
ALERT_COST_OVER="${ALERT_COST_OVER:-}" ALERT_COST_OVER="${ALERT_COST_OVER:-}"
MAX_BUDGET_USD="${MAX_BUDGET_USD:-15}" MAX_BUDGET_USD="${MAX_BUDGET_USD:-15}"
PROVIDER="${PROVIDER:-}" PROVIDER="${PROVIDER:-}"
WORK_DIR="${WORK_DIR:-/home/yichu/Documents/News-Harvester}"
LOG_DIR="${LOG_DIR:-$WORK_DIR/logs}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# --- 加载 provider 配置,构造 --settings 参数 --- # --- 加载 provider 配置,构造 --settings 参数 ---
CONF_DIR="${CONF_DIR:-/etc/news-harvester}"
source "$SCRIPT_DIR/provider.sh" source "$SCRIPT_DIR/provider.sh"
build_claude_settings_args "$PROVIDER" build_claude_settings_args "$PROVIDER"
resolve_max_budget_usd || exit 1 resolve_max_budget_usd || exit 1
......
...@@ -21,6 +21,12 @@ ...@@ -21,6 +21,12 @@
set -euo pipefail set -euo pipefail
# 仅支持 Linux(systemd 服务器工具);macOS 用 bin/setup.sh 自举
if [ "$(uname -s)" != "Linux" ]; then
echo "❌ Linux 服务器安装/迁移 仅支持 Linux(systemd)。macOS 请运行: ./bin/setup.sh" >&2
exit 1
fi
# --- 配置 --- # --- 配置 ---
WORK_DIR="${WORK_DIR:-/home/yichu/Documents/News-Harvester}" WORK_DIR="${WORK_DIR:-/home/yichu/Documents/News-Harvester}"
USER_NAME="${USER_NAME:-yichu}" USER_NAME="${USER_NAME:-yichu}"
......
#!/bin/bash #!/bin/bash
# modify-source.sh — 修改已有栏目的定时运行时间(及可选配置) # modify-source.sh — 修改已有栏目的定时运行时间(及可选配置)
# #
# 用法: # 用法(macOS 用户态直接运行;Linux 写 /etc 需 sudo):
# sudo ./bin/modify-source.sh <source-name> [选项] # ./bin/modify-source.sh <source-name> [选项]
# #
# 选项: # 选项:
# --time HH:MM[,HH:MM] 新的运行时间,如 10:30(必填) # --time HH:MM[,HH:MM] 新的运行时间,如 10:30(必填)
...@@ -26,10 +26,12 @@ ...@@ -26,10 +26,12 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SCHEDULE_HELPER="$SCRIPT_DIR/schedule.py" # shellcheck disable=SC1091
source "$SCRIPT_DIR/platform.sh"
SCHEDULE_HELPER="$NH_SCHEDULE_HELPER"
SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}" WORK_DIR="$(nh_default_work_dir)"
CONF_DIR="${CONF_DIR:-/etc/news-harvester}" CONF_DIR="$(nh_default_conf_dir "$WORK_DIR")"
SOURCE="" SOURCE=""
TIME="" TIME=""
...@@ -79,11 +81,11 @@ confirm() { ...@@ -79,11 +81,11 @@ confirm() {
} }
# --- 校验 --- # --- 校验 ---
[ -n "$SOURCE" ] || err "请指定栏目名:sudo $0 <source-name> --time HH:MM [选项]" [ -n "$SOURCE" ] || err "请指定栏目名: $0 <source-name> --time HH:MM [选项]"
[[ "$SOURCE" =~ ^[a-zA-Z0-9_-]+$ ]] || err "栏目名只能含字母、数字、下划线、连字符: $SOURCE" [[ "$SOURCE" =~ ^[a-zA-Z0-9_-]+$ ]] || err "栏目名只能含字母、数字、下划线、连字符: $SOURCE"
[ "$SOURCE" = "digest" ] && err "digest 是汇总服务,请用 sudo systemctl edit --full news-harvester-digest.timer 修改" [ "$SOURCE" = "digest" ] && err "digest 是汇总服务,修改其时间请编辑对应调度(darwin: com.newsharvester.digest.plist;linux: news-harvester-digest.timer)"
[ -n "$TIME" ] || err "必须指定 --time HH:MM" [ -n "$TIME" ] || err "必须指定 --time HH:MM"
[ "$(id -u)" -eq 0 ] || err "请用 sudo 运行" nh_require_root || exit 1
validate_count() { validate_count() {
[[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ] || err "count 应为正整数: $1" [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ] || err "count 应为正整数: $1"
...@@ -97,32 +99,20 @@ TIME=$(python3 "$SCHEDULE_HELPER" normalize "$TIME") || exit 1 ...@@ -97,32 +99,20 @@ TIME=$(python3 "$SCHEDULE_HELPER" normalize "$TIME") || exit 1
[ "$PROVIDER" = "default" ] && PROVIDER="" [ "$PROVIDER" = "default" ] && PROVIDER=""
[ "$FALLBACK_PROVIDER" = "default" ] && FALLBACK_PROVIDER="" [ "$FALLBACK_PROVIDER" = "default" ] && FALLBACK_PROVIDER=""
# --- 检查 timer 是否存在 --- # --- 检查调度是否存在 ---
TIMER_PATH="$SYSTEMD_DIR/news-harvester@${SOURCE}.timer" TIMER_PATH="$(nh_schedule_file "$SOURCE")"
CONF_PATH="$CONF_DIR/${SOURCE}.conf" CONF_PATH="$CONF_DIR/${SOURCE}.conf"
TIMER_NAME="news-harvester@${SOURCE}.timer" TIMER_NAME="news-harvester@${SOURCE}.timer"
DROPIN_DIR="$SYSTEMD_DIR/${TIMER_NAME}.d" DROPIN_DIR="$TIMER_PATH.d"
[ -f "$TIMER_PATH" ] || err "timer 不存在: $TIMER_PATH\n 可用栏目:list-timers 'news-harvester@*.timer'" nh_schedule_exists "$SOURCE" || err "调度不存在: $TIMER_PATH\n 可用栏目: $(nh_schedule_list | tr '\n' ' ')"
# --- 读取当前生效时间(优先 systemctl show,回退读文件) --- # --- 读取当前生效时间(统一走平台层:linux 含 drop-in,darwin 读 plist) ---
CURRENT_TIME="" CURRENT_TIME="$(nh_schedule_read "$SOURCE")"
# 方法1:从 systemctl show 读实际生效值(能反映 drop-in 覆盖)
# 先读直接属性 OnCalendar,为空再从 TimersCalendar={ OnCalendar=... } 中匹配
local_show=$(systemctl show "$TIMER_NAME" -p OnCalendar -p TimersCalendar 2>/dev/null || true)
local_cal=$(echo "$local_show" | grep '^OnCalendar=' | cut -d= -f2- | tr '\n' ' ' || true)
if [ -z "${local_cal// /}" ]; then
local_cal=$(echo "$local_show" | grep -oE 'OnCalendar=[^;}]*' | cut -d= -f2- | tr '\n' ' ' || true)
fi
CURRENT_TIME=$(echo "$local_cal" | grep -oE '[0-9]{2}:[0-9]{2}' | sort -u | paste -sd, - || true)
# 方法2:回退到直接读 timer 文件
if [ -z "$CURRENT_TIME" ]; then
CURRENT_TIME=$(python3 "$SCHEDULE_HELPER" read "$TIMER_PATH")
fi
[ -z "$CURRENT_TIME" ] && CURRENT_TIME="(未知)" [ -z "$CURRENT_TIME" ] && CURRENT_TIME="(未知)"
# --- 检查是否有 drop-in 覆盖(若有,改主文件不生效,需要先清除) --- # --- 检查是否有 drop-in 覆盖(仅 linux) ---
HAS_DROPIN=0 HAS_DROPIN=0
if [ -d "$DROPIN_DIR" ] && [ -n "$(ls -A "$DROPIN_DIR" 2>/dev/null)" ]; then if nh_is_linux && [ -d "$DROPIN_DIR" ] && [ -n "$(ls -A "$DROPIN_DIR" 2>/dev/null)" ]; then
HAS_DROPIN=1 HAS_DROPIN=1
fi fi
...@@ -154,8 +144,8 @@ fi ...@@ -154,8 +144,8 @@ fi
echo "" echo ""
confirm "确认修改?" || { echo "已取消。"; exit 0; } confirm "确认修改?" || { echo "已取消。"; exit 0; }
# --- 替换全部运行时间,并保留无关 drop-in 设置 --- # --- 替换全部运行时间(linux 保留无关 drop-in 设置;darwin 重新生成 plist) ---
python3 "$SCHEDULE_HELPER" update "$TIMER_PATH" "$TIME" nh_schedule_write "$SOURCE" "$TIME"
ok "已更新 $TIMER_PATH (运行时间 → $TIME)" ok "已更新 $TIMER_PATH (运行时间 → $TIME)"
# --- 更新 conf(若有 conf 选项) --- # --- 更新 conf(若有 conf 选项) ---
...@@ -163,7 +153,7 @@ update_conf() { ...@@ -163,7 +153,7 @@ update_conf() {
local key="$1" val="$2" local key="$1" val="$2"
if grep -qE "^${key}=" "$CONF_PATH" 2>/dev/null; then if grep -qE "^${key}=" "$CONF_PATH" 2>/dev/null; then
# 已存在,替换 # 已存在,替换
sed -i -E "s|^${key}=.*|${key}=${val}|" "$CONF_PATH" nh_sed_inplace "s|^${key}=.*|${key}=${val}|" "$CONF_PATH"
else else
# 不存在,追加 # 不存在,追加
echo "${key}=${val}" >> "$CONF_PATH" echo "${key}=${val}" >> "$CONF_PATH"
...@@ -172,7 +162,7 @@ update_conf() { ...@@ -172,7 +162,7 @@ update_conf() {
remove_conf() { remove_conf() {
local key="$1" local key="$1"
[ -f "$CONF_PATH" ] || return 0 [ -f "$CONF_PATH" ] || return 0
sed -i -E "/^${key}=/d" "$CONF_PATH" nh_sed_inplace "/^${key}=/d" "$CONF_PATH"
} }
if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ] || [ "$PROVIDER_SET" = "1" ] || [ "$FALLBACK_PROVIDER_SET" = "1" ]; then if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ] || [ "$PROVIDER_SET" = "1" ] || [ "$FALLBACK_PROVIDER_SET" = "1" ]; then
mkdir -p "$CONF_DIR" mkdir -p "$CONF_DIR"
...@@ -195,24 +185,21 @@ if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ] || [ "$PROV ...@@ -195,24 +185,21 @@ if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ] || [ "$PROV
ok "已更新 $CONF_PATH" ok "已更新 $CONF_PATH"
fi fi
# --- daemon-reload + 重启 timer --- # --- 重新加载调度(平台层内部完成 linux 的 restart 语义 / darwin 的 bootstrap) ---
# 先 stop,再 reload,再 start —— 某些 systemd 版本 restart 不会重新求值日历表达式 nh_schedule_enable "$SOURCE"
systemctl stop "$TIMER_NAME" 2>/dev/null || true ok "已重新加载调度(新时间生效)"
systemctl daemon-reload
ok "systemd 已重新加载"
systemctl enable --now "$TIMER_NAME"
ok "已重启 $TIMER_NAME(新时间生效)"
echo "" echo ""
echo "========================================" echo "========================================"
ok "栏目 $SOURCE 修改完成!" ok "栏目 $SOURCE 修改完成!"
echo "========================================" echo "========================================"
echo "" echo ""
echo "下次触发时间:"
systemctl list-timers "news-harvester@${SOURCE}.timer" --no-pager 2>/dev/null || true
echo ""
echo "后续操作:" echo "后续操作:"
echo " 查看定时器: systemctl list-timers news-harvester@${SOURCE}.timer" echo " 手动测试: $NH_BIN_DIR/harvest.sh $SOURCE 1 (只采 1 条验证)"
echo " 手动测试: sudo systemctl start news-harvester@${SOURCE}.service" echo " 查看运行结果: cat $WORK_DIR/logs/${SOURCE}.last.log"
echo " 查看运行结果: cat ~/Documents/News-Harvester/logs/${SOURCE}.last.log" if nh_is_darwin; then
echo " 再次改时间: sudo $0 $SOURCE --time HH:MM" echo " 查看调度: launchctl list | grep newsharvester"
else
echo " 查看定时器: systemctl list-timers news-harvester@${SOURCE}.timer"
fi
echo " 再次改时间: $0 $SOURCE --time HH:MM"
#!/bin/bash
# platform.sh — 跨平台抽象层(共享函数库,被所有脚本 source,不直接执行)
#
# 通过 uname 自动识别 darwin / linux,统一消化四类平台差异:
# 1. 路径默认值(WORK_DIR / CONF_DIR / 调度目录)
# 2. 调度器(linux: systemd timer + systemctl;dawrin: launchd plist + launchctl)
# 3. 命令语法(sed -i 差异、GNU sed 定位)
# 4. 权限(linux 写 /etc 需 root;darwin 全程用户态)
#
# 环境变量:
# NH_OS 覆盖平台检测结果(测试用),如 NH_OS=darwin / NH_OS=linux
# WORK_DIR 运行目录(linux 默认 $HOME/Documents/News-Harvester,darwin 默认 $HOME/NewsHarvester)
# CONF_DIR 配置目录(linux 默认 /etc/news-harvester,darwin 默认 $WORK_DIR/etc/news-harvester)
# NH_BIN_DIR 本仓库 bin/ 绝对路径(source 时自动设置)
#
# 约定:调用方先 source 本文件,再按需调用 nh_bootstrap_env 与各 nh_* 函数。
# --- 平台检测 ---
nh_os_detect() {
if [ -n "${NH_OS:-}" ]; then
case "$NH_OS" in darwin|linux) return 0 ;; *) NH_OS="" ;; esac
fi
case "$(uname -s)" in
Darwin) NH_OS="darwin" ;;
Linux) NH_OS="linux" ;;
*) NH_OS="linux" ;; # 未知内核按 linux 处理(项目原生目标)
esac
}
nh_os_detect
nh_is_darwin() { [ "$NH_OS" = "darwin" ]; }
nh_is_linux() { [ "$NH_OS" = "linux" ]; }
NH_BIN_DIR="${NH_BIN_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
NH_SCHEDULE_HELPER="$NH_BIN_DIR/schedule.py"
# --- 环境自举:非交互 shell(systemd/launchd)不加载 rc,PATH 在此声明 ---
# 原则:只在缺少必要命令时才前置平台目录,绝不遮蔽调用方已注入的 PATH
# (测试环境会把 mock 命令放在 PATH 最前,这里的保护对测试至关重要)。
nh_bootstrap_env() {
if nh_is_darwin; then
local need=0
command -v claude >/dev/null 2>&1 || need=1
command -v node >/dev/null 2>&1 || need=1
command -v jq >/dev/null 2>&1 || need=1
if [ "$need" = "1" ]; then
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
fi
# GNU sed 修正(digest 的 \x27 语法需要);当前 sed 已是 GNU 则不动
local gnubin="/opt/homebrew/opt/gnu-sed/libexec/gnubin"
[ -d "$gnubin" ] || gnubin="/usr/local/opt/gnu-sed/libexec/gnubin"
local sed_is_gnu=0
sed --version 2>/dev/null | head -1 | grep -q "GNU sed" && sed_is_gnu=1
if [ "$sed_is_gnu" = "0" ] && [ -d "$gnubin" ]; then
export PATH="$gnubin:$PATH"
fi
else
# nvm 动态探测(不锁死 node 版本);无 nvm 则依赖系统 PATH
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
# shellcheck disable=SC1091
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
export PATH="$HOME/.local/bin:$PATH"
fi
}
# --- 默认路径(与两端既有部署一致,保证零迁移) ---
nh_default_work_dir() {
if [ -n "${WORK_DIR:-}" ]; then echo "$WORK_DIR"
elif nh_is_darwin; then echo "$HOME/NewsHarvester"
else echo "$HOME/Documents/News-Harvester"; fi
}
nh_default_conf_dir() { # $1 = WORK_DIR
if [ -n "${CONF_DIR:-}" ]; then echo "$CONF_DIR"
elif nh_is_darwin; then echo "$1/etc/news-harvester"
else echo "/etc/news-harvester"; fi
}
# --- 调度器:文件定位 ---
nh_schedule_dir() {
if [ -n "${NH_SCHEDULE_DIR:-}" ]; then echo "$NH_SCHEDULE_DIR"
elif nh_is_darwin; then echo "$HOME/Library/LaunchAgents"
else echo "${SYSTEMD_DIR:-/etc/systemd/system}"; fi
}
nh_schedule_file() { # <name> name=源名或 digest
local name="$1"
if nh_is_darwin; then echo "$(nh_schedule_dir)/com.newsharvester.${name}.plist"
else echo "$(nh_schedule_dir)/news-harvester@${name}.timer"; fi
}
nh_schedule_exists() { [ -f "$(nh_schedule_file "$1")" ] || [ -f "$(nh_schedule_disabled_file "$1")" ]; }
# darwin 的"禁用"必须落到文件级:bootout 只对当前会话生效,重启/重新登录会复活。
# 禁用 = bootout + 重命名 plist 为 .disabled(launchd 只认 .plist,重启不再加载,文件保留可恢复)。
nh_schedule_disabled_file() {
local name="$1"
if nh_is_darwin; then echo "$(nh_schedule_dir)/com.newsharvester.${name}.plist.disabled"
else echo "$(nh_schedule_dir)/news-harvester@${name}.timer.disabled"; fi
}
nh_schedule_is_disabled() { [ -f "$(nh_schedule_disabled_file "$1")" ] && [ ! -f "$(nh_schedule_file "$1")" ]; }
# --- 调度器:读取全部运行时间(HH:MM 逗号分隔) ---
nh_schedule_read() { # <name> 活动或已禁用的调度文件都读
local f
f="$(nh_schedule_file "$1")"
[ -f "$f" ] || f="$(nh_schedule_disabled_file "$1")"
python3 "$NH_SCHEDULE_HELPER" read "$f" 2>/dev/null || echo ""
}
# --- 调度器:写入(linux 生成 timer,darwin 生成 plist + agents 入口脚本) ---
nh_schedule_write() { # <name> <times "09:00,14:30">
local name="$1" times="$2"
times=$(python3 "$NH_SCHEDULE_HELPER" normalize "$times") || return 1
if nh_is_darwin; then
nh_launchd_write "$name" "$times"
else
nh_systemd_write_timer "$name" "$times"
fi
}
nh_systemd_write_timer() { # <name> <normalized-times>
local name="$1" times="$2" path
path="$(nh_schedule_file "$name")"
mkdir -p "$(dirname "$path")"
cat > "$path" <<EOF
[Unit]
Description=Run News Harvester (${name}) daily at ${times}
[Timer]
$(python3 "$NH_SCHEDULE_HELPER" calendar "$times")
Persistent=true
AccuracySec=1min
Unit=news-harvester@${name}.service
[Install]
WantedBy=timers.target
EOF
python3 "$NH_SCHEDULE_HELPER" update "$path" "$times"
}
nh_launchd_write() { # <name> <normalized-times>
local name="$1" times="$2" plist entry agents_dir cal t h m
plist="$(nh_schedule_file "$name")"
agents_dir="$WORK_DIR/agents"
mkdir -p "$agents_dir" "$(dirname "$plist")"
# 入口脚本:文件名会显示在 系统设置-登录项,故按名字独立生成
entry="$agents_dir/news-harvester-${name}.sh"
if [ ! -f "$entry" ]; then
if [ "$name" = "digest" ]; then
printf '#!/bin/bash\n# LaunchAgent 入口:%s 每日汇总(文件名显示在 系统设置-登录项)\nexec "%s/daily-digest.sh"\n' "$name" "$NH_BIN_DIR" > "$entry"
else
printf '#!/bin/bash\n# LaunchAgent 入口:%s 定时采集(文件名显示在 系统设置-登录项)\nexec "%s/harvest.sh" "%s"\n' "$name" "$NH_BIN_DIR" "$name" > "$entry"
fi
chmod +x "$entry"
fi
cal=""
while IFS= read -r t; do
[ -z "$t" ] && continue
h="${t%%:*}"; m="${t##*:}"
cal+=" <dict><key>Hour</key><integer>${h}</integer><key>Minute</key><integer>${m}</integer></dict>\n"
done < <(echo "$times" | tr ',' '\n')
cal=$(printf '%b' "$cal")
cal="${cal%$'\n'}"
cat > "$plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.newsharvester.${name}</string>
<key>ProgramArguments</key>
<array><string>${entry}</string></array>
<key>StartCalendarInterval</key>
<array>
${cal}
</array>
<key>StandardOutPath</key><string>${LOG_DIR:-$WORK_DIR/logs}/launchd-${name}.log</string>
<key>StandardErrorPath</key><string>${LOG_DIR:-$WORK_DIR/logs}/launchd-${name}.log</string>
</dict>
</plist>
EOF
/usr/bin/plutil -lint "$plist" >/dev/null 2>&1 || { echo "❌ 生成的 plist 校验失败: $plist" >&2; return 1; }
}
# --- 调度器:启停 ---
nh_schedule_enable() { # <name> 加载/重新加载调度(改时间后同样调用此函数生效)
local name="$1" disabled active
disabled="$(nh_schedule_disabled_file "$name")"
active="$(nh_schedule_file "$name")"
if [ -f "$disabled" ] && [ ! -f "$active" ]; then mv "$disabled" "$active"; fi # 恢复被禁用的调度
if nh_is_darwin; then
launchctl bootout "gui/$(id -u)/com.newsharvester.${name}" >/dev/null 2>&1 || true
launchctl bootstrap "gui/$(id -u)" "$active"
else
systemctl daemon-reload
# restart 使已激活的 timer 重新求值日历表达式(部分 systemd 版本 enable 不会)
systemctl enable "news-harvester@${name}.timer" >/dev/null 2>&1 || true
systemctl restart "news-harvester@${name}.timer" 2>/dev/null || \
systemctl start "news-harvester@${name}.timer"
fi
}
nh_schedule_disable() { # <name> 真正禁用:卸载 + 文件级禁用(重启不复活)
local name="$1" active disabled
active="$(nh_schedule_file "$name")"
disabled="$(nh_schedule_disabled_file "$name")"
if nh_is_darwin; then
launchctl bootout "gui/$(id -u)/com.newsharvester.${name}" >/dev/null 2>&1 || true
if [ -f "$active" ]; then mv "$active" "$disabled"; fi
else
systemctl disable --now "news-harvester@${name}.timer" 2>/dev/null || true
systemctl daemon-reload
fi
}
# --- 服务操作 ---
nh_service_trigger() { # <name> 立即触发一次(digest 传 digest)
local name="$1"
if nh_is_darwin; then
launchctl kickstart "gui/$(id -u)/com.newsharvester.${name}"
else
if [ "$name" = "digest" ]; then systemctl start news-harvester-digest.service
else systemctl start "news-harvester@${name}.service"; fi
fi
}
nh_service_state() { # <name> → running / idle / unknown
local name="$1" out
if nh_is_darwin; then
out=$(launchctl print "gui/$(id -u)/com.newsharvester.${name}" 2>/dev/null) || { echo "unknown"; return; }
if echo "$out" | grep -q 'state = running'; then echo "running"
elif echo "$out" | grep -q 'state = '; then echo "idle"
else echo "unknown"; fi
else
if [ "$name" = "digest" ]; then out=$(systemctl is-active news-harvester-digest.service 2>/dev/null)
else out=$(systemctl is-active "news-harvester@${name}.service" 2>/dev/null); fi
case "$out" in
active) echo "running" ;;
inactive|failed) echo "idle" ;;
*) echo "unknown" ;;
esac
fi
}
# 供面板并发保护/状态展示:列出所有已注册调度名(含已禁用;不含 digest/web)
nh_schedule_list() {
local dir f base
dir="$(nh_schedule_dir)"
if nh_is_darwin; then
for f in "$dir"/com.newsharvester.*.plist "$dir"/com.newsharvester.*.plist.disabled; do
[ -f "$f" ] || continue
base="$(basename "$f")"
base="${base%.plist.disabled}"; base="${base%.plist}"
base="${base#com.newsharvester.}"
[ "$base" = "web" ] || [ "$base" = "digest" ] || echo "$base"
done
else
for f in "$dir"/news-harvester@*.timer "$dir"/news-harvester@*.timer.disabled; do
[ -f "$f" ] || continue
base="$(basename "$f")"
base="${base%.timer.disabled}"; base="${base%.timer}"
echo "${base#news-harvester@}"
done
fi
}
# --- 命令语法差异 ---
nh_sed_inplace() { # <sed-E-表达式> <file> 统一 sed -i -E 语义
local expr="$1" file="$2"
if nh_is_darwin; then /usr/bin/sed -i '' -E "$expr" "$file"
else sed -i -E "$expr" "$file"; fi
}
nh_gsed() { # 打印可用的 GNU sed 命令(darwin 优先 gnubin 里的 sed,其次 gsed,最后 sed)
if nh_is_darwin; then
if [ -x /opt/homebrew/opt/gnu-sed/libexec/gnubin/sed ]; then echo /opt/homebrew/opt/gnu-sed/libexec/gnubin/sed
elif command -v gsed >/dev/null 2>&1; then command -v gsed
else echo sed; fi
else echo sed; fi
}
# --- 权限 ---
nh_require_root() { # linux 下要求 root(写 /etc);darwin 用户态直接通过
if nh_is_linux && [ "$(id -u)" -ne 0 ]; then
echo "❌ Linux 上写入系统配置目录需要 root,请用 sudo 运行" >&2
return 1
fi
return 0
}
# --- 配置加载(systemd EnvironmentFile 的跨平台等价物;单源覆盖全局) ---
# 用法:nh_load_conf <source|digest|"">
nh_load_conf() {
local source="$1"
[ -f "$CONF_DIR/default.conf" ] || return 0
local had_u=0
case "$-" in *u*) had_u=1 ;; esac
set +u
# shellcheck disable=SC1090
source "$CONF_DIR/default.conf"
if [ -n "$source" ] && [ "$source" != "digest" ] && [ -f "$CONF_DIR/${source}.conf" ]; then
# shellcheck disable=SC1090
source "$CONF_DIR/${source}.conf"
fi
if [ "$had_u" = "1" ]; then set -u; else set +u; fi
return 0
}
# --- 技能互操作(news-hub-skills) ---
# 技能仓库位置:SKILLS_DIR(默认 $HOME/news-hub-skills);SKILLS_REPO_URL 供自动 clone。
nh_skills_root() { echo "${SKILLS_DIR:-$HOME/news-hub-skills}"; }
nh_skill_dir() { echo "$(nh_skills_root)/skills/news-harvester"; }
nh_skill_config() { echo "$(nh_skill_dir)/config.json"; }
nh_skill_installed() { [ -f "$(nh_skill_config)" ]; }
nh_skill_list() { # 每行 "id enabled|disabled"(直读 config.json,不解析表格)
local cfg; cfg="$(nh_skill_config)"
[ -f "$cfg" ] || return 0
jq -r '.sources[]? | "\(.id) \(.enabled)"' "$cfg" 2>/dev/null
}
nh_skill_is_enabled() { # <source> → 输出 true/false
jq -r --arg s "$1" '([.sources[]? | select(.id == $s)][0].enabled // false)' "$(nh_skill_config)" 2>/dev/null || echo false
}
nh_skill_enable() { (cd "$(nh_skill_dir)" && node scripts/source-manage.js enable "$1"); }
nh_skill_disable() { (cd "$(nh_skill_dir)" && node scripts/source-manage.js disable "$1"); }
nh_skill_install_bundle() { # <bundle.nhsource.json>
(cd "$(nh_skill_dir)" && node scripts/source-manage.js install "$1")
}
nh_skill_install_all() { # 全量导入 bundles/ 下尚未安装的源;返回 "installed=N skipped=N"
local b id cfg installed=0 skipped=0
cfg="$(nh_skill_config)"
if [ ! -f "$cfg" ]; then # 首次运行:让脚本生成 config.json
(cd "$(nh_skill_dir)" && node scripts/source-manage.js list >/dev/null 2>&1 || true)
fi
for b in "$(nh_skill_dir)/bundles"/*.nhsource.json; do
[ -f "$b" ] || continue
id="$(basename "$b" .nhsource.json)"
if jq -e --arg s "$id" '.sources[]? | select(.id == $s)' "$(nh_skill_config)" >/dev/null 2>&1; then
skipped=$((skipped + 1))
else
if nh_skill_install_bundle "$b" >/dev/null 2>&1; then installed=$((installed + 1))
else echo "⚠️ 安装失败: $id" >&2; fi
fi
done
echo "installed=$installed skipped=$skipped"
}
nh_skill_ensure_repo() { # 目录缺失且配置了仓库地址时自动 clone
local root; root="$(nh_skills_root)"
if [ ! -d "$root/.git" ] && [ -n "${SKILLS_REPO_URL:-}" ]; then
git clone "${SKILLS_REPO_URL}" "$root"
fi
}
nh_skill_configure_vault() { # 首次生成的 config.json vaultPath 是占位符时,指向本机 vault
local cfg vault
cfg="$(nh_skill_config)"; vault="${OBSIDIAN_VAULT_PATH:-$HOME/NewsHub}"
[ -f "$cfg" ] || return 0
local current; current=$(jq -r '.vaultPath // ""' "$cfg" 2>/dev/null)
if [ -z "$current" ] || [ "$current" = "/path/to/your/obsidian/vault" ]; then
tmp="$cfg.tmp"; jq --arg v "$vault" '.vaultPath = $v' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
fi
}
nh_skill_bootstrap() { # clone(可选)+ 依赖安装 + 全量 bundle 导入 + vault 配置
nh_skill_ensure_repo
[ -d "$(nh_skill_dir)" ] || { echo "❌ 技能目录不存在: $(nh_skill_dir)(在 conf 设 SKILLS_REPO_URL 可自动 clone)" >&2; return 1; }
local s
for s in news-harvester news-source-analyzer news-hub-feed; do
(cd "$(nh_skills_root)/skills/$s" && { [ -d node_modules ] || npm install --no-fund --no-audit --silent; })
done
nh_skill_install_all
nh_skill_configure_vault
}
# --- CLI 派发(python 面板等外部调用):platform.sh call <函数> [参数...] ---
# 例:platform.sh call nh_schedule_enable wsj
if [ "${1:-}" = "call" ]; then
shift
_fn="${1:?call 需要函数名}"; shift
# 补齐函数依赖的路径上下文(环境变量优先)
WORK_DIR="$(nh_default_work_dir)"
CONF_DIR="$(nh_default_conf_dir "$WORK_DIR")"
LOG_DIR="${LOG_DIR:-$WORK_DIR/logs}"
"$_fn" "$@"
exit $?
fi
"""platform_layer.py — Web 面板跨平台操作层(darwin / linux)
变更类操作(调度启停、触发、技能开关)统一通过 `platform.sh call <fn>` 复用
bash 侧的同一实现,保证脚本与面板永远行为一致;读取类操作直接读文件,
避免多余子进程。面板只需 import 本模块,不感知平台。
"""
import json
import os
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
BIN_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(BIN_DIR))
from schedule import read_schedule_times # noqa: E402
# 平台判定与 platform.sh 同规则:NH_OS 环境变量可覆盖(测试模拟另一平台用)
IS_DARWIN = os.environ.get("NH_OS", "darwin" if sys.platform == "darwin" else "linux") == "darwin"
def call(fn, *args, timeout=120):
"""调用 platform.sh 中的函数,返回 (rc, stdout, stderr)。"""
try:
r = subprocess.run(
["bash", str(BIN_DIR / "platform.sh"), "call", fn, *map(str, args)],
capture_output=True, text=True, timeout=timeout,
)
return r.returncode, r.stdout.strip(), r.stderr.strip()
except subprocess.TimeoutExpired:
return 124, "", "执行超时"
except Exception as e: # noqa: BLE001
return 1, "", str(e)
# ── 默认路径(与 platform.sh 同规则,供 app.py 初始化) ─────────
def default_work_dir():
if os.environ.get("WORK_DIR"):
return os.environ["WORK_DIR"]
if IS_DARWIN:
return str(Path.home() / "NewsHarvester")
return str(Path.home() / "Documents" / "News-Harvester")
def default_conf_dir(work_dir):
if os.environ.get("CONF_DIR"):
return os.environ["CONF_DIR"]
if IS_DARWIN:
return str(Path(work_dir) / "etc" / "news-harvester")
return "/etc/news-harvester"
def schedule_dir():
if os.environ.get("NH_SCHEDULE_DIR"):
return Path(os.environ["NH_SCHEDULE_DIR"])
if IS_DARWIN:
return Path.home() / "Library" / "LaunchAgents"
return Path(os.environ.get("SYSTEMD_DIR", "/etc/systemd/system"))
# ── 调度文件 ────────────────────────────────────────────────
def schedule_files(name):
"""返回 (活动文件, 禁用文件) 路径。"""
if IS_DARWIN:
base = schedule_dir() / f"com.newsharvester.{name}.plist"
else:
base = schedule_dir() / f"news-harvester@{name}.timer"
return base, base.with_name(base.name + ".disabled")
def schedule_state(name):
"""none / active / disabled / broken(两个文件同时存在视为异常)。"""
active, disabled = schedule_files(name)
if active.exists() and disabled.exists():
return "broken"
if active.exists():
return "active"
if disabled.exists():
return "disabled"
return "none"
def schedule_times(name):
active, disabled = schedule_files(name)
path = active if active.exists() else disabled
if not path.exists():
return ""
try:
return ",".join(read_schedule_times(path))
except OSError:
return ""
def list_schedules():
"""扫描调度目录,返回 {name: state} 按 name 排序(不含 digest/web)。"""
found = {}
directory = schedule_dir()
if not directory.is_dir():
return {}
for path in directory.iterdir():
name = path.name
state = "active"
if name.endswith(".disabled"):
state = "disabled"
name = name[: -len(".disabled")]
if IS_DARWIN:
if not (name.startswith("com.newsharvester.") and name.endswith(".plist")):
continue
name = name[len("com.newsharvester."):-len(".plist")]
else:
if not (name.startswith("news-harvester@") and name.endswith(".timer")):
continue
name = name[len("news-harvester@"):-len(".timer")]
if name in ("digest", "web") or not name:
continue
found[name] = state
return dict(sorted(found.items()))
def next_fire(times):
"""从 HH:MM 列表计算下次触发(格式化字符串)。"""
now = datetime.now()
cands = []
for t in (times or "").split(","):
t = t.strip()
if ":" not in t:
continue
try:
h, m = t.split(":")[:2]
c = now.replace(hour=int(h), minute=int(m), second=0, microsecond=0)
except ValueError:
continue
if c <= now:
c += timedelta(days=1)
cands.append(c)
return min(cands).strftime("%Y-%m-%d %H:%M") if cands else "-"
def last_run_at(name, log_dir):
"""某源最近一次运行的开始时间(取最新 JSON 文件名编码的时间)。"""
import re as _re # 局部引入,避免模块级依赖顺序问题
try:
latest = max(Path(log_dir).glob(f"{name}.*.json"), key=lambda p: p.stat().st_mtime, default=None)
except OSError:
latest = None
if latest is None:
return "从未触发"
m = _re.search(r"(\d{8})\.(\d{6})", latest.name)
if not m:
return "从未触发"
d, t = m.groups()
return f"{d[0:4]}-{d[4:6]}-{d[6:8]} {t[0:2]}:{t[2:4]}:{t[4:6]}"
# ── 技能(news-hub-skills) ──────────────────────────────────
def skills_root():
return Path(os.environ.get("SKILLS_DIR", str(Path.home() / "news-hub-skills")))
def skill_config_path():
return skills_root() / "skills" / "news-harvester" / "config.json"
def skill_sources():
"""返回 {id: {"name":…, "enabled": bool}};技能未安装返回 {}。"""
cfg = skill_config_path()
if not cfg.is_file():
return {}
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return {
s.get("id", ""): {"name": s.get("name", ""), "enabled": bool(s.get("enabled", True))}
for s in data.get("sources", []) if s.get("id")
}
def skill_set_enabled(name, enabled):
action = "enable" if enabled else "disable"
script = skills_root() / "skills" / "news-harvester" / "scripts" / "source-manage.js"
if not script.is_file():
return 1, "", "技能未安装"
try:
r = subprocess.run(
["node", str(script), action, name],
cwd=str(script.parent.parent),
capture_output=True, text=True, timeout=60,
)
return r.returncode, r.stdout.strip(), r.stderr.strip()
except Exception as e: # noqa: BLE001
return 1, "", str(e)
# ── 组合视图 ────────────────────────────────────────────────
def source_rows(log_dir=None):
"""面板源列表全集:已装技能源 ∪ 已有调度。
每行:{name, display, skill_enabled, state(active/disabled/none),
times, toggle_on, harvesting}
"""
schedules = list_schedules()
skills = skill_sources()
rows = []
log_dir = Path(log_dir) if log_dir else None
for name in sorted(set(schedules) | set(skills)):
state = schedules.get(name, "none")
sk = skills.get(name, {})
times = schedule_times(name)
rows.append({
"name": name,
"display": sk.get("name") or name,
"skill_installed": name in skills,
"skill_enabled": sk.get("enabled", False),
"state": state,
"times": times,
"toggle_on": state == "active" and sk.get("enabled", False),
})
return rows
# ── 变更操作(全部走 platform.sh,与命令行同源) ─────────────
def service_trigger(name):
return call("nh_service_trigger", name, timeout=15)
def service_state(name):
rc, out, _ = call("nh_service_state", name, timeout=10)
return out if rc == 0 else "unknown"
def active_harvests():
return [n for n, _ in list_schedules().items() if service_state(n) == "running"]
def schedule_enable(name):
return call("nh_schedule_enable", name)
def schedule_disable(name):
return call("nh_schedule_disable", name)
def schedule_create(name):
"""用 add-source.sh 创建调度(自动错峰);返回 (rc, out, err)。"""
try:
r = subprocess.run(
["bash", str(BIN_DIR / "add-source.sh"), name, "--yes"],
capture_output=True, text=True, timeout=60,
)
return r.returncode, r.stdout.strip(), r.stderr.strip()
except Exception as e: # noqa: BLE001
return 1, "", str(e)
def toggle(name, on):
"""采集总开关:ON = 技能启用 + (有调度则加载,无则自动错峰创建);
OFF = 调度文件级禁用(重启不复活)+ 技能停用。"""
if on:
steps = []
if not skill_sources().get(name, {}).get("enabled", False):
steps.append(("启用技能源", skill_set_enabled(name, True)))
state = schedule_state(name)
if state == "active":
rc, out, err = schedule_enable(name) # 幂等重载
steps.append(("加载调度", (rc, out, err)))
elif state in ("disabled", "none"):
rc, out, err = schedule_create(name) if state == "none" else schedule_enable(name)
steps.append(("创建/加载调度", (rc, out, err)))
return steps
steps = []
if schedule_state(name) != "none":
steps.append(("禁用调度", schedule_disable(name)))
if skill_sources().get(name, {}).get("enabled", False):
steps.append(("停用技能源", skill_set_enabled(name, False)))
return steps
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Daily schedules shared by management scripts and the web panel.""" """Daily schedules shared by management scripts and the web panel.
同时支持两种调度定义文件,按扩展名自动识别:
- systemd timer(*.timer,含 .d/ drop-in)
- launchd plist(*.plist,读 StartCalendarInterval)
"""
import argparse import argparse
import re import re
...@@ -44,6 +49,53 @@ def read_timer_times(path): ...@@ -44,6 +49,53 @@ def read_timer_times(path):
return calendar_times(calendar_specs(text)) return calendar_times(calendar_specs(text))
PLIST_TIME_RE = re.compile(
r"<key>Hour</key>\s*<integer>(\d+)</integer>\s*"
r"<key>Minute</key>\s*<integer>(\d+)</integer>"
)
def read_plist_times(path):
"""读取 launchd plist 的全部 StartCalendarInterval 时间(HH:MM 列表)。"""
text = path.read_text(encoding="utf-8")
return sorted({f"{int(h):02d}:{int(m):02d}" for h, m in PLIST_TIME_RE.findall(text)})
def read_schedule_times(path):
"""按文件名自动识别调度文件(timer/plist,含 .disabled 后缀)并读取全部时间。"""
path = Path(path)
name = path.name
if name.endswith(".disabled"):
name = name[: -len(".disabled")]
if name.endswith(".plist"):
return read_plist_times(path)
return read_timer_times(path)
def schedule_sources(directory):
"""扫描目录里的全部调度定义,返回 [(source, [times...]), ...] 按源名排序。
同时识别 news-harvester@<source>.timer 与 com.newsharvester.<source>.plist,
跳过 digest 与 web 等非采集条目。
"""
directory = Path(directory)
found = {}
for path in list(directory.glob("news-harvester@*.timer")) + \
list(directory.glob("com.newsharvester.*.plist")):
name = path.name
if name.endswith(".timer"):
source = name[len("news-harvester@"):-len(".timer")]
else:
source = name[len("com.newsharvester."):-len(".plist")]
if source in ("digest", "web"):
continue
try:
found[source] = read_schedule_times(path)
except OSError:
continue
return sorted(found.items())
def replace_calendar(text, times=None): def replace_calendar(text, times=None):
lines = [] lines = []
section = "" section = ""
...@@ -77,7 +129,7 @@ def update_timer(path, times): ...@@ -77,7 +129,7 @@ def update_timer(path, times):
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=["normalize", "calendar", "read", "suggest", "update"]) parser.add_argument("command", choices=["normalize", "calendar", "read", "suggest", "update", "sources"])
parser.add_argument("value") parser.add_argument("value")
parser.add_argument("times", nargs="?") parser.add_argument("times", nargs="?")
args = parser.parse_args() args = parser.parse_args()
...@@ -87,9 +139,12 @@ def main(): ...@@ -87,9 +139,12 @@ def main():
print(",".join(times) if args.command == "normalize" else print(",".join(times) if args.command == "normalize" else
"\n".join(f"OnCalendar=*-*-* {t}:00" for t in times)) "\n".join(f"OnCalendar=*-*-* {t}:00" for t in times))
elif args.command == "read": elif args.command == "read":
print(",".join(read_timer_times(Path(args.value)))) print(",".join(read_schedule_times(Path(args.value))))
elif args.command == "update": elif args.command == "update":
update_timer(Path(args.value), parse_times(args.times or "")) update_timer(Path(args.value), parse_times(args.times or ""))
elif args.command == "sources":
for source, times in schedule_sources(Path(args.value)):
print(f"{source}\t{','.join(times)}")
else: else:
times = [t for path in Path(args.value).glob("news-harvester@*.timer") for t in read_timer_times(path)] times = [t for path in Path(args.value).glob("news-harvester@*.timer") for t in read_timer_times(path)]
if times: if times:
......
#!/bin/bash #!/bin/bash
# status.sh — 查看 News Harvester 所有 timer 运行情况(纯文本,无颜色) # status.sh — 查看 News Harvester 所有调度运行情况(纯文本,跨平台)
# #
# 用法: # 用法:
# ./status.sh 查看所有 timer 概览(按运行时间排序) # ./status.sh 查看所有调度概览(按运行时间排序)
# ./status.sh <source> 查看某栏目详情(最近一次运行) # ./status.sh <source> 查看某栏目详情(最近一次运行)
# ./status.sh <source> --log 查看某栏目最近运行的完整日志 # ./status.sh <source> --log 查看某栏目最近运行的完整日志
# ./status.sh <source> --json 查看最近一次的 claude JSON 输出 # ./status.sh <source> --json 查看最近一次的 claude JSON 输出
# ./status.sh <source> --journal 查看 systemd journal 日志 # ./status.sh <source> --journal 查看系统日志(linux: journalctl;macOS: launchd 日志)
# ./status.sh --digest 查看今天的每日汇总 # ./status.sh --digest 查看今天的每日汇总
# ./status.sh --all-logs 列出所有栏目的最近日志摘要 # ./status.sh --all-logs 列出所有栏目的最近日志摘要
# ./status.sh --help, -h 显示帮助 # ./status.sh --help, -h 显示帮助
...@@ -14,19 +14,25 @@ ...@@ -14,19 +14,25 @@
# 不用 set -e,这个脚本是只读查看工具,任何一步失败都不应中断 # 不用 set -e,这个脚本是只读查看工具,任何一步失败都不应中断
set -uo pipefail set -uo pipefail
WORK_DIR="${WORK_DIR:-/home/yichu/Documents/News-Harvester}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/platform.sh"
nh_bootstrap_env
WORK_DIR="$(nh_default_work_dir)"
CONF_DIR="$(nh_default_conf_dir "$WORK_DIR")"
LOG_DIR="${LOG_DIR:-$WORK_DIR/logs}" LOG_DIR="${LOG_DIR:-$WORK_DIR/logs}"
usage() { usage() {
cat <<'EOF' cat <<'EOF'
News Harvester 状态查看工具 News Harvester 状态查看工具(自动识别 macOS/launchd 与 Linux/systemd)
用法: 用法:
./status.sh 查看所有 timer 概览(按运行时间排序) ./status.sh 查看所有调度概览(按运行时间排序)
./status.sh <source> 查看某栏目详情(最近一次运行) ./status.sh <source> 查看某栏目详情(最近一次运行)
./status.sh <source> --log 查看某栏目最近运行的完整日志 ./status.sh <source> --log 查看某栏目最近运行的完整日志
./status.sh <source> --json 查看最近一次的 claude JSON 输出 ./status.sh <source> --json 查看最近一次的 claude JSON 输出
./status.sh <source> --journal 查看 systemd journal 日志 ./status.sh <source> --journal 查看系统日志(linux: journalctl;macOS: launchd 日志)
./status.sh --digest 查看今天的每日汇总 ./status.sh --digest 查看今天的每日汇总
./status.sh --all-logs 列出所有栏目的最近日志摘要 ./status.sh --all-logs 列出所有栏目的最近日志摘要
./status.sh --help, -h 显示本帮助 ./status.sh --help, -h 显示本帮助
...@@ -35,17 +41,41 @@ EOF ...@@ -35,17 +41,41 @@ EOF
hr() { echo "------------------------------------------------------------"; } hr() { echo "------------------------------------------------------------"; }
# 读取 timer 实际生效的 OnCalendar(含 drop-in 覆盖): # 读取某源的调度时间(HH:MM 逗号分隔;空输出表示未安装)
# 先读直接属性 OnCalendar,为空再从 TimersCalendar={ OnCalendar=... ; ... } 中匹配 schedule_times() { nh_schedule_read "$1"; }
# 可能返回多个值(drop-in 追加时),空格拼接;读不到输出空
get_oncalendar() { # 计算下一次触发时间(从 HH:MM 列表推算;跨平台)
local out val next_fire_at() {
out=$(systemctl show "$1" -p OnCalendar -p TimersCalendar 2>/dev/null) || true python3 - "$1" <<'PY'
val=$(echo "$out" | grep '^OnCalendar=' | cut -d= -f2- | tr '\n' ' ') import sys
if [ -z "${val// /}" ]; then from datetime import datetime, timedelta
val=$(echo "$out" | grep -oE 'OnCalendar=[^;}]*' | cut -d= -f2- | tr '\n' ' ') now = datetime.now()
fi cands = []
printf '%s' "$val" for t in (sys.argv[1] or "").split(","):
t = t.strip()
if ":" not in t:
continue
try:
h, m = t.split(":")[:2]
c = now.replace(hour=int(h), minute=int(m), second=0, microsecond=0)
except ValueError:
continue
if c <= now:
c += timedelta(days=1)
cands.append(c)
print(min(cands).strftime("%Y-%m-%d %H:%M") if cands else "-")
PY
}
# 某源最近一次运行的开始时间(取最新 JSON 文件名编码的时间;无记录输出 "-")
last_run_at() {
local latest
latest=$(ls -t "$LOG_DIR/$1."*.json 2>/dev/null | head -1)
[ -z "$latest" ] && { echo "-"; return; }
local base; base=$(basename "$latest" .json)
local stamp; stamp=$(echo "$base" | grep -oE '[0-9]{8}\.[0-9]{6}' | head -1)
[ -z "$stamp" ] && { echo "-"; return; }
python3 -c "import sys; s=sys.argv[1].replace('.',''); print('-'.join([s[0:4],s[4:6],s[6:8]])+' '+':'.join([s[8:10],s[10:12],s[12:14]]))" "$stamp"
} }
# ============================================================ # ============================================================
...@@ -109,55 +139,36 @@ show_all_last_logs() { ...@@ -109,55 +139,36 @@ show_all_last_logs() {
} }
# ============================================================ # ============================================================
# 概览模式:所有 timer,按运行时间排序 # 概览模式:所有调度,按运行时间排序
# ============================================================ # ============================================================
show_overview() { show_overview() {
echo "News Harvester — Timer 概览" echo "News Harvester — 调度概览($NH_OS)"
echo "$(date '+%Y-%m-%d %H:%M:%S')" echo "$(date '+%Y-%m-%d %H:%M:%S')"
echo "" echo ""
# 获取所有 timer 名称(只取名字列,避免 list-units 格式问题) local names
local timer_names names="$(nh_schedule_list)"
timer_names=$(systemctl list-units 'news-harvester@*.timer' 'news-harvester-digest.timer' \ # digest 条目(存在调度文件则一并列出)
--no-legend --all 2>/dev/null | awk '{print $1}' || true) [ -f "$(nh_schedule_file digest)" ] && names="$names
digest"
if [ -z "$timer_names" ]; then if [ -z "$names" ]; then
echo "未发现任何 news-harvester timer。" echo "未发现任何 news-harvester 调度。"
echo "提示:用 sudo ~/news-harvester-pro/bin/add-source.sh <source> 添加栏目" echo "提示:用 $NH_BIN_DIR/add-source.sh <source> 添加栏目"
return return
fi fi
printf "%-42s %-8s %-10s %-26s %-26s\n" "TIMER" "状态" "运行时间" "上次触发" "下次触发" printf "%-42s %-8s %-14s %-26s %-26s\n" "SCHEDULE" "状态" "运行时间" "上次运行" "下次触发"
hr hr
# 收集到临时文件
local tmp local tmp
tmp=$(mktemp) tmp=$(mktemp)
# 用 for 循环遍历 timer 名,避免 while-read 在管道里的坑 local name sub times short sort_key run_fmt next_fmt last_fmt
for name in $timer_names; do for name in $names; do
[ -z "$name" ] && continue [ -z "$name" ] && continue
sub=$(nh_service_state "$name")
# 状态:从 list-units 拿,或用 is-active times="$(schedule_times "$name")"
local sub short=$(echo "$times" | tr ',' ' ' | tr -s ' ' ',' )
sub=$(systemctl is-active "$name" 2>/dev/null || echo "unknown")
# 获取 OnCalendar / NextElapse / LastTrigger
local on_cal next_el last_tr
on_cal=$(get_oncalendar "$name")
next_el=$(systemctl show "$name" -p NextElapseUSecRealtime 2>/dev/null | cut -d= -f2-)
last_tr=$(systemctl show "$name" -p LastTriggerUSec 2>/dev/null | cut -d= -f2-)
# 提取 HH:MM 排序键(从 OnCalendar 或 NextElapse 兜底)
# OnCalendar 格式: *-*-* 09:00:00 或 *-*-* 09:00(秒可省略)
# NextElapse 格式: Wed 2026-08-27 09:00:00 CST
local short sort_key run_fmt
# 先从 OnCalendar 提取(直接取 HH:MM,兼容带秒/不带秒两种写法)
short=$(echo "$on_cal" | grep -oE '[0-9]{2}:[0-9]{2}' | sort -u | paste -sd, -)
# 如果 OnCalendar 没有,从 NextElapse 提取(去掉星期和日期,只留时间)
if [ -z "$short" ] && [ -n "$next_el" ] && [ "$next_el" != "n/a" ]; then
short=$(echo "$next_el" | grep -oE '[0-9]{2}:[0-9]{2}' | head -1)
fi
if [ -n "$short" ]; then if [ -n "$short" ]; then
sort_key="${short%%,*}" sort_key="${short%%,*}"
...@@ -166,33 +177,27 @@ show_overview() { ...@@ -166,33 +177,27 @@ show_overview() {
sort_key="99:99" sort_key="99:99"
run_fmt="-" run_fmt="-"
fi fi
# digest 排到最后,但运行时间仍用从 OnCalendar 读取的实际值 # digest 排到最后
echo "$name" | grep -q "digest" && sort_key="zz:zz" [ "$name" = "digest" ] && sort_key="zz:zz"
local next_fmt last_fmt next_fmt=$(next_fire_at "$short")
if [ -n "$next_el" ] && [ "$next_el" != "n/a" ] && [ "$next_el" != "0" ]; then if [ "$name" = "digest" ]; then
next_fmt="$next_el" last_fmt=$(ls -t "$LOG_DIR"/digest.*.md 2>/dev/null | head -1 | xargs -I{} date -r {} '+%Y-%m-%d %H:%M' 2>/dev/null || echo "从未")
else
next_fmt="-"
fi
if [ -n "$last_tr" ] && [ "$last_tr" != "n/a" ] && [ "$last_tr" != "0" ]; then
last_fmt="$last_tr"
else else
last_fmt="从未触发" last_fmt=$(last_run_at "$name")
fi fi
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$sort_key" "$name" "$sub" "$run_fmt" "$last_fmt" "$next_fmt" >> "$tmp" "$sort_key" "$name" "$sub" "$run_fmt" "$last_fmt" "$next_fmt" >> "$tmp"
done done
# 排序后输出
if [ -s "$tmp" ]; then if [ -s "$tmp" ]; then
sort -t$'\t' -k1,1 "$tmp" | while IFS=$'\t' read -r _ name sub run_fmt last_fmt next_fmt; do sort -t$'\t' -k1,1 "$tmp" | while IFS=$'\t' read -r _ name sub run_fmt last_fmt next_fmt; do
[ -z "$name" ] && continue [ -z "$name" ] && continue
printf "%-42s %-8s %-10s %-26s %-26s\n" "$name" "$sub" "$run_fmt" "$last_fmt" "$next_fmt" printf "%-42s %-8s %-14s %-26s %-26s\n" "$name" "$sub" "$run_fmt" "$last_fmt" "$next_fmt"
done done
else else
echo "(无法获取 timer 信息)" echo "(无法获取调度信息)"
fi fi
rm -f "$tmp" rm -f "$tmp"
...@@ -209,41 +214,30 @@ show_overview() { ...@@ -209,41 +214,30 @@ show_overview() {
# ============================================================ # ============================================================
show_source_detail() { show_source_detail() {
local source="$1" mode="${2:-detail}" local source="$1" mode="${2:-detail}"
local timer_name="news-harvester@${source}.timer"
local service_name="news-harvester@${source}.service"
hr hr
echo "栏目详情: $source" echo "栏目详情: $source"
hr hr
if systemctl cat "$timer_name" >/dev/null 2>&1; then if nh_schedule_exists "$source"; then
echo "Timer:" echo "调度:"
local on_cal next_el last_tr local times
on_cal=$(get_oncalendar "$timer_name") times="$(schedule_times "$source")"
next_el=$(systemctl show "$timer_name" -p NextElapseUSecRealtime 2>/dev/null | cut -d= -f2-) printf " 运行时间: %s\n" "$(echo "$times" | tr ',' ' ')"
last_tr=$(systemctl show "$timer_name" -p LastTriggerUSec 2>/dev/null | cut -d= -f2-) printf " 调度文件: %s\n" "$(nh_schedule_file "$source")"
printf " 下次触发: %s\n" "$(next_fire_at "$times")"
printf " 运行时间: %s\n" "$on_cal" printf " 上次运行: %s\n" "$(last_run_at "$source")"
if [ -n "$next_el" ] && [ "$next_el" != "n/a" ] && [ "$next_el" != "0" ]; then printf " 当前状态: %s\n" "$(nh_service_state "$source")"
printf " 下次触发: %s\n" "$next_el"
else
printf " 下次触发: -\n"
fi
if [ -n "$last_tr" ] && [ "$last_tr" != "n/a" ] && [ "$last_tr" != "0" ]; then
printf " 上次触发: %s\n" "$last_tr"
else
printf " 上次触发: 从未触发\n"
fi
else else
echo " Timer 未安装: $timer_name" echo " 调度未安装: $(nh_schedule_file "$source")"
fi fi
echo "" echo ""
if [ -f "/etc/news-harvester/${source}.conf" ]; then if [ -f "$CONF_DIR/${source}.conf" ]; then
echo "栏目配置 (/etc/news-harvester/${source}.conf):" echo "栏目配置 ($CONF_DIR/${source}.conf):"
grep -v '^#\|^$' "/etc/news-harvester/${source}.conf" | sed 's/^/ /' grep -v '^#\|^$' "$CONF_DIR/${source}.conf" | sed 's/^/ /'
echo "" echo ""
elif [ -f "/etc/news-harvester/default.conf" ]; then elif [ -f "$CONF_DIR/default.conf" ]; then
echo "栏目配置: (继承 default.conf)" echo "栏目配置: (继承 default.conf)"
echo "" echo ""
fi fi
...@@ -276,10 +270,17 @@ show_source_detail() { ...@@ -276,10 +270,17 @@ show_source_detail() {
fi fi
;; ;;
--journal) --journal)
echo "systemd journal (最近 50 行):" if nh_is_darwin; then
hr local ld_log="$LOG_DIR/launchd-${source}.log"
journalctl -u "$service_name" -n 50 --no-pager 2>/dev/null || \ echo "launchd 运行日志 (最近 50 行,$ld_log):"
echo " 无法读取 journal(可能需要 sudo)" hr
tail -50 "$ld_log" 2>/dev/null || echo " 无 launchd 日志(调度可能从未运行)"
else
echo "systemd journal (最近 50 行):"
hr
journalctl -u "news-harvester@${source}.service" -n 50 --no-pager 2>/dev/null || \
echo " 无法读取 journal(可能需要 sudo)"
fi
;; ;;
detail|*) detail|*)
echo "最近运行:" echo "最近运行:"
...@@ -320,7 +321,7 @@ show_digest() { ...@@ -320,7 +321,7 @@ show_digest() {
cat "$digest_md" cat "$digest_md"
else else
echo "今天的汇总尚未生成($digest_md 不存在)" echo "今天的汇总尚未生成($digest_md 不存在)"
echo "提示:手动运行 sudo systemctl start news-harvester-digest.service" echo "提示:手动运行 $NH_BIN_DIR/daily-digest.sh 或面板触发汇总"
fi fi
} }
......
...@@ -286,7 +286,7 @@ class TemporaryEnvironment(ArchiveFixtureMixin, unittest.TestCase): ...@@ -286,7 +286,7 @@ class TemporaryEnvironment(ArchiveFixtureMixin, unittest.TestCase):
'CONF_DIR': str(self.conf), 'SYSTEMD_DIR': str(self.units), 'CONF_DIR': str(self.conf), 'SYSTEMD_DIR': str(self.units),
'PROVIDER': 'default', 'FALLBACK_PROVIDER': '', 'MAIL_TO': '', 'PROVIDER': 'default', 'FALLBACK_PROVIDER': '', 'MAIL_TO': '',
'NH_FIXTURE': str(self.fixture), 'NH_CALLS': str(self.base / 'calls'), 'NH_FIXTURE': str(self.fixture), 'NH_CALLS': str(self.base / 'calls'),
'DIGEST_RETAIN_DAYS': '0'} 'DIGEST_RETAIN_DAYS': '0', 'NH_OS': 'linux'}
self.executable('claude', '''#!/usr/bin/env python3 self.executable('claude', '''#!/usr/bin/env python3
import json, os, sys import json, os, sys
from pathlib import Path from pathlib import Path
...@@ -302,6 +302,27 @@ sys.exit(int(os.environ.get('NH_RC', '0'))) ...@@ -302,6 +302,27 @@ sys.exit(int(os.environ.get('NH_RC', '0')))
mail = self.bin / 'notify-mail.sh' mail = self.bin / 'notify-mail.sh'
mail.write_text('#!/bin/sh\ncat > "$NH_CALLS.mail"\n') mail.write_text('#!/bin/sh\ncat > "$NH_CALLS.mail"\n')
# 进程内(WebTests)也需要同一套平台环境:整套测试按 Linux/systemd 语义搭建,
# NH_OS 强制三个平台判定点(platform.sh / platform_layer / web)走 linux 分支,
# PATH 让面板触发的真实子进程解析到 fake systemctl。
self._platform_keys = ('NH_OS', 'PATH', 'WORK_DIR', 'LOG_DIR', 'CONF_DIR',
'SYSTEMD_DIR', 'NH_SCHEDULE_DIR', 'OBSIDIAN_VAULT_PATH',
'SKILLS_DIR', 'NH_CALLS', 'NH_FIXTURE', 'NH_RC')
self._saved_environ = {k: os.environ.get(k) for k in self._platform_keys}
for key in self._platform_keys:
if key in self.env:
os.environ[key] = self.env[key]
else:
os.environ.pop(key, None)
def _restore_environ():
for key, val in self._saved_environ.items():
if val is None:
os.environ.pop(key, None)
else:
os.environ[key] = val
self.addCleanup(_restore_environ)
def executable(self, name, text): def executable(self, name, text):
path = self.mock_bin / name path = self.mock_bin / name
path.write_text(text) path.write_text(text)
...@@ -460,7 +481,8 @@ class ScheduleTests(TemporaryEnvironment): ...@@ -460,7 +481,8 @@ class ScheduleTests(TemporaryEnvironment):
process = self.run_script('modify-source.sh', 'sample', '--time', '07:00', '--yes') process = self.run_script('modify-source.sh', 'sample', '--time', '07:00', '--yes')
self.assertEqual(process.returncode, 0, process.stderr) self.assertEqual(process.returncode, 0, process.stderr)
self.assertEqual(schedule.read_timer_times(timer), ['07:00']) self.assertEqual(schedule.read_timer_times(timer), ['07:00'])
self.assertIn('enable --now', (self.base / 'calls.systemctl').read_text()) # 新契约:enable 后必须 restart,已激活 timer 才会重新求值日历表达式
self.assertIn('restart news-harvester@sample.timer', (self.base / 'calls.systemctl').read_text())
conf.write_text(conf.read_text() + 'PROVIDER=default\nFALLBACK_PROVIDER=default\n') conf.write_text(conf.read_text() + 'PROVIDER=default\nFALLBACK_PROVIDER=default\n')
process = self.run_script('modify-source.sh', 'sample', '--time', '07:00', process = self.run_script('modify-source.sh', 'sample', '--time', '07:00',
...@@ -504,9 +526,9 @@ class WebTests(TemporaryEnvironment): ...@@ -504,9 +526,9 @@ class WebTests(TemporaryEnvironment):
self.client = self.web.app.test_client() self.client = self.web.app.test_client()
self.timer = self.units / 'news-harvester@sample.timer' self.timer = self.units / 'news-harvester@sample.timer'
self.timer.write_text('[Timer]\nOnCalendar=*-*-* 09:00:00\nOnCalendar=*-*-* 20:00:00\n') self.timer.write_text('[Timer]\nOnCalendar=*-*-* 09:00:00\nOnCalendar=*-*-* 20:00:00\n')
patcher = patch.object(self.web.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, '', '')) # 不再全局 stub subprocess.run:平台层(platform_layer)的调度操作需要真实
patcher.start() # 子进程,由 PATH 里的 fake systemctl 兜住并记录调用;systemctl 读取类调用
self.addCleanup(patcher.stop) # 返回空输出时,app 侧自有文件读取回退,行为等价。
def test_structured_preview_escapes_body(self): def test_structured_preview_escapes_body(self):
item = self.make_article('<script>alert(1)</script>' + '正文' * 200) item = self.make_article('<script>alert(1)</script>' + '正文' * 200)
...@@ -579,15 +601,94 @@ class WebTests(TemporaryEnvironment): ...@@ -579,15 +601,94 @@ class WebTests(TemporaryEnvironment):
with patch.object(self.web.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, show, '')): with patch.object(self.web.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, show, '')):
self.assertEqual(self.web.read_oncalendar_from_systemd('sample'), '09:00,20:00') self.assertEqual(self.web.read_oncalendar_from_systemd('sample'), '09:00,20:00')
def test_disable_clears_all_times_and_overrides(self): def test_disable_unloads_but_preserves_times(self):
"""新停用契约:调度卸载禁用(不再触发),但时间配置保留,一键恢复的前提。"""
dropin = self.timer.with_name(self.timer.name + '.d') dropin = self.timer.with_name(self.timer.name + '.d')
dropin.mkdir() dropin.mkdir()
(dropin / 'override.conf').write_text('[Timer]\nOnCalendar=*-*-* 22:00:00\nAccuracySec=10\n') (dropin / 'override.conf').write_text('[Timer]\nOnCalendar=*-*-* 22:00:00\nAccuracySec=10\n')
response = self.client.post('/disable/sample') response = self.client.post('/disable/sample')
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(schedule.read_timer_times(self.timer), []) # 调度被禁用(fake systemctl 记录了 disable)
calls = ''
calls_path = self.base / 'calls.systemctl'
if calls_path.exists():
calls = calls_path.read_text()
self.assertIn('disable --now news-harvester@sample.timer', calls)
# 时间配置原样保留(含 drop-in),可随时重新启用
self.assertEqual(schedule.read_timer_times(self.timer),
['09:00', '20:00', '22:00'])
self.assertIn('AccuracySec=10', (dropin / 'override.conf').read_text()) self.assertIn('AccuracySec=10', (dropin / 'override.conf').read_text())
@unittest.skipUnless(sys.platform == 'darwin', 'darwin 分支测试(真实 plutil 可用)')
class DarwinPlatformTests(unittest.TestCase):
"""launchd 分支:plist 生成/文件级禁用/恢复,经 NH_OS=darwin + fake launchctl 验证。"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='nh-darwin-')
self.addCleanup(self.tmp.cleanup)
self.base = Path(self.tmp.name)
self.bin = self.base / 'bin'
self.bin.mkdir()
for path in (ROOT / 'bin').iterdir():
if path.is_file():
shutil.copy2(path, self.bin / path.name)
self.mock = self.base / 'mock-bin'
self.mock.mkdir()
self.units = self.base / 'units'
self.units.mkdir()
(self.mock / 'launchctl').write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$NH_CALLS.launchctl"\n')
(self.mock / 'launchctl').chmod(0o755)
self.env = {**os.environ,
'NH_OS': 'darwin', 'NH_SCHEDULE_DIR': str(self.units),
'WORK_DIR': str(self.base), 'LOG_DIR': str(self.base / 'logs'),
'CONF_DIR': str(self.base / 'conf'),
'SKILLS_DIR': str(self.base / 'no-skills'),
'NH_CALLS': str(self.base / 'calls'),
'PATH': f'{self.mock}:{os.environ["PATH"]}'}
def run_cmd(self, *argv, timeout=20):
return subprocess.run(list(argv), env=self.env, text=True,
capture_output=True, timeout=timeout)
def calls(self):
path = self.base / 'calls.launchctl'
return path.read_text() if path.exists() else ''
def test_add_source_generates_plist_entry_and_bootstrap(self):
result = self.run_cmd('bash', str(self.bin / 'add-source.sh'), 'sample', '--time', '09:30', '--yes')
self.assertEqual(result.returncode, 0, result.stderr)
plist = self.units / 'com.newsharvester.sample.plist'
self.assertTrue(plist.exists())
self.assertIn('bootstrap', self.calls())
entry = self.base / 'agents' / 'news-harvester-sample.sh'
self.assertTrue(entry.exists() and entry.stat().st_mode & 0o111)
mod = load_module('darwin_schedule', ROOT / 'bin' / 'schedule.py')
self.assertEqual(mod.read_schedule_times(plist), ['09:30'])
def test_disable_is_file_level_and_recoverable(self):
self.run_cmd('bash', str(self.bin / 'add-source.sh'), 'sample', '--time', '09:30', '--yes')
result = self.run_cmd('bash', str(self.bin / 'platform.sh'), 'call', 'nh_schedule_disable', 'sample')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('bootout', self.calls())
self.assertFalse((self.units / 'com.newsharvester.sample.plist').exists())
disabled = self.units / 'com.newsharvester.sample.plist.disabled'
self.assertTrue(disabled.exists(), '禁用应为文件级(重启不复活)')
# 恢复:enable 把文件改回并重新 bootstrap
result = self.run_cmd('bash', str(self.bin / 'platform.sh'), 'call', 'nh_schedule_enable', 'sample')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((self.units / 'com.newsharvester.sample.plist').exists())
self.assertFalse(disabled.exists())
self.assertEqual(self.calls().count('bootstrap'), 2)
def test_disabled_schedule_times_still_readable(self):
self.run_cmd('bash', str(self.bin / 'add-source.sh'), 'sample', '--time', '08:00,20:15', '--yes')
(self.units / 'com.newsharvester.sample.plist').rename(
self.units / 'com.newsharvester.sample.plist.disabled')
result = self.run_cmd('bash', str(self.bin / 'platform.sh'), 'call', 'nh_schedule_read', 'sample')
self.assertEqual(result.stdout.strip(), '08:00,20:15')
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment