Skill: suidge/openrouter-free-helper (version 1.0.10)

--- name: openrouter-free-helper description: 监控 OpenRouter 免费模型的到期通知和新模型发现，自动每日检查并推送飞书通知 requires: bins: [python3, bb-browser] python: [requests, beautifulsoup4] allowed-tools: Bash, exec --- # OpenRouter Free Helper 监控 OpenRouter 免费模型的两类关键信息： 1. **到期通知** - 识别 "Going away [日期]" 警告，提前提醒 2. **新模型发现** - 自动发现新加入的免费模型 ## 🚀 快速开始 ### 前置依赖 **1. 安装 bb-browser**（可选，用于增强发现链路） ```bash # 使用 Homebrew 安装 brew install bb-browser # 或使用 npm 全局安装 npm install -g bb-browser ``` **2. 配置 Chrome 调试模式** bb-browser 需要 Chrome 在调试模式下运行（端口 9222）： ```bash # 手动启动 Chrome 调试模式 /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ --remote-debugging-port=9222 \ --no-first-run \ --no-default-browser-check ``` **或者**：脚本会自动检测并启动 Chrome 调试模式（首次启动可能需要 10-15 秒） **3. Python 依赖** ```bash pip3 install requests beautifulsoup4 ``` ### 安装技能 ```bash # 使用 ClawHub 安装 clawhub install openrouter-free-helper ``` ### 配置 编辑配置文件 `~/.openclaw/workspace/skills/openrouter-free-helper/config/config.json`： ```json { "check_time": "08:00", "notify_channel": "feishu", "notify_target": "user:ou_xxxxxxxxxxxxxxxxxxxxx", "status_file": "~/.openclaw/workspace/skills/openrouter-free-helper/data/status.json", "openclaw_config": "~/.openclaw/openclaw.json" } ``` **参数说明**： - `notify_target`: 飞书用户 ID（格式：`user:ou_xxx`） - `check_time`: 每日检查时间（24 小时制） - `status_file`: 状态文件路径（自动创建） ### 启用自动化 > ⚠️ **Cron 场景必须使用 `--no-notify`**，禁止脚本自行发送通知，统一交给 cron delivery 输出一次最终结果。 技能安装后**不会自动创建 Cron 任务**。如需每日自动检查，请手动添加一条 Cron，并让它读取固定任务说明文件。 示例：每日 08:00 执行 ```bash openclaw cron add --job '{ "id": "openrouter-monitor-001", "name": "OpenRouter Free Model Monitor", "schedule": {"kind": "cron", "expr": "0 8 * * *", "tz": "Asia/Shanghai"}, "sessionTarget": "isolated", "wakeMode": "now", "payload": { "kind": "agentTurn", "message": "Read /Users/neoshi/.openclaw/workspace/skills/openrouter-free-helper/references/cron-task.md and follow it exactly. Keep the run self-contained in an isolated session rooted at /Users/neoshi/.openclaw/workspace. Return only a brief plain-text summary suitable for Feishu delivery.", "timeoutSeconds": 300 }, "delivery": { "mode": "announce", "channel": "feishu", "to": "user:ou_xxxxxxxxxxxxxxxxxxxxx" } }' ``` 检查 Cron 状态： ```bash openclaw cron list openclaw cron runs --jobId openrouter-monitor-001 ``` --- ## 📖 使用指南 ### 手动检查 ```bash # 基础检查（静默模式，仅在变化时通知） python3 ~/.openclaw/workspace/skills/openrouter-free-helper/scripts/check-models.py # 详细输出（查看执行过程） python3 ~/.openclaw/workspace/skills/openrouter-free-helper/scripts/check-models.py --verbose # 模拟运行（不发送通知） python3 ~/.openclaw/workspace/skills/openrouter-free-helper/scripts/check-models.py --verbose --dry-run # Cron / agent-run 场景：禁止脚本自行发通知，交给上层 delivery python3 ~/.openclaw/workspace/skills/openrouter-free-helper/scripts/check-models.py --no-notify ``` > ⚠️ **Cron 场景必须使用 `--no-notify`**，否则脚本内通知与 cron delivery 可能重复发送。 ### 查询 bb-browser 适配器 ```bash # 获取所有免费模型列表 bb-browser site openrouter/free-models --json --openclaw # 查询特定模型的到期信息 bb-browser site openrouter/model-expiry google/gemma-4-26b-a4b-it:free --json --openclaw ``` ### 查看状态文件 ```bash cat ~/.openclaw/workspace/skills/openrouter-free-helper/data/status.json ``` **状态文件结构**： ```json { "last_check": "2026-04-10T21:59:06.858640", "known_models": ["openrouter/google/gemma-4-26b-a4b-it:free", ...], "expiring_soon": [ { "model": "arcee-ai/trinity-large-preview", "going_away_date": "2026-04-22", "days_left": 11, "url": "https://openrouter.ai/arcee-ai/trinity-large-preview" } ] } ``` --- ## 🔔 通知规则 ### 到期提醒（分级推送） | 级别 | 条件 | 行为 | |------|------|------| | 🚨 **紧急** | ≤1 天到期 | 立即推送 | | ⚠️ **警告** | ≤3 天到期 | 推送提醒 | | 📅 **预告** | >3 天到期 | 仅首次发现时推送 | **去重机制**： - 同一模型的到期通知，只在首次发现或日期变化时推送 - 避免每天重复发送相同提醒 ### 新模型发现 - 仅在首次发现时推送 - 每次最多显示 10 个新模型 - 自动过滤已配置的模型 ### 静默规则 以下情况**不发送通知**： - 无新模型发现 - 到期通知无变化（且不在紧急/警告级别） - 抓取失败（避免误报） --- ## 🛠️ 故障排查 ### Chrome 调试模式问题 **症状**：`bb-browser` 报错 "Chrome not connected" **解决**： ```bash # 1. 检查 Chrome 是否运行 ps aux | grep "remote-debugging-port=9222" # 2. 手动启动 Chrome 调试模式 /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ --remote-debugging-port=9222 # 3. 验证端口是否可访问 curl http://127.0.0.1:9222/json/version ``` ### bb-browser 适配器问题 **症状**：`bb-browser site openrouter/free-models` 返回空列表 **检查**： ```bash # 查看适配器是否存在 ls -la ~/.bb-browser/sites/openrouter/ # 测试适配器 bb-browser site openrouter/free-models --json --openclaw ``` **修复**：重新安装技能或手动创建适配器（见 `references/openrouter-structure.md`） ### Python 依赖缺失 **症状**：`ModuleNotFoundError: No module named 'requests'` **解决**： ```bash pip3 install requests beautifulsoup4 ``` --- ## 📁 文件结构 ``` ~/.openclaw/workspace/skills/openrouter-free-helper/ ├── SKILL.md # 技能文档 ├── config/ │ └── config.json # 配置文件 ├── data/ │ └── status.json # 状态文件（自动生成） ├── scripts/ │ ├── check-models.py # 主检查脚本 │ └── fetch_page.py # 网页抓取模块 ├── references/ │ ├── cron-task.md # Cron 任务说明 │ └── openrouter-structure.md # OpenRouter 结构分析 └── adapters/ └── openrouter/ # bb-browser 适配器（在 ~/.bb-browser/sites/） ``` --- ## 🔧 高级配置 ### 修改检查频率 编辑 Cron 配置： ```bash openclaw cron edit ``` 将 `0 8 * * *` 改为其他时间（如每小时检查：`0 * * * *`） ### 添加更多监控模型 编辑 `~/.openclaw/openclaw.json`，在 `agents.defaults.models` 或 `agents[].model` 中添加 `:free` 后缀的模型 ID： ```json { "agents": { "defaults": { "models": { "openrouter/google/gemma-4-26b-a4b-it:free": {}, "openrouter/google/gemma-4-31b-it:free": {} } } } } ``` 脚本会自动识别并监控这些模型的到期状态。 --- ## 📊 技术实现 ### 页面抓取策略 1. **Layer 1**: `requests`（优先，稳定且快速） 2. **Layer 2**: `urllib`（标准库兜底） 说明：本地 Python 脚本不再伪装调用 OpenClaw 工具。工具型抓取属于 agent 运行期能力，不应在脚本里用 subprocess 硬模拟。 ### 新模型发现链路 1. 优先尝试 `bb-browser site openrouter/free-models --json --openclaw` 2. 若失败，则回退到 OpenRouter 内部 API： - 端点：`https://openrouter.ai/api/frontend/models` - 无需认证，可直接提取免费模型列表 ### Chrome 调试模式管理 - 自动检测 Chrome 调试端口是否可用（`http://127.0.0.1:9222/json/version`） - 若不可用，可启动独立 profile：`/tmp/openclaw-chrome-debug` - 设计目标是不干扰用户现有 Chrome 会话 --- ## 🌟 特性亮点 - ✅ **零打扰**：无变化时静默执行 - ✅ **分级提醒**：紧急/警告/预告三级通知 - ✅ **去重机制**：避免重复推送相同信息 - ✅ **自动修复**：Chrome 未运行时自动启动 - ✅ **多层兜底**：三层抓取 + API fallback - ✅ **时区感知**：所有时间计算基于 Asia/Shanghai --- ## 📝 更新日志 ### v1.0.10 (2026-05-09) - 修正新模型发现链路：使用 `endpoint.model_variant_slug` 替代顶层 `slug`，确保返回带 `:free` 后缀的完整端点 ID ### v1.0.9 (2026-04-16) - 调整发现顺序：API 优先，bb-browser 作为 fallback（更稳定，主路径不依赖 Chrome） ### v1.0.8 (2026-04-16) - 摘要输出现在包含具体的模型名称和到期详情（不再只说数量） - 收紧 cron prompt，禁止 async exec completion 后输出任何消息（不只是禁止重复摘要） - 给 cron 命令加 `--verbose` 让模型能看到详细信息并整合到最终摘要 ### v1.0.7 (2026-04-15) - 为已配置但未带 `:free` 后缀的 `openrouter/*` 模型增加轻量页面探测，补抓“实际免费但未显式标 free”的模型 - 收紧 cron prompt，强制 `--no-notify` 并避免 async exec completion 后重复转述 - 保持检测策略轻量，不引入完整 pricing 系统复杂度 ### v1.0.5 (2026-04-14) - 新增 `--no-notify` 模式，供 cron 场景避免脚本内直接发通知 - 收紧 cron-task 规则，禁止中间态与“已推送”类措辞 - 脱敏发布内容，移除用户特定默认通知目标 ### v1.0.1 (2026-04-11) - 修正配置模型读取路径 - 修正状态文件与 JSON 读取容错 - 收紧到期提醒去重策略，避免重复提醒 - 精简抓取链路，改为 requests + urllib 的诚实 fallback - 新增 cron-task.md 作为 cron 固定任务说明 ### v1.0.0 (2026-04-10) - 初始版本发布 - 支持到期通知检测 - 支持新模型发现 - 分级提醒机制 - 智能 Chrome 管理 - 多层 fallback 机制 --- ## 🤝 贡献 问题反馈或功能建议，欢迎通过 GitHub Issues 提交。 --- **最后更新**: 2026-04-10 **维护者**: Silvermoon

path: README.md
content: # OpenRouter Free Model Monitor

<div align="center">

**监控 OpenRouter 免费模型的到期通知和新模型发现**

*Monitor OpenRouter free models for expiration notices and new model discoveries*

[![ClawHub](https://img.shields.io/badge/ClawHub-openrouter--free--helper-blue)](https://clawhub.ai/skills/openrouter-free-helper)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Python](https://img.shields.io/badge/Python-3.9+-blue)](https://python.org)

</div>

---

## 📖 简介 | Introduction

**中文**：  
OpenRouter Free Model Monitor 是一个自动化工具，用于监控 OpenRouter 平台上的免费模型。它会自动检测两类关键信息：

1. **到期通知** - 识别模型的 "Going away [日期]" 警告，提前提醒用户
2. **新模型发现** - 自动发现新加入的免费模型并推送通知

该工具设计为 OpenClaw 技能，支持每日自动检查并通过飞书推送通知。

**English**:  
OpenRouter Free Model Monitor is an automated tool for monitoring free models on the OpenRouter platform. It automatically detects two types of critical information:

1. **Expiration Notices** - Identifies "Going away [date]" warnings and alerts users in advance
2. **New Model Discovery** - Automatically discovers newly added free models and sends notifications

This tool is designed as an OpenClaw skill, supporting daily automated checks with Feishu notifications.

---

## ✨ 特性 | Features

| 中文 | English |
|------|---------|
| 🚨 **分级提醒** - 紧急 (≤1 天) / 警告 (≤3 天) / 预告 (>3 天) | 🚨 **Tiered Alerts** - Urgent (≤1 day) / Warning (≤3 days) / Notice (>3 days) |
| 🔕 **智能去重** - 仅首次发现、日期变化或严重级别升级时提醒 | 🔕 **Smart Deduplication** - Alerts only on first discovery, date changes, or severity upgrades |
| 🌐 **诚实抓取链路** - requests → urllib | 🌐 **Honest Fetch Chain** - requests → urllib |
| 🔎 **双路发现** - bb-browser 优先，失败回退 OpenRouter API | 🔎 **Dual Discovery Path** - bb-browser first, OpenRouter API fallback |
| 🤖 **自动 Chrome** - 仅用于增强发现链路，不再依赖它完成全部抓取 | 🤖 **Auto Chrome** - Used only for enhanced discovery, no longer required for all fetching |
| 📅 **定时检查** - 支持 Cron 每日自动执行 | 📅 **Scheduled Checks** - Supports daily Cron automation |
| 📱 **飞书通知** - 无缝集成飞书消息推送 | 📱 **Feishu Integration** - Seamless Feishu message notifications |

---

## 🚀 快速开始 | Quick Start

### 前置要求 | Prerequisites

```bash
# 1. 安装 bb-browser (可选 | Optional, for enhanced discovery)
brew install bb-browser
# 或 | Or: npm install -g bb-browser

# 2. Python 依赖 | Python Dependencies
pip3 install requests

# 3. Chrome 调试模式 | Chrome Debug Mode
# 仅当你想启用 bb-browser 增强发现时需要。
# Only needed when you want bb-browser based enhanced discovery.
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --remote-debugging-port=9222
```

### 安装方式 | Installation

**方式 1: 使用 ClawHub (推荐) | Method 1: Via ClawHub (Recommended)**
```bash
clawhub install openrouter-free-helper
```

**方式 2: Git 克隆 | Method 2: Git Clone**
```bash
git clone https://github.com/Suidge/openrouter-free-helper.git \
  ~/.openclaw/workspace/skills/openrouter-free-helper
```

### 添加 Cron 自动检查 | Add a Scheduled Cron Check

> ⚠️ **Cron 场景必须使用 `--no-notify`**，避免脚本内通知与 cron delivery 重复。

> 安装 skill **不会自动创建 Cron**。如果你需要每日自动运行，请手动添加。  
> Installing the skill does **not** auto-create a Cron job. Add it manually if you want scheduled checks.

示例：每日 08:00 检查并投递到飞书  
Example: run daily at 08:00 and deliver to Feishu

```bash
openclaw cron add --job '{
  "id": "openrouter-monitor-001",
  "name": "OpenRouter Free Model Monitor",
  "schedule": {"kind": "cron", "expr": "0 8 * * *", "tz": "Asia/Shanghai"},
  "sessionTarget": "isolated",
  "wakeMode": "now",
  "payload": {
    "kind": "agentTurn",
    "message": "Read /Users/neoshi/.openclaw/workspace/skills/openrouter-free-helper/references/cron-task.md and follow it exactly. Keep the run self-contained in an isolated session rooted at /Users/neoshi/.openclaw/workspace. Return only a brief plain-text summary suitable for Feishu delivery.",
    "timeoutSeconds": 300
  },
  "delivery": {
    "mode": "announce",
    "channel": "feishu",
    "to": "user:ou_xxxxxxxxxxxxxxxxxxxxx"
  }
}'
```

检查状态  
Check status

```bash
openclaw cron list
openclaw cron runs --jobId openrouter-monitor-001
```

### 配置 | Configuration

编辑配置文件 | Edit config file `config/config.json`:

```json
{
  "check_time": "08:00",
  "notify_channel": "feishu",
  "notify_target": "user:ou_xxxxxxxxxxxxxxxxxxxxx",
  "status_file": "~/.openclaw/workspace/skills/openrouter-free-helper/data/status.json",
  "openclaw_config": "~/.openclaw/openclaw.json"
}
```

| 参数 | Parameter | 说明 | Description |
|------|-----------|------|-------------|
| `check_time` | 检查时间 | 每日检查时间 (24 小时制) | Daily check time (24h format) |
| `notify_target` | 通知目标 | 飞书用户 ID (格式：`user:ou_xxx`) | Feishu user ID (format: `user:ou_xxx`) |

---

## 📖 使用指南 | Usage Guide

### 手动检查 | Manual Check

```bash
# 基础检查 (静默模式) | Basic check (silent mode)
python3 scripts/check-models.py

# 详细输出 | Verbose output
python3 scripts/check-models.py --verbose

# 模拟运行 (不发送通知) | Dry run (no notifications)
python3 scripts/check-models.py --verbose --dry-run

# Cron / agent-run 场景：禁止脚本自行发通知，交给上层 delivery
# Cron / agent-run mode: suppress script-side notifications and let outer delivery send once
python3 scripts/check-models.py --no-notify
```

> ⚠️ **Cron 场景必须使用 `--no-notify`**，否则脚本内通知与 cron delivery 可能重复发送。

### 使用 bb-browser 适配器 | Using bb-browser Adapters

```bash
# 获取所有免费模型 | Get all free models
bb-browser site openrouter/free-models --json --openclaw

# 查询特定模型到期信息 | Check specific model expiry
bb-browser site openrouter/model-expiry google/gemma-4-26b-a4b-it:free --json --openclaw
```

### 查看状态文件 | View Status File

```bash
cat data/status.json
```

**示例输出 | Example Output**:
```json
{
  "last_check": "2026-04-11T08:26:03.179157+08:00",
  "known_models": ["openrouter/google/gemma-4-26b-a4b-it:free"],
  "expiring_soon": [],
  "fetch_errors": []
}
```

---

## 🔔 通知规则 | Notification Rules

### 到期提醒分级 | Expiration Alert Tiers

| 级别 | Level | 条件 | Condition | 行为 | Action |
|------|-------|------|-----------|------|--------|
| 🚨 | 紧急 | ≤1 天 | ≤1 day | 立即推送 | Send immediately |
| ⚠️ | 警告 | ≤3 天 | ≤3 days | 推送提醒 | Send reminder |
| 📅 | 预告 | >3 天 | >3 days | 仅首次 | First discovery only |

### 静默规则 | Silent Mode Rules

以下情况**不发送通知** | **No notification** in these cases:
- 无新模型发现 | No new models discovered
- 到期通知无变化 (且不在紧急/警告级别) | No expiration changes (and not urgent/warning level)
- 抓取失败 (避免误报) | Fetch failed (avoid false positives)

---

## 🛠️ 故障排查 | Troubleshooting

### Chrome 调试模式问题 | Chrome Debug Mode Issues

**症状 | Symptom**: `bb-browser` 报错 "Chrome not connected"

**解决方案 | Solution**:
```bash
# 1. 检查 Chrome 是否运行 | Check if Chrome is running
ps aux | grep "remote-debugging-port=9222"

# 2. 手动启动 Chrome 调试模式 | Manually start Chrome debug mode
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --remote-debugging-port=9222

# 3. 验证端口 | Verify port
curl http://127.0.0.1:9222/json/version
```

### Python 依赖缺失 | Missing Python Dependencies

**症状 | Symptom**: `ModuleNotFoundError: No module named 'requests'`

**解决方案 | Solution**:
```bash
pip3 install requests beautifulsoup4
```

### bb-browser 适配器问题 | bb-browser Adapter Issues

**症状 | Symptom**: 返回空列表 | Returns empty list

**检查 | Check**:
```bash
ls -la ~/.bb-browser/sites/openrouter/
bb-browser site openrouter/free-models --json --openclaw
```

---

## 📁 项目结构 | Project Structure

```
openrouter-free-helper/
├── README.md                    # 项目说明 | Project documentation
├── SKILL.md                     # OpenClaw 技能文档 | OpenClaw skill doc
├── package.json                 # 项目元数据 | Project metadata
├── config/
│   └── config.json              # 配置文件 | Configuration file
├── data/
│   └── status.json              # 状态文件 (自动生成) | Status file (auto-generated)
├── scripts/
│   ├── check-models.py          # 主检查脚本 | Main check script
│   └── fetch_page.py            # 网页抓取模块 | Web scraping helper
└── references/
    ├── cron-task.md             # Cron 任务说明 | Cron task instructions
    └── openrouter-structure.md  # OpenRouter 结构分析 | OpenRouter structure analysis
```

---

## 🔧 高级配置 | Advanced Configuration

### 修改检查频率 | Modify Check Frequency

编辑 Cron 配置 | Edit Cron config:
```bash
openclaw cron edit
```

将 `0 8 * * *` 改为其他时间 (如每小时检查：`0 * * * *`)  
Change `0 8 * * *` to other time (e.g., hourly: `0 * * * *`)

### 添加更多监控模型 | Add More Monitored Models

编辑 `~/.openclaw/openclaw.json`，在 `agents.defaults.models` 或 `agents[].model` 中添加 `:free` 后缀的模型 ID：

Edit `~/.openclaw/openclaw.json`, add `:free` suffixed model IDs in `agents.defaults.models` or `agents[].model`:

```json
{
  "agents": {
    "defaults": {
      "models": {
        "openrouter/google/gemma-4-26b-a4b-it:free": {},
        "openrouter/google/gemma-4-31b-it:free": {}
      }
    }
  }
}
```

脚本会自动识别并监控这些模型的到期状态。  
The script will automatically identify and monitor these models' expiration status.

---

## 📊 技术实现 | Technical Implementation

### 页面抓取链路 | Page Fetch Chain

1. **Layer 1**: `requests` (首选 | preferred)
2. **Layer 2**: `urllib` (标准库兜底 | stdlib fallback)

本地 Python 脚本不再伪装调用 OpenClaw 工具。  
The local Python script no longer pretends to call OpenClaw tools via subprocess shims.

### 新模型发现链路 | New Model Discovery Path

1. 优先使用 `bb-browser site openrouter/free-models --json --openclaw`  
   First try `bb-browser site openrouter/free-models --json --openclaw`
2. 若失败，回退 OpenRouter 内部 API  
   If that fails, fall back to OpenRouter internal API

- **端点 | Endpoint**: `https://openrouter.ai/api/frontend/models`
- **无需认证 | No auth required**

### 智能 Chrome 管理 | Smart Chrome Management

- ✅ 自动检测 Chrome 调试模式状态 | Auto-detect Chrome debug mode status
- ✅ 端口探活验证 | Port probing verification
- ✅ 独立 profile 避免冲突 | Isolated profile to avoid conflicts
- ✅ 仅服务于增强发现链路 | Used only for enhanced discovery

---

## 📝 更新日志 | Changelog

### v1.0.9 (2026-04-16)
- 🔄 Switch discovery order: API-first, bb-browser fallback (more stable, no Chrome dependency for primary path)

### v1.0.8 (2026-04-16)
- 🔔 Improve summary output to include specific model names and expiration details
- 🔕 Harder cron prompt to forbid any output after async exec completion (not just duplicate summary)
- 📋 Add `--verbose` to cron command so agent can see detailed discovery info

### v1.0.7 (2026-04-15)
- 🧭 Add lightweight free-page probing for configured `openrouter/*` models that do not use the `:free` suffix
- 🔕 Harden cron prompt to force `--no-notify` and discourage duplicate relay after async exec completion
- 🩹 Keep the detection strategy intentionally lightweight, avoiding full pricing-system complexity

### v1.0.5 (2026-04-14)
- 🔕 Add `--no-notify` mode so cron runs do not send direct Feishu notifications from the script
- 🧹 Update cron instructions to require a single final summary only, with no intermediate delivery wording
- 🔐 Remove user-specific default notification target from published skill content

### v1.0.4 (2026-04-11)
- 📝 Clarify that installing the skill does not auto-create a Cron job
- ➕ Add explicit `openclaw cron add` setup guidance to README and SKILL.md
- 🔗 Point scheduled runs to `references/cron-task.md`

### v1.0.3 (2026-04-11)
- 🔧 Fix configured free-model lookup to read `agents.defaults.models`
- 🛡 Add safer JSON/status loading and preserve known model state on partial failures
- 🔕 Tighten expiration dedup logic to avoid repeated daily warning spam
- 🌐 Replace misleading multi-tool fetch shim with an honest `requests -> urllib` fallback chain
- 📝 Sync README and SKILL docs with actual runtime behavior
- ➕ Add `references/cron-task.md` for cron-safe task guidance

### v1.0.2 (2026-04-10)
- 🌐 Add GitHub repository
- 📝 Update repository URL in package.json

### v1.0.1 (2026-04-10)
- 📝 Fix installation instructions in SKILL.md
- ➕ Add homepage link to ClawHub

### v1.0.0 (2026-04-10)
- 🎉 Initial release
- ✅ Expiration notice detection
- ✅ New model discovery
- ✅ Tiered alert system
- ✅ Smart Chrome management
- ✅ Multi-layer fallback mechanism

---

## 🤝 贡献 | Contributing

欢迎提交 Issue 和 Pull Request！  
Issues and Pull Requests are welcome!

**问题反馈 | Bug Reports**: https://github.com/Suidge/openrouter-free-helper/issues

---

## 📄 许可证 | License

MIT License

---

<div align="center">

**Made with ❤️ by Silvermoon**

[⬆️ 返回顶部 | Back to Top](#openrouter-free-model-monitor)

</div>


path: package.json
content: {
  "name": "openrouter-free-helper",
  "version": "1.0.10",
  "description": "监控 OpenRouter 免费模型的到期通知和新模型发现，自动每日检查并推送飞书通知",
  "author": "Silvermoon",
  "license": "MIT",
  "keywords": [
    "openrouter",
    "free-models",
    "monitor",
    "notification",
    "feishu",
    "llm"
  ],
  "repository": {
    "type": "git",
    "url": "https://github.com/Suidge/openrouter-free-helper.git"
  },
  "homepage": "https://clawhub.ai/skills/openrouter-free-helper",
  "engines": {
    "node": ">=18.0.0"
  },
  "dependencies": {
    "bb-browser": ">=0.11.0"
  },
  "pythonDependencies": {
    "requests": ">=2.31.0"
  },
  "scripts": {
    "check": "python3 scripts/check-models.py --verbose",
    "dry-run": "python3 scripts/check-models.py --verbose --dry-run"
  },
  "clawhub": {
    "category": "automation",
    "tags": ["monitoring", "notification", "llm", "openrouter"],
    "features": [
      "自动监控 OpenRouter 免费模型到期通知",
      "新模型发现与推送",
      "分级提醒机制（紧急/警告/预告）",
      "智能去重，避免重复通知",
      "自动 Chrome 调试模式管理（可选增强）",
      "requests + urllib 抓取兜底",
      "bb-browser 优先，OpenRouter API 回退"
    ],
    "requirements": {
      "bins": ["python3", "bb-browser (optional)"],
      "browser": "Chrome (debug mode, port 9222, optional for enhanced discovery)"
    }
  }
}


path: references/cron-task.md
content: # OpenRouter Free Model Monitor Cron Task

目标：检查 OpenRouter 当前可用的免费模型状态，并输出简短摘要。

执行要求：
1. 在工作区内运行本地检查脚本 `/Users/neoshi/.openclaw/workspace/skills/openrouter-free-helper/scripts/check-models.py --no-notify`
2. 不要依赖浏览器，不要访问无关网站
3. **禁止输出任何中间态消息、解释、过程说明、工具结果转述或“已推送/已通知”类状态描述**
4. 如果脚本成功运行，只基于脚本最终结果给出 **一条** 纯文本摘要
5. 摘要只允许说明以下之一：
   - 有新的免费模型
   - 有免费模型到期/移除
   - 没有变化
   - 脚本执行失败，并简述原因
6. **不要自行发送消息**，不要说“详情已推送至飞书通知”“通知已通过飞书推送”等，因为 cron delivery 会自动发送最终摘要
7. 直接把最终摘要作为唯一输出，交给 cron delivery 发送

注意：
- 仅执行本地脚本与必要的本地读取
- 保持任务自包含，不携带无关对话上下文
- 输出必须简短、明确、可直接发给主人
- 输出必须是一次性最终结果，不得先发脚本状态，再发汇总


path: references/openrouter-structure.md
content: # OpenRouter Page Structure Reference

## Model Page Structure

**URL Pattern**: `https://openrouter.ai/{model_id}`
Example: `https://openrouter.ai/arcee-ai/trinity-large-preview:free`

### Expiration Notice

**Format**: `<span>Going away April 22, 2026</span>`

**Patterns to match**:
1. English: `Going away April 22, 2026`
2. Chinese: `Going away 2026 年 4 月 22 日`

**Regex patterns**:
```python
# English format
r"Going away\s+(\w+)\s+(\d{1,2}),?\s+(\d{4})"

# Chinese format
r"Going away\s+(\d{4})[年\-]\s*(\d{1,2})[月\-]\s*(\d{1,2})[日]?"
```

### Fetch Method

- **Layer 1**: `requests` + `BeautifulSoup` (works for this page)
- User-Agent: Chrome 144 on macOS
- Response: ~200KB HTML

## Free Models List Page

**URL**: `https://openrouter.ai/models?max_price=0`

**Note**: This is a dynamic React/Next.js page. Content is loaded via JavaScript.

**Fetch options**:
1. **bb-browser adapter** (preferred) - `bb-browser site openrouter/free-models --json --openclaw`
2. `requests` - Returns initial HTML (no model list)
3. `browser` tool - Can render JavaScript

## Internal API

**Endpoint**: `GET /api/frontend/models`

Returns JSON array of all models. Filter by `endpoint.is_free === true` for free models.

**Response structure**:
```json
{
  "data": [
    {
      "slug": "google/gemma-4-26b-a4b-it",
      "name": "Google: Gemma 4 26B A4B (free)",
      "endpoint": {
        "is_free": true,
        "provider_slug": "google-ai-studio"
      },
      "context_length": 262144
    }
  ]
}
```

## bb-browser Adapters

**Location**: `~/.bb-browser/sites/openrouter/`

### `openrouter/free-models`
```bash
bb-browser site openrouter/free-models --json --openclaw
```
Returns: `{"count": N, "models": [{"slug", "name", "author", "context_length", "url"}]}`

### `openrouter/model-expiry`
```bash
bb-browser site openrouter/model-expiry google/gemma-4-26b-a4b-it:free --json --openclaw
```
Returns: `{"model": "...", "going_away": "YYYY-MM-DD"|null, "url": "..."}`

## API Endpoints

**`GET /api/models`**: Returns HTML (not JSON) - not useful for programmatic access

## Testing

```bash
# Test fetch on known expiring model
python3 scripts/fetch-page.py https://openrouter.ai/arcee-ai/trinity-large-preview:free --verbose

# Test full check
python3 scripts/check-models.py --verbose
```

## Notes

- OpenRouter does not have a public API for model listings
- Free models are identified by `:free` suffix in model ID
- Expiration notices appear ~1-2 weeks before service ends
- Page structure may change - monitor and update patterns as needed


path: scripts/check-models.py
content: #!/usr/bin/env python3
"""
check-models.py - OpenRouter Free Model Monitor

Checks configured free models for expiration notices and discovers new models.
Sends Feishu notification when changes detected.

Usage:
  python3 check-models.py [--verbose] [--dry-run] [--no-notify]
"""

import json
import os
import re
import sys
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple

try:
    from zoneinfo import ZoneInfo
except ImportError:
    from backports.zoneinfo import ZoneInfo

# Import fetch module
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
    from fetch_page import fetch_page
except ImportError:
    def fetch_page(url, verbose=False):
        try:
            import requests
            headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}
            resp = requests.get(url, headers=headers, timeout=15)
            if resp.status_code == 200:
                return resp.text
        except Exception as e:
            if verbose:
                print(f"  ⚠ Inline fetch fallback failed: {e}")
        return None

# Paths
SCRIPT_DIR = Path(__file__).parent
SKILL_DIR = SCRIPT_DIR.parent
CONFIG_FILE = SKILL_DIR / "config" / "config.json"
STATUS_FILE = SKILL_DIR / "data" / "status.json"
OPENCLAW_CONFIG = Path.home() / ".openclaw" / "openclaw.json"

# OpenRouter URL patterns
OPENROUTER_BASE = "https://openrouter.ai"
MODEL_URL_TEMPLATE = f"{OPENROUTER_BASE}/{{model_id}}"
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")


def load_config() -> dict:
    """Load configuration file"""
    if CONFIG_FILE.exists():
        try:
            with open(CONFIG_FILE) as f:
                return json.load(f)
        except (OSError, json.JSONDecodeError) as e:
            print(f"⚠️ Config load failed: {e}, using defaults", file=sys.stderr)
    
    # Default config
    return {
        "notify_channel": "feishu",
        "notify_target": "user:ou_xxxxxxxxxxxxxxxxxxxxx",
        "status_file": str(STATUS_FILE),
        "openclaw_config": str(OPENCLAW_CONFIG)
    }


def load_status() -> dict:
    """Load last check status"""
    config = load_config()
    status_path = Path(config.get("status_file", str(STATUS_FILE)))
    
    if status_path.exists():
        try:
            with open(status_path) as f:
                return json.load(f)
        except (OSError, json.JSONDecodeError) as e:
            print(f"⚠️ Status load failed: {e}, using empty status", file=sys.stderr)
    
    return {
        "last_check": None,
        "known_models": [],
        "expiring_soon": []
    }


def save_status(status: dict):
    """Save check status"""
    config = load_config()
    status_path = Path(config.get("status_file", str(STATUS_FILE)))

    # Ensure directory exists
    status_path.parent.mkdir(parents=True, exist_ok=True)

    with open(status_path, 'w') as f:
        json.dump(status, f, indent=2, ensure_ascii=False)


def safe_load_json(path: Path) -> dict:
    """Load JSON file safely, return empty dict on failure."""
    try:
        with open(path) as f:
            return json.load(f)
    except (OSError, json.JSONDecodeError) as e:
        print(f"⚠️ JSON load failed for {path}: {e}", file=sys.stderr)
        return {}


def get_configured_models() -> Tuple[List[str], List[str]]:
    """Extract configured free models and configured openrouter non-:free candidates from openclaw.json"""
    config = load_config()
    openclaw_config_path = Path(config.get("openclaw_config", str(OPENCLAW_CONFIG)))
    
    if not openclaw_config_path.exists():
        print(f"ERROR: OpenClaw config not found: {openclaw_config_path}", file=sys.stderr)
        return [], []
    
    data = safe_load_json(openclaw_config_path)
    if not data:
        return [], []
    
    free_models = []
    openrouter_candidates = []

    def collect_model_id(model_id: Optional[str]):
        if not model_id:
            return
        if model_id.endswith(":free"):
            free_models.append(model_id)
        elif model_id.startswith("openrouter/"):
            openrouter_candidates.append(model_id)
    
    # Check agents.defaults.models
    models_section = data.get("agents", {}).get("defaults", {}).get("models", {})
    for model_id in models_section.keys():
        collect_model_id(model_id)
    
    # Check agents.list[].model (for each agent)
    agents = data.get("agents", {}).get("list", [])
    for agent in agents:
        model_config = agent.get("model", {})
        primary = model_config.get("primary")
        fallbacks = model_config.get("fallbacks", [])
        collect_model_id(primary)
        for fb in fallbacks:
            collect_model_id(fb)
    
    return sorted(set(free_models)), sorted(set(openrouter_candidates))


def page_looks_free(model_id: str, verbose: bool = False) -> bool:
    """Lightweight heuristic: treat configured openrouter model as free if its model page clearly signals Free/$0."""
    url = MODEL_URL_TEMPLATE.format(model_id=model_id)
    html = fetch_page(url, verbose)
    if not html:
        return False

    text = re.sub(r"\s+", " ", html).lower()
    markers = [
        '>free<',
        '$0',
        'free to use',
        '0 credits',
        'input: $0',
        'output: $0',
        'free model'
    ]
    matched = any(marker in text for marker in markers)
    if verbose and matched:
        print(f"  ✓ Page probe marked as free: {model_id}")
    return matched


def check_expiration_notice(model_id: str, verbose: bool = False) -> Optional[Dict]:
    """
    Check if a model has "Going away" notice.
    Returns:
        - dict with expiration info if found
        - None if no notice or fetch failed (logged separately)
    """
    url = MODEL_URL_TEMPLATE.format(model_id=model_id)
    
    if verbose:
        print(f"Checking: {model_id}")
    
    html = fetch_page(url, verbose)
    if not html:
        if verbose:
            print(f"  ⚠️ Fetch failed for {model_id}")
        return {
            "model": model_id,
            "error": "fetch_failed",
            "url": url
        }
    
    # Search for "Going away" pattern
    # Patterns: "Going away April 22, 2026" or "Going away 2026 年 4 月 22 日"
    patterns = [
        r"Going away\s+(\w+)\s+(\d{1,2}),?\s+(\d{4})",  # English: April 22, 2026
        r"Going away\s+(\d{4})[年\-]\s*(\d{1,2})[月\-]\s*(\d{1,2})[日]?",  # Chinese
    ]
    
    for pattern in patterns:
        match = re.search(pattern, html, re.IGNORECASE)
        if match:
            if len(match.groups()) == 3 and match.group(1).isdigit():
                # Chinese format: YYYY-MM-DD
                year, month, day = match.groups()[:3]
                date_str = f"{year}-{month.zfill(2)}-{day.zfill(2)}"
            else:
                # English format: Month DD, YYYY
                month_name, day, year = match.groups()
                months = {
                    'jan': '01', 'feb': '02', 'mar': '03', 'apr': '04',
                    'may': '05', 'jun': '06', 'jul': '07', 'aug': '08',
                    'sep': '09', 'oct': '10', 'nov': '11', 'dec': '12'
                }
                month_num = months.get(month_name[:3].lower(), '01')
                date_str = f"{year}-{month_num}-{day.zfill(2)}"
            
            # Calculate days remaining (use Asia/Shanghai timezone)
            try:
                expire_date = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=SHANGHAI_TZ)
                days_left = (expire_date - datetime.now(SHANGHAI_TZ)).days
            except Exception:
                days_left = 0
            
            return {
                "model": model_id,
                "going_away_date": date_str,
                "days_left": days_left,
                "url": url
            }
    
    return None  # No expiration notice found


def ensure_chrome_debug_mode(verbose: bool = False) -> bool:
    """
    Ensure Chrome is running in debug mode (port 9222).
    Returns True if Chrome is available, False otherwise.
    """
    import time
    from urllib.request import urlopen
    from urllib.error import URLError
    
    # Check if Chrome debug port is already responding
    try:
        with urlopen("http://127.0.0.1:9222/json/version", timeout=2) as resp:
            if resp.status == 200:
                if verbose:
                    print(f"  ✓ Chrome debug mode already running (port 9222)")
                return True
    except URLError:
        pass
    except Exception:
        pass
    
    if verbose:
        print(f"  ℹ️  Chrome not in debug mode, starting...")
    
    # Try to start Chrome in debug mode with isolated profile
    try:
        chrome_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
        profile_dir = "/tmp/openclaw-chrome-debug"
        
        # Start Chrome with debug port and isolated profile
        subprocess.Popen(
            [chrome_path, "--remote-debugging-port=9222", "--no-first-run", 
             "--no-default-browser-check", f"--user-data-dir={profile_dir}"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True
        )
        
        # Wait for Chrome to start by polling the debug port
        for i in range(15):
            time.sleep(1)
            try:
                with urlopen("http://127.0.0.1:9222/json/version", timeout=2) as resp:
                    if resp.status == 200:
                        if verbose:
                            print(f"  ✓ Chrome debug mode started (port 9222)")
                        return True
            except URLError:
                pass
            except Exception:
                pass
        
        if verbose:
            print(f"  ⚠ Chrome startup timed out (15s)")
        return False
    except Exception as e:
        if verbose:
            print(f"  ⚠ Failed to start Chrome: {e}")
        return False


def discover_new_models(verbose: bool = False) -> List[str]:
    """
    Discover new free models from OpenRouter.
    API-first, with bb-browser as fallback.
    Returns list of new model IDs.
    """
    if verbose:
        print(f"Discovering new free models...")
    
    models = []
    
    # First: OpenRouter API (fast, stable, no Chrome dependency)
    try:
        import urllib.request
        req = urllib.request.Request(
            "https://openrouter.ai/api/frontend/models",
            headers={"User-Agent": "Mozilla/5.0"}
        )
        with urllib.request.urlopen(req, timeout=30) as resp:
            data = json.loads(resp.read())
            if isinstance(data, dict) and "data" in data:
                api_models = []
                for m in data["data"]:
                    endpoint = m.get("endpoint") or {}
                    if endpoint.get("is_free") and endpoint.get("model_variant_slug"):
                        api_models.append(endpoint.get("model_variant_slug", ""))
                api_models = sorted(set(api_models))
                if verbose:
                    print(f"  ✓ API: Found {len(api_models)} free models")
                return api_models
    except Exception as e:
        if verbose:
            print(f"  ⚠ API failed: {e}")
    
    # Fallback: bb-browser adapter (requires Chrome debug mode)
    if verbose:
        print(f"  Trying bb-browser fallback...")
    
    if ensure_chrome_debug_mode(verbose):
        try:
            result = subprocess.run(
                ["bb-browser", "site", "openrouter/free-models", "--json", "--openclaw"],
                capture_output=True,
                text=True,
                timeout=30
            )
            if result.returncode == 0:
                raw = json.loads(result.stdout)
                if isinstance(raw, dict) and raw.get("success") and "data" in raw:
                    data = raw["data"]
                else:
                    data = raw
                
                if isinstance(data, dict) and "models" in data:
                    models = [m.get("model_variant_slug") or m.get("slug", "") for m in data["models"] if m.get("model_variant_slug") or m.get("slug")]
                    if verbose:
                        print(f"  ✓ bb-browser: Found {len(models)} free models")
                    return models
        except subprocess.TimeoutExpired:
            if verbose:
                print(f"  ⚠ bb-browser timed out")
        except Exception as e:
            if verbose:
                print(f"  ⚠ bb-browser error: {e}")
    
    if verbose:
        print(f"  ⚠ All discovery methods failed")
    return []


def send_feishu_notification(message: str, dry_run: bool = False):
    """Send notification via Feishu"""
    config = load_config()
    target = config.get("notify_target", "user:ou_xxxxxxxxxxxxxxxxxxxxx")
    
    if dry_run:
        print(f"\n[DRY RUN] Would send to Feishu: {target}")
        print(message)
        return
    
    # Use OpenClaw message tool
    try:
        cmd = [
            "openclaw", "message", "send",
            "--channel", "feishu",
            "--target", target,
            "--message", message
        ]
        
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        
        if result.returncode == 0:
            print("✓ Notification sent via Feishu")
        else:
            print(f"⚠ Feishu send failed: {result.stderr}", file=sys.stderr)
    except Exception as e:
        print(f"⚠ Failed to send notification: {e}", file=sys.stderr)


def format_notification(expiring: List[Dict], new_models: List[str], alert_level: str = "normal") -> str:
    """
    Format notification message.
    alert_level: "urgent" (≤1 天), "warning" (≤3 天), "normal" (new models or >3 天)
    """
    lines = []
    
    if expiring:
        if alert_level == "urgent":
            lines.append("🚨 OpenRouter 模型即将到期（紧急）")
        elif alert_level == "warning":
            lines.append("⚠️ OpenRouter 模型到期提醒（3 天内）")
        else:
            lines.append("📅 OpenRouter 模型到期预告")
        lines.append("")
        for item in expiring:
            model = item.get("model", "Unknown")
            date = item.get("going_away_date", "Unknown")
            days = item.get("days_left", 0)
            url = item.get("url", "")
            
            if days < 0:
                status = f"已过期 {abs(days)} 天"
            elif days == 0:
                status = "今天到期"
            else:
                status = f"还剩 {days} 天"
            
            lines.append(f"模型：{model}")
            lines.append(f"到期：{date}（{status}）")
            lines.append(f"链接：{url}")
            lines.append("")
    
    if new_models:
        if expiring:
            lines.append("---")
        lines.append("✨ OpenRouter 新模型发现")
        lines.append("")
        for model in new_models[:10]:  # Limit to 10
            lines.append(f"模型：{model}")
            lines.append(f"链接：{OPENROUTER_BASE}/{model}")
            lines.append("")
        
        if len(new_models) > 10:
            lines.append(f"... 还有 {len(new_models) - 10} 个新模型")
    
    lines.append(f"检查时间：{datetime.now(SHANGHAI_TZ).strftime('%Y-%m-%d %H:%M')} (Asia/Shanghai)")
    
    return "\n".join(lines)


def should_send_expiration_alert(current_expiring: List[Dict], previous_expiring: List[Dict]) -> tuple:
    """
    Determine if expiration alert should be sent.
    Returns (should_send, alert_level)
    alert_level: "urgent" (≤1 天), "warning" (≤3 天), "normal" (first notice)

    Rules:
    - first discovery -> send
    - date change -> send
    - severity upgrade -> send
    - same warning/urgent state as last time -> do not repeat daily
    """
    if not current_expiring:
        return False, "normal"

    prev_lookup = {}
    for item in (previous_expiring or []):
        model_id = item.get("model")
        if model_id:
            prev_lookup[model_id] = item

    should_send = False
    max_alert_level = "normal"

    for item in current_expiring:
        model = item.get("model")
        days_left = item.get("days_left", 999)
        previous = prev_lookup.get(model, {})

        if model not in prev_lookup:
            should_send = True
        elif previous.get("going_away_date") != item.get("going_away_date"):
            should_send = True

        try:
            days = int(days_left)
        except (ValueError, TypeError):
            continue

        prev_days = previous.get("days_left")
        try:
            prev_days = int(prev_days) if prev_days is not None else None
        except (ValueError, TypeError):
            prev_days = None

        current_level = "normal"
        previous_level = "normal"

        if days <= 1:
            current_level = "urgent"
        elif days <= 3:
            current_level = "warning"

        if prev_days is not None:
            if prev_days <= 1:
                previous_level = "urgent"
            elif prev_days <= 3:
                previous_level = "warning"

        if current_level == "urgent":
            max_alert_level = "urgent"
        elif current_level == "warning" and max_alert_level != "urgent":
            max_alert_level = "warning"

        if current_level != previous_level and current_level in {"urgent", "warning"}:
            should_send = True

    return should_send, max_alert_level


def summarize_check_result(expiring: List[Dict], new_models: List[str], fetch_errors: List[Dict]) -> str:
    """Return a short plain-text summary for cron delivery with specific model details."""
    parts = []

    if new_models:
        model_list = ", ".join(new_models[:5])
        if len(new_models) > 5:
            model_list += f" 等{len(new_models)}个"
        parts.append(f"发现 {len(new_models)} 个新免费模型: {model_list}")
    if expiring:
        exp_list = []
        for item in expiring[:3]:
            model = item.get("model", "Unknown")
            date = item.get("going_away_date", "Unknown")
            days = item.get("days_left", 0)
            exp_list.append(f"{model}将于{date}到期(剩{days}天)")
        parts.append(f"有 {len(expiring)} 个模型即将到期: " + ", ".join(exp_list))
    if fetch_errors:
        parts.append(f"{len(fetch_errors)} 个模型到期页抓取失败")

    if not parts:
        return "OpenRouter 免费模型监控结果：无变化。当前免费模型列表与上次检查一致，无新增或到期模型。"

    return "OpenRouter 免费模型监控结果：" + "，".join(parts) + "。"


def main():
    verbose = "--verbose" in sys.argv
    dry_run = "--dry-run" in sys.argv
    no_notify = "--no-notify" in sys.argv
    
    if verbose:
        print("=" * 60)
        print("OpenRouter Free Model Monitor")
        print("=" * 60)
    
    # Load status
    status = load_status()
    last_check = status.get("last_check")
    known_models = set(status.get("known_models", []))
    
    if verbose:
        print(f"Last check: {last_check or 'Never'}")
        print(f"Known models: {len(known_models)}")
    
    # Get configured free models + configured openrouter non-:free candidates
    configured_free_models, configured_openrouter_candidates = get_configured_models()
    configured_models = set(configured_free_models)

    lightweight_detected = []
    for model_id in configured_openrouter_candidates:
        if page_looks_free(model_id, verbose):
            configured_models.add(model_id)
            lightweight_detected.append(model_id)
    
    if verbose:
        print(f"Configured free models: {len(configured_models)}")
        for m in configured_models:
            print(f"  - {m}")
        if lightweight_detected:
            print(f"Lightweight free detection matched: {len(lightweight_detected)}")
            for m in lightweight_detected:
                print(f"  - {m}")
    
    # Check expiration notices
    expiring = []
    fetch_errors = []
    for model_id in configured_models:
        result = check_expiration_notice(model_id, verbose)
        if result and "going_away_date" in result:
            expiring.append(result)
        elif result and result.get("error"):
            fetch_errors.append(result)
    
    if verbose:
        print(f"\nExpiring soon: {len(expiring)}")
        if fetch_errors:
            print(f"Fetch errors: {len(fetch_errors)}")
    
    # Discover new models
    all_free_models = set(discover_new_models(verbose))
    
    # Find new models (not in known list)
    new_models = []
    if all_free_models:
        new_models = sorted(all_free_models - known_models - configured_models)
    
    if verbose:
        print(f"New models discovered: {len(new_models)}")
    
    # Determine if we should send expiration alert (with deduplication)
    prev_expiring = status.get("expiring_soon", [])
    send_expiring, alert_level = should_send_expiration_alert(expiring, prev_expiring)
    
    # New models: only send if there are actually new ones
    send_new_models = len(new_models) > 0
    
    # Update status (only update known_models if discovery succeeded)
    status["last_check"] = datetime.now(SHANGHAI_TZ).isoformat()
    if all_free_models:
        status["known_models"] = sorted(set(status.get("known_models", [])) | configured_models | all_free_models)
    status["expiring_soon"] = expiring
    status["fetch_errors"] = fetch_errors
    save_status(status)
    
    if verbose:
        print(f"\nStatus saved to: {load_config().get('status_file', str(STATUS_FILE))}")
        print(f"Send expiration alert: {send_expiring} (level: {alert_level})")
        print(f"Send new models alert: {send_new_models}")
    
    summary = summarize_check_result(expiring, new_models, fetch_errors)

    # Send notification if needed
    if (send_expiring or send_new_models) and not no_notify:
        message = format_notification(expiring if send_expiring else [], new_models if send_new_models else [], alert_level)
        send_feishu_notification(message, dry_run)
    elif (send_expiring or send_new_models) and no_notify:
        if verbose:
            print("\nℹ Notifications suppressed by --no-notify")
    elif fetch_errors and verbose:
        print("\n⚠ No model changes detected, but some expiration checks failed")
    else:
        print("\n✓ No changes detected (silent)")

    if verbose or dry_run:
        print(f"\nSummary: {summary}")
    else:
        print(summary)

    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/fetch_page.py
content: #!/usr/bin/env python3
"""
fetch_page.py - Web scraping helper for OpenRouter pages.

Effective strategy:
  Layer 1: requests (preferred, stable, fast)
  Layer 2: urllib fallback

This helper intentionally avoids pretending to call OpenClaw tools from a plain
Python script. Tool-driven fetches belong in agent runs, not local subprocess
shims.
"""

import sys
from typing import Optional, Tuple
from urllib.error import URLError
from urllib.request import Request, urlopen

USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"


def fetch_with_requests(url: str, timeout: int = 15) -> Tuple[bool, str]:
    """Layer 1: Direct HTTP request with requests library."""
    try:
        import requests

        headers = {"User-Agent": USER_AGENT}
        resp = requests.get(url, headers=headers, timeout=timeout)

        if resp.status_code != 200:
            return False, f"HTTP {resp.status_code}"

        return True, resp.text
    except ImportError as e:
        return False, f"requests not installed: {e}"
    except Exception as e:
        return False, f"requests failed: {e}"


def fetch_with_urllib(url: str, timeout: int = 20) -> Tuple[bool, str]:
    """Layer 2: Standard-library urllib fallback."""
    try:
        req = Request(url, headers={"User-Agent": USER_AGENT})
        with urlopen(req, timeout=timeout) as resp:
            status = getattr(resp, "status", 200)
            if status != 200:
                return False, f"HTTP {status}"
            return True, resp.read().decode("utf-8", errors="replace")
    except URLError as e:
        return False, f"urllib failed: {e}"
    except Exception as e:
        return False, f"urllib error: {e}"


def fetch_page(url: str, verbose: bool = False) -> Optional[str]:
    """Fetch page content with a small, honest fallback chain."""
    if verbose:
        print(f"Fetching: {url}")

    success, content = fetch_with_requests(url)
    if success:
        if verbose:
            print("  ✓ Layer 1 (requests) succeeded")
        return content
    if verbose:
        print(f"  ✗ Layer 1 (requests) failed: {content}")

    success, content = fetch_with_urllib(url)
    if success:
        if verbose:
            print("  ✓ Layer 2 (urllib) succeeded")
        return content
    if verbose:
        print(f"  ✗ Layer 2 (urllib) failed: {content}")

    print(f"ERROR: All fetch methods failed for {url}", file=sys.stderr)
    return None


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: fetch_page.py <url> [--verbose]")
        sys.exit(1)

    url = sys.argv[1]
    verbose = "--verbose" in sys.argv

    content = fetch_page(url, verbose)
    if content:
        print(content[:1000])
    else:
        sys.exit(1)


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)