快速回答
如何将网页以干净的 Markdown 加载到 LangChain 或 LlamaIndex?
调用 WebReaderAPI 的 /read 端点,并将每个响应包装为 Document:Markdown 作为 content,final_url 和标题作为 metadata。对于 agent,将同一调用暴露为 read_url 工具。每个页面 1 积分,使用浏览器则为 2 积分,自 2026 年 10 月起US$0.07/千页。无需使用我们的任何软件包。无需 KYC;可用加密货币支付。
为什么使用 Markdown 文档而不是原始 HTML?
因为检索作用于文本,而 Markdown 保留了重要的结构,同时丢弃其余部分。标题会保留下来,因此拆分器可以按主题变化进行切分。列表和表格会保留下来,因此步骤和规格保持在一起。导航、cookie 横幅和广告会被去除,因此它们不会污染你的 embeddings,也不会浪费 tokens。
metadata 来自同一次调用:final_url 作为引用来源,以及用于过滤的 title、published_at 和 language。
import os
import requests
API = "https://webreaderapi.com/api/v1/read"
HEADERS = {"Authorization": "Bearer " + os.environ["WEBREADER_API_KEY"]}
class PageError(Exception):
pass
def read_page(url, **options):
resp = requests.get(API, headers=HEADERS, params={"url": url, **options}, timeout=75)
body = resp.json()
if resp.status_code != 200:
raise PageError(body["error"]["code"])
return body
如何构建 LangChain 文档加载器?
将每个页面转换为 langchain_core.documents.Document,然后使用 langchain-text-splitters 包中的 MarkdownHeaderTextSplitter 按标题进行切分。每个分块都在其 metadata 中保留标题路径,并添加来源 URL,以便每个答案都能指回其页面。
被跳过的页面(例如被 robots.txt 拒绝的页面)会抛出带有代码的 PageError;请记录这些错误并继续。
from langchain_core.documents import Document
from langchain_text_splitters import MarkdownHeaderTextSplitter
from reader import PageError, read_page
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")])
def load_pages(urls):
chunks = []
for url in urls:
try:
page = read_page(url)
except PageError as exc:
print("skipped", url, exc)
continue
for part in splitter.split_text(page["markdown"]):
part.metadata.update({"source": page["final_url"], "title": page.get("title", "")})
chunks.append(part)
return chunks
docs = load_pages(["https://example.com/docs/install", "https://example.com/docs/configure"])
print(len(docs), "chunks", isinstance(docs[0], Document) if docs else "")
如何为 LangChain agent 提供 read_url 工具?
用 langchain_core.tools 中的 @tool 装饰一个函数。docstring 会成为模型读取的工具描述,因此请说明该工具返回什么以及何时使用它。返回纯文本:一行标题、来源和 Markdown,并用 max_chars 截断以适配你的模型。
将该工具绑定到任何支持 tool calling 的聊天模型,或将其传递给 agent 构造函数。然后模型会用对话中或搜索结果中的 URL 调用 read_url。
import os
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from reader import PageError, read_page
@tool
def read_url(url: str, max_chars: int = 20000) -> str:
"""Read a public web page and return its main content as Markdown, with its title and source URL."""
try:
page = read_page(url, max_chars=max_chars)
except PageError as exc:
return "Could not read the page: " + str(exc)
return "# " + (page.get("title") or url) + "\nSource: " + page["final_url"] + "\n\n" + page["markdown"]
model = init_chat_model(os.environ.get("CHAT_MODEL", "openai:gpt-4.1-mini")).bind_tools([read_url])
reply = model.invoke("Summarise https://example.com/blog/first-post in three bullet points.")
print(reply.tool_calls)
如何在 LlamaIndex 中做同样的事?
LlamaIndex 使用自己的 Document 类,配合 text 和 metadata,以及 FunctionTool.from_defaults 将函数转换为工具。辅助代码保持不变。使用 VectorStoreIndex.from_documents 从文档构建索引,它会使用你的 LlamaIndex 设置中指定的任何 embedding model。
import os
import time
import requests
from llama_index.core import Document, VectorStoreIndex
from llama_index.core.tools import FunctionTool
from reader import HEADERS, PageError, read_page
def crawl_pages(start_url, limit=200):
job = requests.post("https://webreaderapi.com/api/v1/crawl", headers=HEADERS, json={"url": start_url, "limit": limit}, timeout=30).json()
pages, cursor = [], None
while True:
params = {"cursor": cursor} if cursor else {}
res = requests.get("https://webreaderapi.com/api/v1/jobs/" + job["job_id"], headers=HEADERS, params=params, timeout=30).json()
pages += [r for r in res["results"] if "error" not in r]
cursor = res.get("next_cursor")
if not cursor and res["status"] == "done":
return pages
if not cursor:
time.sleep(5)
pages = crawl_pages("https://docs.example.com/")
docs = [Document(text=p["markdown"], metadata={"source": p["final_url"], "title": p.get("title", "")}) for p in pages]
index = VectorStoreIndex.from_documents(docs)
print(index.as_query_engine().query("How do I install the product?"))
def read_url(url: str, max_chars: int = 20000) -> str:
"""Read a public web page and return its main content as Markdown."""
try:
return read_page(url, max_chars=max_chars)["markdown"]
except PageError as exc:
return "Could not read the page: " + str(exc)
read_tool = FunctionTool.from_defaults(fn=read_url, name="read_url")
print(os.environ.get("CHAT_MODEL", "set CHAT_MODEL for your agent"), read_tool.metadata.name)
小型 RAG 索引需要多大范围的整站抓取 (crawl)?
比你想的少。一个产品的文档通常只有几百页,而将 include_paths 设置为文档分区的整站抓取 (crawl) 只会读取这些页面。按每页 1 积分计算,一个 300 页的文档网站约需 300 积分;10,000 个网页(US$3.75)可以多次刷新它。
按与来源变化频率相匹配的计划重新整站抓取 (crawl),并将 final_url 和 word_count 与上一次运行进行比较,仅对发生变化的页面重新生成 embedding。预付费积分永不过期:未使用的页面数会一直保留在你的账户中,直到你使用。
LangChain 与 LlamaIndex:常见问题
是否有适用于 LangChain 或 LlamaIndex 的 WebReaderAPI 软件包?
没有。本页上的辅助代码和工具就是全部集成内容,因此你可以自由调整它们。WebReaderAPI 是一个普通的 HTTPS API,任何 Python HTTP 客户端都可以调用。
这需要哪个 LangChain 版本?
任何包含 langchain_core、langchain-text-splitters 和 init_chat_model 的近期版本均可。工具装饰器和 Document 类在近期版本中一直保持稳定。
如何避免重新读取未发生变化的页面?
保存 final_url、word_count 以及每次读取的日期。按计划重新读取,仅当页面的单词数或内容哈希发生变化时才重新生成该页面的 embedding。每次重新读取都会消耗一个积分,因此请选择与来源相匹配的节奏。
如果某些页面被拒绝怎么办?
它们会抛出带有代码(例如 blocked_by_robots)的 PageError,且不产生任何费用。如果网站拒绝我们的请求,我们会返回其答复:我们从不破解 CAPTCHA、不登录账户,也不试图绕过封锁或付费墙。