Metadata-Version: 2.5
Name: my-readurl-kit
Version: 0.0.6
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
Requires-Dist: python-dotenv>=1.0.0
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` | 返回解码后的原始响应正文；通过 Search1API 时返回服务提供的 Markdown/文本 |
| `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。

成功抓取的原始页面及其响应元数据会按请求 URL 和抓取来源分别缓存。普通 URL 优先读取
直抓缓存，其次读取之前成功回退的 Search1API 缓存；命中后不会再次请求源站或 Search1API。
Search1API 缓存还按 API base 区分。HTML 解析和 LLM 提取仍会按每次调用执行。
缓存默认存放在
`/tmp/my-readurl-kit/caches`，也可以通过 `MY_READURL_KIT_CACHE_DIR` 修改位置：

```bash
export MY_READURL_KIT_CACHE_DIR="/path/to/readurl-cache"
```

缓存没有自动过期时间；需要重新抓取时，删除该目录中对应的缓存文件或清空缓存目录。
缓存文件损坏或缓存目录不可写不会阻止正常抓取。

### Search1API 站点解析

设置 `SEARCH1API_API_KEY` 后，Search1API 会处理 Wikipedia/Wikimedia URL，以及文档列出的
专用解析 URL 模式：LinkedIn 公开公司、职位、文章和个人资料页；Reddit 帖子、社区和用户页；
X 帖子和文章；YouTube 视频和播放列表；GitHub 仓库 README 和文件；Stack Exchange 网络问题页；
Hugging Face 模型、数据集和 Space 页面；微信公众号文章；Hacker News 页面；npm/PyPI 包页面；
Bilibili 视频和 CSDN 博客文章。仅匹配文档支持的主机与路径才会优先调用专用解析器；
Wikimedia/Wikipedia 使用 Search1API 通用 crawl。其他 URL 先直接抓取；如果源站返回
HTTP 状态错误（包括 400、403）或发生连接、超时等网络错误，会回退调用一次 Search1API
`/crawl`。无效 URL、响应过大、内容类型不支持不会触发回退。

未设置 key 时，所有 URL 都使用原有直接抓取，失败时保留原错误。Search1API 请求失败会作为
`Search1APIError` 返回，不会自动再请求源站或重试。服务默认启用其内部 fallback。
每次实际调用 `/crawl` 消耗 1 credit；直抓失败后的回退调用也会消耗额度。发给 Search1API
的请求包含目标 URL，因此目标 URL 会由 Search1API 处理。
参考 [额度说明](https://s1.dev/docs/essentials/credits-and-limits) 和
[隐私政策](https://blog.s1.dev/pages/privacy)。

```bash
export SEARCH1API_API_KEY="your-search1api-api-key"
# 可选；默认是 Search1API 官网 API 地址
export SEARCH1API_API_BASE="https://api.search1api.com"
```

`SEARCH1API_API_BASE` 和 `Search1APIConfig(api_base=...)` 都可用于切换兼容的 API 地址；默认值为
`https://api.search1api.com`。也可以在 Python API 中传入 `Search1APIConfig`。
`search1api_client=` 是单独注入的 `httpx.AsyncClient`，不会复用源站的 `client=`、请求头或 Cookie：

```python
import asyncio

from my_readurl_kit import Search1APIConfig, read_url


async def main() -> None:
    result = await read_url(
        "https://www.reddit.com/r/python/comments/example/",
        search1api_config=Search1APIConfig(
            api_key="your-api-key",
            api_base="https://api.search1api.com",
        ),
    )
    print(result.content)


asyncio.run(main())
```

服务返回 Markdown/文本时，`text`、`body` 和 `relevant` 模式会直接使用该文本，不会按 HTML
解析；`raw` 返回该内容本身，不保证是 HTML。详细 URL 支持范围以
[Search1API 专用解析器文档](https://s1.dev/docs/guides/site-parsers) 为准。

### 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
read-curl "https://example.com" --log-level debug
```

`--log-level` 支持 `debug`、`info`、`warning`、`error` 和 `critical`，默认为
`warning`。日志写入标准错误，不会混入页面内容所在的标准输出。

CLI 启动时会从当前工作目录加载 `.env`；已经存在于进程环境中的变量优先级更高。
Python API 不会自动加载 `.env`，需要调用方自行设置环境变量或传入配置对象。

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

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

uv run 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
└── search1api.py     # Search1API 路由与 crawl 集成
```

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

## 开发环境

要求：

- 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。
