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"
This diff is collapsed.
"""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:
......
This diff is collapsed.
...@@ -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