Metadata-Version: 2.5
Name: mcp-scrape
Version: 0.4.0
Summary: 反爬感知抓取 + 极省 Token 内容精简的 MCP Server
Author: guanyuyan
License-Expression: MIT
License-File: LICENSE
Keywords: anti-bot,llm,mcp,scraping,token-efficiency,web-fetch
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Python: >=3.10
Requires-Dist: curl-cffi>=0.7.0
Requires-Dist: mcp<3,>=2.0.0
Requires-Dist: playwright>=1.49
Requires-Dist: pydantic-settings>=2.2.0
Requires-Dist: selectolax>=0.4.11
Requires-Dist: tiktoken>=0.7.0
Provides-Extra: dev
Requires-Dist: pre-commit>=3.8; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6.0; extra == 'dev'
Description-Content-Type: text/markdown

# scrape-mcp

> **⚠️ 这是 scrape-mcp 的官方源仓库（upstream）。** 若在别处看到同名/相似项目并带不同作者署名，即为转载或派生，非原作者发布。

> **反爬感知抓取 + 极省 Token 内容精简的 MCP Server —— 让 AI 读真实网页时，只花 1%~5% 的 token 就能拿到干净、真实、免登录的正文。**
>
> 自动化分三层应对：默认 curl_cffi（伪装 TLS/JA3 + HTTP2）低成本打头阵，命中 Cloudflare/验证码/WAF 才升级 Playwright 真浏览器渲染；遇到"登录才给内容"的站，先弹窗手动登录一次、登录态落盘，之后自动带上、过期会提示重登。

[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![PyPI](https://img.shields.io/pypi/v/mcp-scrape?color=blue&label=pypi)](https://pypi.org/project/mcp-scrape/)
[![Python](https://img.shields.io/pypi/pyversions/mcp-scrape)](https://pypi.org/project/mcp-scrape/)
[![Release](https://img.shields.io/github/v/release/guanyuyan/scrape-mcp?color=blue&label=release)](https://github.com/guanyuyan/scrape-mcp/releases)
[![CI](https://github.com/guanyuyan/scrape-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/guanyuyan/scrape-mcp/actions)

---

## ⚡ 60 秒入门

**方式 A：一键安装（推荐，零配置）**

```bash
pip install mcp-scrape
# 或从源码：cd scrape-mcp && pip install -e .
```

然后本机任意目录即可：

```bash
scrape-mcp --version          # 确认装好（应输出 0.x）
claude                        # 在支持 MCP 的客户端里把它当服务接入
```

> 不再需要 clone、手动设 `PYTHONPATH` 或 `python -m`；`scrape-mcp` 命令在任意目录可用，
> 默认 stdio 直接被 MCP 客户端接管。想用 HTTP 直连（Postman/curl），跑 `scrape-mcp --transport streamable-http --port 8000`。

**方式 B：一键演示**（拉源仓库跑 `web_fetch` + `web_extract`，看省 token 效果）

```bash
git clone https://github.com/guanyuyan/scrape-mcp.git
cd scrape-mcp
python -m venv .venv && .venv\Scripts\pip install -e .
.venv\Scripts\python scripts\demo.py
```

预计输出（对不同站点自动选 L1/L2 分级，给出省 token 压缩比）：

```
=== 文档站 | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CORS
  ok=True tier=l1 status=200 html=260128B → tokens=3730 (约 5.7% 压缩)
  标题: 跨源资源共享（CORS） - HTTP | MDN
  ok=True tier=l1 data={"heading": "跨源资源共享（CORS）", "h2_count": 10, "links": 665}
```

**第 2 步：作为 MCP 客户端接入**（Claude / Cursor 等，命令指向已安装的 `scrape-mcp`）

```bash
scrape-mcp            # 已安装时；或 python -m scrape_mcp.server
```

在客户端里调用 `web_fetch(url=...)` 即可拿到极省正文。

> MDN 整页约 260KB，这里只花几千 token 就拿到干净正文；拦截/登录页会自动升级或如实上报，不会把验证码当正文。

---

## 功能说明

**它解决什么**：模型读网页不是在读正文，而是在读整棵 DOM（脚本、导航、版权、链接一应尽收），又贵又脏。这个工具把页面剪成"只有正文"再喂给 AI。

**核心价值（三点）**
1. **极省 token** —— 属性剥离 + 模板块丢弃 + 站外链接降级 + 表格转 CSV + 预算截断。实测 MDN 整页 214KB → 数百 token，压缩率约 1%。
2. **自动分级反爬** —— 85% 的站只走最轻的 L1（快、并发高），拦截/JS 骨架页才升级 L2 真浏览器 + stealth 伪装，绝不暴力逐站上浏览器。
3. **登录墙处理 + 如实上报** —— 判定登录/拦截时返回 `login_required`/`blocked`，引导登录而非硬撞，从不让模型把验证码当正文。

**怎么用（一行）**
```
web_fetch(url="https://xxx/article/1") → JSON：{ok, content(极省正文), token_estimate, tier, blocked?, login_required?, cached?}
```

**覆盖的站点类型**：文章页 / 文档站 / 门户与列表页 / 需要 JS 渲染的 SPA / 需要登录才给正文的站（如知乎）。

**真实站点实测**（一键 `scripts/demo.py` 可复现）：

| 站点 | 类型 | 分级 | 原始 | 抓到正文 | 说明 |
|---|---|---|---|---|---|
| MDN CORS 文档 | 文档站 | L1 | 260KB | ~3.7k token（约 5.7%） | 纯静态，最短路径直达 |
| 腾讯新闻首页 | 列表/门户 | L2 | 338KB | ~1.4k token（约 1.6%） | 动态页升级浏览器渲染 |
| 极客邦首页 | SPA/动态 | L2 | 12KB | ~1.4k token | JS 骨架页，需渲染才有正文 |

**形态**：本地 MCP Server（stdio/HTTP 均可），隐私可控；支持单页 `web_fetch`、批量 `web_batch`、结构化抽取 `web_extract`、登录持久化 `login`，缓存默认开启。

---

## 特性

- **分级抓取**：L1 `curl_cffi` → L2 Playwright，拦截才升级，不误伤正常页面。
- **内容密度选根**：从 article/main/div 中挑出"恰好装住正文的最紧容器"，避开导航/侧栏。
- **compact 精简**：属性剥离、模板块丢弃、站外链接降级、表格转 CSV、token 预算截断，
  压缩率可达 1~20% 数量级（如 MDN 214KB → 数百 token）。
- **拦截如实上报**：不把挑战页/空壳页当正文喂给模型，返回 `blocked`/`block_reason`。
- **稳定错误码 + 可诊断**：失败返回 `error_code` 与 `retryable`，`debug=true` 还能看到分层决策、选择器命中数与 L2 交互结果。
- **登录态持久化**：`login` 工具手动登录并落盘，`web_fetch` 自动带上；过期会提示重登。
- **缓存与批量**：L1 明文响应按 TTL 磁盘缓存，过期后带 `ETag`/`Last-Modified` 做条件请求；`web_batch` 并发抓取一批 URL。
- **轻量爬取**：`web_crawl` 按 `depth`/`limit` 发现站内链接并逐页抓取，静态资源自动跳过。
- **输出格式可选**：`format=markdown|text|json`，正文可按段落数组直接入库。
- **同域并发闸门**：同一站点默认最多 2 个并发请求，跨站仍可并行，避免批量抓取时把单站打穿。
- **Stealth 加固**：Wipe `webdriver`、UA 数据、Canvas/WebGL 确定性指纹噪声、时区对齐。

## 安装

```bash
# L2 渲染所需浏览器（国内可用镜像）
$env:PLAYWRIGHT_DOWNLOAD_HOST="https://cdn.npmmirror.com/binaries/playwright"
pip install "playwright>=1.49"
playwright install chromium

# 或复用系统浏览器，无需下载
$env:SCRAPE_MCP_L2_CHANNEL="chrome"   # 或 msedge
```

## 启动（MCP，stdio）

```bash
scrape-mcp                          # 默认 stdio，被 MCP 客户端接管
scrape-mcp --transport streamable-http --port 8000   # 切 HTTP 直连
scrape-mcp --version / --help       # 版本 / 用法
```

> 不传参默认 stdio。传 `--transport`/`--port` 由 CLI 显式启动，优先级高于 `.env`/环境变量。

## 环境变量（前缀 `SCRAPE_MCP_`）

全部配置可用环境变量（`SCRAPE_MCP_` 前缀）设置，也可集中放到**项目根目录 `.env`** 文件里。

```bash
# 项目根目录执行
cp .env.example .env    # 生成配置模板
# 编辑 .env 后无需改代码，直接启动即可
```

优先级：实际进程环境变量 > `.env` > 默认值。`.env` 被 gitignore，不入库；`.env.example` 是随仓库提交的模板。

| 变量 | 默认 | 说明 |
|---|---|---|
| `IMPERSONATE` | `chrome` | curl_cffi 指纹模板 |
| `REQUEST_TIMEOUT` | `20` | L1 超时（秒） |
| `PROXY` | — | 代理，L1/L2 共用 |
| `L2_ENABLED` | `true` | 是否启用 Playwright 升级链 |
| `L2_CHANNEL` | — | 关联浏览器：空=自带 Chromium，`chrome`/`msedge`=系统 |
| `L2_SETTLE_TIMEOUT` | `8` | 挑战页等待窗口（秒） |
| `L2_SCROLL_STEPS` | `5` | L2 滚动步数（触发懒加载） |
| `L2_SCROLL_DELAY_MS` | `400` | L2 每步滚动间隔（毫秒） |
| `L2_TIMEZONE` | `Asia/Shanghai` | 隐身加固时区 |
| `DATA_DIR` | `~/.scrape_mcp` | 登录态/缓存放盘目录 |
| `CACHE_TTL` | `300` | L1 缓存 TTL（秒），`0` 关闭；过期后带 `ETag`/`Last-Modified` 做条件请求 |
| `RETRY_TIMES` | `2` | 网络异常/5xx 重试次数（429/403 不重试） |
| `RETRY_BACKOFF` | `0.5` | 重试退避基数（秒，指数增长） |
| `RETRY_AFTER_MAX_WAIT` | `0` | 429/503 带 `Retry-After` 时最多等待秒数；`0` 只上报不等待 |
| `LOG_LEVEL` | `WARNING` | 日志级别，JSON 行写 stderr（stdout 留给 MCP 协议） |
| `BATCH_MAX_CONCURRENCY` | `4` | `web_batch` 并发上限 |
| `BATCH_MAX_URLS` | `20` | `web_batch` 单批上限 |
| `HOST_MAX_CONCURRENCY` | `2` | 同一主机的并发上限（`web_batch`/`web_crawl` 共用） |
| `CRAWL_MAX_PAGES` | `20` | `web_crawl` 单次最多抓取页数硬上限 |
| `RESPECT_ROBOTS` | `true` | 默认遵循目标站 robots.txt；`false` 忽略（仅建议自用合规场景关闭） |
| `ALLOWED_HOSTS` | — | 主机白名单（逗号分隔）；非空时仅允许名单内主机 |
| `DENIED_HOSTS` | — | 主机黑名单（逗号分隔），优先于白名单 |
| `MAX_QPS` | `10` | 全网抓取限流（每秒请求数）；`0` 不限速 |

## HTTP 模式（Postman / curl 直连）

默认 stdio，改环境变量即可切到 HTTP，无需改代码：

```bash
$env:SCRAPE_MCP_TRANSPORT="streamable-http"
$env:SCRAPE_MCP_HTTP_PORT="8000"
python -m scrape_mcp.server
```

以 `json_response=1`（默认）启动，请求/响应都是普通 JSON（非事件流）。**stateless 模式无需先 `initialize`，直接发 `tools/call` 即可。**
健康检查：`GET /healthz` 返回版本与关键开关，可用于容器探针/网关巡检。

必需项：请求头 `MCP-Method`、`Mcp-Name`；body 内 `params` 需带 `_meta`（`protocolVersion` + `clientCapabilities`）。完整示例见下。

## 接入 MCP 客户端（Claude / Cursor 等）

先 `pip install mcp-scrape`（或用项目 `.venv` 跑 `pip install -e .`），使 `scrape-mcp` 命令全局可用；然后按标准 MCP 方式接入。

以 Claude Desktop 为例（`claude_desktop_config.json`）：

```json
{
  "mcpServers": {
    "scrape-mcp": {
      "command": "scrape-mcp"
    }
  }
}
```

- 用 `command: "scrape-mcp"`（已全局安装），免去 `PYTHONPATH` 与系统解释器导致的 `ModuleNotFoundError`。
- 若复用系统浏览器（免下载）：设环境变量 `SCRAPE_MCP_L2_CHANNEL` 为 `chrome` 或 `msedge`。
- 配对成功后重启客户端，即可在会话里直接调用 `web_fetch` / `web_extract` / `web_batch` / `web_crawl`。

## MCP 工具

- **`web_fetch(url, max_tokens=4000, link_policy="internal", format="markdown", ...)`** → 状态 + 极省正文
  - `link_policy`: `internal`（站内相对链接+站外降级）/ `all` / `none`
  - `format`: `markdown`（默认，保留标题/列表/链接）/ `text`（剥掉标记的纯文本）/ `json`（`content` 为段落数组）
  - 抓取参数：`headers` / `cookies`（自定义请求，不走共享缓存）、`timeout`、`bypass_cache`
  - 渲染参数（仅 L2 生效）：`wait_for`（等 CSS 选择器）、`scroll`（滚动触发懒加载）、`click`（点"加载更多"）
  - 诊断参数：`debug=true` 时返回 `diagnostics`（决策轨迹、选择器命中数、L2 交互结果），不含页面内容
- **`web_extract(url, schema={...}, ...)`** → 按字段 schema 抽取结构化 JSON（可入库）
  - 字段类型：`text`（默认）/ `html` / `attr`（需配 `attr`）/ `count` / `list` / `object`
  - 嵌套：`list` 配 `fields` 返回对象数组，`object` 在匹配节点内部继续抽取
  - 后处理：`regex` + `group`（正则提取，未命中回落 `default`）、`abs_url`（相对链接转绝对）、`strip`
  - 抓取/渲染参数与 `web_fetch` 相同（不含 `link_policy`/`format`）
- **`web_batch(urls, max_tokens=3000, link_policy="internal", format="markdown", ...)`** → 并发抓取一批，逐 URL 返回；单条异常不影响整批
  - 支持 `headers` / `cookies` / `timeout` / `bypass_cache` / `debug`（对该批所有 URL 生效）
- **`web_crawl(url, depth=1, limit=10, same_host=True, max_tokens=2000, ...)`** → 从起始页按层发现站内链接并逐页抓取
  - `depth`：跳转层数，`0` 只抓起始页，最大 `3`；`limit`：总页数上限（受 `CRAWL_MAX_PAGES` 约束）
  - 单页失败只影响该页；图片/压缩包等静态资源链接自动跳过
- **`login(url, timeout=180)`** → 打开带界面浏览器手动登录并持久化登录态

返回为 JSON 字符串（落在 `content[0].text`，请解析文本，勿依赖 `structured_content`）。
失败时带稳定 `error_code` 与 `retryable`；传输失败/5xx 会自动指数退避重试，`attempts` 为实际尝试次数；L2 交互未命中（如选择器不存在）只在 `notes` 中提示，不判失败。

### 错误码与诊断

`error_code` 是协议的一部分（只增不改），`retryable` 直接告诉你"过一会儿重试是否有意义"：

| error_code | retryable | 含义 / 下一步 |
|---|---|---|
| `timeout` / `dns` / `tls` / `connection` / `unknown` | ✅ | 网络层问题，稍后重试或检查代理 |
| `http_5xx` | ✅ | 目标服务端异常，可退避重试 |
| `http_4xx` | ❌ | 404/410 等，页面本身无效 |
| `blocked` | ❌ | 被反爬拦截（Cloudflare/WAF/限流），换时段或人工确认 |
| `login_required` | ❌ | 需要登录态，先调用 `login` |
| `denied` / `robots_disallowed` | ❌ | 黑白名单或 robots.txt 拒绝 |
| `invalid_url` / `invalid_argument` | ❌ | 参数问题 |

失败时默认带精简 `diagnostics.decisions`（如 `["retry:5xx", "escalate_l2:cloudflare", "l2_ok"]`）；
传 `debug=true` 会展开更多计数：

```json
{"diagnostics": {
  "decisions": ["escalate_l2:shell", "l2_ok"],
  "attempts": 1,
  "html_bytes": 348210,
  "root": "div",
  "selector_hits": {"article": 0, "main": 1, "div": 812},
  "content_chars": 5820,
  "l2": {"wait_for_hit": null, "clicked": null, "scroll_rounds": 0, "error": null}
}}
```

429/503 带 `Retry-After` 时返回 `retry_after_s`；配置 `RETRY_AFTER_MAX_WAIT` 后可在上限内自动补一次重试。

### web_extract 示例

```bash
schema='{"fields": {
  "title":     {"selector": "h1"},
  "price":     {"selector": ".price", "regex": "￥([\\d.]+)", "group": 1},
  "main_link": {"selector": "a", "type": "attr", "attr": "href", "abs_url": true},
  "tag_count": {"selector": ".tag", "type": "count"},
  "tags":      {"selector": ".tag", "type": "list", "list_key": "text"},
  "items":     {"selector": ".item", "type": "list", "fields": {
    "name": {"selector": ".name"},
    "url":  {"selector": "a", "type": "attr", "attr": "href", "abs_url": true}
  }}
}}'
# → {"ok":true, "data": {"title":"…","price":"299.00","main_link":"https://…",
#    "tag_count":3,"tags":["a","b","c"],"items":[{"name":"…","url":"https://…"}]}, "missing":[]}
```

## Docker

```bash
# 仅 L1（镜像小，覆盖大多数页面）
docker build -t scrape-mcp .

# 带 L2 真浏览器（Cloudflare/验证码/SPA）
docker build --build-arg INSTALL_BROWSER=1 -t scrape-mcp:l2 .

docker run -p 8002:8002 scrape-mcp
curl http://127.0.0.1:8002/healthz     # {"ok":true,"version":"…"}
```

## 目录结构

```
src/scrape_mcp
├── server.py          # MCP 入口 + 工具（web_fetch/web_extract/web_batch/web_crawl/login）
├── config.py          # 环境变量配置
├── tokenizer.py       # tiktoken 计数 + 预算截断
├── core/
│   ├── http_client.py # L1 curl_cffi + 磁盘缓存
│   ├── fetcher.py     # 分级调度（L1→L2），对工具层返回 FetchOutcome
│   ├── detector.py    # 拦截/骨架页判定
│   ├── browser.py     # L2 Playwright + stealth
│   ├── session.py     # 登录态持久化
│   ├── cache.py       # HTTP 响应缓存
│   ├── errors.py      # 统一错误码
│   └── hostlimit.py   # 同域并发闸门
└── extract/
    ├── compact.py     # DOM 剪枝 → 极省 token 正文
    ├── schema.py      # 结构化抽取（按字段 schema 出 JSON）
    ├── links.py       # 链接发现（web_crawl 用）
    └── render.py      # markdown → 纯文本 / 分块
```

## 开发

```bash
pip install -e ".[dev]"
python -m pytest
```