# https://github.com/vasylenko/markfetch 项目说明书

生成时间：2026-05-15 00:33:16 UTC

## 目录

- [项目概述](#overview)
- [系统架构](#system-architecture)
- [安装与部署](#installation)
- [命令行界面](#cli-usage)
- [MCP 服务器](#mcp-server)
- [HTTP 指纹与请求模拟](#http-fingerprint)
- [内容提取管道](#content-extraction)
- [写操作沙箱](#write-sandbox)
- [配置与环境变量](#configuration)
- [错误处理机制](#error-handling)

<a id='overview'></a>

## 项目概述

### 相关页面

相关主题：[系统架构](#system-architecture), [HTTP 指纹与请求模拟](#http-fingerprint), [内容提取管道](#content-extraction)

<details>
<summary>相关源码文件</summary>

以下源码文件用于生成本页说明：

- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)
- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)
- [src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)
- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)
- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)
- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)
- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)
- [CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)
</details>

# 项目概述

## 项目简介

markfetch 是一个纯 Node.js 编写的 URL 转 Markdown 工具，同时提供 MCP（Model Context Protocol）服务器模式和命令行界面（CLI）两种使用方式，专为 AI 智能体设计。该项目由 Serhii Vasylenko 开发，采用 MIT 许可证开源发布。

markfetch 的核心功能是接收一个 HTTP/HTTPS URL，获取其 HTML 内容，提取主要文章内容，并将其转换为干净的 Markdown 格式输出。输出结果与人类执行"另存为 Markdown"命令获得的内容高度相似，能够在提供真实浏览器特征指纹的同时，绕过许多网站的反爬虫机制。

## 核心特性

### 多模式支持

markfetch 提供两种使用模式，可根据不同场景灵活选择：

| 模式 | 触发方式 | 输出方式 | 典型用途 |
|------|----------|----------|----------|
| MCP 服务器模式 | 无参数启动或作为 MCP 工具调用 | `content[0].text` 结构化数据 | 集成到 Claude Code、Cursor、Goose 等 AI 客户端 |
| CLI 命令行模式 | `markfetch <url>` | 标准输出或文件 | Shell 脚本、管道操作、直接终端使用 |

### 技术架构特点

- **纯 Node.js 实现**：无任何子进程依赖，不使用 Playwright、headless Chromium 或 Python 等外部运行时 资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)
- **真实浏览器指纹**：通过 HTTP/2 传输协议和完整的 Chrome 浏览器请求头集合，模拟真实浏览器访问行为
- **客户端提示头**：自动从 `MARKFETCH_USER_AGENT` 派生出 `Sec-CH-UA-*` 客户端提示头，确保与 Chrome UA 字符串一致
- **单文档处理**：每次调用处理单个 URL，无递归爬取、无 robots.txt 解析、无速率限制编排

### 核心依赖组件

| 组件 | 版本 | 用途 |
|------|------|------|
| `@modelcontextprotocol/sdk` | ^1.29.0 | MCP 协议实现，提供 stdio 通信能力 |
| `@mozilla/readability` | ^0.5.0 | Mozilla 开源的 HTML 内容提取库，从页面中提取主要文章内容 |
| `turndown` | ^7.0.0 | 将 HTML 转换为 Markdown 的转换器 |
| `turndown-plugin-gfm` | ^1.0.2 | GitHub Flavored Markdown 插件，支持表格、任务列表等格式 |
| `linkedom` | ^0.18.0 | 轻量级 DOM 解析器，用于在 Node.js 环境中解析和操作 HTML |
| `undici` | ^8.2.0 | HTTP/2 客户端库，处理网络请求 |
| `zod` | ^3.0.0 | TypeScript 类型验证库，用于 MCP 输入模式定义 |
| `commander` | ^14.0.3 | CLI 参数解析库 |

## 系统架构

```mermaid
graph TD
    subgraph "入口层"
        A["src/index.ts<br/>参数分发器"]
    end
    
    subgraph "适配器层"
        B["src/mcp.ts<br/>MCP 适配器"]
        C["src/cli.ts<br/>CLI 适配器"]
    end
    
    subgraph "核心层"
        D["src/core.ts<br/>fetchMarkdown 核心逻辑"]
    end
    
    subgraph "依赖库"
        E["@mozilla/readability<br/>内容提取"]
        F["turndown<br/>HTML→Markdown"]
        G["undici<br/>HTTP 客户端"]
        H["linkedom<br/>DOM 解析"]
    end
    
    subgraph "安全层"
        I["src/sandbox.ts<br/>写入沙箱"]
    end
    
    A -->|"process.argv.length > 1"| C
    A -->|"process.argv.length === 1"| B
    B --> D
    C --> D
    D --> E
    D --> F
    D --> G
    D --> H
    
    D -->|"savePath 参数"| I
```

### 目录结构

```
markfetch/
├── src/
│   ├── index.ts      # 入口文件，argv 路由分发
│   ├── core.ts       # 核心业务逻辑：获取→提取→转换
│   ├── mcp.ts        # MCP 服务器适配器
│   ├── cli.ts        # CLI 命令适配器
│   └── sandbox.ts    # 写入路径安全检查
├── dist/             # TypeScript 编译输出目录
├── tests/            # 测试文件目录
├── package.json      # 项目配置
├── README.md         # 项目文档
└── CHANGELOG.md      # 变更日志
```

## 工作流程

### MCP 模式工作流程

```mermaid
sequenceDiagram
    participant Client as MCP 客户端
    participant Server as markfetch MCP 服务器
    participant Core as 核心模块
    participant Web as 目标 URL
    
    Client->>Server: 调用 fetch_markdown(url, savePath?)
    Server->>Core: fetchMarkdown({ url, savePath })
    
    Core->>Web: HTTP/2 GET 请求 (Chrome UA)
    Web-->>Core: HTML 响应
    
    Core->>Core: Readability 提取文章内容
    Core->>Core: Turndown 转换为 Markdown
    
    alt savePath 存在
        Core->>Server: 检查 savePath 安全性
        Core->>Core: 写入文件
    else savePath 不存在
        Core->>Server: 返回 Markdown 内容
    end
    
    Server-->>Client: { content: [{ text: "..." }] }
```

### CLI 模式工作流程

```mermaid
graph LR
    A["markfetch <url>"] --> B["解析参数"]
    B --> C{"-o 参数?"}
    C -->|"是"| D["解析输出路径"]
    C -->|"否"| E["输出到 stdout"]
    D --> F["调用 fetchMarkdown"]
    E --> F
    F --> G{"保存成功?"}
    G -->|"是"| H["console.log 确认信息"]
    G -->|"否"| I["console.error 错误信息"]
    H --> J["process.exit(0)"]
    I --> K["process.exit(1)"]
```

## 错误处理机制

markfetch 定义了 8 种确定性错误代码，所有错误都遵循统一的 `[code] message` 格式返回：

| 错误代码 | 含义 | 触发场景 |
|----------|------|----------|
| `network_error` | 网络错误 | DNS 解析失败、TCP 连接失败、TLS 握手失败、意外内部错误 |
| `http_error` | HTTP 错误 | 目标服务器返回非 2xx 状态码 |
| `timeout` | 请求超时 | 超过 `MARKFETCH_TIMEOUT_MS` 配置的超时时间 |
| `unsupported_content_type` | 不支持的内容类型 | 响应不是 `text/html` 或 `application/xhtml+xml` |
| `extraction_failed` | 提取失败 | Readability 无法提取任何文章内容（常见于纯客户端渲染的 SPA） |
| `too_large` | 内容过大 | 响应体或提取后的 Markdown 超过 `MARKFETCH_MAX_BYTES` 限制 |
| `save_failed` | 保存失败 | 指定了 `savePath` 但写入文件失败（目录不存在、权限不足等） |
| `save_forbidden` | 保存禁止 | `savePath` 路径超出了允许的写入根目录 |

## 配置选项

### 环境变量配置

| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |
| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取后 Markdown 的字节数上限（约 5MB） |
| `MARKFETCH_USER_AGENT` | Chrome 130 固定字符串 | 浏览器标识字符串。必须是 Chrome UA 格式，否则启动时快速失败 |
| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 模式专用。以路径分隔符分隔的绝对路径列表，限定 `savePath` 可写入的根目录范围 |

### MCP 配置示例

```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"],
      "env": {
        "MARKFETCH_TIMEOUT_MS": "60000",
        "MARKFETCH_USER_AGENT": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36"
      }
    }
  }
}
```

## 写入沙箱机制

MCP 模式下的 `savePath` 参数受写入沙箱限制，防止 AI 智能体将文件写入任意目录。CLI 模式不受此限制。

### 沙箱规则

1. **默认根目录**：系统临时目录 + 进程当前工作目录
2. **路径检查**：目标路径必须位于允许的根目录内或为其子路径
3. **符号链接处理**：符号链接指向允许根目录外的内容将被阻止
4. **跨平台支持**：POSIX 系统使用 `:` 分隔符，Windows 使用 `;` 分隔符

### 自定义允许根目录

```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"],
      "env": {
        "MARKFETCH_ALLOWED_WRITE_ROOTS": "/Users/me/markfetch-out:/tmp"
      }
    }
  }
}
```

## 使用场景与限制

### 适用场景

- 获取文章、文档、博客帖子、新闻页面等静态 HTML 内容
- 自动化文档抓取和转换
- AI 智能体的网页内容获取工具
- 需要绕过基础反爬虫机制的网页访问

### 已知限制

| 限制类型 | 说明 |
|----------|------|
| **不进行身份认证** | 仅支持匿名访问，不支持 Cookie、认证头或会话复用 |
| **非递归爬取** | 无多层级页面爬取能力，每次仅处理单个 URL |
| **不支持 SPA 渲染** | 纯客户端渲染（无静态 HTML）的 SPA 返回 `extraction_failed` |
| **遵循 robots.txt** | 不解析也不遵守 robots.txt 规则 |
| **Node.js 版本要求** | 需要 Node.js 24.0.0 或更高版本 资料来源：[package.json](https://github.com/vasylenko/markfetch/blob/main/package.json) |

## 快速开始

### 安装

```bash
npm install -g markfetch
```

### CLI 使用

```bash
# 输出到标准输出
markfetch https://en.wikipedia.org/wiki/Markdown

# 输出到文件
markfetch https://example.com/article -o ./output/article.md
```

### MCP 集成

#### Claude Code

```bash
claude mcp add --scope user markfetch -- npx -y markfetch
```

#### Codex

```bash
codex mcp add markfetch -- npx -y markfetch
```

#### Gemini CLI

```bash
gemini mcp add -s user markfetch npx -y markfetch
```

### MCP 工具调用

```typescript
// 工具名称
fetch_markdown

// 输入参数
{
  url: "https://example.com/page",  // 必填，绝对 URL
  savePath: "/absolute/path/to/file.md"  // 可选，保存路径
}

// 返回格式
{
  content: [{ type: "text", text: "# Markdown 内容..." }]
}
```

## 版本信息

当前版本：**0.6.0**

项目源码托管于 GitHub：https://github.com/vasylenko/markfetch

许可证：MIT 资料来源：[package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)

---

<a id='system-architecture'></a>

## 系统架构

### 相关页面

相关主题：[项目概述](#overview), [命令行界面](#cli-usage), [MCP 服务器](#mcp-server)

<details>
<summary>相关源码文件</summary>

以下源码文件用于生成本页说明：

- [src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)
- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)
- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)
- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)
- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)
</details>

# 系统架构

## 概述

markfetch 是一个用于将网页转换为干净 Markdown 格式的工具，同时提供 CLI 和 MCP（Model Context Protocol）两种调用接口。项目采用**适配器模式**，将核心业务逻辑与接口层分离，确保核心逻辑在两种模式下完全一致。

架构设计遵循以下原则：

- **纯 Node.js，无子进程**：不依赖 Playwright、Chromium 或 Python
- **单通道输出**：MCP 模式下仅使用 `content[0].text`，不使用 `structuredContent`
- **结构化错误**：统一的 8 种错误码，适配器统一转换
- **惰性加载**：stdout 预留给 MCP 帧，CLI 代码在 MCP 模式下永不加载

资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)

## 组件架构

项目由五个核心模块组成，按职责可分为三层：

```mermaid
graph TD
    subgraph "接口层 (Interface Layer)"
        CLI[cli.ts<br/>CLI 适配器]
        MCP[mcp.ts<br/>MCP 适配器]
    end
    
    subgraph "调度层 (Dispatch Layer)"
        IDX[index.ts<br/>参数路由调度器]
    end
    
    subgraph "核心层 (Core Layer)"
        CORE[core.ts<br/>核心业务逻辑]
        SB[sandbox.ts<br/>写入沙箱]
    end
    
    CLI --> IDX
    MCP --> IDX
    IDX -->|lazy import| CLI
    IDX -->|lazy import| MCP
    CORE --> SB
    MCP --> SB
```

### 入口调度器 (index.ts)

index.ts 负责根据命令行参数决定启动模式：

| 条件 | 行为 |
|------|------|
| `process.argv.length === 2`（无参数） | 启动 MCP stdio 服务器 |
| 有命令行参数 | 加载 CLI 适配器 |

```typescript
// 伪代码实现
if (process.argv.length === 2) {
  // 启动 MCP 服务器
  import('./mcp.js').then(m => m.runMcpServer());
} else {
  // 启动 CLI
  import('./cli.js').then(c => c.runCli());
}
```

这种惰性导入机制确保 CLI 相关代码（包含 `console.log` 调用）在 MCP 模式下完全不可达。

资料来源：[src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)

### CLI 适配器 (cli.ts)

CLI 适配器基于 `commander` 库实现，提供命令行界面：

**支持的参数：**

| 参数 | 说明 |
|------|------|
| `<url>` | 必选，HTTP/HTTPS URL |
| `-o, --output <path>` | 可选，输出文件路径 |

**输出行为：**

- **stdout**：Markdown 原始内容（无尾部换行符）
- **stderr**：`[code] message` 格式的错误信息
- **exitCode**：成功 0，失败 1

```typescript
// CLI 核心逻辑伪代码
const savePath = options.output ? resolve(process.cwd(), options.output) : undefined;
const { markdown, bytes, savedTo } = await fetchMarkdown({ url, savePath });

if (savedTo !== undefined) {
  console.log(`Saved ${bytes} bytes to ${savedTo}`);
} else {
  process.stdout.write(markdown);
}
```

资料来源：[src/cli.ts:1-62](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)

### MCP 适配器 (mcp.ts)

MCP 适配器基于 `@modelcontextprotocol/sdk` 实现标准化的 stdio 服务器：

**注册的 Tool：**

```typescript
server.registerTool("fetch_markdown", {
  inputSchema: {
    url: z.string().url(),
    savePath: z.string().refine(isAbsolute).optional()
  }
})
```

**返回格式（单通道，无 structuredContent）：**

```json
{
  "content": [
    { "type": "text", "text": "# Markdown content..." }
  ],
  "isError": false
}
```

**错误返回格式：**

```json
{
  "content": [
    { "type": "text", "text": "[http_error] 404 Not Found" }
  ],
  "isError": true
}
```

资料来源：[src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)

### 核心业务逻辑 (core.ts)

core.ts 包含完整的 URL 到 Markdown 转换管道：

```mermaid
graph LR
    A[URL] --> B[undici HTTP 客户端]
    B --> C{响应状态码?}
    C -->|非 2xx| D[http_error]
    C -->|2xx| E{Content-Type?}
    E -->|非 HTML| F[unsupported_content_type]
    E -->|HTML| G[decodeEncodedCodeTags]
    G --> H[ensureBaseHref]
    H --> I[rewriteForReadability]
    I --> J[Readability 解析]
    J -->|无内容| K[extraction_failed]
    J -->|有内容| L[Turndown 转换]
    L --> M{大小检查}
    M -->|超限| N[too_large]
    M -->|正常| O[Markdown 输出]
```

**主要函数：**

| 函数 | 职责 |
|------|------|
| `fetchMarkdown()` | 主入口，协调整个流程 |
| `extractArticle()` | HTML 解析与内容提取 |
| `convertToMarkdown()` | Markdown 转换与清理 |
| `rewriteForReadability()` | 预处理器：处理脚注、折叠面板等 |
| `decodeEncodedCodeTags()` | 解码 HTML 编码的 `<code>` 标签 |
| `ensureBaseHref()` | 注入 `<base href>` 修复相对链接 |

**关键设计决策：**

1. **keepClasses: true**：保留 `<code class="language-X">` 以支持代码高亮提示
2. **Turndown escape 定制**：禁用 `\_` 和 `\-`/`\=` 的转义，避免噪声
3. **标题去重**：如果 Readability 保留了原始 `<h1>`，不重复添加标题
4. **空标题修剪**：移除连续的空标题节点

资料来源：[src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)

### 写入沙箱 (sandbox.ts)

MCP 模式的 `savePath` 参数受到写入沙箱限制：

**默认允许路径：**

```typescript
os.tmpdir() ∪ process.cwd()
```

**环境变量配置：**

```bash
# POSIX
MARKFETCH_ALLOWED_WRITE_ROOTS="/custom/path:/tmp"

# Windows
MARKFETCH_ALLOWED_WRITE_ROOTS="C:\output;D:\temp"
```

**验证算法：**

```mermaid
graph TD
    A[savePath] --> B{解析为绝对路径}
    B -->|失败| C[save_failed]
    B -->|成功| D{是否在允许路径内?}
    D -->|是| E[允许写入]
    D -->|否| F[save_forbidden]
    
    G[符号链接] --> H[解析到真实路径后再检查]
```

**核心验证逻辑：**

```typescript
function isPathAllowed(path: string, roots: string[]): PathCheckResult {
  const resolved = realpath(path);
  
  for (const root of roots) {
    const rel = relative(root, resolved);
    if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
      return { ok: true, resolved };
    }
  }
  return { ok: false, reason: `...' is outside allowed roots` };
}
```

**注意**：CLI 模式不执行沙箱检查——Shell 层面的用户是安全边界。

资料来源：[src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)

## 数据流图

### CLI 完整数据流

```mermaid
sequenceDiagram
    participant User
    participant CLI as cli.ts
    participant Core as core.ts
    participant Sandbox as sandbox.ts
    participant FS as FileSystem
    
    User->>CLI: markfetch <url> -o /path/out.md
    CLI->>CLI: commander 解析参数
    CLI->>Core: fetchMarkdown({ url, savePath })
    Core->>Core: undici.fetch()
    Core->>Core: Readability 解析
    Core->>Core: Turndown 转换
    Core->>Core: 大小检查
    Core-->>CLI: { markdown, bytes, savedTo }
    
    alt savePath 存在
        CLI->>Core: 调用时传入 savePath
        Core->>Sandbox: isPathAllowed(savePath)
        Sandbox-->>Core: { ok: true, resolved }
        Core->>FS: writeFile(savePath)
        Core-->>CLI: { savedTo: path }
    end
    
    CLI->>User: Saved N bytes to /path/out.md
```

### MCP 完整数据流

```mermaid
sequenceDiagram
    participant LLM as LLM / Agent
    participant MCP as MCP Client
    participant Server as mcp.ts
    participant Core as core.ts
    participant Sandbox as sandbox.ts
    
    LLM->>MCP: fetch_markdown({ url, savePath })
    MCP->>Server: stdio JSON-RPC 请求
    Server->>Core: fetchMarkdown()
    Core->>Core: 处理流程...
    
    alt savePath 在沙箱外
        Core->>Sandbox: isPathAllowed()
        Sandbox-->>Core: { ok: false }
        Core-->>Server: throw MarkfetchError
        Server-->>MCP: [save_forbidden] message
        MCP-->>LLM: 错误响应
    else 成功
        Core-->>Server: { markdown }
        Server-->>MCP: { content: [{ text }] }
        MCP-->>LLM: Markdown 内容
    end
```

## 错误处理架构

所有错误统一通过 `MarkfetchError` 异常类传播，适配器负责转换为各自的格式：

```mermaid
graph TD
    subgraph "错误源"
        NE[network_error]
        HE[http_error]
        TO[timeout]
        UC[unsupported_content_type]
        EF[extraction_failed]
        TL[too_large]
        SF[save_failed]
        SB[save_forbidden]
    end
    
    subgraph "异常传播"
        ERR[MarkfetchError]
    end
    
    subgraph "适配器转换"
        CLI_ERR[["console.error(`[${code}] ${msg}`)"]]
        MCP_ERR[["errorResult() → isError: true"]]
    end
    
    NE --> ERR
    HE --> ERR
    TO --> ERR
    UC --> ERR
    EF --> ERR
    TL --> ERR
    SF --> ERR
    SB --> ERR
    
    ERR --> CLI_ERR
    ERR --> MCP_ERR
```

**8 种错误码对照表：**

| 错误码 | 含义 | CLI 行为 | MCP 行为 |
|--------|------|----------|----------|
| `network_error` | DNS/TCP/TLS 失败 | stderr 输出 | isError: true |
| `http_error` | 非 2xx 状态 | stderr 输出 | isError: true |
| `timeout` | 超过 `MARKFETCH_TIMEOUT_MS` | stderr 输出 | isError: true |
| `unsupported_content_type` | 非 HTML 响应 | stderr 输出 | isError: true |
| `extraction_failed` | Readability 无法提取内容 | stderr 输出 | isError: true |
| `too_large` | 超过 `MARKFETCH_MAX_BYTES` | stderr 输出 | isError: true |
| `save_failed` | 写入文件失败 | stderr 输出 | isError: true |
| `save_forbidden` | savePath 在沙箱外 | 不适用 | isError: true |

资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)

## 依赖关系

```mermaid
graph TD
    subgraph "项目模块"
        INDEX[src/index.ts]
        CLI[src/cli.ts]
        MCP[src/mcp.ts]
        CORE[src/core.ts]
        SANDBOX[src/sandbox.ts]
    end
    
    subgraph "生产依赖"
        SDK[@modelcontextprotocol/sdk]
        READABILITY[@mozilla/readability]
        TURNDOWN[turndown]
        LINKEDOM[linkedom]
        UNDICI[undici]
        COMMANDER[commander]
        ZOD[zod]
    end
    
    INDEX --> CLI
    INDEX --> MCP
    CLI --> CORE
    CLI --> COMMANDER
    MCP --> SDK
    MCP --> CORE
    MCP --> ZOD
    CORE --> READABILITY
    CORE --> TURNDOWN
    CORE --> LINKEDOM
    CORE --> UNDICI
    CORE --> SANDBOX
```

**关键依赖说明：**

| 依赖 | 版本 | 用途 |
|------|------|------|
| `@modelcontextprotocol/sdk` | ^0.6.x | MCP stdio 服务器实现 |
| `@mozilla/readability` | ^0.5.x | HTML 文章内容提取 |
| `turndown` | ^7.x | HTML 转 Markdown |
| `linkedom` | ^0.18.x | 服务端 DOM 解析（替代 jsdom） |
| `undici` | ^6.x | HTTP 客户端 |
| `commander` | ^14.x | CLI 参数解析 |
| `zod` | ^3.x | Schema 验证 |

资料来源：[package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)

## 环境变量配置

| 变量 | 默认值 | 作用域 | 说明 |
|------|--------|--------|------|
| `MARKFETCH_TIMEOUT_MS` | `30000` | 全部 | 单次请求超时（毫秒） |
| `MARKFETCH_MAX_BYTES` | `5000000` | 全部 | 响应体和转换后 Markdown 的字节上限 |
| `MARKFETCH_USER_AGENT` | Chrome 130 UA | 全部 | HTTP User-Agent，必须是 Chrome UA |
| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | 仅 MCP | 沙箱允许的写入根目录列表 |

配置在启动时验证，无效值会快速失败并输出到 stderr。

资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)

## 版本演进

| 版本 | 主要架构变更 |
|------|--------------|
| 0.4.0 | 引入 MCP 适配器，分离核心逻辑 |
| 0.5.0 | 引入 CLI 适配器，`index.ts` 惰性路由 |
| 0.5.0 | 错误处理统一为 `MarkfetchError` 抛出 |
| 0.6.0 | 引入写入沙箱 `sandbox.ts` |

资料来源：[CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)

---

<a id='installation'></a>

## 安装与部署

### 相关页面

相关主题：[命令行界面](#cli-usage), [MCP 服务器](#mcp-server)

<details>
<summary>相关源码文件</summary>

以下源码文件用于生成本页说明：

- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)
- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)
- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)
- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)
- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)
</details>

# 安装与部署

markfetch 是一个用于将网页内容转换为 Markdown 格式的工具，支持 CLI 命令行和 MCP (Model Context Protocol) 两种使用模式。本页面详细介绍如何在不同环境中安装和部署 markfetch。

## 系统要求

| 组件 | 最低版本 | 说明 |
|------|----------|------|
| Node.js | ≥ 24 | 必须使用 ES Modules (`"type": "module"`) |
| npm | - | 用于全局安装或本地依赖管理 |
| 操作系统 | 无限制 | 支持 Linux、macOS、Windows |

资料来源：[package.json:7]()

markfetch 采用纯 Node.js 实现，不依赖 Playwright、Chromium 或 Python 等外部运行时环境。

## 安装方式

### 方式一：npm 全局安装（CLI 模式）

通过 npm 全局安装后，`markfetch` 命令将可在任意目录下使用：

```bash
npm i -g markfetch
```

安装完成后即可在命令行中使用：

```bash
markfetch https://en.wikipedia.org/wiki/Markdown
```

资料来源：[README.md:48-54]()

### 方式二：npx 免安装运行

如不想全局安装，可直接使用 npx 临时下载并执行：

```bash
npx -y markfetch https://example.com
```

### 方式三：MCP Server 部署

markfetch 也可作为 MCP Server 部署到各种 AI 客户端。以下是常见客户端的配置方式：

#### Claude Code

```bash
claude mcp add --scope user markfetch -- npx -y markfetch
```

#### Codex

```json
"mcpServers": {
  "markfetch": {
    "command": "npx",
    "args": ["-y", "markfetch"]
  }
}
```

#### Gemini CLI

```bash
gemini mcp add -s user markfetch npx -y markfetch
```

#### 通用 MCP 配置（标准 JSON）

```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"]
    }
  }
}
```

资料来源：[README.md:56-95]()

### 方式四：本地源码运行

```bash
git clone https://github.com/vasylenko/markfetch.git
cd markfetch
npm install
npm run build
```

构建产物位于 `dist/` 目录，入口文件为 `dist/index.js`。可使用以下方式调用：

```bash
# CLI 模式
node dist/index.js <url>

# MCP 模式（无参数启动）
node dist/index.js
```

资料来源：[package.json:13]()

## CLI 命令行使用

### 基本语法

```bash
markfetch <url> [options]
```

### 命令行参数

| 参数/选项 | 说明 |
|-----------|------|
| `<url>` | 必填，目标网页的绝对 HTTP(S) URL |
| `-o, --output <path>` | 可选，将 Markdown 保存到指定文件（绝对或相对路径） |
| `-V, --version` | 打印版本号并退出 |
| `-h, --help` | 打印帮助信息并退出 |

### 输出模式

CLI 模式根据是否指定输出路径有两种输出行为：

```mermaid
graph TD
    A[执行 markfetch] --> B{是否指定 -o 参数?}
    B -->|是| C[保存到文件]
    B -->|否| D[输出到 stdout]
    C --> E[打印确认信息: Saved X bytes to <path>]
    D --> F[直接输出 Markdown 内容]
```

- **无 `-o` 参数**：Markdown 内容直接输出到标准输出，无额外换行
- **指定 `-o` 参数**：写入文件后打印 `Saved {bytes} bytes to {path}`

资料来源：[src/cli.ts:26-33]()

### 错误处理

CLI 模式下，错误信息输出到 stderr，格式为：

```
[<error_code>] <error_message>
```

进程退出码为非零值。

资料来源：[src/cli.ts:42-43]()

## MCP Server 部署

### 工作模式

markfetch 支持通过 `process.argv.length` 自动区分 MCP 和 CLI 模式：

```mermaid
graph TD
    A[启动 markfetch] --> B{process.argv.length}
    B -->|等于 1| C[启动 MCP stdio Server]
    B -->|大于 1| D[进入 CLI 模式]
```

这种设计确保：
- 零参数启动自动进入 MCP 模式
- 现有 MCP 客户端配置无需任何修改
- CLI 代码 (`src/cli.ts`) 在 MCP 模式下不会被加载

资料来源：[README.md:38-40]()

### MCP 工具接口

markfetch 注册了单一 MCP 工具 `fetch_markdown`：

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `url` | string | 是 | 目标网页的绝对 HTTP(S) URL |
| `savePath` | string | 否 | 绝对路径，用于保存 Markdown 到文件 |

返回结果在 `content[0].text` 中，**不包含** `structuredContent` 字段，这是特意设计以确保与各类 MCP 客户端兼容。

资料来源：[README.md:10-17]()

## 环境变量配置

markfetch 支持多个环境变量用于自定义行为。所有变量在启动时进行验证，无效值会导致快速失败并输出错误到 stderr。

| 环境变量 | 默认值 | 单位 | 说明 |
|----------|--------|------|------|
| `MARKFETCH_TIMEOUT_MS` | 30000 | 毫秒 | 单次请求超时时间 |
| `MARKFETCH_MAX_BYTES` | 5000000 | 字节 | 响应体和提取 Markdown 的最大大小（约 5MB） |
| `MARKFETCH_USER_AGENT` | Chrome 130 | - | HTTP User-Agent，必须为 Chrome UA 字符串 |
| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | - | MCP 模式允许写入的根目录列表 |

### 配置示例

在 MCP 配置文件（如 Claude Desktop 配置）中设置环境变量：

```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"],
      "env": {
        "MARKFETCH_TIMEOUT_MS": "60000"
      }
    }
  }
}
```

资料来源：[README.md:104-115]()

## 写入沙箱安全机制

MCP 模式下，`savePath` 参数的写入操作受到安全沙箱限制。

### 工作原理

```mermaid
graph TD
    A[MCP savePath 请求] --> B{路径合法性检查}
    B --> C{绝对路径?}
    C -->|否| D[拒绝: save_forbidden]
    C -->|是| E{路径是否在允许根目录内?}
    E -->|是| F[写入文件]
    E -->|否| G[拒绝: save_forbidden]
```

### 允许的写入根目录

默认允许的写入根目录为：

- 系统临时目录 (`os.tmpdir()`)
- 当前工作目录 (`process.cwd()`)

路径通过 `fs.realpath` 解析一次后缓存，确保符号链接被正确追踪。

### 自定义允许目录

使用 `MARKFETCH_ALLOWED_WRITE_ROOTS` 覆盖默认集合（注意：**替换而非合并**）：

**Linux/macOS (POSIX)**：
```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"],
      "env": {
        "MARKFETCH_ALLOWED_WRITE_ROOTS": "/Users/me/markfetch-out:/tmp"
      }
    }
  }
}
```

**Windows**：
```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"],
      "env": {
        "MARKFETCH_ALLOWED_WRITE_ROOTS": "C:\\Users\\me\\markfetch-out;C:\\Users\\me\\AppData\\Local\\Temp"
      }
    }
  }
}
```

### 安全特性

1. **符号链接防护**：检查时使用规范化后的路径，写入时也使用相同的规范化路径，防止通过 `link/..` 逃逸
2. **Windows 大小写不敏感处理**：Windows 文件系统大小写不敏感，使用 `.toLowerCase()` 进行比较
3. **CLI 模式无沙箱**：CLI 模式设计为由人类直接在 shell 中使用，不执行任何写入限制

资料来源：[src/sandbox.ts:1-30](), [README.md:72-90]()

## 错误代码参考

markfetch 定义了 8 种确定性错误代码：

| 错误代码 | 含义 | 触发条件 |
|----------|------|----------|
| `network_error` | 网络错误 | DNS、TCP、TLS 失败或内部错误 |
| `http_error` | HTTP 错误 | 上游返回非 2xx 状态码 |
| `timeout` | 超时 | 超过 `MARKFETCH_TIMEOUT_MS` 限制 |
| `unsupported_content_type` | 不支持的类型 | 响应不是 `text/html` 或 `application/xhtml+xml` |
| `extraction_failed` | 提取失败 | Readability 未找到文章内容（常见于纯客户端渲染 SPA） |
| `too_large` | 内容过大 | 响应体或提取的 Markdown 超过 `MARKFETCH_MAX_BYTES` |
| `save_failed` | 保存失败 | `savePath` 指定但写入文件失败（目录不存在、权限不足等） |
| `save_forbidden` | 保存被禁止 | `savePath` 超出允许的写入根目录 |

资料来源：[README.md:100-108]()

## 版本历史

| 版本 | 发布日期 | 关键变更 |
|------|----------|----------|
| 0.6.0 | - | MCP 写入沙箱安全修复 |
| 0.5.1 | - | `MARKFETCH_ALLOWED_WRITE_ROOTS` 环境变量支持 |
| 0.5.0 | 2026-05-12 | 新增 CLI 模式 |
| 0.4.0 | 2026-05-10 | 新增 MCP Server 支持 |
| 0.4.1 | 2026-05-11 | 修复 package.json bin 字段问题 |

资料来源：[CHANGELOG.md:1-45]()

---

<a id='cli-usage'></a>

## 命令行界面

### 相关页面

相关主题：[MCP 服务器](#mcp-server), [错误处理机制](#error-handling), [配置与环境变量](#configuration)

<details>
<summary>相关源码文件</summary>

以下源码文件用于生成本页说明：

- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)
- [src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)
- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)
- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)
- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)
- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)
- [CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)
</details>

# 命令行界面

## 概述

markfetch 提供两种运行模式：MCP（Model Context Protocol）stdio 服务器模式和命令行界面（CLI）模式。CLI 模式允许用户直接从终端获取网页内容并将其转换为 Markdown 格式输出。

命令行界面的核心职责包括：解析用户输入的 URL 和命令行参数、调用核心提取模块处理网页、将 Markdown 结果输出到标准输出或指定文件、以及以结构化格式报告错误信息。

CLI 适配器位于 `src/cli.ts`，采用懒加载机制——只有当进程检测到命令行参数时才加载该模块，确保 MCP 模式下不会引入任何控制台输出代码。

## 工作流程

```mermaid
graph TD
    A[启动 markfetch] --> B{命令行参数数量}
    B -->|0 个参数| C[启动 MCP stdio 服务器]
    B -->|1+ 个参数| D[加载 CLI 适配器]
    D --> E[解析 URL 和选项]
    E --> F[调用 core.fetchMarkdown]
    F --> G{savePath 存在?}
    G -->|是| H[写入文件并输出确认信息]
    G -->|否| I[输出 Markdown 到 stdout]
    F -->|异常| J[输出错误到 stderr 并退出]
    H --> K[process.exitCode = 0]
    I --> K
```

CLI 适配器遵循统一错误处理约定：所有核心模块抛出的 `MarkfetchError` 异常由适配器捕获并转换为带错误代码的标准错误输出。

## 安装与调用

### 全局安装

```bash
npm i -g markfetch
```

安装后，`markfetch` 命令全局可用。

### 基本用法

```bash
markfetch <url>
```

将 URL 对应的网页内容提取为 Markdown 并输出到标准输出。

### 输出到文件

```bash
markfetch <url> -o <path>
markfetch <url> --output <path>
```

路径可以是绝对路径或相对于当前工作目录的相对路径。

## 命令行选项

| 选项 | 说明 |
|------|------|
| `<url>` | 必选参数，目标网页的绝对 HTTP/HTTPS URL |
| `-o, --output <path>` | 可选，将 Markdown 保存到指定文件路径 |
| `-V, --version` | 打印版本号并退出 |
| `-h, --help` | 打印帮助信息并退出 |

资料来源：[src/cli.ts:1-10](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts#L1-L10)

## 输出机制

CLI 适配器对输出的处理遵循"标准输出保留给 Markdown 内容"的原则：

- **无 savePath**：原始 Markdown 内容直接写入 stdout，不添加额外换行符，与 MCP 的 `content[0].text` 格式保持一致
- **有 savePath**：写入文件后输出确认信息 `Saved <bytes> bytes to <path>` 到 stdout

```typescript
if (savedTo !== undefined) {
  // 确认信息——CLI 唯一添加的 stdout 换行
  console.log(`Saved ${bytes} bytes to ${savedTo}`);
} else {
  // 原始 Markdown 内容——无额外换行
  process.stdout.write(markdown);
}
```

资料来源：[src/cli.ts:37-47](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts#L37-L47)

## 错误处理

CLI 采用与 MCP 适配器一致的错误格式，通过 `classifyError` 函数将异常分类并输出：

```
[<error_code>] <error_message>
```

错误信息输出到 stderr，并设置 `process.exitCode = 1` 确保管道脚本能够检测到失败状态。

```typescript
} catch (err) {
  const { code, message } = classifyError(err);
  console.error(`[${code}] ${message}`);
  process.exitCode = 1;
}
```

资料来源：[src/cli.ts:48-53](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts#L48-L53)

### 错误代码对照表

| 错误代码 | 含义 |
|---------|------|
| `network_error` | DNS、TCP、TLS 连接失败或内部网络错误 |
| `http_error` | 目标服务器返回非 2xx 状态码 |
| `timeout` | 请求超时（默认 30 秒，可通过 `MARKFETCH_TIMEOUT_MS` 配置）|
| `unsupported_content_type` | 响应不是 HTML 内容 |
| `extraction_failed` | Readability 无法提取文章内容（常见于纯客户端渲染的 SPA）|
| `too_large` | 内容超过 `MARKFETCH_MAX_BYTES` 限制（默认 5MB）|
| `save_failed` | 文件写入失败（目录不存在、权限不足等）|
| `save_forbidden` | savePath 超出允许的写入根目录 |

资料来源：[README.md:1-100](https://github.com/vasylenko/markfetch/blob/main/README.md#L1-L100)

## 路径解析规则

CLI 在调用核心模块之前统一处理路径解析：

```typescript
const savePath = options.output
  ? resolve(process.cwd(), options.output)
  : undefined;
```

规则说明：

- 绝对路径保持不变
- 相对路径相对于 `process.cwd()` 解析
- 不执行波浪号（`~`）展开——由 shell 在 argv 到达进程前处理
- 核心模块接收的始终是绝对路径，确保行为一致性

资料来源：[src/cli.ts:20-27](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts#L20-L27)

## 与 MCP 适配器的对比

```mermaid
graph TD
    subgraph CLI 模式
        A1[命令行参数] --> B1[cli.ts 适配器]
        B1 --> C1[console.log 控制输出]
        B1 --> C2[console.error 控制错误]
    end
    subgraph MCP 模式
        A2[stdio 帧] --> B2[mcp.ts 适配器]
        B2 --> C3[MCP JSON-RPC 响应]
        B2 --> C4[structuredContent 格式]
    end
    subgraph 共享核心
        D[core.fetchMarkdown]
        E[MarkfetchError]
        F[extractArticle]
    end
    B1 --> D
    B2 --> D
    C1 -.不使用.-> C3
    C2 -.不使用.-> C4
```

关键差异：

| 特性 | CLI 模式 | MCP 模式 |
|------|---------|---------|
| 交互协议 | 终端 stdin/stdout | stdio JSON-RPC |
| 输出格式 | 原始 Markdown | MCP content 数组 |
| 沙箱写入 | 无限制 | 受 `MARKFETCH_ALLOWED_WRITE_ROOTS` 限制 |
| 控制台输出 | 可用 | 不可用 |

CLI 模式没有沙箱限制——shell 用户是安全边界，因此允许写入任意路径。MCP 模式由于由语言模型驱动，引入写入沙箱以防止路径遍历攻击。

资料来源：[src/mcp.ts:1-50](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts#L1-L50)
资料来源：[src/sandbox.ts:1-50](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts#L1-L50)

## 分发器机制

`src/index.ts` 负责根据进程参数数量决定加载哪个适配器：

```typescript
// 懒加载分发逻辑
// process.argv.length <= 1: 仅 node 命令，无参数
// process.argv.length == 2: 仅命令名（如 'node markfetch'），启动 MCP
// process.argv.length > 2: 有额外参数，启动 CLI
```

这种设计确保：
- 零参数启动时始终进入 MCP 模式，保持现有配置的兼容性
- 任何命令行参数触发 CLI 模式
- 适配器模块懒加载，MCP 模式下永不引入 CLI 代码路径

资料来源：[src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)

## 环境变量配置

CLI 与 MCP 共享以下环境变量：

| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时（毫秒）|
| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取 Markdown 的字节上限 |
| `MARKFETCH_USER_AGENT` | Chrome 130 UA 字符串 | HTTP User-Agent 头 |

注意：`MARKFETCH_ALLOWED_WRITE_ROOTS` 仅在 MCP 模式下生效，CLI 模式不受此限制。

资料来源：[README.md:100-150](https://github.com/vasylenko/markfetch/blob/main/README.md#L100-L150)

## 典型使用场景

### 管道处理

```bash
markfetch https://example.com/article | grep -A5 "## Introduction"
```

### 保存长文档

```bash
markfetch https://docs.example.com/guide -o /tmp/guide.md
```

### 批量脚本集成

```bash
#!/bin/bash
for url in "${urls[@]}"; do
  markfetch "$url" -o "/output/$(basename "$url").md" || echo "Failed: $url" >&2
done
```

## 版本历史

- **0.5.0** (2026-05-12)：新增 CLI 模式，采用 commander.js 进行参数解析
- **0.6.0**：当前版本，CLI 与 MCP 双模式稳定运行

资料来源：[CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)

---

<a id='mcp-server'></a>

## MCP 服务器

### 相关页面

相关主题：[命令行界面](#cli-usage), [写操作沙箱](#write-sandbox), [错误处理机制](#error-handling)

<details>
<summary>相关源码文件</summary>

以下源码文件用于生成本页说明：

- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)
- [src/index.ts](https://github.com/vasylenko/markfetch/blob/main/src/index.ts)
- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)
- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)
- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)
- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)
- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)
</details>

# MCP 服务器

## 概述

markfetch 的 MCP 服务器是基于 Model Context Protocol (MCP) 的标准输入输出（stdio）服务器实现，提供 `fetch_markdown` 工具供 AI 代理使用。该服务器通过单一工具接口将网页内容转换为干净的 Markdown 格式，支持直接返回内容或写入文件系统。

```mermaid
graph TD
    A[MCP 客户端] -->|stdio| B[markfetch MCP 服务器]
    B --> C[src/index.ts 分发器]
    C -->|无参数| D[MCP 模式]
    C -->|有参数| E[CLI 模式]
    D --> F[src/mcp.ts]
    E --> G[src/cli.ts]
    F --> H[src/core.ts]
    G --> H
    H --> I[fetchMarkdown 核心逻辑]
```

## 架构设计

### 双模式入口

markfetch 采用单一二进制文件支持两种运行模式，通过 `process.argv.length` 在运行时自动判断： 资料来源：[src/index.ts:1-15]()

| 模式 | 触发条件 | 入口文件 | 输出通道 |
|------|----------|----------|----------|
| MCP stdio 服务器 | 无命令行参数 | `src/mcp.ts` | stdout 用于 MCP 协议帧 |
| CLI 工具 | 存在命令行参数 | `src/cli.ts` | stdout 用于 markdown 输出 |

关键设计原则：**stdout 保留给 MCP 协议帧使用**，stderr 仅用于致命错误输出，确保 stdio 通道的纯净性。 资料来源：[src/cli.ts:45-48]()

### MCP 服务器初始化

服务器使用 `@modelcontextprotocol/sdk` 包提供的 `McpServer` 类进行初始化：

```typescript
const server = new McpServer({ name: "markfetch", version: "0.6.0" });
```

版本号与 `package.json` 中的 `version` 字段保持一致，确保 MCP 客户端能获取准确的版本信息。 资料来源：[src/mcp.ts:12]()

## 工具定义

### fetch_markdown 工具

`fetch_markdown` 是 markfetch 提供的唯一 MCP 工具，具有以下特性：

| 特性 | 说明 |
|------|------|
| 返回通道 | `content[0].text` 单一通道，无 `structuredContent` |
| 适用场景 | 文章、文档、博客帖子、新闻、参考页面 |
| 限制 | 匿名获取，无认证支持 |

#### 输入参数

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `url` | `string` | 是 | 完整的 HTTP/HTTPS URL，服务器自动跟随重定向 |
| `savePath` | `string` | 否 | 绝对路径，将 markdown 写入文件而非返回 |

`url` 参数使用 Zod schema 进行验证：

```typescript
url: z
  .string()
  .url()
  .describe("Absolute http(s) URL of the page to fetch...")
```

`savePath` 必须为绝对路径：

```typescript
savePath: z
  .string()
  .refine(isAbsolute, "savePath must be an absolute filesystem path")
  .optional()
```

资料来源：[src/mcp.ts:14-38]()

#### 返回格式

成功响应：

```json
{
  "content": [
    {
      "type": "text",
      "text": "# Markdown Content Here..."
    }
  ]
}
```

错误响应：

```json
{
  "content": [
    {
      "type": "text",
      "text": "[error_code] Error message"
    }
  ],
  "isError": true
}
```

## 错误处理

### 错误代码体系

MCP 服务器返回 8 种确定性错误代码：

| 错误代码 | 含义 | 触发条件 |
|----------|------|----------|
| `network_error` | 网络故障 | DNS/TCP/TLS 失败或内部错误 |
| `http_error` | HTTP 错误 | 上游返回非 2xx 状态码 |
| `timeout` | 请求超时 | 超过 `MARKFETCH_TIMEOUT_MS` 配置的时间 |
| `unsupported_content_type` | 不支持的类型 | 响应不是 `text/html` 或 `application/xhtml+xml` |
| `extraction_failed` | 提取失败 | Readability 无法提取文章内容（常见于纯客户端渲染的 SPA） |
| `too_large` | 内容过大 | 响应体或提取的 markdown 超过 `MARKFETCH_MAX_BYTES` |
| `save_failed` | 保存失败 | 写入文件失败（目录不存在、权限问题等） |
| `save_forbidden` | 保存禁止 | `savePath` 超出允许的写入根目录 |

资料来源：[src/mcp.ts:4-9]()

### 错误结果生成

```typescript
function errorResult(code: ErrorCode, message: string) {
  return {
    content: [{ type: "text" as const, text: `[${code}] ${message}` }],
    isError: true,
  };
}
```

所有错误统一通过 `errorResult()` 函数格式化，确保错误消息格式的一致性：`[错误代码] 错误描述`。 资料来源：[src/mcp.ts:4-9]()

## 配置选项

MCP 服务器支持以下环境变量配置：

| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |
| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取 markdown 的最大字节数 |
| `MARKFETCH_USER_AGENT` | Chrome 130 固定字符串 | HTTP User-Agent，必须为 Chrome UA |
| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 模式允许的写入根目录列表 |

### 写入沙箱

MCP 模式的 `savePath` 写入被限制在允许的根目录集合内。默认情况下包含系统临时目录和进程工作目录。 资料来源：[src/sandbox.ts:1-10]()

```mermaid
graph LR
    A[savePath] --> B{检查是否在允许根目录内}
    B -->|是| C[允许写入]
    B -->|否| D[返回 save_forbidden 错误]
    
    E[默认允许根目录] --> B
    F[MARKFETCH_ALLOWED_WRITE_ROOTS] --> E
```

#### 自定义允许根目录

```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"],
      "env": {
        "MARKFETCH_ALLOWED_WRITE_ROOTS": "/Users/me/markfetch-out:/tmp"
      }
    }
  }
}
```

设置此变量会**完全替换**默认根目录，而非合并。如需保留默认值，必须显式包含。 资料来源：[README.md:Configuration]()

#### 路径解析逻辑

`sandbox.ts` 中的路径验证逻辑：

1. 解析 `savePath` 为绝对路径
2. 遍历所有允许根目录
3. 检查目标路径是否在任一允许根目录内
4. Windows 平台使用不区分大小写的比较

```typescript
// Win32 case-fold: filesystem is case-insensitive
const fold = process.platform === "win32"
  ? (s: string) => s.toLowerCase()
  : (s: string) => s;
```

资料来源：[src/sandbox.ts:32-36]()

## MCP 客户端集成

### Claude Code

```bash
claude mcp add --scope user markfetch -- npx -y markfetch
```

### Codex

```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"]
    }
  }
}
```

### Gemini CLI

```bash
gemini mcp add -s user markfetch npx -y markfetch
```

### 本地路径配置

对于需要使用本地构建版本的场景：

```json
{
  "mcpServers": {
    "markfetch": {
      "command": "node",
      "args": ["/absolute/path/to/markfetch/dist/index.js"]
    }
  }
}
```

资料来源：[README.md:MCP install commands]()

## 与 CLI 模式的区别

| 特性 | MCP 服务器 | CLI 工具 |
|------|------------|----------|
| 调用方式 | stdio 协议 | 命令行参数 |
| 输出通道 | `content[0].text` | stdout 直接输出 |
| 写入沙箱 | 启用 | 禁用（无限制） |
| 错误输出 | `isError: true` | stderr + 退出码 |
| 相对路径处理 | 不支持（需绝对路径） | 支持（相对 cwd 解析） |

CLI 模式中相对路径会被解析为绝对路径后再传给核心逻辑：

```typescript
const savePath = options.output
  ? resolve(process.cwd(), options.output)
  : undefined;
```

资料来源：[src/cli.ts:13-20]()

## 版本历史

| 版本 | 发布日期 | MCP 相关变更 |
|------|----------|--------------|
| 0.6.0 | 当前版本 | 稳定版 MCP 实现 |
| 0.5.0 | 2026-05-12 | 引入 CLI 模式，源文件重构为独立模块 |
| 0.4.0 | 2026-05-10 | 初始 MCP 工具 `fetch_markdown` |

0.4.0 版本引入了原始的 MCP 实现，0.5.0 版本将源码重构为 `src/mcp.ts`（MCP 适配器）、`src/cli.ts`（CLI 适配器）和 `src/core.ts`（核心管道 + 错误处理）的分离结构，同时保持了 MCP 消费者的公共 API 完全兼容。 资料来源：[CHANGELOG.md:版本历史]()

## 安全考量

### MCP 模式的安全设计

1. **写入沙箱隔离**：MCP 工具由语言模型驱动，可能被页面内容引导，因此 `savePath` 限制在可配置的允许目录内
2. **CLI 模式无沙箱**：命令行由人类直接使用，作为安全边界，不施加写入限制
3. **Symlink 防护**：符号链接指向允许根目录外的路径会被阻止 资料来源：[README.md:Write sandbox]()

### User-Agent 要求

`MARKFETCH_USER_AGENT` 必须为 Chrome 用户代理字符串。非 Chrome 字符串会在启动时快速失败，防止因客户端提示不匹配导致的问题。 资料来源：[README.md:Configuration]()

---

<a id='http-fingerprint'></a>

## HTTP 指纹与请求模拟

### 相关页面

相关主题：[项目概述](#overview), [内容提取管道](#content-extraction)

<details>
<summary>相关源码文件</summary>

以下源码文件用于生成本页说明：

- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)
- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)
- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)
- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)
- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)
- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)
</details>

# HTTP 指纹与请求模拟

## 概述

markfetch 在发起 HTTP 请求时模拟真实浏览器的指纹特征，以绕过网站的反爬虫机制。其核心设计理念是使每个请求在 HTTP 协议层面与真实 Chrome 浏览器发出的请求无法区分，从而在不支持 JavaScript 渲染的环境中也能获取到完整的页面内容。

markfetch 使用 **HTTP/2 传输层** 结合 **一致的 Chrome 请求头集合**，并通过 `MARKFETCH_USER_AGENT` 环境变量动态生成 `Sec-CH-UA-*` 客户端提示头，确保请求指纹的真实性与可配置性。

资料来源：[README.md:47]()

## 核心机制

### HTTP/2 传输

markfetch 默认使用 HTTP/2 协议进行网络请求。相比 HTTP/1.1，HTTP/2 的多路复用、头部压缩和服务器推送特性使得请求模式更接近现代浏览器的实际行为。

### Chrome 请求头集

项目预置了一套完整的 Chrome 请求头集合，覆盖了以下关键头字段：

| 头字段类型 | 说明 |
|---|---|
| `User-Agent` | Chrome 130 版本标识 |
| `Sec-CH-UA-*` | 客户端提示头，基于 User-Agent 动态派生 |
| `Accept` | 内容协商头 |
| `Accept-Language` | 语言偏好 |
| `Accept-Encoding` | 压缩算法支持 |

这套头字段组合确保了请求在 TLS 握手后的 HTTP 层与真实 Chrome 浏览器保持一致。

## 配置项

### 环境变量配置

| 变量名 | 默认值 | 用途 |
|---|---|---|
| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |
| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体与提取后 markdown 的字节数上限 |
| `MARKFETCH_USER_AGENT` | Chrome 130 固定版本字符串 | 覆盖默认 UA，必须为 Chrome UA 格式 |

`MARKFETCH_USER_AGENT` 的值在进程启动时派生 `Sec-CH-UA-*` 客户端提示。如果传入非 Chrome 格式的字符串，程序会在启动时快速失败并在 stderr 输出错误。

资料来源：[README.md:88-93]()

### 配置示例

```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"],
      "env": {
        "MARKFETCH_USER_AGENT": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
        "MARKFETCH_TIMEOUT_MS": "60000"
      }
    }
  }
}
```

## 请求流程

```mermaid
graph TD
    A[入口: fetchMarkdown] --> B[验证环境变量]
    B --> C{配置有效?}
    C -->|无效| D[启动时快速失败]
    C -->|有效| E[构造 HTTP/2 请求]
    E --> F[附加 Chrome 请求头集]
    F --> G[基于 UA 派生 Sec-CH-UA-*]
    G --> H[发送请求到目标 URL]
    H --> I{响应状态}
    I -->|2xx| J[进入内容提取流程]
    I -->|非 2xx| K[抛出 http_error]
    J --> L[解码 HTML 实体]
    L --> M[注入 base href]
    M --> N[解析 DOM 树]
    N --> O[Readability 提取]
    O --> P[Turndown 转 Markdown]
```

## 基础 URL 注入机制

由于 linkedom 解析器在文档没有 `<base href>` 时会将相对路径保留为 `/wiki/...` 形式，markfetch 在 `extractArticle` 函数中通过 `ensureBaseHref` 注入重定向后的规范 URL，确保后续的相对链接能够被正确解析。

```typescript
function ensureBaseHref(html: string, url: string): string {
  const safeUrl = url.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
  const stripped = html.replaceAll(/<base\s[^>]*>/gi, "");
  if (/<head\b[^>]*>/i.test(stripped)) {
    return stripped.replace(
      /<head\b([^>]*)>/i,
      `<head$1><base href="${safeUrl}">`,
    );
  }
  if (/<html\b[^>]*>/i.test(stripped)) {
    return stripped.replace(
      /<html\b([^>]*)/i,
      `<html$1><head><base href="${safeUrl}"></head>`,
    );
  }
  return stripped;
}
```

资料来源：[src/core.ts:18-33]()

该函数会：

1. 对 URL 中的特殊字符进行 HTML 实体转义
2. 移除页面中已有的 `<base>` 标签（上游 URL 更具权威性）
3. 将规范 URL 注入到 `<head>` 或 `<html>` 标签中

## 请求适配层

### MCP 适配器

MCP 适配器 (`src/mcp.ts`) 接收已验证的输入（URL 语法由适配器的 schema 校验，savePath 为绝对路径），并将错误统一映射为 `MarkfetchError`。

```typescript
const server = new McpServer({ name: "markfetch", version: "0.6.0" });

server.registerTool(
  "fetch_markdown",
  {
    description:
      "Fetch a single public HTTP/S URL and return its main article content as clean markdown...",
    inputSchema: {
      url: z.string().url().describe("Absolute http(s) URL..."),
      savePath: z.string().refine(isAbsolute, "savePath must be an absolute filesystem path").optional()
    }
  }
);
```

资料来源：[src/mcp.ts:22-38]()

### CLI 适配器

CLI 适配器 (`src/cli.ts`) 将相对输出路径解析为绝对路径后再传递给核心模块：

```typescript
const savePath = options.output
  ? resolve(process.cwd(), options.output)
  : undefined;
```

CLI 模式下的错误通过 stderr 输出，格式为 `[code] message`，并设置 `process.exitCode = 1`。

资料来源：[src/cli.ts:28-32]()

## 错误码体系

markfetch 定义了 8 种确定性错误码，用于标识请求和处理过程中的各类失败场景：

| 错误码 | 含义 | 触发条件 |
|---|---|---|
| `network_error` | DNS/TCP/TLS 故障或内部错误 | 网络层异常 |
| `http_error` | 上游返回非 2xx 状态 | HTTP 响应状态码 ≥ 400 |
| `timeout` | 超过 `MARKFETCH_TIMEOUT_MS` | 请求超时 |
| `unsupported_content_type` | 响应不是 HTML 类型 | Content-Type 非 `text/html` 或 `application/xhtml+xml` |
| `extraction_failed` | Readability 未提取到内容 | 纯客户端渲染的 SPA |
| `too_large` | 超过 `MARKFETCH_MAX_BYTES` | 响应体或 markdown 超限 |
| `save_failed` | 文件写入失败 | 目录不存在或权限不足 |
| `save_forbidden` | 路径超出允许的写入根目录 | MCP 模式下路径未通过沙箱校验 |

资料来源：[README.md:74-82]()

## 与其他方案的对比

| 方案 | 真实浏览器指纹 | Reader-View 提取 | 结构化错误 | 零配置 |
|---|---|---|---|---|
| 内置 Agent fetch 工具 | ✗ | ✗ | ✗ | ✓ |
| 通用 Playwright/Puppeteer | ✓ | ✗ | ✗ | ✗ |
| mcp-server-fetch (Python) | ✗ | 基础 | ✗ | ✗ |
| CloudFlare /markdown | ✗ | ✓ | ✗ | ✗ |
| **markfetch** | **✓** | **✓** | **✓** | **✓** |

markfetch 的独特优势在于同时实现了真实浏览器指纹和 Reader-View 风格的内容提取，而无需运行无头浏览器。

资料来源：[README.md:55-62]()

## 设计原则

### 单进程架构

markfetch 采用纯 Node.js 实现，不依赖 Playwright、headless Chromium 或 Python 子进程。这使得：

- 启动开销极低
- 内存占用可控
- 适合在 MCP stdio 服务器等受限环境中运行

### Stdio 清洁原则

- **stdout** 保留给 MCP 协议帧
- **stderr** 仅用于致命错误
- 无日志输出、无 ANSI 转义码

### MCP 与 CLI 的行为一致性

两种调用方式使用相同的核心模块，唯一的差异在于：

- MCP 模式启用写入沙箱 (`save_forbidden` 错误码)
- CLI 模式完全不受限（人类用户是安全边界）

资料来源：[README.md:49-54]()

## 版本历史

| 版本 | 变更内容 |
|---|---|
| 0.4.0 | 引入 HTTP/2 传输和 Chrome 请求头集，`MARKFETCH_USER_AGENT` 环境变量 |
| 0.5.0 | 新增 CLI 模式，保持 MCP 和 CLI 行为一致 |
| 0.6.0 | 完善错误码体系和环境变量验证 |

资料来源：[CHANGELOG.md:1-15]()

---

<a id='content-extraction'></a>

## 内容提取管道

### 相关页面

相关主题：[HTTP 指纹与请求模拟](#http-fingerprint), [错误处理机制](#error-handling)

<details>
<summary>相关源码文件</summary>

以下源码文件用于生成本页说明：

- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)
- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)
- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)
- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)
- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)
- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)
- [CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)
</details>

# 内容提取管道

## 概述

内容提取管道（Content Extraction Pipeline）是 markfetch 项目的核心模块，负责将任意 HTTP/HTTPS URL 的 HTML 内容转换为干净的 Markdown 格式。该管道是纯 Node.js 实现，不依赖 Playwright 或无头浏览器，通过 HTTP/2 传输和完整的 Chrome 请求头集实现真实浏览器指纹模拟。

资料来源：[README.md:1-15]()

## 架构总览

markfetch 的内容提取管道遵循经典的"获取-解析-转换"三阶段架构。整个管道从 URL 输入开始，依次经过网络请求层、内容解析层和 Markdown 转换层，最终输出结构化的 Markdown 文档。

```mermaid
graph TD
    A[URL 输入] --> B[HTTP/2 网络请求]
    B --> C{响应状态码}
    C -->|2xx| D[HTML 内容]
    C -->|非2xx| E[http_error]
    D --> F[内容类型检测]
    F -->|text/html| G[解码编码标签]
    F -->|非HTML| I[unsupported_content_type]
    G --> H[注入 Base Href]
    H --> J[Readability 解析]
    J --> K{提取结果}
    K -->|有内容| L[转换为 Markdown]
    K -->|无内容| M[extraction_failed]
    L --> N[大小检查]
    N -->|未超限| O[返回结果]
    N -->|超出限制| P[too_large]
    O --> Q{savePath}
    Q -->|指定路径| R[沙盒写入]
    Q -->|标准输出| S[stdout 输出]
```

资料来源：[src/core.ts:1-100]()

## 核心组件

### 核心模块结构

markfetch 的核心提取逻辑集中在 `src/core.ts` 中，包含以下关键函数：

| 函数名 | 职责 | 返回值 |
|--------|------|--------|
| `decodeEncodedCodeTags()` | 解码 HTML 实体编码的代码标签 | `string` |
| `ensureBaseHref()` | 注入 `<base>` 标签确保相对路径正确解析 | `string` |
| `rewriteForReadability()` | 重写 DOM 结构以优化提取效果 | `void` |
| `extractArticle()` | 使用 Readability 提取文章内容 | `{title, content} \| null` |
| `convertToMarkdown()` | 将 HTML 转换为 Markdown | `string` |

资料来源：[src/core.ts:1-150]()

### 依赖库

| 库名 | 版本 | 用途 |
|------|------|------|
| `@mozilla/readability` | 最新稳定版 | 从 HTML 中提取主要文章内容 |
| `turndown` | 最新稳定版 | 将 HTML DOM 转换为 Markdown |
| `linkedom` | 最新稳定版 | 轻量级 DOM 解析器 |
| `undici` | 最新稳定版 | HTTP/2 客户端 |

资料来源：[package.json:1-30]()

## 管道各阶段详解

### 阶段一：HTML 实体解码

```typescript
function decodeEncodedCodeTags(html: string): string {
  return html.replaceAll(
    /&lt;(\/?(?:code|pre)(?:\s[^&]*?)?\/?)&gt;/g,
    (_, tag) => `<${tag}>`,
  );
}
```

此函数专门处理 HTML 实体编码的 `<code>` 和 `<pre>` 标签。由于代码块在转换过程中需要保留原始格式，markfetch 需要将这些被实体编码的标签转换回标准 HTML 标签形式。

正则表达式说明：
- `&lt;` - 匹配左尖括号的实体编码形式
- `\/?` - 可选的闭合斜杠
- `(?:code|pre)` - 匹配 `code` 或 `pre` 标签名
- `(?:\s[^&]*?)?` - 可选的属性部分
- `&gt;` - 匹配右尖括号的实体编码形式

资料来源：[src/core.ts:10-18]()

### 阶段二：Base Href 注入

```typescript
function ensureBaseHref(html: string, url: string): string {
  const safeUrl = url.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
  const stripped = html.replaceAll(/<base\s[^>]*>/gi, "");
  if (/<head\b[^>]*>/i.test(stripped)) {
    return stripped.replace(
      /<head\b([^>]*)>/i,
      `<head$1><base href="${safeUrl}">`,
    );
  }
  if (/<html\b[^>]*>/i.test(stripped)) {
    return stripped.replace(
      /<html\b([^>]*)>/i,
      `<html$1><head><base href="${safeUrl}"></head>`,
    );
  }
  return stripped;
}
```

此函数的目的是确保相对链接和图片路径能够正确解析。由于 linkedom 解析器不会自动填充 `baseURI`，导致相对路径（如 `/wiki/...`）无法正确转换为绝对 URL。管道通过以下步骤解决此问题：

1. 移除页面原有的 `<base>` 标签（避免冲突）
2. 在 `<head>` 标签内注入新的 `<base href>` 指向最终重定向后的 URL
3. 如果没有 `<head>` 标签，则在 `<html>` 标签后创建新的 `<head>`

资料来源：[src/core.ts:25-50]()

### 阶段三：DOM 重写优化

```typescript
function rewriteForReadability(document: Document): void {
  // 处理脚注aside元素
  const footnoteAsides = document.querySelectorAll(
    'aside.footnote-brackets, ' +
    'aside[role="doc-endnotes"], aside[role="doc-footnote"], aside[role="doc-footnotes"]',
  );
  
  // 处理details/summary折叠元素
  for (const el of Array.from(document.querySelectorAll("details"))) {
    const parent = el.parentNode;
    if (!parent) continue;
    while (el.firstChild) parent.insertBefore(el.firstChild, el);
    el.remove();
  }
  
  // 处理 MediaWiki 样式标题容器
  for (const el of Array.from(document.querySelectorAll("div.mw-heading"))) {
    const heading = el.querySelector("h1, h2, h3, h4, h5, h6");
    if (!heading) continue;
    el.parentNode?.replaceChild(heading, el);
  }
}
```

此函数对 DOM 结构进行预处理，使 Readability 能够更准确地提取主要内容：

| 处理类型 | 选择器 | 处理方式 |
|----------|--------|----------|
| 脚注区域 | `aside.footnote-*` | 替换为 `<section>` 以保留内容 |
| 折叠元素 | `details` | 展开内容并移除容器 |
| 标题容器 | `div.mw-heading` | 提换为纯标题元素 |

资料来源：[src/core.ts:75-115]()

### 阶段四：Readability 内容提取

```typescript
function extractArticle(
  html: string,
  url: string,
): { title: string; content: string } | null {
  const decoded = decodeEncodedCodeTags(html);
  const withBase = ensureBaseHref(decoded, url);
  const { document } = parseHTML(withBase);
  rewriteForReadability(document);
  
  const article = new Readability(document, {
    keepClasses: true,
  }).parse();
  
  if (!article?.content?.trim()) return null;
  return { title: (article.title ?? "").trim(), content: article.content };
}
```

Readability 是 Mozilla 开发的专门用于从网页中提取主要文章内容的库。markfetch 的配置使用 `keepClasses: true` 选项，目的是保留 `<code>` 元素的 `class="language-X"` 属性，使 turndown 能够在代码块中输出语言提示标记。

资料来源：[src/core.ts:118-145]()

### 阶段五：Markdown 转换

```typescript
function convertToMarkdown(article: {
  title: string;
  content: string;
}): string {
  const body = TURNDOWN.turndown(article.content);
  
  // 如果 Readability 保留了页面的 <h1>，不重复添加标题
  const contentLeadsWithH1 = /^\s*<h1[\s>]/i.test(article.content);
  let result = article.title && !contentLeadsWithH1
    ? `# ${article.title}\n\n${body}`
    : body;
  
  // 移除空标题
  result = pruneEmptyHeadings(result);
  
  // 处理代码块语言提示
  result = patchCodeFenceLanguageHints(result);
  
  return result;
}
```

Turndown 配置了自定义的转义规则，以解决以下问题：

1. **行内下划线保护**：Markdown 中行内代码外的下划线通常需要转义，但 markfetch 保留了未转义的形式以提高可读性
2. **标题下划线处理**：CommonMark 的 setext 标题使用 `=` 或 `-` 字符，但后面紧跟字母数字字符的情况不是标题，应保留原样

资料来源：[src/core.ts:50-75]()

## 错误处理机制

### 错误代码表

| 错误代码 | 含义 | 触发条件 |
|----------|------|----------|
| `network_error` | 网络层故障 | DNS 解析、TCP 连接、TLS 握手失败 |
| `http_error` | HTTP 协议错误 | 服务器返回非 2xx 状态码 |
| `timeout` | 请求超时 | 超过 `MARKFETCH_TIMEOUT_MS` 配置的时间 |
| `unsupported_content_type` | 不支持的类型 | 响应不是 `text/html` 或 `application/xhtml+xml` |
| `extraction_failed` | 提取失败 | Readability 返回空内容（典型于纯客户端渲染 SPA） |
| `too_large` | 内容过大 | 响应体或提取后的 Markdown 超过 `MARKFETCH_MAX_BYTES` |
| `save_failed` | 保存失败 | 指定了 `savePath` 但写入文件失败 |
| `save_forbidden` | 写入被禁止 | `savePath` 超出允许的写入根目录 |

资料来源：[README.md:60-80]()

### 错误传播流程

```mermaid
graph LR
    A[核心模块抛出] --> B[MarkfetchError]
    B --> C[MCP 适配器]
    B --> D[CLI 适配器]
    C --> E[errorResult 格式化]
    D --> F[classifyError 处理]
    E --> G[返回给 LLM]
    F --> H[stderr 输出]
    H --> I[非零退出码]
```

资料来源：[src/mcp.ts:1-50]() 和 [src/cli.ts:1-50]()

## 配置参数

### 环境变量配置

| 变量名 | 默认值 | 用途 |
|--------|--------|------|
| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |
| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取 Markdown 的最大字节数 |
| `MARKFETCH_USER_AGENT` | Chrome 130 UA 字符串 | HTTP User-Agent 头 |
| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 模式下允许写入的根目录列表 |

所有配置变量在启动时进行验证，无效值会立即失败并输出到 stderr，避免产生难以调试的运行时错误。

资料来源：[README.md:55-65]()

## 适配器模式

markfetch 采用适配器模式同时支持 CLI 和 MCP 两种调用方式。入口文件 `src/index.ts` 根据 `process.argv.length` 延迟加载对应的适配器：

```typescript
// 伪代码示例
if (process.argv.length > 1) {
  // CLI 模式
  import('./cli.js');
} else {
  // MCP 模式
  import('./mcp.js');
}
```

这种设计确保了"stdout 保留给 MCP 帧"这一不变量是结构性的——CLI 代码在 MCP 模式下永远不会加载，因此不存在通过 `console.log` 污染输出的可能性。

资料来源：[CHANGELOG.md:40-60]()

## 安全沙盒

MCP 模式下的文件写入操作受到沙盒限制，防止恶意提示词诱导 LLM 将文件写入任意位置：

```mermaid
graph TD
    A[savePath 参数] --> B{绝对路径检查}
    B -->|否| C[返回 save_forbidden]
    B -->|是| D[符号链接解析]
    D --> E{路径限制检查}
    E -->|在允许范围内| F[写入文件]
    E -->|超出允许范围| G[返回 save_forbidden]
```

沙盒默认允许写入 `os.tmpdir()` 和 `process.cwd()` 目录。可以通过 `MARKFETCH_ALLOWED_WRITE_ROOTS` 环境变量覆盖（注意：覆盖是替换而非合并）。

资料来源：[src/sandbox.ts:1-80]()

## 性能特性

### 内存效率

markfetch 采用流式处理策略：
- 响应体直接流入解析器，不经过完整的内存缓冲
- DOM 操作在 linkedom 轻量级解析器中完成，相比原生 DOM 节省内存
- Markdown 转换采用增量处理，避免一次性加载整个文档树

### 大小限制

`MARKFETCH_MAX_BYTES` 限制应用于两个阶段：
1. 原始响应体大小
2. 转换后的 Markdown 大小

如果任一阶段超出限制，管道返回 `too_large` 错误而非返回不完整的内容。

资料来源：[README.md:50-55]()

## 版本历史

| 版本 | 变更内容 |
|------|----------|
| 0.4.0 | 初始 MCP 工具实现，引入 Readability + Turndown 管道 |
| 0.4.1 | 修复 `bin` 入口路径问题，改进文档 |
| 0.5.0 | 新增 CLI 模式，引入适配器架构 |
| 0.6.0 | 新增沙盒写入限制，8 种确定性错误码体系完成 |

资料来源：[CHANGELOG.md:1-100]()

---

<a id='write-sandbox'></a>

## 写操作沙箱

### 相关页面

相关主题：[MCP 服务器](#mcp-server), [配置与环境变量](#configuration)

<details>
<summary>相关源码文件</summary>

以下源码文件用于生成本页说明：

- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)
- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)
- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)
</details>

# 写操作沙箱

## 概述

写操作沙箱（Write Sandbox）是 markfetch 项目中为 MCP（Model Context Protocol）模式设计的文件系统写入安全机制。其核心功能是限制 MCP 工具的 `savePath` 参数只能写入预定义的允许目录范围内，防止语言模型通过路径遍历（如 `../../etc/passwd`）将文件写入敏感位置。

该沙箱**仅在 MCP 模式下生效**，CLI 模式不执行任何沙箱检查，因为在命令行环境中人类用户本身就是安全边界。

资料来源：[README.md:沙箱设计说明]()

## 架构设计

### 组件关系

```mermaid
graph TD
    A[MCP 客户端调用 fetch_markdown] --> B{是否提供 savePath?}
    B -->|否| Z[直接返回 markdown]
    B -->|是| C[沙箱校验模块]
    C --> D[路径解析]
    C --> E[符号链接展开]
    C --> F[边界检查]
    D --> G{是否在允许范围内?}
    G -->|是| H[写入文件]
    G -->|否| I[返回 save_forbidden 错误]
    
    J[环境变量配置] --> C
    K[默认根目录] --> C
```

### 核心模块

| 模块 | 文件位置 | 职责 |
|------|----------|------|
| 沙箱校验核心 | `src/sandbox.ts` | 实现路径合法性检查逻辑 |
| MCP 适配器 | `src/mcp.ts` | 在工具调用前调用沙箱校验 |
| 环境配置解析 | `src/mcp.ts` | 读取并验证 `MARKFETCH_ALLOWED_WRITE_ROOTS` |

资料来源：[src/sandbox.ts:1-50](), [src/mcp.ts:构建允许根目录]()

## 工作流程

### 写入校验流程

```mermaid
sequenceDiagram
    participant MCP as MCP 客户端
    participant Server as markfetch Server
    participant Sandbox as 沙箱模块
    participant FS as 文件系统
    
    MCP->>Server: fetch_markdown(url, savePath="/tmp/out.md")
    Server->>Sandbox: validatePath("/tmp/out.md")
    Sandbox->>Sandbox: realpath("/tmp/out.md")
    Sandbox->>Sandbox: 获取 allowedRoots
    Sandbox->>Sandbox: 检查相对路径
    alt 路径在允许范围内
        Sandbox-->>Server: { ok: true, resolved: "/private/tmp/out.md" }
        Server->>FS: writeFile(resolved)
        FS-->>Server: 写入成功
        Server-->>MCP: { content: [...], isError: false }
    else 路径超出范围
        Sandbox-->>Server: { ok: false, reason: "..." }
        Server-->>MCP: { content: "[save_forbidden] ...", isError: true }
    end
```

### 默认允许根目录

启动时，markfetch 会自动解析并记录两个默认写根目录：

| 默认根目录 | 获取方式 | 说明 |
|------------|----------|------|
| 系统临时目录 | `os.tmpdir()` | 通常为 `/tmp`（POSIX）或 `C:\Users\xxx\AppData\Local\Temp`（Windows） |
| 当前工作目录 | `process.cwd()` | markfetch 进程启动时的目录 |

两个路径都会通过 `fs.realpath` 展开符号链接，确保路径规范唯一。

资料来源：[src/mcp.ts:构建 allowedRoots](), [README.md:默认值说明]()

## 路径校验算法

### 核心检查逻辑

沙箱的路径校验算法位于 `src/sandbox.ts`，主要步骤如下：

```mermaid
graph TD
    A[接收 savePath] --> B[realpath 解析符号链接]
    B --> C{Win32 平台?}
    C -->|是| D[转换为小写比较]
    C -->|否| E[保持原大小写]
    D --> F[遍历 allowedRoots]
    E --> F
    F --> G{计算相对路径}
    G --> H{rel === ''?}
    H -->|是| J[允许 - 目标即为根目录]
    G --> K{rel 不以 '..' 开头且非绝对路径?}
    K -->|是| J
    K -->|否| L[拒绝 - 路径超出边界]
    L --> M[返回 ok: false]
    J --> N[返回 ok: true]
```

### Windows 大小写处理

Windows 文件系统大小写不敏感，但 `fs.realpath` 不会自动规范化大小写。为防止 `/Users/Me/../me/` 这类绕过检查，沙箱在 Windows 平台会将路径和小写的根目录都转换为小写进行比较。

```typescript
const fold = process.platform === "win32"
  ? (s: string) => s.toLowerCase()
  : (s: string) => s;
```

资料来源：[src/sandbox.ts:Win32 case-fold 处理]()

## 配置选项

### 环境变量

| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir() + ':' + process.cwd()` | 冒号（POSIX）或分号（Windows）分隔的绝对路径列表 |

### 配置规则

1. **替换而非合并**：设置该变量会**完全替换**默认值，不会追加
2. **必须为绝对路径**：每个路径必须是绝对路径，相对路径会导致启动失败
3. **目录必须存在**：路径指向的目录必须在启动时存在，否则失败
4. **平台分隔符**：
   - POSIX（Linux/macOS）：使用冒号 `:` 分隔
   - Windows：使用分号 `;` 分隔

### 配置示例

**Linux/macOS 配置：**
```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"],
      "env": {
        "MARKFETCH_ALLOWED_WRITE_ROOTS": "/Users/me/markfetch-out:/tmp"
      }
    }
  }
}
```

**Windows 配置：**
```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"],
      "env": {
        "MARKFETCH_ALLOWED_WRITE_ROOTS": "C:\\Users\\me\\markfetch-out;C:\\Users\\me\\AppData\\Local\\Temp"
      }
    }
  }
}
```

资料来源：[README.md:环境变量配置说明]()

## 符号链接处理

### 安全修复历史

早期版本存在符号链接逃逸漏洞：检查时对 `<sandbox>/link/../out.md` 进行路径规范化后看似合法，但写入时路径从左到右解析符号链接，导致实际写入位置超出沙箱。

**修复方案**：将 `realpath` 解析后的规范路径直接传递给 `writeFile`，确保检查路径和写入路径完全一致。

| 版本 | 问题 | 影响 |
|------|------|------|
| 修复前 | 检查规范化路径，但写入原始路径 | `link/..` 可逃逸到沙箱外 |
| 修复后 | 检查路径即为写入路径 | 无逃逸可能 |

资料来源：[CHANGELOG.md:符号链接逃逸修复说明]()

## 错误处理

### 错误码

| 错误码 | 触发条件 | 返回内容示例 |
|--------|----------|--------------|
| `save_forbidden` | `savePath` 解析后不在允许的写根目录内 | `[save_forbidden] '/etc/passwd' is outside the allowed write roots: ['/tmp', '/project']` |

### 返回数据结构

```typescript
// 允许的路径
{
  ok: true,
  resolved: "/private/tmp/out.md"  // realpath 展开后的规范路径
}

// 拒绝的路径
{
  ok: false,
  reason: "'/etc/passwd' is outside the allowed write roots: ['/tmp', '/project']"
}
```

注意：确认消息中仍会回显原始 `savePath`，以确保在 tmpdir 本身是符号链接的主机（如 macOS `/var` → `/private/var`）上消息的稳定性。

资料来源：[src/sandbox.ts:返回值结构](), [src/mcp.ts:错误转换]()

## MCP 工具 schema 约束

MCP 适配器在 `src/mcp.ts` 中使用 Zod 对 `savePath` 参数进行预校验：

```typescript
savePath: z
  .string()
  .refine(isAbsolute, "savePath must be an absolute filesystem path")
  .optional()
```

这确保了：
1. `savePath` 必须是字符串
2. 字符串必须是绝对路径（`isAbsolute` 校验）
3. 参数可选（未提供时直接返回 markdown）

如果路径不是绝对路径，校验会直接失败，不会进入沙箱检查阶段。

资料来源：[src/mcp.ts:savePath schema 定义]()

## CLI 模式差异

| 特性 | MCP 模式 | CLI 模式 |
|------|----------|----------|
| 沙箱检查 | ✅ 启用 | ❌ 禁用 |
| 路径要求 | 必须为绝对路径 | 可为相对路径（相对 cwd） |
| 符号链接检查 | ✅ 启用 | ❌ 禁用 |
| 错误码 | `save_forbidden` | 直接拒绝写入 |

CLI 模式的无限制设计基于以下假设：命令行环境中的人类用户是天然的安全边界，可以自行判断路径的适当性。

资料来源：[README.md:CLI 模式说明](), [README.md:沙箱设计说明]()

## 安全考量

### 设计原则

1. **最小权限原则**：默认只允许写入临时目录和当前工作目录
2. **显式优于隐式**：环境变量会替换而非合并默认配置
3. **fail-fast**：配置错误在启动时立即失败，而非运行时
4. **路径规范化**：所有路径经过 `realpath` 展开，防止符号链接攻击

### 潜在风险

- **并发写入冲突**：多个请求写入相同文件名时，后写覆盖前写（无锁保护）
- **临时目录清理**：系统清理 `/tmp` 可能导致未保存的文件丢失
- **符号链接竞态**：在路径检查和写入之间存在微小时间窗口，攻击难度极高但理论上可能

资料来源：[README.md:安全说明](), [src/sandbox.ts:安全设计]()

---

<a id='configuration'></a>

## 配置与环境变量

### 相关页面

相关主题：[命令行界面](#cli-usage), [MCP 服务器](#mcp-server), [写操作沙箱](#write-sandbox)

<details>
<summary>相关源码文件</summary>

以下源码文件用于生成本页说明：

- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)
- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)
- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)
- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)
- [package.json](https://github.com/vasylenko/markfetch/blob/main/package.json)
</details>

# 配置与环境变量

markfetch 通过环境变量提供运行时配置能力，支持自定义超时、响应大小限制、用户代理以及写入沙箱策略。这些配置项在服务启动时进行验证，确保在处理请求前就能捕获无效配置。

## 环境变量总览

markfetch 提供以下四个环境变量用于运行时配置：

| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| `MARKFETCH_TIMEOUT_MS` | `30000` | 单次请求超时时间（毫秒） |
| `MARKFETCH_MAX_BYTES` | `5000000` | 响应体和提取后 Markdown 的最大字节数 |
| `MARKFETCH_USER_AGENT` | Chrome 130 固定字符串 | HTTP 请求的用户代理 |
| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 模式下允许写入的根目录列表 |

所有环境变量在服务启动时进行验证，无效值会快速失败并将错误输出到 stderr，避免在请求处理时才产生混淆的错误信息。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)

## 超时配置

`MARKFETCH_TIMEOUT_MS` 控制单个 HTTP 请求的最大等待时间。当请求超过设定时间未完成时，返回 `timeout` 错误码。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)

```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"],
      "env": {
        "MARKFETCH_TIMEOUT_MS": "60000"
      }
    }
  }
}
```

增加超时值适用于网络环境较差或目标服务器响应缓慢的场景。

## 响应大小限制

`MARKFETCH_MAX_BYTES` 设置响应体和最终提取的 Markdown 内容的大小上限。当任一阶段超过此限制时，返回 `too_large` 错误码。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)

```
MARKFETCH_MAX_BYTES=5000000
```

默认值约 5MB，适用于绝大多数网页场景。超大型文档建议使用 `savePath` 参数将结果直接写入磁盘。

## 用户代理配置

`MARKFETCH_USER_AGENT` 用于自定义 HTTP 请求头中的 User-Agent 字段。默认值是固定的 Chrome 130 字符串，用于模拟真实浏览器指纹。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)

```json
{
  "env": {
    "MARKFETCH_ALLOWED_WRITE_ROOTS": "/Users/me/markfetch-out:/tmp"
  }
}
```

markfetch 会从 `MARKFETCH_USER_AGENT` 推导出 `Sec-CH-UA-*` 客户端提示头。非 Chrome 浏览器的 UA 字符串会启动时快速失败。

## 写入沙箱（Write Sandbox）

写入沙箱是 MCP 模式下特有的安全机制，用于限制 `savePath` 参数可以写入的目录范围。CLI 模式下不实施沙箱限制，因为命令行用户本身就是安全边界。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)

### 默认允许的写入根目录

默认情况下，允许写入的根目录为：

- 系统临时目录（`os.tmpdir()`）
- 当前工作目录（`process.cwd()`）

两个路径在服务启动时通过 `fs.realpath` 解析一次，后续路径验证使用解析后的绝对路径。

### 自定义允许根目录

通过 `MARKFETCH_ALLOWED_WRITE_ROOTS` 可以覆盖默认的允许根目录列表：

| 平台 | 路径分隔符 | 示例 |
|------|-----------|------|
| POSIX | `:` | `/Users/me/markfetch-out:/tmp` |
| Windows | `;` | `C:\Users\me\markfetch-out;C:\Users\me\AppData\Local\Temp` |

配置此变量会**完全替换**默认值，不会合并。因此如果需要保留临时目录访问权限，必须显式列出。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)

```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"],
      "env": {
        "MARKFETCH_ALLOWED_WRITE_ROOTS": "/Users/me/markfetch-out:/tmp"
      }
    }
  }
}
```

### 沙箱验证逻辑

沙箱验证在 `src/sandbox.ts` 中实现，核心逻辑如下：资料来源：[src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)

```typescript
// 将 savePath 重新附加到可能的根目录
const reattached = isAbsolute(savePath)
  ? savePath
  : join(resolvedAncestor, ...trailing);

// Windows 平台大小写不敏感，使用小写比较
const fold = process.platform === "win32"
  ? (s: string) => s.toLowerCase()
  : (s: string) => s;

// 检查重附加后的路径是否在允许的根目录范围内
for (const root of roots) {
  const rel = relative(fold(root), foldedTarget);
  if (rel === "") return { ok: true, resolved: reattached };
  if (!rel.startsWith("..") && !isAbsolute(rel)) {
    return { ok: true, resolved: reattached };
  }
}
```

### 验证失败处理

当 `savePath` 解析后不在允许的根目录范围内时：

- MCP 模式返回 `save_forbidden` 错误码
- CLI 模式不受沙箱限制，无此检查

```typescript
return {
  ok: false,
  reason: `'${reattached}' is outside the allowed write roots: [${roots.map((r) => `'${r}'`).join(", ")}]`
};
```

### 符号链接处理

沙箱机制会阻止指向允许根目录外的符号链接。每个路径都通过 `fs.realpath` 解析后再验证。资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)

### 配置验证

沙箱配置在服务启动时进行验证：

- 路径必须是绝对路径
- 目录必须存在
- 格式错误会在启动时快速失败并输出到 stderr

## 配置传递方式

### MCP 模式

MCP 客户端通过配置文件的 `env` 块传递环境变量：资料来源：[src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)

```json
{
  "mcpServers": {
    "markfetch": {
      "command": "npx",
      "args": ["-y", "markfetch"],
      "env": {
        "MARKFETCH_TIMEOUT_MS": "60000",
        "MARKFETCH_MAX_BYTES": "10000000",
        "MARKFETCH_USER_AGENT": "Mozilla/5.0 ...",
        "MARKFETCH_ALLOWED_WRITE_ROOTS": "/custom/path"
      }
    }
  }
}
```

### CLI 模式

CLI 模式下可直接通过 shell 环境变量设置：

```bash
export MARKFETCH_TIMEOUT_MS=60000
markfetch https://example.com
```

或在一行内设置：

```bash
MARKFETCH_TIMEOUT_MS=60000 markfetch https://example.com
```

## 架构流程图

```mermaid
graph TD
    A[服务启动] --> B{环境变量验证}
    B -->|有效| C[启动 MCP/CLI 服务]
    B -->|无效| D[输出错误到 stderr<br/>进程退出]
    
    C --> E[接收请求]
    E --> F{MCP 模式?}
    F -->|是| G[检查 savePath 沙箱]
    F -->|否| H[直接处理请求]
    
    G -->|在允许范围内| H
    G -->|超出范围| I[返回 save_forbidden]
    
    H --> J[执行 fetch_markdown]
    J --> K{超时?}
    J --> L{响应大小?}
    J --> M{内容类型?}
    
    K -->|是| N[返回 timeout]
    L -->|是| O[返回 too_large]
    M -->|否| P[返回 unsupported_content_type]
    
    J --> Q[提取文章内容]
    Q --> R{提取成功?}
    R -->|否| S[返回 extraction_failed]
    R -->|是| T[转换为 Markdown]
    
    T --> U{保存到文件?}
    U -->|是| V[写入 savePath]
    U -->|否| W[返回 content[0].text]
    
    V --> X[返回确认信息]
```

## 错误码与配置关系

| 错误码 | 相关配置 | 说明 |
|--------|----------|------|
| `timeout` | `MARKFETCH_TIMEOUT_MS` | 请求超时 |
| `too_large` | `MARKFETCH_MAX_BYTES` | 响应或内容超出限制 |
| `save_failed` | 路径写入权限 | 写入文件失败 |
| `save_forbidden` | `MARKFETCH_ALLOWED_WRITE_ROOTS` | 路径超出沙箱范围 |

资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)

## 最佳实践建议

1. **生产环境**：建议设置合理的 `MARKFETCH_TIMEOUT_MS`（如 30000-60000ms）和 `MARKFETCH_MAX_BYTES`（如 5-10MB）

2. **MCP 部署**：显式设置 `MARKFETCH_ALLOWED_WRITE_ROOTS`，明确允许的输出目录

3. **安全考虑**：不要在 MCP 模式下完全移除沙箱限制，保持最小权限原则

4. **Windows 平台**：使用 `;` 作为分隔符，并确保路径格式正确（反斜杠或正斜杠均可）

5. **调试场景**：可以临时增大超时和大小限制来排查问题

---

<a id='error-handling'></a>

## 错误处理机制

### 相关页面

相关主题：[命令行界面](#cli-usage), [MCP 服务器](#mcp-server), [内容提取管道](#content-extraction)

<details>
<summary>相关源码文件</summary>

以下源码文件用于生成本页说明：

- [src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)
- [src/cli.ts](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)
- [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)
- [src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)
- [README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)
- [CHANGELOG.md](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)
</details>

# 错误处理机制

## 概述

markfetch 采用统一的错误处理架构，确保在 CLI 和 MCP 两种运行模式下都能提供一致的、机器可读的诊断信息。所有错误均通过预定义的错误代码标识，支持确定性错误处理，便于调用方进行逻辑分支和日志记录。

核心设计原则：

- **确定性错误代码**：8 种标准化错误代码覆盖所有故障场景
- **统一错误类型**：`MarkfetchError` 类在 core 层统一抛出
- **适配器隔离**：CLI 和 MCP 适配器各自负责错误格式转换
- **启动时验证**：环境变量在进程启动时即进行校验，失败快速报错

资料来源：[README.md](https://github.com/vasylenko/markfetch/blob/main/README.md)

---

## 错误代码体系

### 错误代码一览表

| 错误代码 | 含义 | 触发条件 |
|---------|------|----------|
| `network_error` | 网络故障 | DNS 解析失败、TCP 连接失败、TLS 握手错误，或 fetcher 内部未预期错误 |
| `http_error` | HTTP 错误 | 上游服务器返回非 2xx 状态码 |
| `timeout` | 请求超时 | 超过 `MARKFETCH_TIMEOUT_MS` 配置的超时时间 |
| `unsupported_content_type` | 不支持的 Content-Type | 响应不是 `text/html` 或 `application/xhtml+xml` |
| `extraction_failed` | 内容提取失败 | Readability 算法无法提取文章内容（典型于纯客户端渲染 SPA） |
| `too_large` | 响应过大 | 响应体或提取的 markdown 超过 `MARKFETCH_MAX_BYTES` |
| `save_failed` | 文件保存失败 | 指定了 `savePath` 但写入失败（目录不存在、权限不足等） |
| `save_forbidden` | 写入路径禁止 | `savePath` 超出允许的写入根目录范围 |

资料来源：[README.md:错误代码表](https://github.com/vasylenko/markfetch/blob/main/README.md)

### 错误格式

所有错误均遵循统一格式：`[<错误代码>] <错误消息>`

```
[network_error] getaddrinfo ENOTFOUND example.invalid
[http_error] HTTP 403 Forbidden
[timeout] Per-request timeout exceeded (30000ms)
[unsupported_content_type] application/json
[extraction_failed] No article content found
[too_large] Response body (52428800 bytes) exceeds MARKFETCH_MAX_BYTES (5242880)
[save_failed] write EPERM /root/restricted.txt
[save_forbidden] '/etc/passwd' is outside the allowed write roots
```

资料来源：[src/cli.ts:错误输出格式](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)

---

## 架构设计

### 错误处理流程图

```mermaid
graph TD
    A[请求入口] --> B{运行模式}
    B -->|MCP| C[mcp.ts 适配器]
    B -->|CLI| D[cli.ts 适配器]
    
    C --> E[调用 core.fetchMarkdown]
    D --> E
    
    E --> F{处理结果}
    F -->|成功| G[返回 Markdown]
    F -->|失败| H[抛出 MarkfetchError]
    
    H --> I{MCP 适配器}
    H --> J{CLI 适配器}
    
    I --> K[构建 errorResult]
    J --> L[classifyError 分类]
    
    K --> M[返回 MCP 错误响应<br/>isError: true]
    L --> N[console.error 输出<br/>process.exitCode = 1]
    
    style H fill:#ff6b6b
    style K fill:#feca57
    style L fill:#feca57
    style M fill:#48dbfb
    style N fill:#48dbfb
```

### 核心组件职责

| 组件 | 文件 | 职责 |
|------|------|------|
| MarkfetchError | src/core.ts | 统一的错误类型，携带错误代码和消息 |
| errorResult | src/mcp.ts | 构建 MCP 格式的错误响应 |
| classifyError | src/core.ts | 对异常进行分类，提取代码和消息 |
| 写入沙箱验证 | src/sandbox.ts | 验证 savePath 是否在允许范围内 |

资料来源：[src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts) 和 [src/mcp.ts](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)

---

## 统一错误类型

### MarkfetchError 类

```typescript
// src/core.ts 中的核心实现
class MarkfetchError extends Error {
  constructor(
    public readonly code: ErrorCode,
    message: string
  ) {
    super(message);
    this.name = "MarkfetchError";
  }
}
```

`MarkfetchError` 是项目的核心异常类型，继承自 `Error`，包含：

- **code**：标准化的错误代码（ErrorCode 枚举）
- **message**：人类可读的错误描述

资料来源：[src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)

### 错误代码枚举

```typescript
type ErrorCode =
  | "network_error"
  | "http_error"
  | "timeout"
  | "unsupported_content_type"
  | "extraction_failed"
  | "too_large"
  | "save_failed"
  | "save_forbidden";
```

资料来源：[src/core.ts](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)

### 错误分类器

```typescript
// src/core.ts
function classifyError(err: unknown): { code: string; message: string } {
  if (err instanceof MarkfetchError) {
    return { code: err.code, message: err.message };
  }
  // 处理未知异常
  return { code: "network_error", message: String(err) };
}
```

资料来源：[src/core.ts:classifyError](https://github.com/vasylenko/markfetch/blob/main/src/core.ts)

---

## MCP 适配器错误处理

### MCP 错误响应格式

MCP 适配器通过 `errorResult` 函数构建符合 MCP 协议的错误响应：

```typescript
// src/mcp.ts
function errorResult(code: ErrorCode, message: string) {
  return {
    content: [{ type: "text" as const, text: `[${code}] ${message}` }],
    isError: true,
  };
}
```

响应结构：

| 字段 | 类型 | 说明 |
|------|------|------|
| content | Array | 包含单个文本消息的数组 |
| content[0].type | `"text"` | 固定值 |
| content[0].text | string | 格式化的错误信息 `[code] message` |
| isError | `true` | 标识为错误响应 |

资料来源：[src/mcp.ts:errorResult](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)

### MCP 工具注册

```typescript
server.registerTool(
  "fetch_markdown",
  {
    description: "...",
    inputSchema: {
      url: z.string().url(),
      savePath: z.string().refine(isAbsolute).optional(),
    },
  },
  async ({ url, savePath }) => {
    try {
      const { markdown, bytes, savedTo } = await fetchMarkdown({ url, savePath });
      return {
        content: [{ type: "text", text: markdown }],
        meta?: { bytes, savedTo },
      };
    } catch (err) {
      const { code, message } = classifyError(err);
      return errorResult(code, message);
    }
  }
);
```

资料来源：[src/mcp.ts:工具注册](https://github.com/vasylenko/markfetch/blob/main/src/mcp.ts)

---

## CLI 适配器错误处理

### CLI 错误输出

CLI 适配器使用 `classifyError` 函数对异常进行分类，然后将结果输出到 stderr：

```typescript
// src/cli.ts
try {
  const { markdown, bytes, savedTo } = await fetchMarkdown({ url, savePath });
  if (savedTo !== undefined) {
    console.log(`Saved ${bytes} bytes to ${savedTo}`);
  } else {
    process.stdout.write(markdown);
  }
} catch (err) {
  const { code, message } = classifyError(err);
  console.error(`[${code}] ${message}`);
  process.exitCode = 1;
}
```

CLI 错误处理特点：

- **stderr 专用**：错误信息写入 stderr，不干扰 stdout 的 markdown 输出
- **进程退出码**：设置 `process.exitCode = 1` 确保管道脚本能检测到失败
- **优雅关闭**：使用 `exitCode` 而非 `exit()`，确保输出缓冲区排空

资料来源：[src/cli.ts:错误处理](https://github.com/vasylenko/markfetch/blob/main/src/cli.ts)

---

## 写入沙箱错误

### save_forbidden 错误机制

当 MCP 调用指定 `savePath` 时，系统会验证目标路径是否在允许的写入根目录范围内：

```typescript
// src/sandbox.ts
export function validateSavePath(
  savePath: string,
  roots: string[]
): { ok: true; resolved: string } | { ok: false; reason: string } {
  // 解析绝对路径
  const resolved = resolve(savePath);
  
  // 检查是否在允许的根目录下
  for (const root of roots) {
    const rel = relative(fold(root), foldedTarget);
    if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
      return { ok: true, resolved };
    }
  }
  
  return {
    ok: false,
    reason: `'${resolved}' is outside the allowed write roots: [${roots.join(", ")}]`,
  };
}
```

验证规则：

| 检查项 | 说明 |
|--------|------|
| 绝对路径 | savePath 必须为绝对路径（由 schema 的 `isAbsolute` 约束） |
| 根目录匹配 | 解析后的路径必须位于允许的根目录下 |
| 符号链接 | 指向外部的符号链接会被阻止 |

资料来源：[src/sandbox.ts](https://github.com/vasylenko/markfetch/blob/main/src/sandbox.ts)

### 允许的写入根目录

| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `MARKFETCH_ALLOWED_WRITE_ROOTS` | `os.tmpdir()` + `process.cwd()` | MCP 专用，可覆盖默认值 |

平台差异：

- **POSIX**：路径分隔符为 `:`
- **Windows**：路径分隔符为 `;`

资料来源：[README.md:Write sandbox](https://github.com/vasylenko/markfetch/blob/main/README.md)

---

## 环境变量验证

### 启动时验证机制

markfetch 在进程启动时对所有环境变量进行校验，无效配置会快速失败：

| 环境变量 | 默认值 | 校验规则 |
|----------|--------|----------|
| `MARKFETCH_TIMEOUT_MS` | `30000` | 正整数，最大 300000 |
| `MARKFETCH_MAX_BYTES` | `5000000` | 正整数，最大 104857600 |
| `MARKFETCH_USER_AGENT` | Chrome 130 UA | 必须是 Chrome User-Agent 字符串 |
| `MARKFETCH_ALLOWED_WRITE_ROOTS` | 参见上表 | 所有路径必须为绝对路径且存在 |

验证失败时：

- 错误信息输出到 stderr
- 进程立即退出（非零退出码）
- 不执行任何请求处理

资料来源：[README.md:Configuration](https://github.com/vasylenko/markfetch/blob/main/README.md) 和 [CHANGELOG.md:0.6.0](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)

---

## 错误恢复策略

### 调用方建议

```mermaid
graph LR
    A[调用 fetch_markdown] --> B{isError?}
    B -->|是| C[解析错误代码]
    B -->|否| D[处理 markdown]
    
    C --> E{错误代码分支}
    E -->|network_error| F[重试 / 记录日志]
    E -->|http_error| G[检查 URL 有效性]
    E -->|timeout| H[增加 MARKFETCH_TIMEOUT_MS]
    E -->|unsupported_content_type| I[确认 URL 是否为 HTML]
    E -->|extraction_failed| J[尝试直接访问 API]
    E -->|too_large| K[增加 MARKFETCH_MAX_BYTES]
    E -->|save_failed| L[检查目录权限]
    E -->|save_forbidden| M[使用允许的路径]
```

### 错误处理建议

| 错误代码 | 建议操作 |
|----------|----------|
| `network_error` | 检查网络连接，等待后重试 |
| `http_error` | 验证 URL 是否有效，检查网站是否允许访问 |
| `timeout` | 增大 `MARKFETCH_TIMEOUT_MS` 环境变量 |
| `unsupported_content_type` | 确认目标 URL 返回 HTML 内容 |
| `extraction_failed` | 该页面可能是纯 JS 渲染，暂无解决方案 |
| `too_large` | 增大 `MARKFETCH_MAX_BYTES` 或使用 `savePath` 写入文件 |
| `save_failed` | 检查目录存在性和写入权限 |
| `save_forbidden` | 使用允许的根目录路径，或设置 `MARKFETCH_ALLOWED_WRITE_ROOTS` |

---

## 版本演进

### 0.6.0（当前版本）

统一错误处理架构完成：

- 3 处内联 `return errorResult(...)` 站点改为抛出 `MarkfetchError`
- CLI 和 MCP 适配器统一捕获并转换错误
- 错误消息格式保持一致

资料来源：[CHANGELOG.md:0.6.0](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)

### 0.4.0

引入 `save_forbidden` 错误代码和写入沙箱机制：

- MCP `savePath` schema 改用 `z.string().refine(path.isAbsolute)`
- 不符合规范的路径直接返回 `save_forbidden`

资料来源：[CHANGELOG.md:0.4.0](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)

### 0.3.0

确立 7 种基础错误代码：

- 完整的错误代码体系首次定义
- 环境变量验证机制引入

资料来源：[CHANGELOG.md:0.3.0](https://github.com/vasylenko/markfetch/blob/main/CHANGELOG.md)

---

## 总结

markfetch 的错误处理机制具有以下核心特征：

1. **8 种确定性错误代码**：覆盖所有可能的故障场景
2. **统一格式**：`[<代码>] <消息>` 便于解析和日志处理
3. **双通道输出**：MCP 使用 `isError: true` 响应，CLI 使用 stderr
4. **启动时验证**：环境变量错误提前暴露
5. **写入安全**：MCP 模式下的路径沙箱保护

这套机制确保了工具在各种调用场景下都能提供一致、可预测的错误反馈。

---

---

## Doramagic 踩坑日志

项目：vasylenko/markfetch

摘要：发现 7 个潜在踩坑项，其中 0 个为 high/blocking；最高优先级：安装坑 - 来源证据：v0.4.1。

## 1. 安装坑 · 来源证据：v0.4.1

- 严重度：medium
- 证据强度：source_linked
- 发现：GitHub 社区证据显示该项目存在一个安装相关的待验证问题：v0.4.1
- 对用户的影响：可能增加新用户试用和生产接入成本。
- 建议检查：来源显示可能已有修复、规避或版本变化，说明书中必须标注适用版本。
- 防护动作：不得脱离来源链接放大为确定性结论；需要标注适用版本和复核状态。
- 证据：community_evidence:github | cevd_749b65614f7b40e0b524f4e932cd4aca | https://github.com/vasylenko/markfetch/releases/tag/v0.4.1 | 来源讨论提到 node 相关条件，需在安装/试用前复核。

## 2. 能力坑 · 能力判断依赖假设

- 严重度：medium
- 证据强度：source_linked
- 发现：README/documentation is current enough for a first validation pass.
- 对用户的影响：假设不成立时，用户拿不到承诺的能力。
- 建议检查：将假设转成下游验证清单。
- 防护动作：假设必须转成验证项；没有验证结果前不能写成事实。
- 证据：capability.assumptions | github_repo:1234238440 | https://github.com/vasylenko/markfetch | README/documentation is current enough for a first validation pass.

## 3. 维护坑 · 维护活跃度未知

- 严重度：medium
- 证据强度：source_linked
- 发现：未记录 last_activity_observed。
- 对用户的影响：新项目、停更项目和活跃项目会被混在一起，推荐信任度下降。
- 建议检查：补 GitHub 最近 commit、release、issue/PR 响应信号。
- 防护动作：维护活跃度未知时，推荐强度不能标为高信任。
- 证据：evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | last_activity_observed missing

## 4. 安全/权限坑 · 下游验证发现风险项

- 严重度：medium
- 证据强度：source_linked
- 发现：no_demo
- 对用户的影响：下游已经要求复核，不能在页面中弱化。
- 建议检查：进入安全/权限治理复核队列。
- 防护动作：下游风险存在时必须保持 review/recommendation 降级。
- 证据：downstream_validation.risk_items | github_repo:1234238440 | https://github.com/vasylenko/markfetch | no_demo; severity=medium

## 5. 安全/权限坑 · 存在评分风险

- 严重度：medium
- 证据强度：source_linked
- 发现：no_demo
- 对用户的影响：风险会影响是否适合普通用户安装。
- 建议检查：把风险写入边界卡，并确认是否需要人工复核。
- 防护动作：评分风险必须进入边界卡，不能只作为内部分数。
- 证据：risks.scoring_risks | github_repo:1234238440 | https://github.com/vasylenko/markfetch | no_demo; severity=medium

## 6. 维护坑 · issue/PR 响应质量未知

- 严重度：low
- 证据强度：source_linked
- 发现：issue_or_pr_quality=unknown。
- 对用户的影响：用户无法判断遇到问题后是否有人维护。
- 建议检查：抽样最近 issue/PR，判断是否长期无人处理。
- 防护动作：issue/PR 响应未知时，必须提示维护风险。
- 证据：evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | issue_or_pr_quality=unknown

## 7. 维护坑 · 发布节奏不明确

- 严重度：low
- 证据强度：source_linked
- 发现：release_recency=unknown。
- 对用户的影响：安装命令和文档可能落后于代码，用户踩坑概率升高。
- 建议检查：确认最近 release/tag 和 README 安装命令是否一致。
- 防护动作：发布节奏未知或过期时，安装说明必须标注可能漂移。
- 证据：evidence.maintainer_signals | github_repo:1234238440 | https://github.com/vasylenko/markfetch | release_recency=unknown

<!-- canonical_name: vasylenko/markfetch; human_manual_source: deepwiki_human_wiki -->
