Commit c2d5873b authored by 谢宇轩's avatar 谢宇轩

feat: firsr version

parents
output/
.wb_config.json
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Visual Studio Code files
.vscode
.vs
# PyCharm files
.idea
# Eclipse Project settings
*.*project
.settings
# Sublime Text settings
*.sublime-workspace
*.sublime-project
# Editor temporaries
*.swn
*.swo
*.swp
*.swm
*~
*.wav
# IPython notebook checkpoints
.ipynb_checkpoints
# macOS dir files
.DS_Store
This diff is collapsed.
#!/bin/bash
# add-source.sh — 新增一个抓取栏目(timer + 可选 conf),自动错开时间
#
# 用法:
# sudo ./add-source.sh <source-name> [选项]
#
# 选项:
# --time HH:MM 运行时间,如 09:30(不指定则自动错开:现有最晚时间 +15 分钟)
# --count N 抓取条数(写入 <source>.conf)
# --mail-to ADDR 收件人(写入 <source>.conf)
# --mail-on-success 0|1 成功是否发邮件(写入 <source>.conf)
# --yes, -y 跳过确认
# --no-enable 只创建文件,不 enable
# --force 同名 timer 已存在时覆盖
# --help, -h 显示帮助
#
# 示例:
# sudo ./add-source.sh bloomberg --time 09:30 --count 30 --mail-on-success 0
# sudo ./add-source.sh nikkei-asia # 自动错开到下一个空档
#
# 环境变量(可覆盖默认路径):
# SYSTEMD_DIR systemd unit 目录(默认 /etc/systemd/system)
# CONF_DIR 栏目配置目录(默认 /etc/news-harvester)
set -euo pipefail
SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
CONF_DIR="${CONF_DIR:-/etc/news-harvester}"
SOURCE=""
TIME=""
COUNT=""
MAIL_TO=""
MAIL_ON_SUCCESS=""
ASSUME_NO=0
NO_ENABLE=0
FORCE=0
usage() {
sed -n '3,30p' "$0"
}
# --- 参数解析 ---
while [ $# -gt 0 ]; do
case "$1" in
--time) TIME="${2:?--time 需要值}"; shift 2;;
--count) COUNT="${2:?--count 需要值}"; shift 2;;
--mail-to) MAIL_TO="${2:?--mail-to 需要值}"; shift 2;;
--mail-on-success) MAIL_ON_SUCCESS="${2:?--mail-on-success 需要值}"; shift 2;;
--yes|-y) ASSUME_NO=1; shift;;
--no-enable) NO_ENABLE=1; shift;;
--force) FORCE=1; shift;;
--help|-h) usage; exit 0;;
--) shift; break;;
-*) echo "未知选项: $1" >&2; usage >&2; exit 1;;
*)
if [ -z "$SOURCE" ]; then SOURCE="$1"
else echo "多余的参数: $1" >&2; exit 1; fi
shift;;
esac
done
# --- 辅助函数 ---
err() { echo "❌ $*" >&2; exit 1; }
ok() { echo "✅ $*"; }
warn() { echo "⚠️ $*"; }
info() { echo "ℹ️ $*"; }
confirm() {
[ "$ASSUME_NO" = "1" ] && return 0
local reply
read -rp "$1 [y/N] " reply
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
}
# --- 校验 ---
[ -n "$SOURCE" ] || err "请指定栏目名:sudo $0 <source-name> [选项]"
[[ "$SOURCE" =~ ^[a-zA-Z0-9_-]+$ ]] || err "栏目名只能含字母、数字、下划线、连字符: $SOURCE"
# 避免和 digest 冲突
[ "$SOURCE" = "digest" ] && err "栏目名不能用 digest(与汇总服务冲突)"
[ "$(id -u)" -eq 0 ] || err "请用 sudo 运行"
# 检查依赖:service 模板 + harvest.sh
[ -f "$SYSTEMD_DIR/news-harvester@.service" ] || err "未找到 $SYSTEMD_DIR/news-harvester@.service,请先运行 migrate.sh 安装"
[ -f "/home/yichu/Documents/News-Harvester/bin/harvest.sh" ] || warn "harvest.sh 不在默认位置,确认已安装(可忽略若路径不同)"
# 校验时间格式
validate_time() {
[[ "$1" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]] || err "时间格式应为 HH:MM(24小时制),如 09:30,得到: $1"
}
validate_count() {
[[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ] || err "count 应为正整数: $1"
}
validate_mos() {
case "$1" in 0|1) ;; *) err "--mail-on-success 只能是 0 或 1: $1";; esac
}
[ -n "$COUNT" ] && validate_count "$COUNT"
[ -n "$MAIL_ON_SUCCESS" ] && validate_mos "$MAIL_ON_SUCCESS"
# --- 检查是否已存在 ---
TIMER_PATH="$SYSTEMD_DIR/news-harvester@${SOURCE}.timer"
CONF_PATH="$CONF_DIR/${SOURCE}.conf"
if [ -f "$TIMER_PATH" ] && [ "$FORCE" = "0" ]; then
err "timer 已存在: $TIMER_PATH\n 如需覆盖,加 --force;如需改时间,用:\n sudo systemctl edit --full news-harvester@${SOURCE}.timer"
fi
# --- 自动推算时间 ---
suggest_time() {
# 扫描现有所有 news-harvester@*.timer 的 OnCalendar,取最晚时间 +15 分钟
local max_epoch=0
shopt -s nullglob
for t in "$SYSTEMD_DIR"/news-harvester@*.timer; do
[ -f "$t" ] || continue
local line tc h m epoch
line=$(grep '^OnCalendar=' "$t" 2>/dev/null | head -1)
[ -z "$line" ] && continue
# 形如 OnCalendar=*-*-* 09:00:00,提取 HH:MM:SS
tc=$(printf '%s' "$line" | grep -oE '[0-9]{2}:[0-9]{2}:[0-9]{2}' | head -1)
[ -z "$tc" ] && continue
h=${tc:0:2}; m=${tc:3:2}
epoch=$((10#$h * 60 + 10#$m))
[ "$epoch" -gt "$max_epoch" ] && max_epoch=$epoch
done
shopt -u nullglob
if [ "$max_epoch" -eq 0 ]; then
echo "09:00" # 没有任何现有 timer,默认 09:00
return
fi
local new_epoch=$((max_epoch + 15))
if [ "$new_epoch" -ge 1440 ]; then
# 超过 23:59,wrap 到次日 00:00 起算
new_epoch=$((new_epoch - 1440))
warn "最晚栏目已接近午夜,新栏目自动 wrap 到次日 $new_epoch"
fi
printf '%02d:%02d\n' $((new_epoch / 60)) $((new_epoch % 60))
}
if [ -z "$TIME" ]; then
TIME=$(suggest_time)
AUTO_TIME=1
else
validate_time "$TIME"
AUTO_TIME=0
fi
# --- 展示计划,确认 ---
echo "=== 即将创建栏目: $SOURCE ==="
echo " 运行时间: $TIME"
[ "$AUTO_TIME" = "1" ] && echo " (自动推算:现有最晚栏目 +15 分钟)"
echo " timer 文件: $TIMER_PATH"
if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ]; then
echo " conf 文件: $CONF_PATH"
[ -n "$COUNT" ] && echo " COUNT=$COUNT"
[ -n "$MAIL_TO" ] && echo " MAIL_TO=$MAIL_TO"
[ -n "$MAIL_ON_SUCCESS" ] && echo " MAIL_ON_SUCCESS=$MAIL_ON_SUCCESS"
else
echo " conf 文件: (不创建,继承 default.conf)"
fi
[ "$NO_ENABLE" = "1" ] && echo " 启用: 否(--no-enable)"
echo ""
confirm "确认创建?" || { echo "已取消。"; exit 0; }
# --- 写 timer ---
mkdir -p "$SYSTEMD_DIR"
cat > "$TIMER_PATH" <<EOF
[Unit]
Description=Run News Harvester ($SOURCE) daily at $TIME
[Timer]
OnCalendar=*-*-* ${TIME}:00
Persistent=true
AccuracySec=1min
Unit=news-harvester@${SOURCE}.service
[Install]
WantedBy=timers.target
EOF
ok "已创建 $TIMER_PATH"
# --- 写 conf(仅当有覆盖项) ---
if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ]; then
mkdir -p "$CONF_DIR"
{
echo "# /etc/news-harvester/${SOURCE}.conf — 由 add-source.sh 生成 $(date '+%Y-%m-%d %H:%M:%S')"
echo "# 仅写需要覆盖的字段,其余继承 default.conf"
echo ""
[ -n "$COUNT" ] && echo "COUNT=$COUNT"
[ -n "$MAIL_TO" ] && echo "MAIL_TO=$MAIL_TO"
[ -n "$MAIL_ON_SUCCESS" ] && echo "MAIL_ON_SUCCESS=$MAIL_ON_SUCCESS"
} > "$CONF_PATH"
ok "已创建 $CONF_PATH"
fi
# --- daemon-reload + enable ---
systemctl daemon-reload
ok "systemd 已重新加载"
if [ "$NO_ENABLE" = "0" ]; then
systemctl enable --now "news-harvester@${SOURCE}.timer"
ok "已启用并启动 news-harvester@${SOURCE}.timer"
fi
echo ""
echo "========================================"
ok "栏目 $SOURCE 添加完成!"
echo "========================================"
echo ""
echo "后续操作:"
echo " 查看定时器: systemctl list-timers news-harvester@${SOURCE}.timer"
echo " 手动测试: sudo systemctl start news-harvester@${SOURCE}.service"
echo " 实时日志: journalctl -u news-harvester@${SOURCE}.service -f"
echo " 改时间: sudo systemctl edit --full news-harvester@${SOURCE}.timer (改后 daemon-reload)"
echo " 查看运行结果: cat ~/Documents/News-Harvester/logs/${SOURCE}.last.log"
echo ""
echo "提示:此栏目今天的抓取记录会自动被每日汇总(digest)纳入分析。"
This diff is collapsed.
This diff is collapsed.
#!/bin/bash
# 通用新闻抓取脚本 —— 由 systemd 模板单元 news-harvester@<source>.service 调用
# 用法: harvest.sh <source-name> [count]
# $1 栏目/源名称,如 nikkei-world、reuters(由 systemd %i 传入)
# $2 抓取条数,默认 20(可被 /etc/news-harvester/<source>.conf 里的 COUNT 覆盖)
#
# 使用 claude 无头模式 --output-format json 捕获完整运行元数据:
# 成本、turns、耗时、session_id、token 用量、权限拒绝、结果文本。
# 每次运行落盘:
# logs/<source>.<YYYYMMDD>.<HHMMSS>.json 完整 JSON 结果(daily-digest 读取)
# logs/<source>.<YYYYMMDD>.<HHMMSS>.raw claude stderr(排错用)
# logs/<source>.last.log 人类可读上次运行摘要(覆盖)
#
# 环境变量(由 service 的 EnvironmentFile 注入):
# MAIL_TO 收件人;留空则不发邮件
# MAIL_ON_SUCCESS 1=成功也发,0=仅失败发(默认 1)
# ALERT_COST_OVER 单次成本(美元)超过此值则在邮件标题标记 [ALERT],留空不判断
# WORK_DIR 项目工作目录(默认 /home/yichu/Documents/News-Harvester)
# LOG_DIR 日志目录(默认 <WORK_DIR>/logs)
# --- 设置 PATH 和 nvm(等价于 .zshrc 中的配置) ---
# systemd service (User=yichu) 非交互 shell 不加载 .zshrc,需显式设置
export NVM_DIR="/home/yichu/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
export PATH="/home/yichu/.local/bin:/home/yichu/.nvm/versions/node/v24.14.0/bin:$PATH"
SOURCE="${1:?用法: harvest.sh <source-name> [count]}"
COUNT="${2:-20}"
MAIL_TO="${MAIL_TO:-}"
MAIL_ON_SUCCESS="${MAIL_ON_SUCCESS:-1}"
ALERT_COST_OVER="${ALERT_COST_OVER:-}"
WORK_DIR="${WORK_DIR:-/home/yichu/Documents/News-Harvester}"
LOG_DIR="${LOG_DIR:-$WORK_DIR/logs}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$LOG_DIR"
DATE_TAG="$(date +%Y%m%d)"
TIME_TAG="$(date +%H%M%S)"
JSON_OUT="$LOG_DIR/${SOURCE}.${DATE_TAG}.${TIME_TAG}.json"
RAW_OUT="$LOG_DIR/${SOURCE}.${DATE_TAG}.${TIME_TAG}.raw"
HUMAN_LOG="$LOG_DIR/${SOURCE}.last.log"
cd "$WORK_DIR"
START_EPOCH=$(date +%s)
# --- 运行 claude 无头模式,输出 JSON ---
# 不用 set -e,因为要捕获 claude 的退出码
set +e
claude -p \
--permission-mode bypassPermissions \
--allowed-tools Bash Write \
--output-format json \
--max-budget-usd 15 \
"使用news-harvester获取${SOURCE}的最新${COUNT}条数据,并保存到obsidian中。
执行要求:
- 必须在前台同步完成所有抓取和归档,不要把浏览器自动化或任何步骤放到后台任务中运行。
- 不要使用后台 shell、nohup、& 或任何形式的异步执行。
- 所有文章抓取完毕后再依次生成摘要并写入 Obsidian,全部完成后再返回结果。
- 如果某篇文章抓取失败,跳过它继续下一篇,不要因此中断整个任务。
- 最终结果中简要说明:成功归档几篇、失败几篇、各失败原因。" \
> "$JSON_OUT" 2> "$RAW_OUT"
RC=$?
set -e
# 如果 claude 完全没输出(可能 PATH 未生效),写入诊断信息到 RAW_OUT
if [ ! -s "$JSON_OUT" ] && [ ! -s "$RAW_OUT" ]; then
{
echo "===== 诊断信息(claude 无任何输出) ====="
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "退出码: $RC"
echo "当前用户: $(whoami)"
echo "PATH: $PATH"
echo ""
echo "尝试检测 claude 是否可用:"
echo " claude --version:"
claude --version 2>&1 || echo " 失败(exit $?)"
echo ""
echo "which claude:"
which claude 2>&1 || echo " 未找到"
} > "$RAW_OUT"
fi
END_EPOCH=$(date +%s)
WALL_DURATION=$((END_EPOCH - START_EPOCH))
# --- 解析 JSON 元数据 ---
HAVE_JQ=0
command -v jq >/dev/null 2>&1 && [ -s "$JSON_OUT" ] && HAVE_JQ=1
if [ "$HAVE_JQ" = "1" ]; then
# 校验是否为合法 JSON(claude 失败时 stdout 可能为空或非 JSON)
if ! jq empty "$JSON_OUT" 2>/dev/null; then
HAVE_JQ=0
fi
fi
if [ "$HAVE_JQ" = "1" ]; then
IS_ERROR=$(jq -r '.is_error // "unknown"' "$JSON_OUT")
SUBTYPE=$(jq -r '.subtype // "unknown"' "$JSON_OUT")
COST=$(jq -r '.total_cost_usd // 0' "$JSON_OUT")
NUM_TURNS=$(jq -r '.num_turns // 0' "$JSON_OUT")
API_MS=$(jq -r '.duration_api_ms // 0' "$JSON_OUT")
SESSION_ID=$(jq -r '.session_id // "n/a"' "$JSON_OUT")
IN_TOK=$(jq -r '.usage.input_tokens // 0' "$JSON_OUT")
OUT_TOK=$(jq -r '.usage.output_tokens // 0' "$JSON_OUT")
CACHE_READ=$(jq -r '.usage.cache_read_input_tokens // 0' "$JSON_OUT")
CACHE_CREATE=$(jq -r '.usage.cache_creation_input_tokens // 0' "$JSON_OUT")
DENIALS=$(jq -r '.permission_denials | length' "$JSON_OUT")
TERMINAL=$(jq -r '.terminal_reason // "n/a"' "$JSON_OUT")
RESULT_TEXT=$(jq -r '.result // "(无输出)"' "$JSON_OUT")
else
IS_ERROR="unknown"; SUBTYPE="unknown"; COST=0; NUM_TURNS=0; API_MS=0
SESSION_ID="n/a"; IN_TOK=0; OUT_TOK=0; CACHE_READ=0; CACHE_CREATE=0
DENIALS=0; TERMINAL="n/a"
RESULT_TEXT="(无法解析 JSON 输出,请查看 .raw 文件;可能 claude 启动失败或未安装 jq)"
fi
# --- 判定状态 ---
# 成功条件:进程退出码 0 且 is_error 非 true
if [ "$RC" -eq 0 ] && [ "$IS_ERROR" != "true" ]; then
STATUS_EMOJI="✅"
STATUS_TEXT="成功"
STATUS_TAG="SUCCESS"
else
STATUS_EMOJI="❌"
STATUS_TEXT="失败"
STATUS_TAG="FAILED"
fi
# 成本告警判断
COST_ALERT=0
if [ -n "$ALERT_COST_OVER" ]; then
awk -v c="$COST" -v t="$ALERT_COST_OVER" 'BEGIN{exit !(c+0 > t+0)}' && COST_ALERT=1
fi
# 权限拒绝也算异常
if [ "$DENIALS" -gt 0 ] && [ "$STATUS_TAG" = "SUCCESS" ]; then
STATUS_EMOJI="⚠️"
STATUS_TEXT="成功(但有 ${DENIALS} 次权限拒绝)"
fi
# --- 格式化耗时 ---
MINS=$((WALL_DURATION / 60))
SECS=$((WALL_DURATION % 60))
API_SECS=$(awk -v ms="$API_MS" 'BEGIN{printf "%.1f", ms/1000}')
# --- 写人类可读日志 ---
{
echo "===== $(date '+%Y-%m-%d %H:%M:%S') 抓取 [$SOURCE] ====="
echo "状态: ${STATUS_EMOJI} ${STATUS_TEXT}"
echo "退出码: ${RC}"
echo "耗时: ${MINS}m${SECS}s (wall) / ${API_SECS}s (api)"
echo "成本: \$${COST}"
echo "Turns: ${NUM_TURNS}"
echo "Token: 输入 ${IN_TOK} / 输出 ${OUT_TOK} / cache读 ${CACHE_READ} / cache写 ${CACHE_CREATE}"
echo "Session: ${SESSION_ID}"
echo "拒绝: ${DENIALS} 次权限拒绝"
echo "Terminal: ${TERMINAL}"
echo ""
echo "----- result -----"
echo "$RESULT_TEXT"
echo ""
echo "----- JSON 结果文件 -----"
echo "$JSON_OUT"
} > "$HUMAN_LOG"
# --- 邮件通知 ---
SHOULD_NOTIFY=0
if [ -n "$MAIL_TO" ]; then
if [ "$STATUS_TAG" = "FAILED" ]; then
SHOULD_NOTIFY=1
elif [ "$MAIL_ON_SUCCESS" = "1" ]; then
SHOULD_NOTIFY=1
fi
fi
if [ "$SHOULD_NOTIFY" = "1" ]; then
ALERT_PREFIX=""
if [ "$STATUS_TAG" = "FAILED" ] || [ "$COST_ALERT" = "1" ]; then
ALERT_PREFIX="[ALERT] "
fi
SUBJECT="[News Harvester] ${ALERT_PREFIX}${STATUS_TAG}: ${SOURCE}"
# 取 result 末尾 60 行,避免邮件过长
RESULT_TAIL=$(printf '%s\n' "$RESULT_TEXT" | tail -n 60)
BODY="$(cat <<EOF
${STATUS_EMOJI} News Harvester 任务通知
栏目: ${SOURCE}
状态: ${STATUS_TEXT}
退出码: ${RC}
耗时: ${MINS}m${SECS}s (wall) / ${API_SECS}s (api)
成本: \$${COST}
Turns: ${NUM_TURNS}
Token: 输入 ${IN_TOK} / 输出 ${OUT_TOK}
cache 读 ${CACHE_READ} / cache 写 ${CACHE_CREATE}
Session: ${SESSION_ID}
权限拒绝: ${DENIALS}
Terminal: ${TERMINAL}
EOF
)"
if [ -n "$ALERT_COST_OVER" ]; then
if [ "$COST_ALERT" = "1" ]; then
BODY+=$'\n成本阈值: \$'"${ALERT_COST_OVER} (本次超标 ⚠️)"
else
BODY+=$'\n成本阈值: \$'"${ALERT_COST_OVER} (本次正常)"
fi
fi
BODY+=$'\n\n----- result 末尾 60 行 -----\n'"${RESULT_TAIL}"
# 通知失败不影响任务退出码
printf '%s' "$BODY" | "$SCRIPT_DIR/notify-mail.sh" "$MAIL_TO" "$SUBJECT" \
2>> "$RAW_OUT" || echo "[notify] 邮件发送失败(已忽略)" >> "$HUMAN_LOG"
fi
exit "$RC"
#!/bin/bash
# 通用邮件发送脚本 —— 被 harvest.sh / daily-digest.sh 调用
# 用法: echo "正文" | notify-mail.sh <to> <subject> [format]
# $1 收件人,支持多个:空格或逗号分隔
# 例: "a@x.com b@y.com"
# "a@x.com,b@y.com,c@z.com"
# $2 主题(建议 ASCII,避免邮件头编码问题)
# $3 格式:text(默认) | html
# 正文从 stdin 读取(UTF-8)
# 环境变量:
# MSMTP_CONFIG 指定 msmtp 配置文件路径(可选)
# 依赖:msmtp(配置文件 ~/.msmtprc,权限 600)
set -euo pipefail
RAW_TO="${1:?用法: notify-mail.sh <to> <subject> [text|html]}"
SUBJECT="${2:?用法: notify-mail.sh <to> <subject> [text|html]}"
FORMAT="${3:-text}"
BODY="$(cat)"
if ! command -v msmtp >/dev/null 2>&1; then
echo "[notify] msmtp 未安装,跳过邮件。安装:sudo pacman -S msmtp" >&2
exit 0
fi
# --- 解析收件人:统一逗号→空格,再拆成数组 ---
NORMALIZED="${RAW_TO//,/ }"
NORMALIZED="${NORMALIZED//,/ }"
read -r -a RECIPIENTS <<< "$NORMALIZED"
if [ "${#RECIPIENTS[@]}" -eq 0 ]; then
echo "[notify] 收件人为空,跳过邮件。" >&2
exit 0
fi
TO_HEADER="$(IFS=,; printf '%s' "${RECIPIENTS[*]}")"
# --- Content-Type ---
case "$FORMAT" in
html|HTML) CT="text/html; charset=UTF-8" ;;
*) CT="text/plain; charset=UTF-8" ;;
esac
# --- msmtp 配置文件 ---
MSMTP_ARGS=()
if [ -n "${MSMTP_CONFIG:-}" ] && [ -f "$MSMTP_CONFIG" ]; then
MSMTP_ARGS+=(--file="$MSMTP_CONFIG")
fi
printf 'To: %s\nSubject: %s\nContent-Type: %s\n\n%s' \
"$TO_HEADER" "$SUBJECT" "$CT" "$BODY" \
| msmtp "${MSMTP_ARGS[@]}" "${RECIPIENTS[@]}"
This diff is collapsed.
# /etc/news-harvester/default.conf
# 所有栏目的全局默认配置。单栏目可在 /etc/news-harvester/<source>.conf 中覆盖。
# 注意:systemd EnvironmentFile 格式,每行 VAR=value,不要加 export,不要加引号包裹。
# === 抓取 ===
# 默认抓取条数(未在此设置时脚本兜底 20)
COUNT=20
# === 邮件通知 ===
# 默认收件人;留空则不发邮件。强烈建议在此设一个兜底地址。
# 支持多个收件人:空格或逗号分隔(中英文逗号均可)。
# 例: MAIL_TO=alice@example.com bob@example.com
# MAIL_TO=alice@example.com,bob@example.com
MAIL_TO=you@example.com
# 成功是否也发邮件:1=每次都发,0=仅失败发
MAIL_ON_SUCCESS=1
# === 成本告警 ===
# 单次抓取成本(美元)超过此值则在邮件标题标记 [ALERT],留空不判断
ALERT_COST_OVER=1
# === 每日汇总(digest 相关) ===
# 无论是否有告警都发汇总邮件:1=每次发,0=仅告警时发
DIGEST_MAIL_ALWAYS=1
# 旧日志保留天数,超过则自动清理
DIGEST_RETAIN_DAYS=30
# 汇总分析的 claude 预算上限(美元)
DIGEST_BUDGET_USD=2
# === 路径(通常不用改) ===
# WORK_DIR=/home/yichu/Documents/News-Harvester
# LOG_DIR=/home/yichu/Documents/News-Harvester/logs
# ~/.msmtprc —— msmtp 配置文件(放在运行用户 yichu 的家目录)
# 权限必须为 600,否则 msmtp 拒绝使用:chmod 600 ~/.msmtprc
#
# 下面以 Gmail 应用专用密码为例;其他 SMTP 服务替换 host/port/auth_user/password 即可。
# Gmail 需开启两步验证后生成「应用专用密码」,不能直接用登录密码。
defaults
auth on
tls on
tls_starttls on
logfile ~/.msmtp.log
account default
host smtp.gmail.com
port 587
from you@gmail.com
user you@gmail.com
password 你的应用专用密码
# 默认使用的 account
account default : default
# /etc/news-harvester/nikkei-world.conf
# 单栏目覆盖配置示例。仅写需要覆盖的字段,其余继承 default.conf。
# 此例:nikkei-world 抓 30 条,成功不单独发邮件(只在 digest 里看),成本超 $2 告警。
COUNT=30
MAIL_ON_SUCCESS=0
ALERT_COST_OVER=2
# /etc/news-harvester/web.conf — Web 面板认证配置
# 由 news-harvester-web.service 通过 EnvironmentFile 加载
# 文件权限应为 600: sudo chmod 600 /etc/news-harvester/web.conf
# Basic Auth 用户名
WEB_USER=admin
# Basic Auth 密码(请修改为强密码)
WEB_PASS=change-me-to-a-strong-password
# 可选:Flask session 密钥(留空则使用默认值)
# WEB_SECRET=your-random-secret-key
#!/bin/bash
# deploy.sh — 一键同步项目到服务器
#
# 用法:
# ./deploy.sh <server-ip> # 默认用户 yichu
# ./deploy.sh yichu@192.168.1.100 # 显式指定用户
# ./deploy.sh <host> --no-systemd # 只更新 bin 脚本,不动 systemd unit
# ./deploy.sh <host> --no-restart-web # 不重启 web 面板
# ./deploy.sh <host> --test # 部署后自动跑一次 nikkei-world 测试
#
# 做的事:
# 1. rsync 整个项目目录到 ~/news-harvester-pro/(--delete 保持镜像)
# 2. 复制 bin/*.sh 到 ~/Documents/News-Harvester/bin/ 并赋可执行权限
# 3. 同步 systemd 模板 service + digest unit 到 /etc/systemd/system/(需要 sudo)
# ⚠️ 栏目 timer(news-harvester@<source>.timer)不在同步范围,由服务器端维护
# 4. daemon-reload
#
# 不动的东西:
# - /etc/news-harvester/*.conf(用户配置)
# - 各栏目 timer(服务器端可能改过时间)
# - ~/.msmtprc(SMTP 配置)
# - logs 目录
set -euo pipefail
SYNC_SYSTEMD=1
RESTART_WEB=1
RUN_TEST=0
HOST=""
# --- 参数解析 ---
while [ $# -gt 0 ]; do
case "$1" in
--no-systemd) SYNC_SYSTEMD=0; shift;;
--no-restart-web) RESTART_WEB=0; shift;;
--test) RUN_TEST=1; shift;;
--help|-h)
sed -n '3,24p' "$0"; exit 0;;
-*) echo "未知选项: $1" >&2; exit 1;;
*)
if [ -z "$HOST" ]; then HOST="$1"
else echo "多余的参数: $1" >&2; exit 1; fi
shift;;
esac
done
[ -n "$HOST" ] || { echo "用法: $0 <[user@]host> [--no-systemd] [--no-restart-web] [--test]" >&2; exit 1; }
# 默认加用户名(如果没写 user@)
case "$HOST" in
*@*) ;;
*) HOST="yichu@${HOST}" ;;
esac
err() { echo "❌ $*" >&2; exit 1; }
ok() { echo "✅ $*"; }
info() { echo "ℹ️ $*"; }
warn() { echo "⚠️ $*"; }
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_NAME="news-harvester-pro"
# --- 检查 rsync ---
command -v rsync >/dev/null 2>&1 || err "本地需要 rsync。macOS 自带,如缺失:brew install rsync"
echo "=== 部署到 $HOST ==="
echo " 项目: $SCRIPT_DIR"
echo " 同步 bin: 是"
[ "$SYNC_SYSTEMD" = "1" ] && echo " 同步 systemd: 是(需要远程 sudo)" || echo " 同步 systemd: 否(--no-systemd)"
[ "$RESTART_WEB" = "1" ] && echo " 重启 web: 是" || echo " 重启 web: 否(--no-restart-web)"
[ "$RUN_TEST" = "1" ] && echo " 部署后测试: 是(--test)"
echo ""
# --- 1. rsync 项目目录 ---
info "1/4 同步项目目录 → ~/news-harvester-pro/"
rsync -avz --delete \
--exclude='.git' \
--exclude='*.log' \
--exclude='logs/' \
--exclude='__pycache__/' \
--exclude='*.pyc' \
"$SCRIPT_DIR/" "$HOST:~/${PROJECT_NAME}/"
ok "项目目录已同步"
# --- 2. 复制 bin + web 到工作目录 ---
info "2/5 复制 bin/*.sh + web/ → ~/Documents/News-Harvester/"
ssh "$HOST" 'mkdir -p ~/Documents/News-Harvester/bin ~/Documents/News-Harvester/web/templates && cp ~/news-harvester-pro/bin/*.sh ~/Documents/News-Harvester/bin/ && chmod +x ~/Documents/News-Harvester/bin/*.sh && cp ~/news-harvester-pro/web/app.py ~/Documents/News-Harvester/web/ && cp ~/news-harvester-pro/web/templates/*.html ~/Documents/News-Harvester/web/templates/'
ok "bin 脚本 + web 应用已就位"
# --- 3. 同步 systemd unit ---
if [ "$SYNC_SYSTEMD" = "1" ]; then
info "3/5 同步 systemd 模板 + digest + web unit → /etc/systemd/system/"
# 用 -t 分配伪终端,让远程 sudo 能交互式提示输入密码
# 只同步模板 service / digest / web,栏目 timer 由服务器端维护
ssh -t "$HOST" 'sudo cp ~/news-harvester-pro/systemd/news-harvester@.service /etc/systemd/system/ && sudo cp ~/news-harvester-pro/systemd/news-harvester-digest.service /etc/systemd/system/ && sudo cp ~/news-harvester-pro/systemd/news-harvester-digest.timer /etc/systemd/system/ && sudo cp ~/news-harvester-pro/systemd/news-harvester-web.service /etc/systemd/system/ && sudo systemctl daemon-reload'
ok "systemd unit 已同步并 reload"
else
info "3/5 跳过 systemd(--no-systemd)"
fi
# --- 4. 提示 Web 面板首次配置 + 重启 ---
info "4/5 Web 面板检查"
ssh "$HOST" 'test -f /etc/news-harvester/web.conf && echo "EXISTS" || echo "MISSING"' 2>/dev/null | grep -q EXISTS \
&& ok "web.conf 已存在" \
|| warn "web.conf 不存在,首次部署需创建:\n ssh $HOST 'sudo cp ~/news-harvester-pro/config/web.conf.example /etc/news-harvester/web.conf && sudo chmod 600 /etc/news-harvester/web.conf && sudo nano /etc/news-harvester/web.conf'\n 然后:ssh $HOST 'sudo systemctl enable --now news-harvester-web.service'"
# 重启 web 面板使新代码生效(首次未安装时忽略失败)
if [ "$RESTART_WEB" = "1" ]; then
# 用 ssh -t 让 sudo 交互式输密码(同 step 3),不接管道避免死锁;靠退出码判断
if ssh -t "$HOST" 'sudo systemctl restart news-harvester-web.service' 2>/dev/null; then
ok "news-harvester-web.service 已重启(新代码生效)"
else
info "web 服务重启失败(可能未安装),若已安装可手动:ssh $HOST 'sudo systemctl restart news-harvester-web.service'"
fi
else
info "跳过 web 重启(--no-restart-web)"
fi
# --- 5. 完成 ---
info "5/5 完成"
echo ""
echo "========================================"
ok "部署完成!"
echo "========================================"
echo ""
echo "验证:"
echo " 查看定时器: ssh $HOST 'systemctl list-timers \"news-harvester@*\" news-harvester-digest.timer'"
echo " 手动测试: ssh $HOST 'sudo systemctl start news-harvester@nikkei-world.service'"
echo " 看日志: ssh $HOST 'journalctl -u news-harvester@nikkei-world.service -f'"
echo " 测试汇总: ssh $HOST 'sudo systemctl start news-harvester-digest.service'"
echo " Web 面板: ssh -L 8080:127.0.0.1:8080 $HOST 然后浏览器打开 http://localhost:8080"
echo ""
# --- 可选:自动测试 ---
if [ "$RUN_TEST" = "1" ]; then
warn "正在触发测试运行 news-harvester@nikkei-world.service ..."
ssh -t "$HOST" 'sudo systemctl start news-harvester@nikkei-world.service' || warn "触发失败,请手动检查"
info "日志(实时,按 Ctrl+C 退出):"
ssh -t "$HOST" 'journalctl -u news-harvester@nikkei-world.service -f -n 20' || true
fi
#!/bin/bash
# migrate.sh — 从旧版 news-harvester-systemd 迁移到 news-harvester-pro
#
# 功能:
# 1. 检测旧版安装(旧 harvest.sh / 旧 service / 旧 notify-mail.sh)
# 2. 备份旧文件到带时间戳的目录
# 3. 安装新版 bin / systemd / config
# 4. 保留 /etc/news-harvester/*.conf(不覆盖用户配置)
# 5. 停用旧 timer,daemon-reload,启用新 timer(保持原栏目)
# 6. 可选:安装 digest service + timer
#
# 用法:
# sudo ./migrate.sh # 交互式(会提示确认)
# sudo ./migrate.sh --yes # 跳过所有确认
# sudo ./migrate.sh --uninstall # 卸载新版,恢复旧版备份
#
# 环境变量:
# WORK_DIR 项目工作目录(默认 /home/yichu/Documents/News-Harvester)
# USER_NAME 运行用户(默认 yichu)
# INSTALL_DIGEST 是否安装 digest(1/0,默认 1)
set -euo pipefail
# --- 配置 ---
WORK_DIR="${WORK_DIR:-/home/yichu/Documents/News-Harvester}"
USER_NAME="${USER_NAME:-yichu}"
INSTALL_DIGEST="${INSTALL_DIGEST:-1}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BACKUP_DIR="${WORK_DIR}/.backup-$(date +%Y%m%d%H%M%S)"
ASSUME_NO=0
UNINSTALL=0
# --- 解析参数 ---
for arg in "$@"; do
case "$arg" in
--yes|-y) ASSUME_NO=1 ;;
--uninstall) UNINSTALL=1 ;;
--help|-h)
sed -n '3,20p' "$0"; exit 0 ;;
*) echo "未知参数: $arg"; exit 1 ;;
esac
done
# --- 辅助函数 ---
confirm() {
[ "$ASSUME_NO" = "1" ] && return 0
local prompt="$1" reply
read -rp "$prompt [y/N] " reply
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
}
err() { echo "❌ $*" >&2; exit 1; }
ok() { echo "✅ $*"; }
warn() { echo "⚠️ $*"; }
info() { echo "ℹ️ $*"; }
need_root() {
[ "$(id -u)" -eq 0 ] || err "请用 sudo 运行:sudo $0 $*"
}
# --- 卸载模式 ---
if [ "$UNINSTALL" = "1" ]; then
need_root
echo "=== 卸载 news-harvester-pro ==="
# 找到最近的备份
LATEST_BACKUP=$(ls -d "${WORK_DIR}"/.backup-* 2>/dev/null | sort | tail -1)
if [ -z "$LATEST_BACKUP" ]; then
err "未找到备份目录(${WORK_DIR}/.backup-*),无法恢复。"
fi
warn "将恢复备份: $LATEST_BACKUP"
confirm "确认卸载新版并恢复?" || exit 0
# 停用所有 news-harvester timer
for t in $(systemctl list-unit-files 'news-harvester@*.timer' 'news-harvester-digest.timer' --no-legend 2>/dev/null | awk '{print $1}'); do
systemctl disable --now "$t" 2>/dev/null || true
done
# 删除新版 unit 文件
rm -f /etc/systemd/system/news-harvester@.service
rm -f /etc/systemd/system/news-harvester@*.timer
rm -f /etc/systemd/system/news-harvester-digest.service
rm -f /etc/systemd/system/news-harvester-digest.timer
rm -f /etc/systemd/system/news-harvester@*.service.d/*.conf 2>/dev/null
rmdir /etc/systemd/system/news-harvester@*.service.d 2>/dev/null || true
# 恢复备份的 unit 文件
if [ -d "$LATEST_BACKUP/systemd" ]; then
cp -v "$LATEST_BACKUP/systemd/"* /etc/systemd/system/ 2>/dev/null || true
fi
# 恢复备份的 bin
if [ -d "$LATEST_BACKUP/bin" ]; then
mkdir -p "$WORK_DIR/bin"
cp -v "$LATEST_BACKUP/bin/"* "$WORK_DIR/bin/" 2>/dev/null || true
fi
systemctl daemon-reload
# 重新启用恢复的 timer
for t in /etc/systemd/system/news-harvester@*.timer; do
[ -f "$t" ] && systemctl enable --now "$(basename "$t")" 2>/dev/null || true
done
ok "卸载完成,已恢复 $LATEST_BACKUP"
exit 0
fi
# === 正常安装/迁移 ===
need_root "$@"
# 前置检查:jq / msmtp / claude
echo "=== 前置依赖检查 ==="
command -v jq >/dev/null && ok "jq 已安装" || warn "jq 未安装: sudo pacman -S jq"
command -v msmtp >/dev/null && ok "msmtp 已安装" || warn "msmtp 未安装: sudo pacman -S msmtp"
command -v claude >/dev/null && ok "claude 已安装" || warn "claude 未在 root PATH 中(可能正常,脚本以 $USER_NAME 身份运行时会从用户 profile 加载)"
echo ""
echo "=== 配置确认 ==="
echo " WORK_DIR = $WORK_DIR"
echo " USER_NAME = $USER_NAME"
echo " INSTALL_DIGEST = $INSTALL_DIGEST"
echo " 备份目录 = $BACKUP_DIR"
confirm "确认开始迁移?" || exit 0
# --- 1. 备份旧文件 ---
echo ""
echo "=== 步骤 1/5: 备份旧文件 ==="
mkdir -p "$BACKUP_DIR/bin" "$BACKUP_DIR/systemd" "$BACKUP_DIR/conf"
# 备份旧的 bin 脚本(可能是项目根目录下的 harvest.sh / notify-mail.sh,也可能是 bin/)
for f in harvest.sh notify-mail.sh daily-digest.sh; do
for loc in "$WORK_DIR/$f" "$WORK_DIR/bin/$f"; do
if [ -f "$loc" ]; then
cp -v "$loc" "$BACKUP_DIR/bin/" 2>/dev/null || true
fi
done
done
# 备份旧的 systemd unit
for f in /etc/systemd/system/news-harvester@.service \
/etc/systemd/system/news-harvester@*.timer \
/etc/systemd/system/news-harvester-digest.service \
/etc/systemd/system/news-harvester-digest.timer; do
for loc in $f; do
[ -f "$loc" ] && cp -v "$loc" "$BACKUP_DIR/systemd/" 2>/dev/null || true
done
done
# 备份 drop-in
cp -r /etc/systemd/system/news-harvester@*.service.d "$BACKUP_DIR/systemd/" 2>/dev/null || true
# 备份 conf(这些不覆盖,但留个快照)
if [ -d /etc/news-harvester ]; then
cp -rv /etc/news-harvester "$BACKUP_DIR/conf/" 2>/dev/null || true
fi
ok "备份完成 → $BACKUP_DIR"
# --- 2. 停用旧 timer ---
echo ""
echo "=== 步骤 2/5: 停用旧 timer ==="
for t in $(systemctl list-unit-files 'news-harvester@*.timer' --no-legend 2>/dev/null | awk '{print $1}'); do
info "停用 $t"
systemctl disable --now "$t" 2>/dev/null || true
done
if [ "$INSTALL_DIGEST" = "1" ]; then
systemctl disable --now news-harvester-digest.timer 2>/dev/null || true
fi
# --- 3. 安装新版 bin ---
echo ""
echo "=== 步骤 3/5: 安装新版脚本 ==="
mkdir -p "$WORK_DIR/bin"
install -m755 -o "$USER_NAME" -g "$USER_NAME" "$SCRIPT_DIR/bin/harvest.sh" "$WORK_DIR/bin/harvest.sh"
install -m755 -o "$USER_NAME" -g "$USER_NAME" "$SCRIPT_DIR/bin/notify-mail.sh" "$WORK_DIR/bin/notify-mail.sh"
if [ "$INSTALL_DIGEST" = "1" ]; then
install -m755 -o "$USER_NAME" -g "$USER_NAME" "$SCRIPT_DIR/bin/daily-digest.sh" "$WORK_DIR/bin/daily-digest.sh"
fi
ok "脚本已安装到 $WORK_DIR/bin/"
# --- 4. 安装 systemd unit ---
echo ""
echo "=== 步骤 4/5: 安装 systemd 单元 ==="
# 删除旧的(避免残留)
rm -f /etc/systemd/system/news-harvester@.service
rm -f /etc/systemd/system/news-harvester-digest.service /etc/systemd/system/news-harvester-digest.timer
install -m644 "$SCRIPT_DIR/systemd/news-harvester@.service" /etc/systemd/system/
# 安装栏目 timer:优先用备份里用户改过的版本,否则用项目自带示例
# 收集所有旧 timer 的栏目名
INSTALLED_TIMERS=()
for backup_timer in "$BACKUP_DIR/systemd"/news-harvester@*.timer; do
[ -f "$backup_timer" ] || continue
timer_name="$(basename "$backup_timer")"
# 用用户改过的版本(保留其 OnCalendar)
install -m644 "$backup_timer" "/etc/systemd/system/$timer_name"
info "恢复栏目 timer(保留用户配置): $timer_name"
INSTALLED_TIMERS+=("$timer_name")
done
# 如果备份里没有(全新安装),装示例 timer
if [ "${#INSTALLED_TIMERS[@]}" -eq 0 ]; then
for example_timer in "$SCRIPT_DIR/systemd"/news-harvester@*.timer; do
[ -f "$example_timer" ] || continue
timer_name="$(basename "$example_timer")"
install -m644 "$example_timer" "/etc/systemd/system/$timer_name"
info "安装示例 timer: $timer_name"
INSTALLED_TIMERS+=("$timer_name")
done
fi
if [ "$INSTALL_DIGEST" = "1" ]; then
install -m644 "$SCRIPT_DIR/systemd/news-harvester-digest.service" /etc/systemd/system/
install -m644 "$SCRIPT_DIR/systemd/news-harvester-digest.timer" /etc/systemd/system/
ok "digest service + timer 已安装"
fi
systemctl daemon-reload
ok "systemd 已重新加载"
# --- 5. 安装配置(不覆盖已有) ---
echo ""
echo "=== 步骤 5/5: 配置文件 ==="
install -d -m755 /etc/news-harvester
if [ ! -f /etc/news-harvester/default.conf ]; then
install -m644 "$SCRIPT_DIR/config/default.conf.example" /etc/news-harvester/default.conf
warn "已创建 /etc/news-harvester/default.conf(示例),请编辑填入真实 MAIL_TO"
else
ok "/etc/news-harvester/default.conf 已存在,保留不覆盖"
# 检查是否缺少新增的 digest 相关变量
if ! grep -q 'DIGEST_MAIL_ALWAYS' /etc/news-harvester/default.conf 2>/dev/null; then
warn "default.conf 缺少 digest 相关变量(DIGEST_MAIL_ALWAYS/DIGEST_RETAIN_DAYS/DIGEST_BUDGET_USD)"
info "可参考 config/default.conf.example 补充,否则用脚本默认值"
fi
fi
# --- 6. 启用 timer ---
echo ""
echo "=== 启用 timer ==="
for t in "${INSTALLED_TIMERS[@]}"; do
systemctl enable --now "$t" 2>/dev/null && ok "已启用 $t" || warn "启用 $t 失败"
done
if [ "$INSTALL_DIGEST" = "1" ]; then
systemctl enable --now news-harvester-digest.timer 2>/dev/null && ok "已启用 news-harvester-digest.timer" || warn "启用 digest timer 失败"
fi
# --- 完成 ---
echo ""
echo "========================================"
ok "迁移完成!"
echo "========================================"
echo ""
echo "后续操作:"
echo " 1. 编辑配置: sudo vim /etc/news-harvester/default.conf"
echo " 2. 配置 SMTP: install -m600 config/msmtprc.example ~${USER_NAME}/.msmtprc && vim ~${USER_NAME}/.msmtprc"
echo " 3. 查看定时器: systemctl list-timers 'news-harvester@*' news-harvester-digest.timer"
echo " 4. 手动测试: sudo systemctl start news-harvester@nikkei-world.service"
echo " 5. 查看日志: journalctl -u 'news-harvester@*' --since today -f"
echo " 6. 测试汇总: sudo systemctl start news-harvester-digest.service"
echo ""
echo "备份在: $BACKUP_DIR (如需回滚: sudo $0 --uninstall)"
echo ""
warn "重要:首次运行前请确认 default.conf 里的 MAIL_TO 已改为真实邮箱"
#!/bin/bash
# modify-source.sh — 修改已有栏目的定时运行时间(及可选配置)
#
# 用法:
# sudo ./modify-source.sh <source-name> [选项]
#
# 选项:
# --time HH:MM 新的运行时间,如 10:30(必填)
# --count N 更新抓取条数(写入/覆盖 <source>.conf)
# --mail-to ADDR 更新收件人
# --mail-on-success 0|1 更新成功是否发邮件
# --yes, -y 跳过确认
# --help, -h 显示帮助
#
# 示例:
# sudo ./modify-source.sh bloomberg --time 10:30
# sudo ./modify-source.sh nikkei-asia --time 14:00 --count 20 --mail-on-success 0
#
# 环境变量(可覆盖默认路径):
# SYSTEMD_DIR systemd unit 目录(默认 /etc/systemd/system)
# CONF_DIR 栏目配置目录(默认 /etc/news-harvester)
set -euo pipefail
SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
CONF_DIR="${CONF_DIR:-/etc/news-harvester}"
SOURCE=""
TIME=""
COUNT=""
MAIL_TO=""
MAIL_ON_SUCCESS=""
ASSUME_NO=0
usage() {
sed -n '3,26p' "$0"
}
# --- 参数解析 ---
while [ $# -gt 0 ]; do
case "$1" in
--time) TIME="${2:?--time 需要值}"; shift 2;;
--count) COUNT="${2:?--count 需要值}"; shift 2;;
--mail-to) MAIL_TO="${2:?--mail-to 需要值}"; shift 2;;
--mail-on-success) MAIL_ON_SUCCESS="${2:?--mail-on-success 需要值}"; shift 2;;
--yes|-y) ASSUME_NO=1; shift;;
--help|-h) usage; exit 0;;
--) shift; break;;
-*) echo "未知选项: $1" >&2; usage >&2; exit 1;;
*)
if [ -z "$SOURCE" ]; then SOURCE="$1"
else echo "多余的参数: $1" >&2; exit 1; fi
shift;;
esac
done
# --- 辅助函数 ---
err() { echo "❌ $*" >&2; exit 1; }
ok() { echo "✅ $*"; }
warn() { echo "⚠️ $*"; }
info() { echo "ℹ️ $*"; }
confirm() {
[ "$ASSUME_NO" = "1" ] && return 0
local reply
read -rp "$1 [y/N] " reply
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
}
# --- 校验 ---
[ -n "$SOURCE" ] || err "请指定栏目名:sudo $0 <source-name> --time HH:MM [选项]"
[[ "$SOURCE" =~ ^[a-zA-Z0-9_-]+$ ]] || err "栏目名只能含字母、数字、下划线、连字符: $SOURCE"
[ "$SOURCE" = "digest" ] && err "digest 是汇总服务,请用 sudo systemctl edit --full news-harvester-digest.timer 修改"
[ -n "$TIME" ] || err "必须指定 --time HH:MM"
[ "$(id -u)" -eq 0 ] || err "请用 sudo 运行"
validate_time() {
[[ "$1" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]] || err "时间格式应为 HH:MM(24小时制),如 10:30,得到: $1"
}
validate_count() {
[[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ] || err "count 应为正整数: $1"
}
validate_mos() {
case "$1" in 0|1) ;; *) err "--mail-on-success 只能是 0 或 1: $1";; esac
}
validate_time "$TIME"
[ -n "$COUNT" ] && validate_count "$COUNT"
[ -n "$MAIL_ON_SUCCESS" ] && validate_mos "$MAIL_ON_SUCCESS"
# --- 检查 timer 是否存在 ---
TIMER_PATH="$SYSTEMD_DIR/news-harvester@${SOURCE}.timer"
CONF_PATH="$CONF_DIR/${SOURCE}.conf"
TIMER_NAME="news-harvester@${SOURCE}.timer"
DROPIN_DIR="$SYSTEMD_DIR/${TIMER_NAME}.d"
[ -f "$TIMER_PATH" ] || err "timer 不存在: $TIMER_PATH\n 可用栏目:list-timers 'news-harvester@*.timer'"
# --- 读取当前生效时间(优先 systemctl show,回退读文件) ---
CURRENT_TIME=""
# 方法1:从 systemctl show 读实际生效值(能反映 drop-in 覆盖)
local_show=$(systemctl show "$TIMER_NAME" -p OnCalendar 2>/dev/null || true)
if [ -n "$local_show" ] && [ "$local_show" != "OnCalendar=" ]; then
CURRENT_TIME=$(echo "$local_show" | grep -oE '[0-9]{2}:[0-9]{2}:[0-9]{2}' | head -1 || true)
fi
# 方法2:回退到直接读 timer 文件
if [ -z "$CURRENT_TIME" ]; then
CURRENT_TIME=$(grep '^OnCalendar=' "$TIMER_PATH" 2>/dev/null | head -1 | grep -oE '[0-9]{2}:[0-9]{2}:[0-9]{2}' | head -1 || true)
fi
[ -n "$CURRENT_TIME" ] && CURRENT_TIME="${CURRENT_TIME%:*}" # 取 HH:MM
[ -z "$CURRENT_TIME" ] && CURRENT_TIME="(未知)"
# --- 检查是否有 drop-in 覆盖(若有,改主文件不生效,需要先清除) ---
HAS_DROPIN=0
if [ -d "$DROPIN_DIR" ] && [ -n "$(ls -A "$DROPIN_DIR" 2>/dev/null)" ]; then
HAS_DROPIN=1
fi
# --- 读取当前 conf(若有) ---
read_conf_val() {
local key="$1" file="$CONF_PATH"
[ -f "$file" ] || { echo ""; return 0; }
grep -E "^${key}=" "$file" 2>/dev/null | head -1 | cut -d= -f2- || true
}
CUR_COUNT=$(read_conf_val COUNT)
CUR_MAIL_TO=$(read_conf_val MAIL_TO)
CUR_MOS=$(read_conf_val MAIL_ON_SUCCESS)
# --- 展示变更计划,确认 ---
echo "=== 修改栏目: $SOURCE ==="
echo " 定时时间: $CURRENT_TIME → $TIME"
[ -n "$COUNT" ] && { echo " 抓取条数: ${CUR_COUNT:-(继承default)} → $COUNT"; }
[ -n "$MAIL_TO" ] && { echo " 收件人: ${CUR_MAIL_TO:-(继承default)} → $MAIL_TO"; }
[ -n "$MAIL_ON_SUCCESS" ] && { echo " 成功发信: ${CUR_MOS:-(继承default)} → $MAIL_ON_SUCCESS"; }
echo " timer 文件: $TIMER_PATH"
if [ "$HAS_DROPIN" = "1" ]; then
warn "检测到 drop-in 覆盖目录: $DROPIN_DIR"
echo " 这会覆盖主文件的 OnCalendar,将被清除以确保新时间生效。"
fi
echo ""
confirm "确认修改?" || { echo "已取消。"; exit 0; }
# --- 改 timer 文件的 OnCalendar + Description ---
# 先清除 drop-in 覆盖目录(若有),否则会覆盖主文件的新 OnCalendar
if [ "$HAS_DROPIN" = "1" ]; then
rm -rf "$DROPIN_DIR"
warn "已清除 drop-in 目录: $DROPIN_DIR"
fi
# 用 sed 原地替换 OnCalendar 行
sed -i -E "s|^OnCalendar=.*|OnCalendar=*-*-* ${TIME}:00|" "$TIMER_PATH"
# 同步更新 Description 里的时间(若有)
sed -i -E "s|^(Description=.* at ).*|\1${TIME}|" "$TIMER_PATH"
ok "已更新 $TIMER_PATH (OnCalendar → ${TIME}:00)"
# --- 更新 conf(若有 conf 选项) ---
update_conf() {
local key="$1" val="$2"
if grep -qE "^${key}=" "$CONF_PATH" 2>/dev/null; then
# 已存在,替换
sed -i -E "s|^${key}=.*|${key}=${val}|" "$CONF_PATH"
else
# 不存在,追加
echo "${key}=${val}" >> "$CONF_PATH"
fi
}
if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ]; then
mkdir -p "$CONF_DIR"
if [ ! -f "$CONF_PATH" ]; then
{
echo "# /etc/news-harvester/${SOURCE}.conf — 由 modify-source.sh 更新 $(date '+%Y-%m-%d %H:%M:%S')"
echo "# 仅写需要覆盖的字段,其余继承 default.conf"
echo ""
} > "$CONF_PATH"
fi
[ -n "$COUNT" ] && { update_conf COUNT "$COUNT"; }
[ -n "$MAIL_TO" ] && { update_conf MAIL_TO "$MAIL_TO"; }
[ -n "$MAIL_ON_SUCCESS" ] && { update_conf MAIL_ON_SUCCESS "$MAIL_ON_SUCCESS"; }
ok "已更新 $CONF_PATH"
fi
# --- daemon-reload + 重启 timer ---
# 先 stop,再 reload,再 start —— 某些 systemd 版本 restart 不会重新求值日历表达式
systemctl stop "$TIMER_NAME" 2>/dev/null || true
systemctl daemon-reload
ok "systemd 已重新加载"
systemctl start "$TIMER_NAME"
ok "已重启 $TIMER_NAME(新时间生效)"
echo ""
echo "========================================"
ok "栏目 $SOURCE 修改完成!"
echo "========================================"
echo ""
echo "下次触发时间:"
systemctl list-timers "news-harvester@${SOURCE}.timer" --no-pager 2>/dev/null || true
echo ""
echo "后续操作:"
echo " 查看定时器: systemctl list-timers news-harvester@${SOURCE}.timer"
echo " 手动测试: sudo systemctl start news-harvester@${SOURCE}.service"
echo " 查看运行结果: cat ~/Documents/News-Harvester/logs/${SOURCE}.last.log"
echo " 再次改时间: sudo $0 $SOURCE --time HH:MM"
# 每日汇总服务 —— 扫描当天所有抓取记录,用 claude 无头模式生成汇总并报警
# 由 news-harvester-digest.timer 每天 22:00 拉起(等所有栏目跑完再汇总)。
[Unit]
Description=News Harvester — daily digest & alert
Documentation=file:/home/yichu/Documents/News-Harvester/bin/daily-digest.sh
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=yichu
Group=yichu
WorkingDirectory=/home/yichu/Documents/News-Harvester
# 只读 default.conf;digest 不需要每栏目配置
EnvironmentFile=-/etc/news-harvester/default.conf
ExecStart=/home/yichu/Documents/News-Harvester/bin/daily-digest.sh
TimeoutStartSec=600
Nice=10
Environment=HOME=/home/yichu
StandardOutput=journal
StandardError=journal
[Unit]
Description=Run News Harvester daily digest at 22:00
[Timer]
# 晚上 22:00 运行,确保当天所有栏目(通常上午 9 点档)都已跑完
OnCalendar=*-*-* 22:00:00
Persistent=true
AccuracySec=1min
Unit=news-harvester-digest.service
[Install]
WantedBy=timers.target
[Unit]
Description=News Harvester — Web Panel
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/home/yichu/Documents/News-Harvester
EnvironmentFile=-/etc/news-harvester/web.conf
ExecStart=/usr/bin/python3 /home/yichu/Documents/News-Harvester/web/app.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
# 模板单元:实例化时 %i = 栏目名
# 例:news-harvester@nikkei-world.service → %i = nikkei-world
# 不需要 enable 此 service,由对应的 timer 拉起。
[Unit]
Description=News Harvester — fetch %i news into Obsidian
Documentation=file:/home/yichu/Documents/News-Harvester/bin/harvest.sh
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=yichu
Group=yichu
WorkingDirectory=/home/yichu/Documents/News-Harvester
# 配置加载顺序:default.conf 提供全局默认,%i.conf 覆盖/补充单栏目设置。
# 同名变量后者覆盖前者(systemd 规则)。文件不存在不报错(前导 -)。
# 可用变量:COUNT、MAIL_TO、MAIL_ON_SUCCESS、ALERT_COST_OVER、WORK_DIR、LOG_DIR
EnvironmentFile=-/etc/news-harvester/default.conf
EnvironmentFile=-/etc/news-harvester/%i.conf
# %i=栏目名, ${COUNT}=来自 conf 的条数(未设则为空,脚本兜底 20)
ExecStart=/home/yichu/Documents/News-Harvester/bin/harvest.sh %i ${COUNT}
TimeoutStartSec=1800
Nice=10
Environment=HOME=/home/yichu
StandardOutput=journal
StandardError=journal
[Unit]
Description=Run News Harvester (nikkei-world) daily at 09:00
[Timer]
OnCalendar=*-*-* 09:00:00
Persistent=true
AccuracySec=1min
Unit=news-harvester@nikkei-world.service
[Install]
WantedBy=timers.target
[Unit]
Description=Run News Harvester (reuters) daily at 09:15
[Timer]
# 错开 15 分钟,避免与 nikkei-world 同时启动多个 claude 实例
OnCalendar=*-*-* 09:15:00
Persistent=true
AccuracySec=1min
Unit=news-harvester@reuters.service
[Install]
WantedBy=timers.target
This diff is collapsed.
{% extends "base.html" %}
{% block title %}添加栏目 — News Harvester{% endblock %}
{% block content %}
<h1>添加栏目</h1>
<form method="POST" action="{{ url_for('add_source') }}">
<div class="form-group">
<label for="source">栏目名称 *</label>
<input type="text" id="source" name="source" required
pattern="[a-zA-Z0-9_-]+"
placeholder="如 bloomberg-technology"
value="{{ form.source if form else '' }}">
<div class="hint">只能含字母、数字、下划线、连字符。不能用 digest。</div>
</div>
<div class="form-group">
<label for="time">运行时间 (HH:MM)</label>
<input type="text" id="time" name="time"
pattern="([01]\d|2[0-3]):[0-5]\d"
placeholder="留空自动错开(现有最晚 +15 分钟)"
value="{{ form.time if form else '' }}">
<div class="hint">24 小时制,如 09:30。留空则自动推算。</div>
</div>
<div class="form-group">
<label for="count">抓取条数</label>
<input type="number" id="count" name="count" min="1"
placeholder="留空继承 default.conf"
value="{{ form.count if form else '' }}">
</div>
<div class="form-group">
<label for="mail_to">收件人</label>
<input type="email" id="mail_to" name="mail_to"
placeholder="留空继承 default.conf(支持多个,逗号分隔)"
value="{{ form.mail_to if form else '' }}">
</div>
<div class="form-group">
<label for="mail_on_success">成功是否发邮件</label>
<select id="mail_on_success" name="mail_on_success">
<option value="">留空继承 default.conf</option>
<option value="1">是 — 每次都发</option>
<option value="0">否 — 仅失败时发</option>
</select>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">创建栏目</button>
<a class="btn" href="{{ url_for('index') }}">取消</a>
</div>
</form>
{% endblock %}
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}News Harvester{% endblock %}</title>
{% block extra_head %}{% endblock %}
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, "Segoe UI", Roboto, "Noto Sans SC", sans-serif;
background: #0f1117;
color: #d4d4d4;
line-height: 1.6;
padding: 0;
}
nav {
background: #1a1d27;
border-bottom: 1px solid #2a2d37;
padding: 14px 24px;
display: flex;
align-items: center;
gap: 24px;
}
nav .logo {
font-size: 18px;
font-weight: 700;
color: #569cd6;
text-decoration: none;
}
nav a {
color: #888;
text-decoration: none;
font-size: 14px;
transition: color 0.2s;
}
nav a:hover { color: #d4d4d4; }
.container {
max-width: 1600px;
margin: 0 auto;
padding: 24px;
}
h1 { font-size: 22px; margin-bottom: 16px; color: #e0e0e0; }
h2 { font-size: 18px; margin: 20px 0 10px; color: #c0c0c0; }
/* Flash messages */
.flash {
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
white-space: pre-wrap;
word-break: break-all;
}
.flash.success { background: #1a3a1a; border: 1px solid #2a5a2a; color: #6bbb6b; }
.flash.error { background: #3a1a1a; border: 1px solid #5a2a2a; color: #bb6b6b; }
/* Running banner */
.running-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
background: #1a2333;
border: 1px solid #2a4a6a;
color: #569cd6;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #2a4a6a;
border-top-color: #569cd6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Table */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th {
text-align: left;
padding: 10px 12px;
background: #1a1d27;
font-size: 12px;
text-transform: uppercase;
color: #888;
border-bottom: 1px solid #2a2d37;
letter-spacing: 0.5px;
}
td {
padding: 10px 12px;
border-bottom: 1px solid #1e2128;
font-size: 14px;
white-space: nowrap;
}
tr:hover { background: #161922; }
tr.ok td:first-child { border-left: 3px solid #4ec9b0; }
tr.fail td:first-child { border-left: 3px solid #f44747; }
tr.unknown td:first-child { border-left: 3px solid #888; }
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.badge.active { background: #1a3a1a; color: #6bbb6b; }
.badge.inactive { background: #3a1a1a; color: #bb6b6b; }
.badge.unknown { background: #2a2a2a; color: #888; }
.badge.ok { background: #1a3a1a; color: #6bbb6b; }
.badge.fail { background: #3a1a1a; color: #bb6b6b; }
/* Buttons */
.btn {
display: inline-block;
padding: 6px 14px;
border-radius: 4px;
border: 1px solid #3a3d47;
background: #252830;
color: #d4d4d4;
font-size: 13px;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
}
.btn:hover { background: #2d303a; border-color: #4a4d57; }
.btn-primary { background: #264f78; border-color: #3a6fa0; color: #fff; }
.btn-primary:hover { background: #2d5a8a; }
.btn-danger { background: #5a2a2a; border-color: #7a3a3a; color: #fff; }
.btn-danger:hover { background: #6a3333; }
.btn-sm { padding: 3px 10px; font-size: 12px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Forms */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
color: #aaa;
margin-bottom: 4px;
}
.form-group input, .form-group select {
width: 100%;
max-width: 400px;
padding: 8px 12px;
background: #1a1d27;
border: 1px solid #2a2d37;
border-radius: 4px;
color: #d4d4d4;
font-size: 14px;
}
.form-group input:focus { border-color: #569cd6; outline: none; }
.form-group .hint { font-size: 12px; color: #666; margin-top: 3px; }
/* Pre / code */
pre {
background: #1a1d27;
border: 1px solid #2a2d37;
border-radius: 6px;
padding: 14px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
font-family: "SF Mono", "Fira Code", "Noto Sans Mono", monospace;
color: #c8c8c8;
}
code { font-family: "SF Mono", "Fira Code", monospace; color: #ce9178; }
/* Detail page cards */
.cards { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
.card {
background: #1a1d27;
border: 1px solid #2a2d37;
border-radius: 6px;
padding: 14px 18px;
min-width: 140px;
}
.card .label { font-size: 12px; color: #888; text-transform: uppercase; margin-bottom: 4px; }
.card .value { font-size: 18px; font-weight: 600; color: #e0e0e0; }
.card .value.green { color: #4ec9b0; }
.card .value.red { color: #f44747; }
.actions { display: flex; gap: 8px; margin: 16px 0; flex-wrap: wrap; }
.session-id { font-family: monospace; font-size: 12px; color: #569cd6; }
a.source-link { color: #569cd6; text-decoration: none; }
a.source-link:hover { text-decoration: underline; }
.footer { text-align: center; color: #555; font-size: 12px; margin-top: 30px; padding: 16px; }
</style>
</head>
<body>
<nav>
<a class="logo" href="{{ url_for('index') }}">📰 News Harvester</a>
<a href="{{ url_for('index') }}">栏目列表</a>
<a href="{{ url_for('add_source') }}">添加栏目</a>
</nav>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<div class="footer">News Harvester Web Panel · {{ now }}</div>
</body>
</html>
{% extends "base.html" %}
{% block title %}{{ name }} — News Harvester{% endblock %}
{% block extra_head %}
{% if auto_refresh %}
<meta http-equiv="refresh" content="10">
{% endif %}
{% endblock %}
{% block content %}
<h1>{{ name }}</h1>
{% if continue_status and continue_status.running %}
<div class="running-banner">
<div class="spinner"></div>
<span>{{ continue_status.mode | replace('resume', '续采') | replace('repair', '修复') }}任务运行中… (启动于 {{ continue_status.start }})</span>
<span style="margin-left:auto;color:#666;font-size:12px">页面每 10 秒自动刷新</span>
</div>
{% endif %}
<div class="actions">
<a class="btn" href="{{ url_for('edit_source', name=name) }}">编辑配置</a>
<form method="POST" action="{{ url_for('run_source', name=name) }}" style="display:inline"
onsubmit="return confirm('确定手动触发 {{ name }} 抓取?')">
<button type="submit" class="btn btn-primary" {% if continue_status and continue_status.running %}disabled{% endif %}>手动触发</button>
</form>
<form method="POST" action="{{ url_for('continue_source', name=name) }}" style="display:inline"
onsubmit="return confirm('确定对 {{ name }} 执行继续采集?\n将恢复上次 Claude 对话,在已有基础上继续完成中断的采集。')">
<input type="hidden" name="mode" value="resume">
<button type="submit" class="btn" {% if continue_status and continue_status.running %}disabled{% endif %}>
{% if continue_status and continue_status.running and continue_status.mode == 'resume' %}续采中…{% else %}继续采集{% endif %}
</button>
</form>
<form method="POST" action="{{ url_for('continue_source', name=name) }}" style="display:inline"
onsubmit="return confirm('⚠️ 确定对 {{ name }} 执行采集修复?\n\n这将:\n1. 恢复上次 Claude 对话\n2. 清理之前归档的错误数据\n3. 重新完整采集并 force 推送\n\n此操作会删除之前本次采集的文章,无法撤销!')">
<input type="hidden" name="mode" value="repair">
<button type="submit" class="btn btn-danger" {% if continue_status and continue_status.running %}disabled{% endif %}>
{% if continue_status and continue_status.running and continue_status.mode == 'repair' %}修复中…{% else %}采集修复{% endif %}
</button>
</form>
<a class="btn" href="{{ url_for('index') }}">返回列表</a>
<form method="POST" action="{{ url_for('disable_source', name=name) }}" style="display:inline"
onsubmit="return confirm('确定停用栏目 {{ name }}?\n\n这将:\n1. 停止并 disable 定时器\n2. 清空运行时间(不再触发)\n\n文件保留,可随时通过编辑重新设置时间来启用。')">
<button type="submit" class="btn btn-danger" {% if continue_status and continue_status.running %}disabled{% endif %}>停用栏目</button>
</form>
</div>
<h2>Timer 信息</h2>
<div class="cards">
<div class="card">
<div class="label">运行时间</div>
<div class="value"><code>{{ run_time }}</code></div>
</div>
<div class="card">
<div class="label">状态</div>
<div class="value">
{% if props.active == 'active' %}
<span class="green">active</span>
{% else %}
<span class="red">{{ props.active }}</span>
{% endif %}
</div>
</div>
<div class="card">
<div class="label">上次触发</div>
<div class="value" style="font-size:14px">{{ props.last }}</div>
</div>
<div class="card">
<div class="label">下次触发</div>
<div class="value" style="font-size:14px">{{ props.next }}</div>
</div>
</div>
{% if conf %}
<h2>栏目配置 ({{ name }}.conf)</h2>
<table>
<tbody>
{% for k, v in conf.items() %}
<tr><td><code>{{ k }}</code></td><td>{{ v }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<h2>栏目配置</h2>
<p style="color:#666">无独立配置,继承 <code>default.conf</code></p>
{% endif %}
{% if log_info %}
<h2>最近运行</h2>
<div class="cards">
<div class="card">
<div class="label">状态</div>
<div class="value
{% if '成功' in log_info.get('status', '') %}green
{% elif '失败' in log_info.get('status', '') %}red{% endif %}">
{{ log_info.get('status', '?') }}
</div>
</div>
<div class="card">
<div class="label">成本</div>
<div class="value">{{ log_info.get('cost', '-') }}</div>
</div>
<div class="card">
<div class="label">耗时</div>
<div class="value" style="font-size:14px">{{ log_info.get('duration', '-') }}</div>
</div>
<div class="card">
<div class="label">Turns</div>
<div class="value">{{ log_info.get('turns', '-') }}</div>
</div>
<div class="card">
<div class="label">修改时间</div>
<div class="value" style="font-size:14px">{{ log_info.get('mtime', '?') }}</div>
</div>
</div>
{% if log_info.get('session') %}
<div class="card" style="margin-bottom:16px">
<div class="label">Claude Session ID</div>
<div class="value" style="font-size:13px">
<span class="session-id">{{ log_info.session }}</span>
</div>
</div>
{% endif %}
<h2>运行日志 (.last.log)</h2>
<pre>{{ log_info.raw }}</pre>
{% else %}
<h2>最近运行</h2>
<p style="color:#666">暂无运行记录</p>
{% endif %}
{% if json_meta %}
<h2>Claude JSON 元数据</h2>
<div class="cards">
<div class="card">
<div class="label">总成本</div>
<div class="value">${{ json_meta.cost }}</div>
</div>
<div class="card">
<div class="label">Turns</div>
<div class="value">{{ json_meta.turns }}</div>
</div>
<div class="card">
<div class="label">API 耗时</div>
<div class="value" style="font-size:14px">{{ json_meta.duration_api }} ms</div>
</div>
<div class="card">
<div class="label">is_error</div>
<div class="value {% if not json_meta.is_error %}green{% else %}red{% endif %}">
{{ json_meta.is_error }}
</div>
</div>
<div class="card">
<div class="label">Terminal</div>
<div class="value" style="font-size:14px">{{ json_meta.terminal }}</div>
</div>
</div>
{% if json_meta.session_id and json_meta.session_id != '-' %}
<div class="card" style="margin-bottom:16px">
<div class="label">Session ID</div>
<div class="value" style="font-size:13px">
<span class="session-id">{{ json_meta.session_id }}</span>
</div>
</div>
{% endif %}
{% if json_meta.usage %}
<h3>Token 用量</h3>
<table>
<thead>
<tr><th>类型</th><th>Token 数</th></tr>
</thead>
<tbody>
{% for k, v in json_meta.usage.items() %}
<tr><td><code>{{ k }}</code></td><td>{{ v }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if json_meta.permission_denials %}
<h3 style="color:#f44747">权限拒绝 ({{ json_meta.permission_denials|length }} 次)</h3>
<pre>{{ json_meta.permission_denials | tojson(indent=2) }}</pre>
{% endif %}
<p style="color:#666;font-size:12px;margin-top:8px">来源: {{ json_info.path }}</p>
{% endif %}
{% endblock %}
{% extends "base.html" %}
{% block title %}编辑 {{ name }} — News Harvester{% endblock %}
{% block content %}
<h1>编辑栏目: {{ name }}</h1>
<form method="POST" action="{{ url_for('edit_source', name=name) }}">
<div class="form-group">
<label for="time">运行时间 (HH:MM) *</label>
<input type="text" id="time" name="time" required
pattern="([01]\d|2[0-3]):[0-5]\d"
value="{{ current_time }}">
<div class="hint">24 小时制,如 10:30。当前: {{ current_time }}</div>
</div>
<div class="form-group">
<label for="count">抓取条数</label>
<input type="number" id="count" name="count" min="1"
placeholder="留空不变"
value="{{ conf.get('COUNT', '') }}">
<div class="hint">当前: {{ conf.get('COUNT', '(继承 default.conf)') }}</div>
</div>
<div class="form-group">
<label for="mail_to">收件人</label>
<input type="text" id="mail_to" name="mail_to"
placeholder="留空不变"
value="{{ conf.get('MAIL_TO', '') }}">
<div class="hint">当前: {{ conf.get('MAIL_TO', '(继承 default.conf)') }}</div>
</div>
<div class="form-group">
<label for="mail_on_success">成功是否发邮件</label>
<select id="mail_on_success" name="mail_on_success">
<option value="">留空不变</option>
<option value="1" {% if conf.get('MAIL_ON_SUCCESS') == '1' %}selected{% endif %}>是 — 每次都发</option>
<option value="0" {% if conf.get('MAIL_ON_SUCCESS') == '0' %}selected{% endif %}>否 — 仅失败时发</option>
</select>
<div class="hint">当前: {{ conf.get('MAIL_ON_SUCCESS', '(继承 default.conf)') }}</div>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">保存修改</button>
<a class="btn" href="{{ url_for('source_detail', name=name) }}">取消</a>
</div>
</form>
{% endblock %}
{% extends "base.html" %}
{% block title %}News Harvester — 栏目列表{% endblock %}
{% block extra_head %}
{% if auto_refresh %}
<meta http-equiv="refresh" content="10">
{% endif %}
{% endblock %}
{% block content %}
{% if digest_status and digest_status.running %}
<div class="running-banner">
<div class="spinner"></div>
<span>每日汇总运行中… (启动于 {{ digest_status.start }})</span>
<span style="margin-left:auto;color:#666;font-size:12px">页面每 10 秒自动刷新</span>
</div>
{% endif %}
<div class="actions">
<a class="btn btn-primary" href="{{ url_for('add_source') }}">+ 添加栏目</a>
<form method="POST" action="{{ url_for('run_digest') }}" style="display:inline"
onsubmit="return confirm('确定手动触发每日汇总?')">
<button type="submit" class="btn" {% if digest_status and digest_status.running %}disabled{% endif %}>
{% if digest_status and digest_status.running %}汇总中…{% else %}手动触发汇总{% endif %}
</button>
</form>
</div>
<h1>栏目列表 <span style="font-size:14px;color:#666">({{ sources|length }} 个)</span></h1>
<table>
<thead>
<tr>
<th>栏目</th>
<th>状态</th>
<th>运行时间</th>
<th>上次触发</th>
<th>下次触发</th>
<th>最近运行</th>
<th>成本</th>
<th>Session</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for s in sources %}
<tr class="{{ s.status_class }}">
<td>
<a class="source-link" href="{{ url_for('source_detail', name=s.source) }}">{{ s.source }}</a>
</td>
<td>
{% if s.active == 'active' %}
<span class="badge active">active</span>
{% elif s.active == 'inactive' %}
<span class="badge inactive">inactive</span>
{% else %}
<span class="badge unknown">{{ s.active }}</span>
{% endif %}
</td>
<td><code>{{ s.run_time }}</code></td>
<td>{{ s.last }}</td>
<td>{{ s.next }}</td>
<td>
{% if s.log %}
{{ s.log.mtime }} ·
{% if '成功' in s.log.get('status', '') %}
<span class="badge ok">成功</span>
{% elif '失败' in s.log.get('status', '') %}
<span class="badge fail">失败</span>
{% else %}
{{ s.log.get('status', '?') }}
{% endif %}
{% else %}
<span style="color:#666">—</span>
{% endif %}
</td>
<td>
{% if s.log and s.log.get('cost') %}
{% set cost_num = s.log.cost | replace('$', '') | replace('成本:', '') | trim %}
{% set cost_str = cost_num | float %}
${{ "%.2f"|format(cost_str) }}
{% else %}
<span style="color:#666">—</span>
{% endif %}
</td>
<td>
{% if s.log and s.log.get('session') %}
<span class="session-id" title="{{ s.log.session }}">{{ s.log.session }}</span>
{% else %}
<span style="color:#666">—</span>
{% endif %}
</td>
<td>
<a class="btn btn-sm" href="{{ url_for('source_detail', name=s.source) }}">详情</a>
<a class="btn btn-sm" href="{{ url_for('edit_source', name=s.source) }}">编辑</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h2>每日汇总</h2>
<table>
<thead>
<tr>
<th>Timer</th>
<th>状态</th>
<th>运行时间</th>
<th>上次触发</th>
<th>下次触发</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>news-harvester-digest.timer</code></td>
<td>
{% if digest_props.active == 'active' %}
<span class="badge active">active</span>
{% else %}
<span class="badge {{ digest_props.active }}">{{ digest_props.active }}</span>
{% endif %}
</td>
<td><code>{{ digest_run_time }}</code></td>
<td>{{ digest_props.last }}</td>
<td>{{ digest_props.next }}</td>
<td>
<form method="POST" action="{{ url_for('run_digest') }}" style="display:inline"
onsubmit="return confirm('确定手动触发每日汇总?')">
<button type="submit" class="btn btn-sm" {% if digest_status and digest_status.running %}disabled{% endif %}>
{% if digest_status and digest_status.running %}汇总中…{% else %}触发{% endif %}
</button>
</form>
</td>
</tr>
</tbody>
</table>
{% endblock %}
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