Metadata-Version: 2.5
Name: my-readurl-kit
Version: 0.0.2
Summary: Fetch textual web resources and convert HTML to readable text.
Requires-Python: >=3.10
Requires-Dist: beautifulsoup4>=4.15.0
Requires-Dist: httpx>=0.28.1
Requires-Dist: my-llmkit>=0.3.12
Description-Content-Type: text/markdown

# my-readurl-kit

`my-readurl-kit` 是一个独立的 Python 网页读取与内容提取库。当前的 `read_url`
实现迁移自 `gede/gede/llm/tools/read_url_tool.py`，但不依赖 `gede` 本身。

## 目标

- 获取给定 URL 的页面内容，支持返回原始 HTML 或可读文本。
- 通过 `my-llmkit` 非流式调用 LLM，提取正文或与查询相关的内容。
- 同一套核心能力既可作为 Python 函数库使用，也可通过 CLI、MCP server 和
  HTTP server 调用。
- 将抓取、解析、LLM 提取和传输协议分层，避免任一入口绑死核心库。

## 当前 Python API

网络 I/O 使用异步 API，成功结果通过 `ReadUrlResult` 返回；请求、状态码、响应
大小和内容类型错误会抛出 `ReadUrlError` 的具体子类，不会伪装成普通内容字符串。

```python
import asyncio

from my_readurl_kit import read_url


async def main() -> None:
    result = await read_url("https://example.com")
    print(result.content)
    print(result.final_url, result.status_code)


asyncio.run(main())
```

`output_format` 支持以下取值：

| 值 | 行为 |
| --- | --- |
| `raw` | 返回解码后的原始响应正文，适用于 HTML、JSON 和 JavaScript |
| `text` | 使用 BeautifulSoup 将 HTML 转为规范化纯文本，也是默认值 |
| `body` | 通过 LLM 提取正文并剔除作者来源、推荐等非正文内容 |
| `relevant` | 通过 LLM 提取与 `query` 直接相关的完整原文段落 |

`relevant` 传入非空 `query` 时提取相关段落；未传入或仅传入空白 `query` 时等同于
`body`。其他模式会忽略 `query`。`body` 和 `relevant` 只有在实际使用时才需要
LLM 配置；`raw` 和 `text` 不会创建或调用模型客户端。

调用方可以注入 `httpx.AsyncClient`、请求头和超时。默认跟随最多 10 次重定向，
接受文本、JSON、JavaScript 或 XML 响应，并将解压后的响应正文限制为 5 MiB。

### LLM 配置

`LLMConfig` 接收 API key、API base URL、模型名称和客户端类型。
客户端支持 `openai_compatible` 和 `claude`，分别对应 `my-llmkit` 的
`OpenAICompatibleChatCompletion` 和 `ClaudeChatCompletion`。

未显式传入 `llm_config` 或 `llm_client` 时，`body` 和 `relevant` 会自动
读取以下环境变量：

```bash
export LLM_EXTRACT_API_KEY="your-api-key"
export LLM_EXTRACT_API_BASE="https://api.openai.com/v1"
export LLM_EXTRACT_MODEL="gpt-4.1-mini"
export LLM_EXTRACT_CLIENT="openai_compatible"
```

四个变量需要同时配置。库本身不保存密钥，`raw` 和 `text` 模式也不会
读取这些环境变量。

```python
import asyncio

from my_readurl_kit import read_url


async def main() -> None:
    body = await read_url(
        "https://example.com/article",
        output_format="body",
    )
    relevant = await read_url(
        "https://example.com/article",
        query="WebAssembly 的技术细节",
        output_format="relevant",
    )
    print(body.content)
    print(relevant.content)


asyncio.run(main())
```

高级调用方也可以通过 `llm_client=` 直接注入已构造的
`my_llmkit.chat.LLMChatCompletion`，此时不需要 `llm_config`，也不会创建
第二个模型客户端。也可显式构造 `LLMConfig`，或调用
`LLMConfig.from_env()` 读取上述变量。`llm_config` 与 `llm_client` 不能同时传入，
且它们的优先级都高于环境变量。

LLM 通过 `LLMChatCompletion.run()` 以非流式方式运行。未提供配置时，
`body` 返回 `无法获取正文`，`relevant` 返回 `无相关内容`。模型调用失败
或返回空内容时，`body` 回退到规范化后的网页全文，`relevant` 返回
`无相关内容`。

## CLI

安装项目后可以使用 `read-curl` 读取网页。URL 是必需的位置参数，
`--output-format` 支持与 Python API 相同的 `raw`、`text`、`body` 和
`relevant`，默认值为 `text`。成功时标准输出只包含读取到的内容，便于通过
管道继续处理或重定向到文件。

```bash
read-curl "https://example.com"
read-curl "https://example.com" --output-format raw
```

`body` 和 `relevant` 会在发起网页请求前从四个 `LLM_EXTRACT_*` 环境变量
构建并校验 `LLMConfig`。这些变量缺失、不完整或无效时，命令会向标准错误
输出错误并以非零状态退出。`raw` 和 `text` 不读取或要求这些变量。

```bash
uv run --env-file .env read-curl \
  "https://example.com/article" \
  --output-format body

uv run --env-file .env read-curl \
  "https://example.com/article" \
  --output-format relevant \
  --query "WebAssembly 的技术细节"
```

`--query` 在 `--output-format relevant` 模式下用于提取相关段落；未提供或仅包含
空白时，`relevant` 等同于 `body`。其他模式会忽略它。
LLM 模式会发起外部模型请求，可能产生费用。

## 计划中的其他入口

| 入口 | 用途 |
| --- | --- |
| MCP server | 向支持 MCP 的客户端暴露 read-url 工具 |
| HTTP server | 向其他进程或服务提供 HTTP API |

CLI、MCP 和 HTTP 层保持轻量，只负责参数转换、调用核心 API 以及输出结果。

## 设计边界

- 基础抓取与 HTML 解析不依赖 LLM，应可单独使用。
- LLM 提取建立在抓取与确定性文本解析结果之上，并通过 `my-llmkit` 接入模型。
- 网络 I/O 以异步 API 为核心；如需同步 API，应由薄封装提供。
- 公共 API 使用明确的类型标注和结构化结果，区分成功结果与请求、解析、提取错误。
- 不在核心库中保存 API key 或其他密钥；配置由调用方显式传入或从
  `LLM_EXTRACT_*` 环境变量读取。

当前核心包结构：

```text
src/my_readurl_kit/
├── cli.py            # read-curl 命令行适配层
├── errors.py         # 明确的错误类型
├── extract.py        # 基于 my-llmkit 的非流式内容提取
├── fetch.py          # 基于 httpx 的 HTTP 请求与响应处理
├── models.py         # 公共结构化结果
├── parse.py          # HTML 到可读文本的确定性转换
└── reader.py         # read_url 高层 API
```

后续的协议入口会继续按上述边界拆分为独立模块。

## 开发环境

要求：

- Python 3.10 或更高版本
- [`uv`](https://docs.astral.sh/uv/)

安装项目及开发依赖：

```bash
uv sync
```

运行静态检查：

```bash
uv run pyright
```

运行不包含 LLM 的真实网站集成测试（会向微信和 Orchid Files
发起外部网络请求）：

```bash
uv run pytest -m "network and not llm"
```

测试 `body` 模式（启动时从 `.env` 注入 `LLM_EXTRACT_*` 变量）：

```bash
uv run --env-file .env pytest -s tests/test_read_url_network.py::test_read_url_body_with_llm
```

测试 `relevant` 模式（启动时从 `.env` 注入 `LLM_EXTRACT_*` 变量）：

```bash
uv run --env-file .env pytest -s tests/test_read_url_network.py::test_read_url_relevant_with_llm
```

两个 LLM 测试都会抓取 Orchid Files 的公开文章并发起一次非流式模型
请求。测试函数会通过 `LLMConfig.from_env()` 构造配置，并将它显式传给
`read_url(llm_config=...)`。测试会输出提取结果，也可能产生模型调用费用。未注入完整环境
变量时，测试会显式跳过，不会误用回退文本当作模型输出。

构建发行包：

```bash
uv build
```

当前实现可用以下方式手工检查（会发起外部网络请求）：

```bash
uv run python -c 'import asyncio; from my_readurl_kit import read_url; print(asyncio.run(read_url("https://example.com")).content)'
```

项目默认不要求为改动新增单元测试；需要验证行为时，应在变更说明中提供简短、
可复现的手工测试步骤。只有在明确要求时才新增单元测试。

## 初步迁移顺序

1. 稳定当前 Python API 和错误模型（已完成）。
2. 在核心 API 之上增加 CLI（已完成）。
3. 后续增加 MCP server 和 HTTP server。
