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

feat: add provider support

parent c2d5873b
...@@ -34,3 +34,6 @@ __pycache__/ ...@@ -34,3 +34,6 @@ __pycache__/
# macOS dir files # macOS dir files
.DS_Store .DS_Store
# Claude Provider 配置(含敏感 token,不提交;只保留 .example.json)
config/providers/*.json
!config/providers/*.example.json
...@@ -19,10 +19,11 @@ ...@@ -19,10 +19,11 @@
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ harvest.sh <source> <count> │ │ harvest.sh <source> <count> │
│ ├─ 加载 PROVIDER → --settings '{"env":{...}}' │
│ ├─ claude -p --output-format json │ │ ├─ claude -p --output-format json │
│ ├─ 解析元数据(成本/turns/token/session_id) │ │ ├─ 解析元数据(成本/turns/token/session_id) │
│ ├─ 落盘: logs/<source>.<date>.<time>.json │ │ ├─ 落盘: logs/<source>.<date>.<time>.json │
│ │ logs/<source>.last.log │ │ │ logs/<source>.last.log (含 Provider) │
│ └─ 按状态发邮件(✅/❌/⚠️,含成本告警) │ │ └─ 按状态发邮件(✅/❌/⚠️,含成本告警) │
│ │ │ │
├─────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────┤
...@@ -36,6 +37,7 @@ ...@@ -36,6 +37,7 @@
│ daily-digest.sh │ │ daily-digest.sh │
│ ├─ 扫描 logs/ 下今天的 *.json │ │ ├─ 扫描 logs/ 下今天的 *.json │
│ ├─ jq 提取每源元数据 → 紧凑 JSON 数组 │ │ ├─ jq 提取每源元数据 → 紧凑 JSON 数组 │
│ ├─ 加载 PROVIDER → --settings (可选) │
│ ├─ claude -p --json-schema(结构化分析) │ │ ├─ claude -p --json-schema(结构化分析) │
│ │ → {alert, alert_reasons, digest, total_cost} │ │ │ → {alert, alert_reasons, digest, total_cost} │
│ ├─ 写 logs/digest.<date>.md │ │ ├─ 写 logs/digest.<date>.md │
...@@ -52,7 +54,8 @@ ...@@ -52,7 +54,8 @@
news-harvester-pro/ news-harvester-pro/
├── bin/ ├── bin/
│ ├── harvest.sh # 单源抓取(被 @.service 调用) │ ├── harvest.sh # 单源抓取(被 @.service 调用)
│ ├── continue.sh # 续采/修复(--resume 恢复上次对话) │ ├── continue.sh # 续采/修复(--resume 恢复上次对话,默认用 FALLBACK_PROVIDER)
│ ├── provider.sh # Claude Provider 配置管理(共享函数库)
│ ├── notify-mail.sh # 通用邮件发送(msmtp) │ ├── notify-mail.sh # 通用邮件发送(msmtp)
│ ├── daily-digest.sh # 每日汇总 + 告警(被 digest.service 调用) │ ├── daily-digest.sh # 每日汇总 + 告警(被 digest.service 调用)
│ └── status.sh # 友好查看 timer 状态 / 栏目详情 / 汇总 │ └── status.sh # 友好查看 timer 状态 / 栏目详情 / 汇总
...@@ -63,16 +66,18 @@ news-harvester-pro/ ...@@ -63,16 +66,18 @@ news-harvester-pro/
│ ├── news-harvester-digest.service # 每日汇总服务 │ ├── news-harvester-digest.service # 每日汇总服务
│ └── news-harvester-digest.timer # 每天 22:00 │ └── news-harvester-digest.timer # 每天 22:00
├── config/ ├── config/
│ ├── default.conf.example # 全局默认配置(COUNT/MAIL_*/DIGEST_*) │ ├── default.conf.example # 全局默认配置(COUNT/MAIL_*/PROVIDER/DIGEST_*)
│ ├── msmtprc.example # msmtp SMTP 配置示例 │ ├── msmtprc.example # msmtp SMTP 配置示例
│ └── nikkei-world.conf.example # 单栏目覆盖示例 │ ├── nikkei-world.conf.example # 单栏目覆盖示例(含 PROVIDER/FALLBACK_PROVIDER)
│ └── providers/ # Claude Provider 配置目录
│ └── easyrouter.json.example # Provider JSON 示例(脱敏)
├── migrate.sh # 智能迁移脚本(备份+安装+保留配置) ├── migrate.sh # 智能迁移脚本(备份+安装+保留配置)
├── add-source.sh # 新增栏目脚本(自动错开时间+生成timer/conf) ├── add-source.sh # 新增栏目脚本(自动错开时间+生成timer/conf)
├── modify-source.sh # 修改栏目定时时间及配置(改OnCalendar+conf+重启timer) ├── modify-source.sh # 修改栏目定时时间及配置(改OnCalendar+conf+重启timer)
├── deploy.sh # 一键部署到服务器(rsync + 同步 bin + systemd) ├── deploy.sh # 一键部署到服务器(rsync + 同步 bin + systemd + providers)
├── web/ # Flask Web 管理面板 ├── web/ # Flask Web 管理面板
│ ├── app.py # 单文件 Flask 应用(list/add/edit/detail/run) │ ├── app.py # 单文件 Flask 应用(list/add/edit/detail/run/provider CRUD)
│ └── templates/ # Jinja2 模板(base/index/add/edit/detail) │ └── templates/ # Jinja2 模板(base/index/add/edit/detail/providers)
└── README.md └── README.md
``` ```
...@@ -88,6 +93,7 @@ news-harvester-pro/ ...@@ -88,6 +93,7 @@ news-harvester-pro/
| `systemd/news-harvester-web.service` | `/etc/systemd/system/` | | `systemd/news-harvester-web.service` | `/etc/systemd/system/` |
| `config/default.conf` | `/etc/news-harvester/default.conf` | | `config/default.conf` | `/etc/news-harvester/default.conf` |
| `config/<source>.conf` | `/etc/news-harvester/<source>.conf` | | `config/<source>.conf` | `/etc/news-harvester/<source>.conf` |
| `config/providers/*.json` | `/etc/news-harvester/providers/*.json`(Claude Provider 配置) |
| `config/web.conf` | `/etc/news-harvester/web.conf`(Web 面板认证) | | `config/web.conf` | `/etc/news-harvester/web.conf`(Web 面板认证) |
| `.msmtprc` | `~/.msmtprc`(用户家目录) | | `.msmtprc` | `~/.msmtprc`(用户家目录) |
...@@ -149,6 +155,12 @@ sudo ./migrate.sh ...@@ -149,6 +155,12 @@ sudo ./migrate.sh
# 只更新 bin 脚本,不动 systemd(改脚本不需要 sudo 时用) # 只更新 bin 脚本,不动 systemd(改脚本不需要 sudo 时用)
./deploy.sh 192.168.1.100 --no-systemd ./deploy.sh 192.168.1.100 --no-systemd
# 不同步 provider 配置(只改了脚本,不动 provider)
./deploy.sh 192.168.1.100 --no-providers
# 不重启 web 面板
./deploy.sh 192.168.1.100 --no-restart-web
# 部署后自动跑一次 nikkei-world 测试并实时看日志 # 部署后自动跑一次 nikkei-world 测试并实时看日志
./deploy.sh 192.168.1.100 --test ./deploy.sh 192.168.1.100 --test
``` ```
...@@ -157,8 +169,10 @@ deploy.sh 做的事: ...@@ -157,8 +169,10 @@ deploy.sh 做的事:
1. `rsync` 整个项目到 `~/news-harvester-pro/`(`--delete` 保持镜像,排除 logs/.git) 1. `rsync` 整个项目到 `~/news-harvester-pro/`(`--delete` 保持镜像,排除 logs/.git)
2. 复制 `bin/*.sh` → `~/Documents/News-Harvester/bin/` 并赋可执行权限 2. 复制 `bin/*.sh` → `~/Documents/News-Harvester/bin/` 并赋可执行权限
3. 同步 systemd 模板 service + digest unit → `/etc/systemd/system/` 并 `daemon-reload` 3. 同步 systemd 模板 service + digest unit → `/etc/systemd/system/` 并 `daemon-reload`
4. 同步本地 `config/providers/*.json` → `/etc/systemd/system/providers/`(需要 sudo,不含 `.example.json`,不用 `--delete` 保留服务器端新增的 provider)
5. 检查 web.conf + 重启 web 面板(可选)
**不动的**:栏目 timer(服务器端可能改过时间)、`/etc/news-harvester/*.conf`、`~/.msmtprc`、logs 目录。 **不动的**:栏目 timer(服务器端可能改过时间)、`/etc/news-harvester/*.conf`(栏目配置)、`~/.msmtprc`、logs 目录。
--- ---
...@@ -179,6 +193,8 @@ service 模板按顺序加载两个 `EnvironmentFile`,后者覆盖前者(systemd ...@@ -179,6 +193,8 @@ service 模板按顺序加载两个 `EnvironmentFile`,后者覆盖前者(systemd
| `MAIL_TO` | 收件人;**留空不发邮件**;支持多个(空格或逗号分隔) | 空 | harvest + digest | | `MAIL_TO` | 收件人;**留空不发邮件**;支持多个(空格或逗号分隔) | 空 | harvest + digest |
| `MAIL_ON_SUCCESS` | 成功是否发邮件:1=发,0=仅失败发 | 1 | harvest | | `MAIL_ON_SUCCESS` | 成功是否发邮件:1=发,0=仅失败发 | 1 | harvest |
| `ALERT_COST_OVER` | 单次成本超此值(美元)则标题标 `[ALERT]` | 空(不判断) | harvest | | `ALERT_COST_OVER` | 单次成本超此值(美元)则标题标 `[ALERT]` | 空(不判断) | harvest |
| `PROVIDER` | Claude provider 名称;空或 `default` = 系统全局配置 | 空 | harvest + digest |
| `FALLBACK_PROVIDER` | 备用 provider;continue.sh 续采/修复时默认使用 | 空 | continue |
| `DIGEST_MAIL_ALWAYS` | 每日汇总:1=每次发,0=仅告警时发 | 1 | digest | | `DIGEST_MAIL_ALWAYS` | 每日汇总:1=每次发,0=仅告警时发 | 1 | digest |
| `DIGEST_RETAIN_DAYS` | 旧日志保留天数 | 30 | digest | | `DIGEST_RETAIN_DAYS` | 旧日志保留天数 | 30 | digest |
| `DIGEST_BUDGET_USD` | 汇总分析的 claude 预算上限 | 2 | digest | | `DIGEST_BUDGET_USD` | 汇总分析的 claude 预算上限 | 2 | digest |
...@@ -193,6 +209,8 @@ COUNT=20 ...@@ -193,6 +209,8 @@ COUNT=20
MAIL_TO=you@example.com MAIL_TO=you@example.com
MAIL_ON_SUCCESS=1 MAIL_ON_SUCCESS=1
ALERT_COST_OVER=1 ALERT_COST_OVER=1
# PROVIDER= # 留空=系统全局配置;设为 easyrouter 等则用对应 provider
# FALLBACK_PROVIDER= # 全局备用 provider(可选)
DIGEST_MAIL_ALWAYS=1 DIGEST_MAIL_ALWAYS=1
DIGEST_RETAIN_DAYS=30 DIGEST_RETAIN_DAYS=30
DIGEST_BUDGET_USD=2 DIGEST_BUDGET_USD=2
...@@ -203,10 +221,109 @@ DIGEST_BUDGET_USD=2 ...@@ -203,10 +221,109 @@ DIGEST_BUDGET_USD=2
COUNT=30 COUNT=30
MAIL_ON_SUCCESS=0 # 成功不单独发邮件,只在 digest 里看 MAIL_ON_SUCCESS=0 # 成功不单独发邮件,只在 digest 里看
ALERT_COST_OVER=2 ALERT_COST_OVER=2
PROVIDER=easyrouter # 此栏目用 easyrouter provider(覆盖全局默认)
FALLBACK_PROVIDER=default # 续采时退回系统默认配置
``` ```
--- ---
## Claude Provider 配置
Provider 机制允许不同数据源使用不同的 Claude API 端点和认证(第三方中转、多 key 轮换等),而无需修改脚本代码。
### 工作原理
1. Provider 配置是 JSON 文件,存放在 `/etc/news-harvester/providers/<name>.json`
2. 每个 JSON 文件是一个 env 对象,包含 Claude 的环境变量:
```json
{
"ANTHROPIC_AUTH_TOKEN": "sk-xxx",
"ANTHROPIC_BASE_URL": "https://easyrouter.io",
"ANTHROPIC_MODEL": "claude-sonnet-5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-5",
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-5"
}
```
3. 数据源通过 conf 中的 `PROVIDER=<name>` 引用;脚本启动 Claude 时自动加上 `--settings '{"env": {...}}'` 注入这些环境变量
4. `PROVIDER` 留空或设为 `default` → 不加 `--settings`,使用系统全局配置(Claude CLI 自带的认证和模型)
5. `FALLBACK_PROVIDER` → continue.sh 续采/修复时**默认使用**此 provider(因为 continue 本身就是失败重试)
6. continue.sh 也支持 `--provider <name>` 参数手动指定,优先级最高
### Provider 优先级(continue.sh)
```
--provider 参数 > FALLBACK_PROVIDER(conf) > PROVIDER(conf/default.conf) > default
```
### 创建 Provider
#### 方式一:Web 面板(推荐)
1. 浏览器打开 Web 面板 → 导航栏 "Provider"
2. 点击 "添加 Provider"
3. 填入名称 + JSON 配置 → 创建
4. 在栏目编辑页面选择该栏目使用的 Provider 和 Fallback Provider
#### 方式二:手动创建
```bash
# 1. 创建目录
sudo mkdir -p /etc/news-harvester/providers
# 2. 创建 provider JSON
sudo nano /etc/news-harvester/providers/easyrouter.json
# 粘入 JSON 内容(参考 config/providers/easyrouter.json.example)
# 3. 设置权限
sudo chmod 644 /etc/news-harvester/providers/easyrouter.json
# 4. 在栏目 conf 中引用
echo "PROVIDER=easyrouter" | sudo tee -a /etc/news-harvester/nikkei-world.conf
echo "FALLBACK_PROVIDER=default" | sudo tee -a /etc/news-harvester/nikkei-world.conf
```
### 从本地部署 Provider 到服务器
将本地 `config/providers/*.json`(不含 `.example.json`)同步到服务器:
```bash
# deploy.sh 默认会同步 providers(需要远程 sudo)
./deploy.sh 192.168.1.100
# 只同步 providers 不动其他(配合 --no-systemd --no-restart-web)
./deploy.sh 192.168.1.100 --no-systemd --no-restart-web
# 跳过 provider 同步
./deploy.sh 192.168.1.100 --no-providers
```
deploy.sh 同步 provider 时**不用 `--delete`**,保留服务器端通过 Web 面板创建的 provider,只覆盖/新增同名文件。
### Provider 在日志中的记录
每次运行的 `.last.log` 会记录实际使用的 provider:
```
===== 2026-09-02 10:00:00 抓取 [nikkei-world] =====
状态: ✅ 成功
Provider: easyrouter
退出码: 0
...
```
status.sh 概览和详情模式都会显示 Provider 列/行。Web 面板的栏目列表和详情页也会显示。
### 注意事项
- Provider JSON 中的 `ANTHROPIC_AUTH_TOKEN` 等敏感值在 Web 面板详情页会**脱敏显示**(只显示前 8 位)
- `default` 是保留名称,不能用作 provider 文件名
- Provider 文件不存在或 JSON 无效时,脚本会 warn 并退回默认配置(不加 `--settings`),不会中断运行
- digest(每日汇总)使用 `default.conf` 中的 `PROVIDER`(通常不设或 default)
---
## 日志文件 ## 日志文件
每次抓取运行会落盘: 每次抓取运行会落盘:
...@@ -284,7 +401,7 @@ echo -e 'To: you@example.com\nSubject: test\n\nhello' | msmtp you@example.com ...@@ -284,7 +401,7 @@ echo -e 'To: you@example.com\nSubject: test\n\nhello' | msmtp you@example.com
## Web 管理面板 ## Web 管理面板
除了命令行操作,项目还提供一个 Flask Web 面板,可以在浏览器中查看所有栏目状态、添加/编辑/删除栏目、手动触发抓取和汇总。 除了命令行操作,项目还提供一个 Flask Web 面板,可以在浏览器中查看所有栏目状态、添加/编辑/停用栏目、管理 Claude Provider、手动触发抓取和汇总、续采/修复。
### 首次安装 ### 首次安装
...@@ -311,13 +428,18 @@ ssh -L 8080:127.0.0.1:8080 yichu@<server-ip> ...@@ -311,13 +428,18 @@ ssh -L 8080:127.0.0.1:8080 yichu@<server-ip>
| 页面 | 路径 | 功能 | | 页面 | 路径 | 功能 |
|------|------|------| |------|------|------|
| 栏目列表 | `/` | 所有 source 的 timer 状态、运行时间、上次/下次触发、最近运行摘要(状态/成本/session) | | 栏目列表 | `/` | 所有 source 的 timer 状态、运行时间、Provider、上次/下次触发、最近运行摘要(状态/成本/session/provider) |
| 栏目详情 | `/source/<name>` | timer 信息、配置、最近运行日志(.last.log 全文)、Claude JSON 元数据(成本/turns/tokens/session_id/权限拒绝) | | 栏目详情 | `/source/<name>` | timer 信息、配置、最近运行日志(.last.log 全文)、Claude JSON 元数据、续采/修复按钮(带 provider 选择) |
| 添加栏目 | `/add` | 表单创建新栏目,调用 add-source.sh(自动错开时间) | | 添加栏目 | `/add` | 表单创建新栏目,调用 add-source.sh(自动错开时间,可选 provider) |
| 编辑栏目 | `/edit/<name>` | 表单修改运行时间/抓取条数/收件人,调用 modify-source.sh | | 编辑栏目 | `/edit/<name>` | 表单修改运行时间/抓取条数/收件人/provider/fallback,调用 modify-source.sh |
| 手动触发 | `/run/<name>` | 一键触发指定栏目的抓取 | | 手动触发 | `/run/<name>` | 一键触发指定栏目的抓取 |
| 手动汇总 | `/digest` | 一键触发每日汇总 | | 手动汇总 | `/digest` | 一键触发每日汇总(后台异步,页面轮询进度) |
| 删除栏目 | `/delete/<name>` | 停用并删除 timer/conf(需二次确认) | | 续采/修复 | `/continue/<name>` | 后台异步执行 continue.sh,支持选择 provider,页面轮询进度 |
| 停用栏目 | `/disable/<name>` | 停用 timer + 清空 OnCalendar(不删文件,可重新编辑启用) |
| Provider 列表 | `/providers` | 所有 provider 配置(名称、env key 数量) |
| Provider 详情 | `/provider/<name>` | 查看 provider 配置(敏感值脱敏) |
| 添加 Provider | `/provider/add` | 创建新 provider(JSON 输入) |
| 删除 Provider | `/provider/delete/<name>` | 删除 provider(使用中的栏目退回默认) |
### 安全说明 ### 安全说明
...@@ -412,11 +534,15 @@ cat ~/Documents/News-Harvester/logs/digest.$(date +%Y%m%d).md ...@@ -412,11 +534,15 @@ cat ~/Documents/News-Harvester/logs/digest.$(date +%Y%m%d).md
cd ~/Documents/News-Harvester/bin cd ~/Documents/News-Harvester/bin
# 续采(默认):在已有基础上继续完成中断的采集 # 续采(默认):在已有基础上继续完成中断的采集
# 默认使用 FALLBACK_PROVIDER(如果配置了),否则用 PROVIDER
./continue.sh nikkei-world --mode resume ./continue.sh nikkei-world --mode resume
# 修复:清理上次错误数据后重新完整采集(force 推送) # 修复:清理上次错误数据后重新完整采集(force 推送)
./continue.sh nikkei-world --mode repair ./continue.sh nikkei-world --mode repair
# 手动指定 provider(优先级最高,覆盖 FALLBACK_PROVIDER)
./continue.sh nikkei-world --mode resume --provider easyrouter
# 指定预算上限(默认 $15) # 指定预算上限(默认 $15)
./continue.sh nikkei-world --mode resume --budget 10 ./continue.sh nikkei-world --mode resume --budget 10
...@@ -467,6 +593,8 @@ sudo ./modify-source.sh <source-name> --time HH:MM [选项] ...@@ -467,6 +593,8 @@ sudo ./modify-source.sh <source-name> --time HH:MM [选项]
| `--count N` | 更新抓取条数,写入/覆盖 `<source>.conf` | | `--count N` | 更新抓取条数,写入/覆盖 `<source>.conf` |
| `--mail-to ADDR` | 更新收件人 | | `--mail-to ADDR` | 更新收件人 |
| `--mail-on-success 0\|1` | 更新成功是否发邮件 | | `--mail-on-success 0\|1` | 更新成功是否发邮件 |
| `--provider NAME` | 更新 Claude provider,写入 `PROVIDER=` |
| `--fallback-provider NAME` | 更新备用 provider,写入 `FALLBACK_PROVIDER=` |
| `--yes` / `-y` | 跳过确认 | | `--yes` / `-y` | 跳过确认 |
示例: 示例:
...@@ -517,6 +645,7 @@ sudo ./add-source.sh <source-name> [选项] ...@@ -517,6 +645,7 @@ sudo ./add-source.sh <source-name> [选项]
| `--count N` | 抓取条数,写入 `<source>.conf` | | `--count N` | 抓取条数,写入 `<source>.conf` |
| `--mail-to ADDR` | 收件人,写入 `<source>.conf` | | `--mail-to ADDR` | 收件人,写入 `<source>.conf` |
| `--mail-on-success 0\|1` | 成功是否发邮件,写入 `<source>.conf` | | `--mail-on-success 0\|1` | 成功是否发邮件,写入 `<source>.conf` |
| `--provider NAME` | Claude provider 名称,写入 `PROVIDER=` |
| `--yes` / `-y` | 跳过确认 | | `--yes` / `-y` | 跳过确认 |
| `--no-enable` | 只创建文件不启用 | | `--no-enable` | 只创建文件不启用 |
| `--force` | 同名 timer 已存在时覆盖 | | `--force` | 同名 timer 已存在时覆盖 |
...@@ -605,7 +734,13 @@ harvest.sh 用 `jq` 解析 JSON,提取元数据写入日志和邮件。digest.sh ...@@ -605,7 +734,13 @@ harvest.sh 用 `jq` 解析 JSON,提取元数据写入日志和邮件。digest.sh
## 注意事项 ## 注意事项
1. **PATH 问题(最易踩坑)**:systemd 不加载 shell profile,脚本循环 source 了 `.bashrc/.bash_profile/.profile/.zshrc`。若手动测试仍报 `claude: command not found`,先 `which claude` 拿绝对路径替换脚本里的 `claude`,或取消脚本里 `export PATH=...` 注释。 1. **PATH 问题(最易踩坑)**:systemd 和 `sudo -u yichu` 都是非交互 shell,不加载 `.zshrc`。所有脚本(harvest/continue/digest)在开头显式设置 PATH 和 nvm:
```bash
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"
```
若 Node 版本升级导致路径变化,需同步修改这三个脚本中的 `v24.14.0`。
2. **jq 必需**:新版依赖 `jq` 解析 JSON 输出。未安装时 harvest 会降级为无法提取元数据,digest 直接报错退出。 2. **jq 必需**:新版依赖 `jq` 解析 JSON 输出。未安装时 harvest 会降级为无法提取元数据,digest 直接报错退出。
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
# --count N 抓取条数(写入 <source>.conf) # --count N 抓取条数(写入 <source>.conf)
# --mail-to ADDR 收件人(写入 <source>.conf) # --mail-to ADDR 收件人(写入 <source>.conf)
# --mail-on-success 0|1 成功是否发邮件(写入 <source>.conf) # --mail-on-success 0|1 成功是否发邮件(写入 <source>.conf)
# --provider NAME Claude provider 名称(写入 <source>.conf 的 PROVIDER=)
# --yes, -y 跳过确认 # --yes, -y 跳过确认
# --no-enable 只创建文件,不 enable # --no-enable 只创建文件,不 enable
# --force 同名 timer 已存在时覆盖 # --force 同名 timer 已存在时覆盖
...@@ -32,6 +33,7 @@ TIME="" ...@@ -32,6 +33,7 @@ TIME=""
COUNT="" COUNT=""
MAIL_TO="" MAIL_TO=""
MAIL_ON_SUCCESS="" MAIL_ON_SUCCESS=""
PROVIDER=""
ASSUME_NO=0 ASSUME_NO=0
NO_ENABLE=0 NO_ENABLE=0
FORCE=0 FORCE=0
...@@ -47,6 +49,7 @@ while [ $# -gt 0 ]; do ...@@ -47,6 +49,7 @@ while [ $# -gt 0 ]; do
--count) COUNT="${2:?--count 需要值}"; shift 2;; --count) COUNT="${2:?--count 需要值}"; shift 2;;
--mail-to) MAIL_TO="${2:?--mail-to 需要值}"; shift 2;; --mail-to) MAIL_TO="${2:?--mail-to 需要值}"; shift 2;;
--mail-on-success) MAIL_ON_SUCCESS="${2:?--mail-on-success 需要值}"; shift 2;; --mail-on-success) MAIL_ON_SUCCESS="${2:?--mail-on-success 需要值}"; shift 2;;
--provider) PROVIDER="${2:?--provider 需要值}"; shift 2;;
--yes|-y) ASSUME_NO=1; shift;; --yes|-y) ASSUME_NO=1; shift;;
--no-enable) NO_ENABLE=1; shift;; --no-enable) NO_ENABLE=1; shift;;
--force) FORCE=1; shift;; --force) FORCE=1; shift;;
...@@ -149,11 +152,12 @@ echo "=== 即将创建栏目: $SOURCE ===" ...@@ -149,11 +152,12 @@ echo "=== 即将创建栏目: $SOURCE ==="
echo " 运行时间: $TIME" echo " 运行时间: $TIME"
[ "$AUTO_TIME" = "1" ] && echo " (自动推算:现有最晚栏目 +15 分钟)" [ "$AUTO_TIME" = "1" ] && echo " (自动推算:现有最晚栏目 +15 分钟)"
echo " timer 文件: $TIMER_PATH" echo " timer 文件: $TIMER_PATH"
if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ]; then if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ] || [ -n "$PROVIDER" ]; then
echo " conf 文件: $CONF_PATH" echo " conf 文件: $CONF_PATH"
[ -n "$COUNT" ] && echo " COUNT=$COUNT" [ -n "$COUNT" ] && echo " COUNT=$COUNT"
[ -n "$MAIL_TO" ] && echo " MAIL_TO=$MAIL_TO" [ -n "$MAIL_TO" ] && echo " MAIL_TO=$MAIL_TO"
[ -n "$MAIL_ON_SUCCESS" ] && echo " MAIL_ON_SUCCESS=$MAIL_ON_SUCCESS" [ -n "$MAIL_ON_SUCCESS" ] && echo " MAIL_ON_SUCCESS=$MAIL_ON_SUCCESS"
[ -n "$PROVIDER" ] && echo " PROVIDER=$PROVIDER"
else else
echo " conf 文件: (不创建,继承 default.conf)" echo " conf 文件: (不创建,继承 default.conf)"
fi fi
...@@ -179,7 +183,7 @@ EOF ...@@ -179,7 +183,7 @@ EOF
ok "已创建 $TIMER_PATH" ok "已创建 $TIMER_PATH"
# --- 写 conf(仅当有覆盖项) --- # --- 写 conf(仅当有覆盖项) ---
if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ]; then if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ] || [ -n "$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 "# /etc/news-harvester/${SOURCE}.conf — 由 add-source.sh 生成 $(date '+%Y-%m-%d %H:%M:%S')"
...@@ -188,6 +192,7 @@ if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ]; then ...@@ -188,6 +192,7 @@ if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ]; then
[ -n "$COUNT" ] && echo "COUNT=$COUNT" [ -n "$COUNT" ] && echo "COUNT=$COUNT"
[ -n "$MAIL_TO" ] && echo "MAIL_TO=$MAIL_TO" [ -n "$MAIL_TO" ] && echo "MAIL_TO=$MAIL_TO"
[ -n "$MAIL_ON_SUCCESS" ] && echo "MAIL_ON_SUCCESS=$MAIL_ON_SUCCESS" [ -n "$MAIL_ON_SUCCESS" ] && echo "MAIL_ON_SUCCESS=$MAIL_ON_SUCCESS"
[ -n "$PROVIDER" ] && echo "PROVIDER=$PROVIDER"
} > "$CONF_PATH" } > "$CONF_PATH"
ok "已创建 $CONF_PATH" ok "已创建 $CONF_PATH"
fi fi
......
...@@ -7,7 +7,9 @@ ...@@ -7,7 +7,9 @@
# 选项: # 选项:
# --mode resume|repair resume=中断续采(默认),repair=清理重采 # --mode resume|repair resume=中断续采(默认),repair=清理重采
# --budget N 单次预算上限美元(默认 15) # --budget N 单次预算上限美元(默认 15)
# --provider NAME 指定 claude provider(优先级最高;默认用 FALLBACK_PROVIDER)
# --yes, -y 跳过确认 # --yes, -y 跳过确认
# --debug 详细诊断输出
# --help, -h 显示帮助 # --help, -h 显示帮助
# #
# 模式说明: # 模式说明:
...@@ -34,6 +36,7 @@ MODE="resume" ...@@ -34,6 +36,7 @@ MODE="resume"
BUDGET=15 BUDGET=15
ASSUME_YES=0 ASSUME_YES=0
DEBUG=0 DEBUG=0
CLI_PROVIDER=""
usage() { usage() {
sed -n '3,28p' "$0" sed -n '3,28p' "$0"
...@@ -44,6 +47,7 @@ while [ $# -gt 0 ]; do ...@@ -44,6 +47,7 @@ while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--mode) MODE="${2:?--mode 需要值}"; shift 2;; --mode) MODE="${2:?--mode 需要值}"; shift 2;;
--budget) BUDGET="${2:?--budget 需要值}"; shift 2;; --budget) BUDGET="${2:?--budget 需要值}"; shift 2;;
--provider) CLI_PROVIDER="${2:?--provider 需要值}"; shift 2;;
--yes|-y) ASSUME_YES=1; shift;; --yes|-y) ASSUME_YES=1; shift;;
--debug) DEBUG=1; shift;; --debug) DEBUG=1; shift;;
--help|-h) usage; exit 0;; --help|-h) usage; exit 0;;
...@@ -89,10 +93,18 @@ done ...@@ -89,10 +93,18 @@ done
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:-}"
PROVIDER="${PROVIDER:-}"
FALLBACK_PROVIDER="${FALLBACK_PROVIDER:-}"
WORK_DIR="${WORK_DIR:-/home/yichu/Documents/News-Harvester}" 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)"
# --- 加载 provider 配置,确定使用哪个 provider ---
# continue.sh 默认用 FALLBACK_PROVIDER(因为 continue 本身就是失败重试)
# --provider 参数优先级最高
source "$SCRIPT_DIR/provider.sh"
resolve_continue_provider "$CLI_PROVIDER"
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"
# --- 从最新 JSON 日志提取 session_id --- # --- 从最新 JSON 日志提取 session_id ---
...@@ -125,8 +137,10 @@ esac ...@@ -125,8 +137,10 @@ esac
info "找到上次运行日志: $LATEST_JSON" info "找到上次运行日志: $LATEST_JSON"
info "Session ID: $SESSION_ID" info "Session ID: $SESSION_ID"
info "模式: ${MODE_TAG} (${MODE})" info "模式: ${MODE_TAG} (${MODE})"
info "Provider: ${USED_PROVIDER}"
debug "当前用户: $(whoami)" debug "当前用户: $(whoami)"
debug "claude 路径: $(command -v claude 2>/dev/null || echo 未找到)" debug "claude 路径: $(command -v claude 2>/dev/null || echo 未找到)"
debug "PROVIDER=$PROVIDER FALLBACK_PROVIDER=$FALLBACK_PROVIDER CLI_PROVIDER=$CLI_PROVIDER"
debug "WORK_DIR=$WORK_DIR LOG_DIR=$LOG_DIR" debug "WORK_DIR=$WORK_DIR LOG_DIR=$LOG_DIR"
echo "" echo ""
...@@ -187,6 +201,7 @@ debug "PROMPT 长度: ${#PROMPT} 字符" ...@@ -187,6 +201,7 @@ debug "PROMPT 长度: ${#PROMPT} 字符"
set +e set +e
claude -p \ claude -p \
"${CLAUDE_SETTINGS_ARGS[@]}" \
--resume "$SESSION_ID" \ --resume "$SESSION_ID" \
--permission-mode bypassPermissions \ --permission-mode bypassPermissions \
--allowed-tools Bash Write \ --allowed-tools Bash Write \
...@@ -290,6 +305,7 @@ API_SECS=$(awk -v ms="$API_MS" 'BEGIN{printf "%.1f", ms/1000}') ...@@ -290,6 +305,7 @@ API_SECS=$(awk -v ms="$API_MS" 'BEGIN{printf "%.1f", ms/1000}')
{ {
echo "===== $(date '+%Y-%m-%d %H:%M:%S') ${MODE_TAG} [${SOURCE}] (mode=${MODE}) =====" echo "===== $(date '+%Y-%m-%d %H:%M:%S') ${MODE_TAG} [${SOURCE}] (mode=${MODE}) ====="
echo "状态: ${STATUS_EMOJI} ${STATUS_TEXT}" echo "状态: ${STATUS_EMOJI} ${STATUS_TEXT}"
echo "Provider: ${USED_PROVIDER}"
echo "退出码: ${RC}" echo "退出码: ${RC}"
echo "耗时: ${MINS}m${SECS}s (wall) / ${API_SECS}s (api)" echo "耗时: ${MINS}m${SECS}s (wall) / ${API_SECS}s (api)"
echo "成本: \$${COST}" echo "成本: \$${COST}"
......
...@@ -35,6 +35,7 @@ CONF_DIR="${CONF_DIR:-/etc/news-harvester}" ...@@ -35,6 +35,7 @@ CONF_DIR="${CONF_DIR:-/etc/news-harvester}"
MAIL_TO="${MAIL_TO:-}" MAIL_TO="${MAIL_TO:-}"
DIGEST_MAIL_ALWAYS="${DIGEST_MAIL_ALWAYS:-1}" DIGEST_MAIL_ALWAYS="${DIGEST_MAIL_ALWAYS:-1}"
PROVIDER="${PROVIDER:-}"
WORK_DIR="${WORK_DIR:-/home/yichu/Documents/News-Harvester}" 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}"
...@@ -43,6 +44,10 @@ COST_ALERT_THRESHOLD="${COST_ALERT_THRESHOLD:-1}" ...@@ -43,6 +44,10 @@ COST_ALERT_THRESHOLD="${COST_ALERT_THRESHOLD:-1}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# --- 加载 provider 配置,构造 --settings 参数 ---
source "$SCRIPT_DIR/provider.sh"
build_claude_settings_args "$PROVIDER"
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"
TODAY="$(date +%Y%m%d)" TODAY="$(date +%Y%m%d)"
TODAY_HUMAN="$(date '+%Y-%m-%d')" TODAY_HUMAN="$(date '+%Y-%m-%d')"
...@@ -149,6 +154,7 @@ SUMMARY_CONTENT="$(cat "$SUMMARY_JSON")" ...@@ -149,6 +154,7 @@ SUMMARY_CONTENT="$(cat "$SUMMARY_JSON")"
set +e set +e
claude -p \ claude -p \
"${CLAUDE_SETTINGS_ARGS[@]}" \
--permission-mode bypassPermissions \ --permission-mode bypassPermissions \
--allowed-tools Bash Read Write \ --allowed-tools Bash Read Write \
--output-format json \ --output-format json \
...@@ -214,6 +220,7 @@ fi ...@@ -214,6 +220,7 @@ fi
echo "" echo ""
echo "数据源: ${UNIQUE_SOURCES} 个(共 ${SOURCE_COUNT} 次运行)" echo "数据源: ${UNIQUE_SOURCES} 个(共 ${SOURCE_COUNT} 次运行)"
echo "成功/失败: ${SUCCESS_COUNT}/${FAILED_COUNT}" echo "成功/失败: ${SUCCESS_COUNT}/${FAILED_COUNT}"
echo "Provider: ${USED_PROVIDER}"
echo "总成本: \$${TOTAL_COST}" echo "总成本: \$${TOTAL_COST}"
echo "总 Turns: ${TOTAL_TURNS} 总耗时: ${TOTAL_DURATION_S}s" echo "总 Turns: ${TOTAL_TURNS} 总耗时: ${TOTAL_DURATION_S}s"
echo "总 Token: 输入 ${TOTAL_IN_TOK} / 输出 ${TOTAL_OUT_TOK}" echo "总 Token: 输入 ${TOTAL_IN_TOK} / 输出 ${TOTAL_OUT_TOK}"
......
...@@ -29,11 +29,17 @@ COUNT="${2:-20}" ...@@ -29,11 +29,17 @@ COUNT="${2:-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:-}"
PROVIDER="${PROVIDER:-}"
WORK_DIR="${WORK_DIR:-/home/yichu/Documents/News-Harvester}" 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)"
# --- 加载 provider 配置,构造 --settings 参数 ---
CONF_DIR="${CONF_DIR:-/etc/news-harvester}"
source "$SCRIPT_DIR/provider.sh"
build_claude_settings_args "$PROVIDER"
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"
DATE_TAG="$(date +%Y%m%d)" DATE_TAG="$(date +%Y%m%d)"
TIME_TAG="$(date +%H%M%S)" TIME_TAG="$(date +%H%M%S)"
...@@ -49,6 +55,7 @@ START_EPOCH=$(date +%s) ...@@ -49,6 +55,7 @@ START_EPOCH=$(date +%s)
# 不用 set -e,因为要捕获 claude 的退出码 # 不用 set -e,因为要捕获 claude 的退出码
set +e set +e
claude -p \ claude -p \
"${CLAUDE_SETTINGS_ARGS[@]}" \
--permission-mode bypassPermissions \ --permission-mode bypassPermissions \
--allowed-tools Bash Write \ --allowed-tools Bash Write \
--output-format json \ --output-format json \
...@@ -150,6 +157,7 @@ API_SECS=$(awk -v ms="$API_MS" 'BEGIN{printf "%.1f", ms/1000}') ...@@ -150,6 +157,7 @@ API_SECS=$(awk -v ms="$API_MS" 'BEGIN{printf "%.1f", ms/1000}')
{ {
echo "===== $(date '+%Y-%m-%d %H:%M:%S') 抓取 [$SOURCE] =====" echo "===== $(date '+%Y-%m-%d %H:%M:%S') 抓取 [$SOURCE] ====="
echo "状态: ${STATUS_EMOJI} ${STATUS_TEXT}" echo "状态: ${STATUS_EMOJI} ${STATUS_TEXT}"
echo "Provider: ${USED_PROVIDER}"
echo "退出码: ${RC}" echo "退出码: ${RC}"
echo "耗时: ${MINS}m${SECS}s (wall) / ${API_SECS}s (api)" echo "耗时: ${MINS}m${SECS}s (wall) / ${API_SECS}s (api)"
echo "成本: \$${COST}" echo "成本: \$${COST}"
......
#!/bin/bash
# provider.sh — Claude Provider 配置管理(共享函数库,被 harvest/continue/digest source)
#
# Provider 配置文件存放在 $CONF_DIR/providers/<name>.json,
# 内容是 env 对象(key-value),如:
# {"ANTHROPIC_AUTH_TOKEN":"sk-xxx","ANTHROPIC_BASE_URL":"https://xxx.io",...}
#
# 数据源通过 conf 中的 PROVIDER=<name> 指定主 provider,
# FALLBACK_PROVIDER=<name> 指定备用 provider(continue.sh 默认用这个)。
#
# PROVIDER 为空或 "default" 时不加 --settings,使用系统全局配置。
# 依赖外部变量: CONF_DIR (默认 /etc/news-harvester)
# 设置外部变量: CLAUDE_SETTINGS_ARGS (数组,可能为空)
PROVIDER_DIR="${PROVIDER_DIR:-${CONF_DIR:-/etc/news-harvester}/providers}"
# build_claude_settings_args [provider_name]
# 读取指定 provider 的 JSON,构造 --settings '{"env":{...}}' 参数。
# 不传参数时用 $PROVIDER 变量。
# 结果写入全局数组 CLAUDE_SETTINGS_ARGS(空数组=不加 --settings)。
# 同时设置 USED_PROVIDER 变量记录实际使用的 provider 名。
build_claude_settings_args() {
local name="${1:-${PROVIDER:-}}"
CLAUDE_SETTINGS_ARGS=()
USED_PROVIDER="${name:-default}"
# default 或空 → 不加 --settings
if [ -z "$name" ] || [ "$name" = "default" ]; then
USED_PROVIDER="default"
return 0
fi
local json_file="$PROVIDER_DIR/${name}.json"
if [ ! -f "$json_file" ]; then
echo "[provider] ⚠️ Provider 配置文件不存在: $json_file,退回默认配置" >&2
USED_PROVIDER="default(missing:${name})"
return 0
fi
# 读取 JSON 文件内容,构造 --settings '{"env": <文件内容>}'
local json_content
json_content=$(cat "$json_file" 2>/dev/null)
if [ -z "$json_content" ]; then
echo "[provider] ⚠️ Provider 配置文件为空: $json_file,退回默认配置" >&2
USED_PROVIDER="default(empty:${name})"
return 0
fi
# 校验是否为合法 JSON(需要 jq)
if ! command -v jq >/dev/null 2>&1; then
echo "[provider] ⚠️ 未安装 jq,无法校验 provider JSON,退回默认配置" >&2
USED_PROVIDER="default(no-jq:${name})"
return 0
fi
if ! echo "$json_content" | jq empty 2>/dev/null; then
echo "[provider] ⚠️ Provider JSON 格式无效: $json_file,退回默认配置" >&2
USED_PROVIDER="default(invalid:${name})"
return 0
fi
# 构造 settings JSON: {"env": {...文件内容...}}
local settings_json
settings_json=$(echo "$json_content" | jq -c '{env: .}')
CLAUDE_SETTINGS_ARGS=(--settings "$settings_json")
}
# build_fallback_claude_settings_args
# 读取 FALLBACK_PROVIDER,构造参数。结果同上写入 CLAUDE_SETTINGS_ARGS。
build_fallback_claude_settings_args() {
local fb="${FALLBACK_PROVIDER:-}"
build_claude_settings_args "$fb"
}
# resolve_continue_provider
# continue.sh 专用:确定续采/修复使用哪个 provider。
# 优先级: --provider 参数 > FALLBACK_PROVIDER > PROVIDER > default
# 参数: $1 = CLI 传入的 --provider 值(可能为空)
# 设置: PROVIDER(覆盖为实际使用的值), USED_PROVIDER, CLAUDE_SETTINGS_ARGS
resolve_continue_provider() {
local cli_provider="${1:-}"
if [ -n "$cli_provider" ]; then
# 手动指定优先
PROVIDER="$cli_provider"
elif [ -n "${FALLBACK_PROVIDER:-}" ]; then
# continue.sh 默认用 fallback(因为 continue 本身就是失败重试)
PROVIDER="$FALLBACK_PROVIDER"
fi
# PROVIDER 为空或 default → 不加 --settings
build_claude_settings_args "$PROVIDER"
}
...@@ -44,11 +44,12 @@ show_all_last_logs() { ...@@ -44,11 +44,12 @@ show_all_last_logs() {
for f in "$LOG_DIR"/*.last.log; do for f in "$LOG_DIR"/*.last.log; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
found=1 found=1
local src status_line cost_line session_line mtime_fmt local src status_line cost_line session_line provider_line mtime_fmt
src=$(basename "$f" .last.log) src=$(basename "$f" .last.log)
status_line=$(grep '^状态:' "$f" 2>/dev/null | head -1) status_line=$(grep '^状态:' "$f" 2>/dev/null | head -1)
cost_line=$(grep '^成本:' "$f" 2>/dev/null | head -1) cost_line=$(grep '^成本:' "$f" 2>/dev/null | head -1)
session_line=$(grep '^Session:' "$f" 2>/dev/null | head -1) session_line=$(grep '^Session:' "$f" 2>/dev/null | head -1)
provider_line=$(grep '^Provider:' "$f" 2>/dev/null | head -1)
# 取日志文件修改时间,格式 YYYY-MM-DD HH:MM # 取日志文件修改时间,格式 YYYY-MM-DD HH:MM
mtime_fmt=$(date -r "$f" '+%Y-%m-%d %H:%M' 2>/dev/null || stat -c '%y' "$f" 2>/dev/null | cut -d. -f1 || echo "?") mtime_fmt=$(date -r "$f" '+%Y-%m-%d %H:%M' 2>/dev/null || stat -c '%y' "$f" 2>/dev/null | cut -d. -f1 || echo "?")
...@@ -69,15 +70,20 @@ show_all_last_logs() { ...@@ -69,15 +70,20 @@ show_all_last_logs() {
session_id="${session_line#Session: }" session_id="${session_line#Session: }"
[ -z "$session_id" ] && session_id="${session_line#Session:}" [ -z "$session_id" ] && session_id="${session_line#Session:}"
local provider_text
provider_text="${provider_line#Provider: }"
[ -z "$provider_text" ] && provider_text="${provider_line#Provider:}"
[ -z "$provider_text" ] && provider_text="-"
local icon local icon
if echo "$status_text" | grep -q "成功"; then icon="OK" if echo "$status_text" | grep -q "成功"; then icon="OK"
elif echo "$status_text" | grep -q "失败"; then icon="FAIL" elif echo "$status_text" | grep -q "失败"; then icon="FAIL"
else icon="?"; fi else icon="?"; fi
if [ -n "$session_id" ]; then if [ -n "$session_id" ]; then
printf "%-6s %-22s %-16s %s | %s | sid:%s\n" "[$icon]" "$src" "$mtime_fmt" "$status_text" "$cost_fmt" "$session_id" printf "%-6s %-20s %-16s %-14s %s | %s | sid:%s\n" "[$icon]" "$src" "$mtime_fmt" "$provider_text" "$status_text" "$cost_fmt" "$session_id"
else else
printf "%-6s %-22s %-16s %s | %s\n" "[$icon]" "$src" "$mtime_fmt" "$status_text" "$cost_fmt" printf "%-6s %-20s %-16s %-14s %s | %s\n" "[$icon]" "$src" "$mtime_fmt" "$provider_text" "$status_text" "$cost_fmt"
fi fi
done done
shopt -u nullglob shopt -u nullglob
...@@ -182,7 +188,7 @@ show_overview() { ...@@ -182,7 +188,7 @@ show_overview() {
echo "最近运行" echo "最近运行"
hr hr
printf "%-6s %-22s %-16s %-20s %-10s %s\n" "" "来源" "修改时间" "状态" "成本" "session" printf "%-6s %-20s %-16s %-14s %-20s %-10s %s\n" "" "来源" "修改时间" "Provider" "状态" "成本" "session"
show_all_last_logs show_all_last_logs
} }
...@@ -269,6 +275,7 @@ show_source_detail() { ...@@ -269,6 +275,7 @@ show_source_detail() {
case "$line" in case "$line" in
=====*) echo " $line" ;; =====*) echo " $line" ;;
状态:*) printf " %-12s %s\n" "状态:" "${line#状态: }" ;; 状态:*) printf " %-12s %s\n" "状态:" "${line#状态: }" ;;
Provider:*)printf " %-12s %s\n" "Provider:" "${line#Provider: }" ;;
退出码:*) printf " %-12s %s\n" "退出码:" "${line#退出码: }" ;; 退出码:*) printf " %-12s %s\n" "退出码:" "${line#退出码: }" ;;
耗时:*) printf " %-12s %s\n" "耗时:" "${line#耗时: }" ;; 耗时:*) printf " %-12s %s\n" "耗时:" "${line#耗时: }" ;;
成本:*) printf " %-12s %s\n" "成本:" "${line#成本: }" ;; 成本:*) printf " %-12s %s\n" "成本:" "${line#成本: }" ;;
......
...@@ -19,6 +19,23 @@ MAIL_ON_SUCCESS=1 ...@@ -19,6 +19,23 @@ MAIL_ON_SUCCESS=1
# 单次抓取成本(美元)超过此值则在邮件标题标记 [ALERT],留空不判断 # 单次抓取成本(美元)超过此值则在邮件标题标记 [ALERT],留空不判断
ALERT_COST_OVER=1 ALERT_COST_OVER=1
# === Claude Provider(可选) ===
# 指定默认使用的 Claude provider 名称。provider 配置文件存放在:
# /etc/news-harvester/providers/<name>.json
# 每个 JSON 文件是 env 对象(ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL / 模型配置等)。
# 脚本启动 claude 时会加上 --settings '{"env": {...}}' 注入这些环境变量。
#
# 留空或设为 default → 不加 --settings,使用系统全局配置(claude CLI 自带的认证和模型)
# 设为具体名称(如 easyrouter)→ 从 /etc/news-harvester/providers/easyrouter.json 加载
#
# 此处设置的是全局默认,单栏目可在 <source>.conf 中用 PROVIDER= 覆盖。
# PROVIDER=
#
# 全局备用 provider(可选):continue.sh 续采/修复时默认使用此 provider。
# 如果某栏目没有单独设置 FALLBACK_PROVIDER,会继承这里的值。
# 留空 = 续采时退回 PROVIDER(或 default)。
# FALLBACK_PROVIDER=
# === 每日汇总(digest 相关) === # === 每日汇总(digest 相关) ===
# 无论是否有告警都发汇总邮件:1=每次发,0=仅告警时发 # 无论是否有告警都发汇总邮件:1=每次发,0=仅告警时发
DIGEST_MAIL_ALWAYS=1 DIGEST_MAIL_ALWAYS=1
......
# /etc/news-harvester/nikkei-world.conf # /etc/news-harvester/nikkei-world.conf
# 单栏目覆盖配置示例。仅写需要覆盖的字段,其余继承 default.conf。 # 单栏目覆盖配置示例。仅写需要覆盖的字段,其余继承 default.conf。
# 此例:nikkei-world 抓 30 条,成功不单独发邮件(只在 digest 里看),成本超 $2 告警。 # 此例:nikkei-world 抓 30 条,成功不单独发邮件(只在 digest 里看),成本超 $2 告警。
# 同时指定了 Claude provider 和备用 provider。
COUNT=30 COUNT=30
MAIL_ON_SUCCESS=0 MAIL_ON_SUCCESS=0
ALERT_COST_OVER=2 ALERT_COST_OVER=2
# === Claude Provider(可选) ===
# 指定此栏目使用的 Claude provider,覆盖 default.conf 中的全局设置。
# provider 配置文件: /etc/news-harvester/providers/<name>.json
# 留空或 default → 使用系统全局配置(不加 --settings)
# 也可通过 Web 面板的"编辑栏目"页面或 modify-source.sh --provider 设置
PROVIDER=easyrouter
# 备用 provider(可选):continue.sh 续采/修复时默认使用此 provider。
# 当主 provider 出问题(限流、API 故障等)时,用此 provider 重试。
# 留空 = 续采时退回 PROVIDER(或 default)。
# 也可通过 modify-source.sh --fallback-provider 设置
FALLBACK_PROVIDER=default
{
"ANTHROPIC_AUTH_TOKEN": "sk-xxx",
"ANTHROPIC_BASE_URL": "https://easyrouter.io",
"ANTHROPIC_DEFAULT_FABLE_MODEL": "claude-sonnet-5",
"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME": "claude-sonnet-5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "claude-haiku-4-5",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-sonnet-5",
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "claude-sonnet-5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-5",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "claude-sonnet-5",
"ANTHROPIC_MODEL": "claude-sonnet-5",
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-5"
}
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
# ./deploy.sh yichu@192.168.1.100 # 显式指定用户 # ./deploy.sh yichu@192.168.1.100 # 显式指定用户
# ./deploy.sh <host> --no-systemd # 只更新 bin 脚本,不动 systemd unit # ./deploy.sh <host> --no-systemd # 只更新 bin 脚本,不动 systemd unit
# ./deploy.sh <host> --no-restart-web # 不重启 web 面板 # ./deploy.sh <host> --no-restart-web # 不重启 web 面板
# ./deploy.sh <host> --no-providers # 不同步 provider 配置
# ./deploy.sh <host> --test # 部署后自动跑一次 nikkei-world 测试 # ./deploy.sh <host> --test # 部署后自动跑一次 nikkei-world 测试
# #
# 做的事: # 做的事:
...@@ -14,6 +15,8 @@ ...@@ -14,6 +15,8 @@
# 3. 同步 systemd 模板 service + digest unit 到 /etc/systemd/system/(需要 sudo) # 3. 同步 systemd 模板 service + digest unit 到 /etc/systemd/system/(需要 sudo)
# ⚠️ 栏目 timer(news-harvester@<source>.timer)不在同步范围,由服务器端维护 # ⚠️ 栏目 timer(news-harvester@<source>.timer)不在同步范围,由服务器端维护
# 4. daemon-reload # 4. daemon-reload
# 5. 同步本地 provider 配置到 /etc/news-harvester/providers/(需要 sudo)
# 只同步 .json 文件(不含 .example),保留服务器端已有的额外 provider
# #
# 不动的东西: # 不动的东西:
# - /etc/news-harvester/*.conf(用户配置) # - /etc/news-harvester/*.conf(用户配置)
...@@ -25,6 +28,7 @@ set -euo pipefail ...@@ -25,6 +28,7 @@ set -euo pipefail
SYNC_SYSTEMD=1 SYNC_SYSTEMD=1
RESTART_WEB=1 RESTART_WEB=1
SYNC_PROVIDERS=1
RUN_TEST=0 RUN_TEST=0
HOST="" HOST=""
...@@ -33,6 +37,7 @@ while [ $# -gt 0 ]; do ...@@ -33,6 +37,7 @@ while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--no-systemd) SYNC_SYSTEMD=0; shift;; --no-systemd) SYNC_SYSTEMD=0; shift;;
--no-restart-web) RESTART_WEB=0; shift;; --no-restart-web) RESTART_WEB=0; shift;;
--no-providers) SYNC_PROVIDERS=0; shift;;
--test) RUN_TEST=1; shift;; --test) RUN_TEST=1; shift;;
--help|-h) --help|-h)
sed -n '3,24p' "$0"; exit 0;; sed -n '3,24p' "$0"; exit 0;;
...@@ -44,7 +49,7 @@ while [ $# -gt 0 ]; do ...@@ -44,7 +49,7 @@ while [ $# -gt 0 ]; do
esac esac
done done
[ -n "$HOST" ] || { echo "用法: $0 <[user@]host> [--no-systemd] [--no-restart-web] [--test]" >&2; exit 1; } [ -n "$HOST" ] || { echo "用法: $0 <[user@]host> [--no-systemd] [--no-restart-web] [--no-providers] [--test]" >&2; exit 1; }
# 默认加用户名(如果没写 user@) # 默认加用户名(如果没写 user@)
case "$HOST" in case "$HOST" in
...@@ -67,12 +72,13 @@ echo "=== 部署到 $HOST ===" ...@@ -67,12 +72,13 @@ echo "=== 部署到 $HOST ==="
echo " 项目: $SCRIPT_DIR" echo " 项目: $SCRIPT_DIR"
echo " 同步 bin: 是" echo " 同步 bin: 是"
[ "$SYNC_SYSTEMD" = "1" ] && echo " 同步 systemd: 是(需要远程 sudo)" || echo " 同步 systemd: 否(--no-systemd)" [ "$SYNC_SYSTEMD" = "1" ] && echo " 同步 systemd: 是(需要远程 sudo)" || echo " 同步 systemd: 否(--no-systemd)"
[ "$SYNC_PROVIDERS" = "1" ] && echo " 同步 providers: 是(需要远程 sudo)" || echo " 同步 providers: 否(--no-providers)"
[ "$RESTART_WEB" = "1" ] && echo " 重启 web: 是" || echo " 重启 web: 否(--no-restart-web)" [ "$RESTART_WEB" = "1" ] && echo " 重启 web: 是" || echo " 重启 web: 否(--no-restart-web)"
[ "$RUN_TEST" = "1" ] && echo " 部署后测试: 是(--test)" [ "$RUN_TEST" = "1" ] && echo " 部署后测试: 是(--test)"
echo "" echo ""
# --- 1. rsync 项目目录 --- # --- 1. rsync 项目目录 ---
info "1/4 同步项目目录 → ~/news-harvester-pro/" info "1/6 同步项目目录 → ~/news-harvester-pro/"
rsync -avz --delete \ rsync -avz --delete \
--exclude='.git' \ --exclude='.git' \
--exclude='*.log' \ --exclude='*.log' \
...@@ -83,23 +89,56 @@ rsync -avz --delete \ ...@@ -83,23 +89,56 @@ rsync -avz --delete \
ok "项目目录已同步" ok "项目目录已同步"
# --- 2. 复制 bin + web 到工作目录 --- # --- 2. 复制 bin + web 到工作目录 ---
info "2/5 复制 bin/*.sh + web/ → ~/Documents/News-Harvester/" info "2/6 复制 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/' 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 应用已就位" ok "bin 脚本 + web 应用已就位"
# --- 3. 同步 systemd unit --- # --- 3. 同步 systemd unit ---
if [ "$SYNC_SYSTEMD" = "1" ]; then if [ "$SYNC_SYSTEMD" = "1" ]; then
info "3/5 同步 systemd 模板 + digest + web unit → /etc/systemd/system/" info "3/6 同步 systemd 模板 + digest + web unit → /etc/systemd/system/"
# 用 -t 分配伪终端,让远程 sudo 能交互式提示输入密码 # 用 -t 分配伪终端,让远程 sudo 能交互式提示输入密码
# 只同步模板 service / digest / web,栏目 timer 由服务器端维护 # 只同步模板 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' 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" ok "systemd unit 已同步并 reload"
else else
info "3/5 跳过 systemd(--no-systemd)" info "3/6 跳过 systemd(--no-systemd)"
fi fi
# --- 4. 提示 Web 面板首次配置 + 重启 --- # --- 3.5 同步 provider 配置 ---
info "4/5 Web 面板检查" if [ "$SYNC_PROVIDERS" = "1" ]; then
info "4/6 同步 provider 配置 → /etc/news-harvester/providers/"
# 检查本地是否有 provider JSON(不含 .example)
local_providers=""
if [ -d "$SCRIPT_DIR/config/providers" ]; then
local_providers=$(ls "$SCRIPT_DIR/config/providers/"*.json 2>/dev/null | grep -v '\.example\.json$' || true)
fi
if [ -z "$local_providers" ]; then
info " 本地无 provider JSON(只有 .example),跳过"
else
# 先 rsync 到远程临时目录(普通用户权限),再用 sudo 移到目标目录
# 不用 --delete,保留服务器端已有的 provider
info " 本地 provider 文件:"
for f in $local_providers; do echo " $(basename "$f")"; done
rsync -avz \
--include='*/' \
--include='*.json' \
--exclude='*.example.json' \
--exclude='*' \
"$SCRIPT_DIR/config/providers/" "$HOST:~/news-harvester-pro/.providers-tmp/"
# 远程:创建目录(如不存在),复制 JSON,设置权限,清理临时目录
ssh -t "$HOST" 'sudo mkdir -p /etc/news-harvester/providers && sudo cp ~/news-harvester-pro/.providers-tmp/*.json /etc/news-harvester/providers/ 2>/dev/null && sudo chmod 644 /etc/news-harvester/providers/*.json && rm -rf ~/news-harvester-pro/.providers-tmp' 2>/dev/null \
&& ok "provider 配置已同步" \
|| warn "provider 同步失败(可能需要 sudo 密码),可手动:\n rsync -avz config/providers/*.json $HOST:~/providers-tmp/\n ssh -t $HOST 'sudo mkdir -p /etc/news-harvester/providers && sudo cp ~/providers-tmp/*.json /etc/news-harvester/providers/'"
fi
else
info "4/6 跳过 providers(--no-providers)"
fi
# --- 5. 提示 Web 面板首次配置 + 重启 ---
info "5/6 Web 面板检查"
ssh "$HOST" 'test -f /etc/news-harvester/web.conf && echo "EXISTS" || echo "MISSING"' 2>/dev/null | grep -q EXISTS \ ssh "$HOST" 'test -f /etc/news-harvester/web.conf && echo "EXISTS" || echo "MISSING"' 2>/dev/null | grep -q EXISTS \
&& ok "web.conf 已存在" \ && 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'" || 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'"
...@@ -116,8 +155,8 @@ else ...@@ -116,8 +155,8 @@ else
info "跳过 web 重启(--no-restart-web)" info "跳过 web 重启(--no-restart-web)"
fi fi
# --- 5. 完成 --- # --- 6. 完成 ---
info "5/5 完成" info "6/6 完成"
echo "" echo ""
echo "========================================" echo "========================================"
ok "部署完成!" ok "部署完成!"
......
...@@ -9,6 +9,8 @@ ...@@ -9,6 +9,8 @@
# --count N 更新抓取条数(写入/覆盖 <source>.conf) # --count N 更新抓取条数(写入/覆盖 <source>.conf)
# --mail-to ADDR 更新收件人 # --mail-to ADDR 更新收件人
# --mail-on-success 0|1 更新成功是否发邮件 # --mail-on-success 0|1 更新成功是否发邮件
# --provider NAME 更新 Claude provider(写入 PROVIDER=)
# --fallback-provider NAME 更新备用 provider(写入 FALLBACK_PROVIDER=)
# --yes, -y 跳过确认 # --yes, -y 跳过确认
# --help, -h 显示帮助 # --help, -h 显示帮助
# #
...@@ -30,6 +32,8 @@ TIME="" ...@@ -30,6 +32,8 @@ TIME=""
COUNT="" COUNT=""
MAIL_TO="" MAIL_TO=""
MAIL_ON_SUCCESS="" MAIL_ON_SUCCESS=""
PROVIDER=""
FALLBACK_PROVIDER=""
ASSUME_NO=0 ASSUME_NO=0
usage() { usage() {
...@@ -43,6 +47,8 @@ while [ $# -gt 0 ]; do ...@@ -43,6 +47,8 @@ while [ $# -gt 0 ]; do
--count) COUNT="${2:?--count 需要值}"; shift 2;; --count) COUNT="${2:?--count 需要值}"; shift 2;;
--mail-to) MAIL_TO="${2:?--mail-to 需要值}"; shift 2;; --mail-to) MAIL_TO="${2:?--mail-to 需要值}"; shift 2;;
--mail-on-success) MAIL_ON_SUCCESS="${2:?--mail-on-success 需要值}"; shift 2;; --mail-on-success) MAIL_ON_SUCCESS="${2:?--mail-on-success 需要值}"; shift 2;;
--provider) PROVIDER="${2:?--provider 需要值}"; shift 2;;
--fallback-provider) FALLBACK_PROVIDER="${2:?--fallback-provider 需要值}"; shift 2;;
--yes|-y) ASSUME_NO=1; shift;; --yes|-y) ASSUME_NO=1; shift;;
--help|-h) usage; exit 0;; --help|-h) usage; exit 0;;
--) shift; break;; --) shift; break;;
...@@ -122,6 +128,8 @@ read_conf_val() { ...@@ -122,6 +128,8 @@ read_conf_val() {
CUR_COUNT=$(read_conf_val COUNT) CUR_COUNT=$(read_conf_val COUNT)
CUR_MAIL_TO=$(read_conf_val MAIL_TO) CUR_MAIL_TO=$(read_conf_val MAIL_TO)
CUR_MOS=$(read_conf_val MAIL_ON_SUCCESS) CUR_MOS=$(read_conf_val MAIL_ON_SUCCESS)
CUR_PROVIDER=$(read_conf_val PROVIDER)
CUR_FALLBACK=$(read_conf_val FALLBACK_PROVIDER)
# --- 展示变更计划,确认 --- # --- 展示变更计划,确认 ---
echo "=== 修改栏目: $SOURCE ===" echo "=== 修改栏目: $SOURCE ==="
...@@ -129,6 +137,8 @@ echo " 定时时间: $CURRENT_TIME → $TIME" ...@@ -129,6 +137,8 @@ echo " 定时时间: $CURRENT_TIME → $TIME"
[ -n "$COUNT" ] && { echo " 抓取条数: ${CUR_COUNT:-(继承default)} → $COUNT"; } [ -n "$COUNT" ] && { echo " 抓取条数: ${CUR_COUNT:-(继承default)} → $COUNT"; }
[ -n "$MAIL_TO" ] && { echo " 收件人: ${CUR_MAIL_TO:-(继承default)} → $MAIL_TO"; } [ -n "$MAIL_TO" ] && { echo " 收件人: ${CUR_MAIL_TO:-(继承default)} → $MAIL_TO"; }
[ -n "$MAIL_ON_SUCCESS" ] && { echo " 成功发信: ${CUR_MOS:-(继承default)} → $MAIL_ON_SUCCESS"; } [ -n "$MAIL_ON_SUCCESS" ] && { echo " 成功发信: ${CUR_MOS:-(继承default)} → $MAIL_ON_SUCCESS"; }
[ -n "$PROVIDER" ] && { echo " Provider: ${CUR_PROVIDER:-(继承default)} → $PROVIDER"; }
[ -n "$FALLBACK_PROVIDER" ] && { echo " Fallback: ${CUR_FALLBACK:-(未设置)} → $FALLBACK_PROVIDER"; }
echo " timer 文件: $TIMER_PATH" echo " timer 文件: $TIMER_PATH"
if [ "$HAS_DROPIN" = "1" ]; then if [ "$HAS_DROPIN" = "1" ]; then
warn "检测到 drop-in 覆盖目录: $DROPIN_DIR" warn "检测到 drop-in 覆盖目录: $DROPIN_DIR"
...@@ -160,7 +170,7 @@ update_conf() { ...@@ -160,7 +170,7 @@ update_conf() {
echo "${key}=${val}" >> "$CONF_PATH" echo "${key}=${val}" >> "$CONF_PATH"
fi fi
} }
if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ]; 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"
if [ ! -f "$CONF_PATH" ]; then if [ ! -f "$CONF_PATH" ]; then
{ {
...@@ -172,6 +182,8 @@ if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ]; then ...@@ -172,6 +182,8 @@ if [ -n "$COUNT" ] || [ -n "$MAIL_TO" ] || [ -n "$MAIL_ON_SUCCESS" ]; then
[ -n "$COUNT" ] && { update_conf COUNT "$COUNT"; } [ -n "$COUNT" ] && { update_conf COUNT "$COUNT"; }
[ -n "$MAIL_TO" ] && { update_conf MAIL_TO "$MAIL_TO"; } [ -n "$MAIL_TO" ] && { update_conf MAIL_TO "$MAIL_TO"; }
[ -n "$MAIL_ON_SUCCESS" ] && { update_conf MAIL_ON_SUCCESS "$MAIL_ON_SUCCESS"; } [ -n "$MAIL_ON_SUCCESS" ] && { update_conf MAIL_ON_SUCCESS "$MAIL_ON_SUCCESS"; }
[ -n "$PROVIDER" ] && { update_conf PROVIDER "$PROVIDER"; }
[ -n "$FALLBACK_PROVIDER" ] && { update_conf FALLBACK_PROVIDER "$FALLBACK_PROVIDER"; }
ok "已更新 $CONF_PATH" ok "已更新 $CONF_PATH"
fi fi
......
...@@ -30,6 +30,7 @@ from flask import ( ...@@ -30,6 +30,7 @@ from flask import (
# ── 路径常量(与 bash 脚本一致) ────────────────────────────── # ── 路径常量(与 bash 脚本一致) ──────────────────────────────
SYSTEMD_DIR = Path(os.environ.get("SYSTEMD_DIR", "/etc/systemd/system")) SYSTEMD_DIR = Path(os.environ.get("SYSTEMD_DIR", "/etc/systemd/system"))
CONF_DIR = Path(os.environ.get("CONF_DIR", "/etc/news-harvester")) CONF_DIR = Path(os.environ.get("CONF_DIR", "/etc/news-harvester"))
PROVIDER_DIR = CONF_DIR / "providers"
WORK_DIR = Path(os.environ.get("WORK_DIR", "/home/yichu/Documents/News-Harvester")) WORK_DIR = Path(os.environ.get("WORK_DIR", "/home/yichu/Documents/News-Harvester"))
LOG_DIR = Path(os.environ.get("LOG_DIR", str(WORK_DIR / "logs"))) LOG_DIR = Path(os.environ.get("LOG_DIR", str(WORK_DIR / "logs")))
PROJECT_DIR = Path(__file__).resolve().parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent
...@@ -216,6 +217,7 @@ def parse_last_log(source): ...@@ -216,6 +217,7 @@ def parse_last_log(source):
("duration", "耗时:"), ("exit_code", "退出码:"), ("duration", "耗时:"), ("exit_code", "退出码:"),
("turns", "Turns:"), ("token", "Token:"), ("turns", "Turns:"), ("token", "Token:"),
("denials", "拒绝:"), ("terminal", "Terminal:"), ("denials", "拒绝:"), ("terminal", "Terminal:"),
("provider", "Provider:"),
]: ]:
if line.startswith(label): if line.startswith(label):
# 去掉 label 前缀(含可能的对齐空格) # 去掉 label 前缀(含可能的对齐空格)
...@@ -261,6 +263,52 @@ def read_conf(source): ...@@ -261,6 +263,52 @@ def read_conf(source):
return result return result
# ── Provider 辅助函数 ──────────────────────────────────────
SENSITIVE_KEY_HINTS = ("TOKEN", "KEY", "SECRET", "PASSWORD", "AUTH")
def mask_provider_value(key, value):
"""脱敏 provider 配置值:敏感 key 只显示前 8 位"""
if any(h in key.upper() for h in SENSITIVE_KEY_HINTS):
if len(value) > 8:
return value[:8] + "..."
return value[:4] + "***"
return value
def get_providers():
"""扫描 providers 目录,返回 [{name, env_keys, key_count}]"""
if not PROVIDER_DIR.exists():
return []
result = []
for f in sorted(PROVIDER_DIR.glob("*.json")):
name = f.stem
try:
data = json.loads(f.read_text(encoding="utf-8"))
keys = list(data.keys()) if isinstance(data, dict) else []
except Exception:
keys = []
result.append({"name": name, "env_keys": keys, "key_count": len(keys)})
return result
def read_provider(name):
"""读取单个 provider JSON,值脱敏。返回 {name, env: [{key, value}]} 或 None"""
path = PROVIDER_DIR / f"{name}.json"
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as e:
return {"name": name, "error": str(e), "env": []}
env = []
if isinstance(data, dict):
for k, v in data.items():
env.append({"key": k, "value": mask_provider_value(k, str(v))})
return {"name": name, "env": env}
def get_source_list(): def get_source_list():
"""获取所有 source 的完整信息列表""" """获取所有 source 的完整信息列表"""
sources = [] sources = []
...@@ -279,6 +327,11 @@ def get_source_list(): ...@@ -279,6 +327,11 @@ def get_source_list():
# 最近运行日志 # 最近运行日志
log_info = parse_last_log(source) log_info = parse_last_log(source)
# 从 conf 读 provider 配置
conf = read_conf(source)
provider = conf.get("PROVIDER", "")
fallback_provider = conf.get("FALLBACK_PROVIDER", "")
# 状态判定 # 状态判定
status_text = log_info.get("status", "") if log_info else "" status_text = log_info.get("status", "") if log_info else ""
if "成功" in status_text: if "成功" in status_text:
...@@ -297,6 +350,8 @@ def get_source_list(): ...@@ -297,6 +350,8 @@ def get_source_list():
"last": props["last"], "last": props["last"],
"log": log_info, "log": log_info,
"status_class": status_class, "status_class": status_class,
"provider": provider,
"fallback_provider": fallback_provider,
}) })
# 按运行时间排序 # 按运行时间排序
sources.sort(key=lambda s: s["run_time"]) sources.sort(key=lambda s: s["run_time"])
...@@ -393,14 +448,15 @@ def source_detail(name): ...@@ -393,14 +448,15 @@ def source_detail(name):
return render_template("detail.html", name=name, run_time=run_time, return render_template("detail.html", name=name, run_time=run_time,
props=props, log_info=log_info, json_info=json_info, props=props, log_info=log_info, json_info=json_info,
json_meta=json_meta, conf=conf, json_meta=json_meta, conf=conf,
continue_status=continue_status, auto_refresh=auto_refresh) continue_status=continue_status, auto_refresh=auto_refresh,
providers=get_providers())
@app.route("/add", methods=["GET", "POST"]) @app.route("/add", methods=["GET", "POST"])
@require_auth @require_auth
def add_source(): def add_source():
if request.method == "GET": if request.method == "GET":
return render_template("add.html") return render_template("add.html", providers=get_providers())
# POST: 执行 add-source.sh # POST: 执行 add-source.sh
source = request.form.get("source", "").strip() source = request.form.get("source", "").strip()
...@@ -408,16 +464,17 @@ def add_source(): ...@@ -408,16 +464,17 @@ def add_source():
count = request.form.get("count", "").strip() count = request.form.get("count", "").strip()
mail_to = request.form.get("mail_to", "").strip() mail_to = request.form.get("mail_to", "").strip()
mail_on_success = request.form.get("mail_on_success", "").strip() mail_on_success = request.form.get("mail_on_success", "").strip()
provider = request.form.get("provider", "").strip()
if not source or not re.match(r"^[a-zA-Z0-9_-]+$", source): if not source or not re.match(r"^[a-zA-Z0-9_-]+$", source):
flash("栏目名只能含字母、数字、下划线、连字符", "error") flash("栏目名只能含字母、数字、下划线、连字符", "error")
return render_template("add.html", form=request.form) return render_template("add.html", form=request.form, providers=get_providers())
cmd = [ADD_SOURCE, source, "--yes"] cmd = [ADD_SOURCE, source, "--yes"]
if time: if time:
if not re.match(r"^([01]\d|2[0-3]):[0-5]\d$", time): if not re.match(r"^([01]\d|2[0-3]):[0-5]\d$", time):
flash("时间格式应为 HH:MM(24小时制)", "error") flash("时间格式应为 HH:MM(24小时制)", "error")
return render_template("add.html", form=request.form) return render_template("add.html", form=request.form, providers=get_providers())
cmd += ["--time", time] cmd += ["--time", time]
if count: if count:
cmd += ["--count", count] cmd += ["--count", count]
...@@ -425,6 +482,8 @@ def add_source(): ...@@ -425,6 +482,8 @@ def add_source():
cmd += ["--mail-to", mail_to] cmd += ["--mail-to", mail_to]
if mail_on_success: if mail_on_success:
cmd += ["--mail-on-success", mail_on_success] cmd += ["--mail-on-success", mail_on_success]
if provider and provider != "default":
cmd += ["--provider", provider]
rc, stdout, stderr = run_script(cmd, timeout=30) rc, stdout, stderr = run_script(cmd, timeout=30)
if rc == 0: if rc == 0:
...@@ -432,7 +491,7 @@ def add_source(): ...@@ -432,7 +491,7 @@ def add_source():
return redirect(url_for("source_detail", name=source)) return redirect(url_for("source_detail", name=source))
else: else:
flash(f"添加失败 (exit {rc})\n{stdout}\n{stderr}", "error") flash(f"添加失败 (exit {rc})\n{stdout}\n{stderr}", "error")
return render_template("add.html", form=request.form) return render_template("add.html", form=request.form, providers=get_providers())
@app.route("/edit/<name>", methods=["GET", "POST"]) @app.route("/edit/<name>", methods=["GET", "POST"])
...@@ -448,20 +507,24 @@ def edit_source(name): ...@@ -448,20 +507,24 @@ def edit_source(name):
timer_name = f"news-harvester@{name}.timer" timer_name = f"news-harvester@{name}.timer"
current_time = read_oncalendar_from_systemd(timer_name) or parse_timer_file(timer_path) current_time = read_oncalendar_from_systemd(timer_name) or parse_timer_file(timer_path)
conf = read_conf(name) conf = read_conf(name)
return render_template("edit.html", name=name, current_time=current_time, conf=conf) return render_template("edit.html", name=name, current_time=current_time, conf=conf,
providers=get_providers())
# POST: 执行 modify-source.sh # POST: 执行 modify-source.sh
time = request.form.get("time", "").strip() time = request.form.get("time", "").strip()
count = request.form.get("count", "").strip() count = request.form.get("count", "").strip()
mail_to = request.form.get("mail_to", "").strip() mail_to = request.form.get("mail_to", "").strip()
mail_on_success = request.form.get("mail_on_success", "").strip() mail_on_success = request.form.get("mail_on_success", "").strip()
provider = request.form.get("provider", "").strip()
fallback_provider = request.form.get("fallback_provider", "").strip()
if not time or not re.match(r"^([01]\d|2[0-3]):[0-5]\d$", time): if not time or not re.match(r"^([01]\d|2[0-3]):[0-5]\d$", time):
flash("时间格式应为 HH:MM(24小时制)", "error") flash("时间格式应为 HH:MM(24小时制)", "error")
timer_name = f"news-harvester@{name}.timer" timer_name = f"news-harvester@{name}.timer"
current_time = read_oncalendar_from_systemd(timer_name) or parse_timer_file(timer_path) current_time = read_oncalendar_from_systemd(timer_name) or parse_timer_file(timer_path)
conf = read_conf(name) conf = read_conf(name)
return render_template("edit.html", name=name, current_time=current_time, conf=conf) return render_template("edit.html", name=name, current_time=current_time, conf=conf,
providers=get_providers())
cmd = [MODIFY_SOURCE, name, "--time", time, "--yes"] cmd = [MODIFY_SOURCE, name, "--time", time, "--yes"]
if count: if count:
...@@ -470,6 +533,10 @@ def edit_source(name): ...@@ -470,6 +533,10 @@ def edit_source(name):
cmd += ["--mail-to", mail_to] cmd += ["--mail-to", mail_to]
if mail_on_success: if mail_on_success:
cmd += ["--mail-on-success", mail_on_success] cmd += ["--mail-on-success", mail_on_success]
if provider:
cmd += ["--provider", provider]
if fallback_provider:
cmd += ["--fallback-provider", fallback_provider]
rc, stdout, stderr = run_script(cmd, timeout=30) rc, stdout, stderr = run_script(cmd, timeout=30)
if rc == 0: if rc == 0:
...@@ -542,12 +609,15 @@ def continue_source(name): ...@@ -542,12 +609,15 @@ def continue_source(name):
if mode not in ("resume", "repair"): if mode not in ("resume", "repair"):
mode = "resume" mode = "resume"
debug_flag = request.form.get("debug", "").strip() debug_flag = request.form.get("debug", "").strip()
provider = request.form.get("provider", "").strip()
# 后台启动 continue.sh(非阻塞) # 后台启动 continue.sh(非阻塞)
# 以 yichu 用户执行(通过 sudo -u),确保文件权限/邮件/PATH 全部正确 # 以 yichu 用户执行(通过 sudo -u),确保文件权限/邮件/PATH 全部正确
cmd = ["sudo", "-u", "yichu", CONTINUE_SCRIPT, name, "--mode", mode, "--yes"] cmd = ["sudo", "-u", "yichu", CONTINUE_SCRIPT, name, "--mode", mode, "--yes"]
if debug_flag: if debug_flag:
cmd.append("--debug") cmd.append("--debug")
if provider:
cmd += ["--provider", provider]
proc = subprocess.Popen( proc = subprocess.Popen(
cmd, cmd,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
...@@ -605,5 +675,77 @@ def disable_source(name): ...@@ -605,5 +675,77 @@ def disable_source(name):
return redirect(url_for("source_detail", name=name)) return redirect(url_for("source_detail", name=name))
# ── Provider 管理路由 ──────────────────────────────────────
@app.route("/providers")
@require_auth
def provider_list():
providers = get_providers()
return render_template("providers.html", providers=providers)
@app.route("/provider/<name>")
@require_auth
def provider_detail(name):
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
abort(404)
provider = read_provider(name)
if not provider:
abort(404)
return render_template("provider_detail.html", provider=provider)
@app.route("/provider/add", methods=["GET", "POST"])
@require_auth
def provider_add():
if request.method == "GET":
return render_template("provider_add.html")
# POST
name = request.form.get("name", "").strip()
json_text = request.form.get("json", "").strip()
if not name or not re.match(r"^[a-zA-Z0-9_-]+$", name):
flash("Provider 名称只能含字母、数字、下划线、连字符", "error")
return render_template("provider_add.html", form=request.form)
if name == "default":
flash("Provider 名称不能用 default(reserved)", "error")
return render_template("provider_add.html", form=request.form)
# 验证 JSON
try:
data = json.loads(json_text)
if not isinstance(data, dict):
raise ValueError("JSON 顶层必须是对象(key-value)")
except Exception as e:
flash(f"JSON 格式无效: {e}", "error")
return render_template("provider_add.html", form=request.form)
# 写入文件
PROVIDER_DIR.mkdir(parents=True, exist_ok=True)
path = PROVIDER_DIR / f"{name}.json"
if path.exists():
flash(f"Provider {name} 已存在,请用其他名称或先删除", "error")
return render_template("provider_add.html", form=request.form)
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
flash(f"Provider {name} 创建成功({len(data)} 个环境变量)", "success")
return redirect(url_for("provider_detail", name=name))
@app.route("/provider/delete/<name>", methods=["POST"])
@require_auth
def provider_delete(name):
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
abort(404)
path = PROVIDER_DIR / f"{name}.json"
if not path.exists():
flash(f"Provider {name} 不存在", "error")
return redirect(url_for("provider_list"))
path.unlink()
flash(f"Provider {name} 已删除", "success")
return redirect(url_for("provider_list"))
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="127.0.0.1", port=8080, debug=False) app.run(host="127.0.0.1", port=8080, debug=False)
...@@ -46,6 +46,17 @@ ...@@ -46,6 +46,17 @@
</select> </select>
</div> </div>
<div class="form-group">
<label for="provider">Claude Provider</label>
<select id="provider" name="provider">
<option value="default">默认 (系统全局配置)</option>
{% for p in providers %}
<option value="{{ p.name }}" {% if form and form.get('provider') == p.name %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
<div class="hint">选择该栏目使用的 Claude provider。默认 = 不加 --settings,用系统全局配置。</div>
</div>
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary">创建栏目</button> <button type="submit" class="btn btn-primary">创建栏目</button>
<a class="btn" href="{{ url_for('index') }}">取消</a> <a class="btn" href="{{ url_for('index') }}">取消</a>
......
...@@ -204,6 +204,7 @@ ...@@ -204,6 +204,7 @@
<a class="logo" href="{{ url_for('index') }}">📰 News Harvester</a> <a class="logo" href="{{ url_for('index') }}">📰 News Harvester</a>
<a href="{{ url_for('index') }}">栏目列表</a> <a href="{{ url_for('index') }}">栏目列表</a>
<a href="{{ url_for('add_source') }}">添加栏目</a> <a href="{{ url_for('add_source') }}">添加栏目</a>
<a href="{{ url_for('provider_list') }}">Provider</a>
</nav> </nav>
<div class="container"> <div class="container">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
......
...@@ -24,19 +24,56 @@ ...@@ -24,19 +24,56 @@
<button type="submit" class="btn btn-primary" {% if continue_status and continue_status.running %}disabled{% endif %}>手动触发</button> <button type="submit" class="btn btn-primary" {% if continue_status and continue_status.running %}disabled{% endif %}>手动触发</button>
</form> </form>
<form method="POST" action="{{ url_for('continue_source', name=name) }}" style="display:inline" <form method="POST" action="{{ url_for('continue_source', name=name) }}" style="display:inline"
onsubmit="return confirm('确定对 {{ name }} 执行继续采集?\n将恢复上次 Claude 对话,在已有基础上继续完成中断的采集。')"> onsubmit="return confirmContinue('resume', '{{ name }}', '{{ conf.get("FALLBACK_PROVIDER", "") }}', '{{ conf.get("PROVIDER", "") }}')">
<input type="hidden" name="mode" value="resume"> <input type="hidden" name="mode" value="resume">
<select name="provider" style="background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;padding:6px 8px;font-size:13px" {% if continue_status and continue_status.running %}disabled{% endif %}>
<option value="">默认 (FALLBACK)</option>
{% for p in providers %}
<option value="{{ p.name }}">{{ p.name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn" {% if continue_status and continue_status.running %}disabled{% endif %}> <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 %} {% if continue_status and continue_status.running and continue_status.mode == 'resume' %}续采中…{% else %}继续采集{% endif %}
</button> </button>
</form> </form>
<form method="POST" action="{{ url_for('continue_source', name=name) }}" style="display:inline" <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此操作会删除之前本次采集的文章,无法撤销!')"> onsubmit="return confirmContinue('repair', '{{ name }}', '{{ conf.get("FALLBACK_PROVIDER", "") }}', '{{ conf.get("PROVIDER", "") }}')">
<input type="hidden" name="mode" value="repair"> <input type="hidden" name="mode" value="repair">
<select name="provider" style="background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;padding:6px 8px;font-size:13px" {% if continue_status and continue_status.running %}disabled{% endif %}>
<option value="">默认 (FALLBACK)</option>
{% for p in providers %}
<option value="{{ p.name }}">{{ p.name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-danger" {% if continue_status and continue_status.running %}disabled{% endif %}> <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 %} {% if continue_status and continue_status.running and continue_status.mode == 'repair' %}修复中…{% else %}采集修复{% endif %}
</button> </button>
</form> </form>
<script>
function confirmContinue(mode, source, fallbackProvider, currentProvider) {
var selects = document.querySelectorAll('select[name="provider"]');
var form = event.target.closest('form');
var sel = form.querySelector('select[name="provider"]');
var selectedProvider = sel ? sel.value : '';
var modeLabel = mode === 'repair' ? '采集修复' : '继续采集';
// 没有选 provider (用默认),且没有 fallback
if (!selectedProvider && !fallbackProvider) {
var msg = '确定对 ' + source + ' 执行' + modeLabel + '?\n' +
'将恢复上次 Claude 对话' +
(mode === 'repair' ? ',清理之前归档的错误数据,重新完整采集并 force 推送' : ',在已有基础上继续完成中断的采集') + '。\n\n' +
'⚠️ 该数据源没有配置备用 provider(FALLBACK_PROVIDER),\n' +
'将使用与上次相同的 provider 重试。确认继续?';
return confirm(msg);
}
if (mode === 'repair') {
return confirm('⚠️ 确定对 ' + source + ' 执行采集修复?\n\n' +
'这将:\n1. 恢复上次 Claude 对话\n2. 清理之前归档的错误数据\n' +
'3. 重新完整采集并 force 推送\n\n此操作会删除之前本次采集的文章,无法撤销!');
}
return confirm('确定对 ' + source + ' 执行继续采集?\n将恢复上次 Claude 对话,在已有基础上继续完成中断的采集。');
}
</script>
<a class="btn" href="{{ url_for('index') }}">返回列表</a> <a class="btn" href="{{ url_for('index') }}">返回列表</a>
<form method="POST" action="{{ url_for('disable_source', name=name) }}" style="display:inline" <form method="POST" action="{{ url_for('disable_source', name=name) }}" style="display:inline"
onsubmit="return confirm('确定停用栏目 {{ name }}?\n\n这将:\n1. 停止并 disable 定时器\n2. 清空运行时间(不再触发)\n\n文件保留,可随时通过编辑重新设置时间来启用。')"> onsubmit="return confirm('确定停用栏目 {{ name }}?\n\n这将:\n1. 停止并 disable 定时器\n2. 清空运行时间(不再触发)\n\n文件保留,可随时通过编辑重新设置时间来启用。')">
...@@ -107,6 +144,10 @@ ...@@ -107,6 +144,10 @@
<div class="label">Turns</div> <div class="label">Turns</div>
<div class="value">{{ log_info.get('turns', '-') }}</div> <div class="value">{{ log_info.get('turns', '-') }}</div>
</div> </div>
<div class="card">
<div class="label">Provider</div>
<div class="value" style="font-size:14px">{{ log_info.get('provider', '-') }}</div>
</div>
<div class="card"> <div class="card">
<div class="label">修改时间</div> <div class="label">修改时间</div>
<div class="value" style="font-size:14px">{{ log_info.get('mtime', '?') }}</div> <div class="value" style="font-size:14px">{{ log_info.get('mtime', '?') }}</div>
......
...@@ -39,6 +39,28 @@ ...@@ -39,6 +39,28 @@
<div class="hint">当前: {{ conf.get('MAIL_ON_SUCCESS', '(继承 default.conf)') }}</div> <div class="hint">当前: {{ conf.get('MAIL_ON_SUCCESS', '(继承 default.conf)') }}</div>
</div> </div>
<div class="form-group">
<label for="provider">Claude Provider</label>
<select id="provider" name="provider">
<option value="default" {% if not conf.get('PROVIDER') or conf.get('PROVIDER') == 'default' %}selected{% endif %}>默认 (系统全局配置)</option>
{% for p in providers %}
<option value="{{ p.name }}" {% if conf.get('PROVIDER') == p.name %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
<div class="hint">当前: {{ conf.get('PROVIDER', '(继承 default.conf / 默认)') }}</div>
</div>
<div class="form-group">
<label for="fallback_provider">备用 Provider (FALLBACK)</label>
<select id="fallback_provider" name="fallback_provider">
<option value="default" {% if not conf.get('FALLBACK_PROVIDER') or conf.get('FALLBACK_PROVIDER') == 'default' %}selected{% endif %}>无 (不使用备用)</option>
{% for p in providers %}
<option value="{{ p.name }}" {% if conf.get('FALLBACK_PROVIDER') == p.name %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
<div class="hint">continue.sh 续采/修复时默认使用此 provider。当前: {{ conf.get('FALLBACK_PROVIDER', '(未设置)') }}</div>
</div>
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary">保存修改</button> <button type="submit" class="btn btn-primary">保存修改</button>
<a class="btn" href="{{ url_for('source_detail', name=name) }}">取消</a> <a class="btn" href="{{ url_for('source_detail', name=name) }}">取消</a>
......
...@@ -32,6 +32,7 @@ ...@@ -32,6 +32,7 @@
<th>栏目</th> <th>栏目</th>
<th>状态</th> <th>状态</th>
<th>运行时间</th> <th>运行时间</th>
<th>Provider</th>
<th>上次触发</th> <th>上次触发</th>
<th>下次触发</th> <th>下次触发</th>
<th>最近运行</th> <th>最近运行</th>
...@@ -56,6 +57,16 @@ ...@@ -56,6 +57,16 @@
{% endif %} {% endif %}
</td> </td>
<td><code>{{ s.run_time }}</code></td> <td><code>{{ s.run_time }}</code></td>
<td>
{% if s.provider %}
<span class="badge ok">{{ s.provider }}</span>
{% else %}
<span style="color:#666">默认</span>
{% endif %}
{% if s.fallback_provider %}
<span style="font-size:11px;color:#888" title="备用 provider">→ {{ s.fallback_provider }}</span>
{% endif %}
</td>
<td>{{ s.last }}</td> <td>{{ s.last }}</td>
<td>{{ s.next }}</td> <td>{{ s.next }}</td>
<td> <td>
......
{% extends "base.html" %}
{% block title %}添加 Provider — News Harvester{% endblock %}
{% block content %}
<h1>添加 Provider</h1>
<p style="color:#888;font-size:13px;margin-bottom:16px">
Provider 配置是一个 JSON 对象,包含 Claude 的环境变量。
数据源通过 <code>PROVIDER=name</code> 引用此配置,
脚本启动 Claude 时会加上 <code>--settings '{"env": {...}}'</code>。
</p>
<form method="POST" action="{{ url_for('provider_add') }}">
<div class="form-group">
<label for="name">Provider 名称 *</label>
<input type="text" id="name" name="name" required
pattern="[a-zA-Z0-9_-]+"
placeholder="如 easyrouter"
value="{{ form.name if form else '' }}">
<div class="hint">只能含字母、数字、下划线、连字符。不能用 default(reserved)。</div>
</div>
<div class="form-group">
<label for="json">JSON 配置 *</label>
<textarea id="json" name="json" required rows="16"
style="width:100%;max-width:700px;padding:12px;background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;font-size:13px;font-family:'SF Mono','Fira Code',monospace;line-height:1.5"
placeholder='{
"ANTHROPIC_AUTH_TOKEN": "sk-xxx",
"ANTHROPIC_BASE_URL": "https://xxx.io",
"ANTHROPIC_MODEL": "claude-sonnet-5"
}'>{{ form.json if form else '{
"ANTHROPIC_AUTH_TOKEN": "sk-xxx",
"ANTHROPIC_BASE_URL": "https://xxx.io",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-5",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "claude-sonnet-5",
"ANTHROPIC_MODEL": "claude-sonnet-5",
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-5"
}' }}</textarea>
<div class="hint">JSON 对象,key-value 格式。文件将保存到 <code>/etc/news-harvester/providers/&lt;name&gt;.json</code></div>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">创建 Provider</button>
<a class="btn" href="{{ url_for('provider_list') }}">取消</a>
</div>
</form>
{% endblock %}
{% extends "base.html" %}
{% block title %}Provider {{ provider.name }} — News Harvester{% endblock %}
{% block content %}
<h1>Provider: {{ provider.name }}</h1>
<div class="actions">
<a class="btn" href="{{ url_for('provider_list') }}">返回列表</a>
<form method="POST" action="{{ url_for('provider_delete', name=provider.name) }}" style="display:inline"
onsubmit="return confirm('确定删除 provider {{ provider.name }}?\n正在使用此 provider 的数据源将退回默认配置。')">
<button type="submit" class="btn btn-danger">删除 Provider</button>
</form>
</div>
{% if provider.get('error') %}
<div class="flash error">JSON 解析失败: {{ provider.error }}</div>
{% else %}
<h2>环境变量配置 <span style="font-size:12px;color:#666">(敏感值已脱敏)</span></h2>
<table>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% for item in provider.env %}
<tr>
<td><code>{{ item.key }}</code></td>
<td style="font-size:13px">{{ item.value }}</td>
</tr>
{% endfor %}
{% if not provider.env %}
<tr><td colspan="2" style="color:#666;text-align:center">无配置项</td></tr>
{% endif %}
</tbody>
</table>
<p style="color:#666;font-size:12px;margin-top:12px">
此 provider 在 Claude 启动时通过 <code>--settings '{"env": {...}}'</code> 注入。
敏感 key(TOKEN/KEY/SECRET/PASSWORD/AUTH)的值已脱敏显示。
</p>
{% endif %}
{% endblock %}
{% extends "base.html" %}
{% block title %}Provider 列表 — News Harvester{% endblock %}
{% block content %}
<div class="actions">
<a class="btn btn-primary" href="{{ url_for('provider_add') }}">+ 添加 Provider</a>
</div>
<h1>Provider 列表 <span style="font-size:14px;color:#666">({{ providers|length }} 个)</span></h1>
<p style="color:#888;font-size:13px;margin-bottom:16px">
Provider 配置存放在 <code>/etc/news-harvester/providers/</code>,
每个 JSON 文件包含 Claude 的 env 配置(ANTHROPIC_AUTH_TOKEN、ANTHROPIC_BASE_URL 等)。
数据源通过 conf 中的 <code>PROVIDER=name</code> 引用;
<code>default</code> 或未设置 = 使用系统全局配置(不加 --settings)。
</p>
<table>
<thead>
<tr>
<th>名称</th>
<th>环境变量数</th>
<th>包含的 Key</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for p in providers %}
<tr>
<td>
<a class="source-link" href="{{ url_for('provider_detail', name=p.name) }}">{{ p.name }}</a>
</td>
<td>{{ p.key_count }}</td>
<td style="font-size:12px;color:#888">
{{ p.env_keys | join(', ') }}
</td>
<td>
<a class="btn btn-sm" href="{{ url_for('provider_detail', name=p.name) }}">详情</a>
<form method="POST" action="{{ url_for('provider_delete', name=p.name) }}" style="display:inline"
onsubmit="return confirm('确定删除 provider {{ p.name }}?\n正在使用此 provider 的数据源将退回默认配置。')">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
{% endfor %}
{% if not providers %}
<tr>
<td colspan="4" style="color:#666;text-align:center;padding:24px">
暂无 provider 配置。点击上方"添加 Provider"创建。
</td>
</tr>
{% endif %}
</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