npm package: @neuxmind/web-fetch
Description: High-performance webpage fetch CLI for agents
Latest version: 0.0.1

# Web Fetch CLI

一个面向 Agent / LLM 使用场景的网页抓取工具集，支持把网页转换成适合模型消费的 `Markdown` / `JSON`。

当前版本：`v0.0.1`

## 当前能力

- 默认优先 `https`，必要时按规则回退 `http`
- 支持单 URL、文件输入、stdin 输入
- 支持 `md` / `json` / `both`
- 支持 `http` / `browser` / `auto`
- 支持并发抓取
- 支持本地文件缓存：TTL + `ETag` / `Last-Modified` revalidate
- 支持批量输出到 stdout / file / jsonl / files
- 支持最小可用 server：`GET /health`、`POST /fetch`、`POST /fetch/batch`
- 默认约束“页面内容拿到后”的处理预算为 `3000ms`
- 支持配置文件驱动的正文抽取
- 支持 LLM 正文抽取，按 provider 顺序降级
- 每个 LLM provider 最多重试 3 次，重试次数可配置
- LLM 全部失败时可回退启发式抽取
- 内置真实网页 live suite：国外 100、中国 100、browser 20

## 项目结构

```text
crates/
  browser-client/
  cli/
  core/
  extractor/
  schemas/
  server/
  transform/

browser-worker/
docs/
packages/npm/
web-fetch.example.toml
```

## 构建

要求：

- Rust / Cargo
- Node.js（仅 browser worker 需要）

```bash
cargo build
```

## 全量回归测试

统一回归脚本：

```bash
chmod +x scripts/run-full-regression.sh
scripts/run-full-regression.sh --config ~/.config/web-fetch/web-fetch.toml
```

特点：

- 走 LLM 全流程
- 同时覆盖正常抓取与 browser 抓取
- 默认并发 `5`
- LLM provider 由配置文件控制
- 输出落盘到 `tests/results/full-regression/<timestamp>`
- 自动生成 `summary.md` / `summary.json` / `timing-breakdown.csv`
- `summary` 包含 wall clock、吞吐量和分段时延指标

## npm 安装

推荐公开安装方式：

```bash
npm install -g @neuxmind/web-fetch
```

安装后可直接使用：

```bash
web-fetch --version
web-fetch-server --help
```

npm 安装会：

- 自动按平台安装对应预编译二进制包
- 同时提供 `web-fetch` 与 `web-fetch-server`
- 初始化默认配置到 `~/.config/web-fetch/web-fetch.toml`
- 若默认配置已存在，则保留原文件不覆盖

## 脚本安装

### macOS / Linux

```bash
curl -fsSL https://git.neuxnet.com/cli/web-fetch-cli/-/raw/main/scripts/install.sh | sh
```

安装脚本会自动：

- 识别平台与架构
- 优先下载对应的预编译包
- 若当前版本还没有预编译包，则自动回退到源码编译安装
- 默认安装到 `~/.local/bin`
- 同时安装 `web-fetch` 与 `web-fetch-server`
- 初始化默认配置到 `~/.config/web-fetch/web-fetch.toml`
- 若默认配置已存在，则保留原文件不覆盖

可选环境变量：

- `WEB_FETCH_VERSION=v0.0.1`
- `WEB_FETCH_REF=main`
- `WEB_FETCH_INSTALL_DIR=$HOME/.local/bin`
- `WEB_FETCH_REPO_BASE=https://git.neuxnet.com/cli/web-fetch-cli`

例如：

```bash
curl -fsSL https://git.neuxnet.com/cli/web-fetch-cli/-/raw/main/scripts/install.sh | \
  WEB_FETCH_INSTALL_DIR=/usr/local/bin sh
```

### Windows PowerShell

```powershell
irm https://git.neuxnet.com/cli/web-fetch-cli/-/raw/main/scripts/install.ps1 | iex
```

安装后可验证：

```bash
web-fetch --version
web-fetch-server --help
```

## npm 打包

发布 npm 产物：

```bash
node scripts/package-npm.mjs
```

只为指定目标生成：

```bash
node scripts/package-npm.mjs --targets aarch64-apple-darwin
```

产物输出到：

```text
dist/npm/
```

## npm 自动发布

仓库支持通过 Git tag 自动触发 npm 发布：

- 触发条件：tag 名称以 `v` 开头
- 例如：`v0.0.1`
- 发布目标：`@neuxmind/web-fetch`

发布流程：

1. 各平台 runner 分别构建并打包平台 npm 包
2. 汇总 tarball
3. 先发布平台包
4. 最后发布主包 `@neuxmind/web-fetch`

当前 GitLab CI 配置文件：

```text
.gitlab-ci.yml
```

需要准备：

- GitLab CI/CD 变量：`NPM_TOKEN`
- 可选 GitLab CI/CD 变量：`NPM_DIST_TAG`
- 可用 runner tags：
  - `macos-arm64`
  - `macos-x64`
  - `linux-arm64`
  - `linux-x64`
  - `windows-arm64`
  - `windows-x64`

发布前会校验：

- `CI_COMMIT_TAG` 去掉前缀 `v` 后
- 必须等于根目录 `package.json` 中的 `version`

创建并推送 tag：

```bash
git tag v0.0.1
git push origin v0.0.1
```

## CLI 用法

### 最简单示例

输出 JSON：

```bash
web-fetch https://example.com --pretty
```

输出 Markdown：

```bash
web-fetch https://example.com --format md
```

批量抓取：

```bash
web-fetch --input urls.txt --concurrency 16 --output out/results.jsonl --jsonl
```

从 stdin 读取：

```bash
cat urls.txt | web-fetch --stdin --format json
```

### 常用参数

- `--config <path>`
- `--format md|json|both`
- `--render auto|http|browser`
- `--scheme auto|https|http`
- `--selector <css>`
- `--timeout <ms>`
- `--header key:value`
- `--user-agent <ua>`
- `--concurrency <n>`
- `--cache-dir <path>`
- `--cache-ttl <seconds>`
- `--cache-mode default|refresh|only-if-cached`
- `--no-cache`
- `--output <path>`
- `--output-mode auto|stdout|jsonl|files`
- `--failures-output <path>`

完整帮助：

```bash
web-fetch --help
```

## 配置文件

CLI 和 server 都支持配置文件：

- 显式指定：`--config /path/to/web-fetch.toml`
- 未显式指定时，默认优先加载：`~/.config/web-fetch/web-fetch.toml`

可参考：`web-fetch.example.toml`

建议初始化默认配置：

```bash
mkdir -p ~/.config/web-fetch
curl -fsSL https://git.neuxnet.com/cli/web-fetch-cli/-/raw/main/web-fetch.example.toml \
  -o ~/.config/web-fetch/web-fetch.toml
```

### 配置优先级

- 配置文件提供默认值
- CLI 参数覆盖配置文件中的请求默认值
- 请求体中的显式字段覆盖 server 端默认值

### 示例

```toml
[server]
host = "127.0.0.1"
port = 8080
request_timeout_ms = 18000
batch_request_timeout_ms = 30000
max_batch_size = 32
max_batch_concurrency = 8

[browser]
worker_url = "http://127.0.0.1:4000"

[request_defaults]
render_mode = "auto"
scheme_policy = "auto"
timeout_ms = 15000
max_body_size = 5242880
cache_mode = "default"
cache_ttl_secs = 1800
cache_dir = ".web-fetch-cache"
user_agent = "web-fetch-cli/0.0.1"

[extraction]
mode = "llm"
fallback_to_heuristic = true
processing_timeout_ms = 3000
max_input_chars = 1500

[extraction.llm]
prompt = "You are an extraction engine for agents. Return strict JSON only, no markdown fence, no commentary. You will receive candidate_markdown generated by a local heuristic extractor, not raw HTML. The JSON schema is {\"title\": string|null, \"byline\": string|null, \"published_at\": string|null, \"lang\": string|null, \"site_name\": string|null, \"markdown\": string}. Clean and keep only meaningful page content: the article body, documentation body, product details, or the central list/search results that carry user-visible information. Remove navigation menus, site chrome, headers, footers, sidebars, breadcrumbs, ads, cookie banners, login/account widgets, newsletter/signup prompts, social/share buttons, related/recommended links, comment areas, duplicated text, scripts, styles, tracking/legal boilerplate, and decorative images. Preserve useful headings, paragraphs, lists, code blocks, tables, and essential links in markdown. If the page is a portal/index page, keep only the meaningful content cards/items and summaries, not global menus or repeated module titles."
circuit_breaker_threshold = 3
circuit_breaker_open_ms = 30000
slow_request_threshold_ms = 800
slow_request_degrade_threshold = 2

[[extraction.llm.providers]]
name = "primary"
base_url = "https://api.openai.com/v1"
model = "gpt-4.1-mini"
api_key_env = "OPENAI_API_KEY"
retries = 1
timeout_ms = 1200

[extraction.llm.providers.extra_body]
response_format = { type = "json_object" }

[[extraction.llm.providers]]
name = "backup"
base_url = "https://openrouter.ai/api/v1"
model = "openai/gpt-4.1-mini"
api_key_env = "OPENROUTER_API_KEY"
retries = 1
timeout_ms = 1000

[extraction.llm.providers.extra_body]
response_format = { type = "json_object" }
```

## LLM 正文抽取

### 工作方式

当配置：

```toml
[extraction]
mode = "llm"
```

抽取器会：

1. 先用本地 heuristic 从 HTML 中抽取候选正文块
2. 把候选正文块转换成受 `max_input_chars` 约束的 `candidate_markdown`
3. 准备 `candidate_markdown` 和 metadata hints
4. 按 `providers` 顺序调用 OpenAI-compatible `/chat/completions`
5. 单个 provider 按配置重试
6. 单个 provider 的最大实际重试次数会被限制在 `3`
7. 当前 provider 失败后自动降级到下一个 provider
8. 全部 provider 失败时，如果 `fallback_to_heuristic = true`，则回退启发式抽取

为了控制速度和 token 成本，LLM **不再直接处理原始 HTML**；它只处理本地抽取出来的候选 Markdown。

### 3 秒处理预算

为了服务端延迟可控，默认按 `processing_timeout_ms = 3000` 执行。

含义是：

- 网页 HTML / browser DOM 一旦拿到
- 后续正文抽取、LLM 调用、markdown 转换等处理
- 默认必须在 `3000ms` 内结束

如果超时：

- `fallback_to_heuristic = true`：快速降级到 heuristic
- `fallback_to_heuristic = false`：直接返回 timeout 错误

建议在严格低延迟场景下：

- provider `retries` 设为 `1`
- 单 provider `timeout_ms` 设为 `800~1200`
- `max_input_chars` 设为 `800~1500`
- 保留 heuristic fallback

某些会输出可见推理的模型，建议通过 provider `extra_body` 关闭 thinking / reasoning。例如：

```toml
[[extraction.llm.providers]]
name = "tokenhubpro-qwen"
base_url = "https://tokenhubpro.com/v1"
model = "qwen/qwen3.5-9b"
api_key_env = "TOKENHUBPRO_API_KEY"
retries = 0
timeout_ms = 12000

[extraction.llm.providers.extra_body]
chat_template_kwargs = { enable_thinking = false }
```

### provider 配置项

- `name`
- `base_url`
- `model`
- `api_key` 或 `api_key_env`
- `retries`
- `timeout_ms`
- `headers`
- `extra_body`

### provider 健康度与自动降级

当前实现会在进程内维护 provider 运行时健康度：

- 连续失败达到 `circuit_breaker_threshold` 后，provider 会熔断 `circuit_breaker_open_ms`
- 连续慢请求达到 `slow_request_degrade_threshold` 后，provider 会被标记为 `degraded`
- 后续请求会优先尝试健康 provider，再尝试 degraded provider

相关配置位于 `[extraction.llm]`：

- `circuit_breaker_threshold`
- `circuit_breaker_open_ms`
- `slow_request_threshold_ms`
- `slow_request_degrade_threshold`

### 返回格式要求

上游 LLM 需要返回严格 JSON，字段为：

- `title`
- `byline`
- `published_at`
- `lang`
- `site_name`
- `markdown`

为增强 OpenAI-compatible provider 兼容性，当前实现也会接受常见正文别名：

- `content`
- `text`
- `body`

## 输出逻辑

CLI 支持 4 种输出模式：

- `stdout`
- `file`
- `jsonl`
- `files`

默认 `--output-mode auto`。

### stdout

```bash
web-fetch https://example.com --pretty
```

### 单文件

```bash
web-fetch https://example.com --output out/result.json --pretty
```

### JSONL

```bash
web-fetch \
  --input urls.txt \
  --output out/results.jsonl \
  --jsonl
```

### 目录输出

```bash
web-fetch \
  --input urls.txt \
  --format both \
  --output-mode files \
  --output out/pages
```

输出示例：

```text
out/pages/
  0001-example-com.json
  0001-example-com.md
  0002-docs-python-org-3-tutorial-index-html.json
  0002-docs-python-org-3-tutorial-index-html.md
  failures.jsonl
```

## 缓存策略

缓存不是“同 URL 永久命中”，而是“抓取快照 + TTL + revalidate”。

### Cache Key

缓存 key 包含：

- 规范化 URL
- `render_mode`
- `selector`
- headers
- `user-agent`

### Cache Value

缓存内容包含：

- 抓取结果
- `fetched_at`
- `etag`
- `last_modified`

### 工作模式

- `default`
  - 未过期：直接返回缓存
  - 已过期：带 `If-None-Match` / `If-Modified-Since` 重验
  - `304`：刷新缓存时间并返回旧结果
- `refresh`
  - 忽略旧缓存，强制重新抓取
- `only-if-cached`
  - 只读缓存，不发网络请求；无缓存则报错

## Browser Worker

动态页面抓取通过独立 Playwright worker 实现。

安装依赖：

```bash
cd browser-worker
npm install
npx playwright install chromium
```

启动：

```bash
cd browser-worker
node src/server.js
```

默认监听：

```text
http://127.0.0.1:4000
```

CLI 使用 browser 模式：

```bash
web-fetch \
  --config web-fetch.example.toml \
  https://example.com \
  --render browser
```

## Server 用法

启动最小可用 server：

```bash
web-fetch-server --config web-fetch.example.toml
```

健康检查：

```bash
curl http://127.0.0.1:8080/health
```

返回格式：

```json
{
  "status": "ok",
  "version": "0.0.1",
  "server": {
    "request_timeout_ms": 18000,
    "batch_request_timeout_ms": 30000,
    "max_batch_size": 32,
    "max_batch_concurrency": 8
  },
  "llm_providers": [
    {
      "name": "primary",
      "state": "healthy",
      "consecutive_failures": 0,
      "consecutive_slow": 0,
      "last_latency_ms": 412,
      "last_error": null,
      "opened_until_epoch_ms": null
    }
  ]
}
```

单条抓取接口：

```bash
curl -X POST http://127.0.0.1:8080/fetch \
  -H 'content-type: application/json' \
  -d '{
    "url": "example.com",
    "render_mode": "auto",
    "scheme_policy": "auto",
    "selector": null,
    "timeout_ms": 15000,
    "headers": {},
    "output_format": "json",
    "prefer_cache": true,
    "cache_mode": "default",
    "cache_ttl_secs": 1800,
    "cache_dir": ".web-fetch-cache",
    "max_body_size": 5242880,
    "retries": 0,
    "user_agent": null
  }'
```

如果请求体中仍保留默认值，server 会用配置文件中的 `request_defaults` 补齐。

返回格式：

```json
{
  "request_url": "example.com",
  "resolved_url": "https://example.com/",
  "final_url": "https://example.com/",
  "status": "ok",
  "trace_id": "wf-1743991200000-1",
  "metadata": {
    "title": "Example Domain",
    "byline": null,
    "published_at": null,
    "lang": "en",
    "site_name": "example.com",
    "status_code": 200,
    "content_type": "text/html",
    "fetched_at": "2026-04-07T10:00:00Z"
  },
  "markdown": "# Example Domain\n\nExample body",
  "text": "Example Domain Example body",
  "blocks": [
    { "type": "heading", "level": 1, "text": "Example Domain" },
    { "type": "paragraph", "text": "Example body" }
  ],
  "content_hash": "sha256:...",
  "diagnostics": {
    "render_mode_used": "http",
    "scheme_used": "https",
    "fallbacks": [],
    "warnings": [],
    "timings_ms": { "fetch": 120, "parse_transform": 18, "server_total": 145 },
    "cache_hit": false
  },
  "error": null
}
```

批量抓取接口：

```bash
curl -X POST http://127.0.0.1:8080/fetch/batch \
  -H 'content-type: application/json' \
  -d '{
    "requests": [
      {
        "url": "example.com",
        "render_mode": "auto",
        "scheme_policy": "auto",
        "selector": null,
        "timeout_ms": 15000,
        "headers": {},
        "output_format": "json",
        "prefer_cache": true,
        "cache_mode": "default",
        "cache_ttl_secs": 1800,
        "cache_dir": ".web-fetch-cache",
        "max_body_size": 5242880,
        "retries": 0,
        "user_agent": null
      },
      {
        "url": "python.org",
        "render_mode": "auto",
        "scheme_policy": "auto",
        "selector": null,
        "timeout_ms": 15000,
        "headers": {},
        "output_format": "json",
        "prefer_cache": true,
        "cache_mode": "default",
        "cache_ttl_secs": 1800,
        "cache_dir": ".web-fetch-cache",
        "max_body_size": 5242880,
        "retries": 0,
        "user_agent": null
      }
    ],
    "concurrency": 8
  }'
```

返回结构包含：

- `results`
- `requested`
- `succeeded`
- `failed`

返回格式：

```json
{
  "trace_id": "wf-1743991200000-2",
  "results": [
    { "status": "ok", "request_url": "example.com", "error": null },
    {
      "status": "error",
      "request_url": "bad url",
      "error": {
        "code": "invalid_url",
        "message": "URL must not be empty"
      }
    }
  ],
  "requested": 2,
  "succeeded": 1,
  "failed": 1,
  "timings_ms": {
    "server_total": 380
  }
}
```

### Trace ID 与日志

server 会为每个 `/fetch` 和 `/fetch/batch` 请求生成一个 `trace_id`：

- 响应头：`x-trace-id`
- 单条响应体：`trace_id`
- 批量响应体：`trace_id`

可用这个值关联服务端结构化日志。

如需 JSON 日志：

```bash
WEB_FETCH_SERVER_LOG_JSON=1 web-fetch-server --config web-fetch.example.toml
```

server 默认还会限制：

- `request_timeout_ms = 18000`
- `batch_request_timeout_ms = 30000`
- `max_batch_size = 32`
- `max_batch_concurrency = 8`

用于保护延迟与吞吐稳定性。超时后在途 future 会被 drop，从而触发协作式取消。

## 测试

默认测试：

```bash
cargo test
```

### 真实网页 live suite

- 国外样本：100 个
- 中国样本：100 个
- browser 样本：20 个

运行 HTTP/auto live suite：

```bash
cargo test -p web-fetch-core live_fetch_suite_covers_global_and_cn_cases -- --ignored --nocapture
```

运行 browser suite：

```bash
BROWSER_WORKER_URL=http://127.0.0.1:4000 \
cargo test -p web-fetch-core live_browser_suite_covers_browser_cases -- --ignored --nocapture
```

导出 live 报告：

```bash
WEB_FETCH_LIVE_REPORT_DIR=.reports/live \
cargo test -p web-fetch-core live_fetch_suite_covers_global_and_cn_cases -- --ignored --nocapture
```

## 文档

- `docs/README.md`
- `docs/v0.0.1-requirements.md`
- `docs/v0.0.1-design.md`
- `docs/v0.0.1-implementation.md`
- `docs/v0.0.1-testing.md`
- `RELEASING.md`

## 路线

- `v0.0.1`：单机 CLI + 最小 server + 配置化 LLM 抽取
- `v0.0.x`：抽取质量、缓存、稳定性增强
- `v0.1.x`：更完整服务化、任务化与可观测性