Firecrawl 使用教程:站点发现、批量提取与结果导出
使用 Firecrawl v2 API 发现文档链接、提交有范围限制的抓取任务、保存任务 ID、轮询状态并取完分页结果,导出可核对的页面数据。
如果你希望把网页采集接入应用,又不想在应用里管理浏览器进程,可以通过 Firecrawl 的 API 提交任务。但“任务创建成功”只表示服务接收了请求,接下来还要保存任务 ID、等待执行、取完结果分页,并判断哪些页面失败。
本文用一个文档目录演示完整流程:先发现链接,再抓取最多十页指定路径下的文档,保存 Markdown 和来源元数据。本例直接使用 v2 HTTP API,避免把不同 SDK 版本的字段混在一起。
运行需要你自己的 Firecrawl 账户与 API key,可能产生服务费用。本文给出的是使用方法与示例结构,不承诺账户额度、价格、覆盖率或运行效果。
准备输入地址
先在浏览器找到下列页面并确认内容,再从地址栏复制完整地址。服务或仓库地址从相应账户设置、官方项目的接入说明或克隆菜单取得。不要从示例输出中反推真实地址。
FIRECRAWL_API_BASE:账户接入文档中的 v2 API 基地址,含 v2 路径。TARGET_URL:Python 教程目录入口地址,路径为 /3/tutorial/。
在执行后续命令的同一个 Bash/WSL 终端中设置:
export FIRECRAWL_API_BASE='<paste-the-actual-firecrawl-api-base>'
export TARGET_URL='<paste-the-actual-target-url>'下文 JSON 或任务参数中的 <变量名> 是待替换标记,提交前填写刚复制的实际值;JSON 不会自动展开环境变量。结果示例中的 example.16yun.cn 只表示来源字段的形状,不是本文部署的演示站。
Map、Scrape 与 Crawl 怎样选择
已知一个 URL,只读取单页时选 Scrape;想先了解站点有哪些候选页面时选 Map;需要服务持续发现并处理站内页面时选 Crawl。Map 返回链接信息,不等于取得了所有正文;Crawl 也受路径、发现方式和数量上限影响。
本例的公开文档来源是 TARGET_URL。只保留 /3/tutorial/ 下的页面,最多十页,因此它是一轮范围受限的采集,不是整个 Python 文档站的完整镜像。API 的 Map 与 Crawl 含义见 Map 文档 和 Crawl 文档。
准备配置
使用 Python 3.12 或更新版本,本例只有标准库依赖。创建新目录,然后配置本地环境变量:
export FIRECRAWL_API_KEY='replace-with-your-key'
# TARGET_URL and FIRECRAWL_API_BASE are configured above
export TARGET_PATH_PATTERN='^/3/tutorial/.*'换成自己的帮助中心时,同时修改 URL 与路径表达式。入口也要匹配路径规则,否则可能得到零页。不要把自己的站点路径原样套到另一站点,也不要为了抓到数据直接取消全部范围限制。
提交与收集使用同一个脚本
创建 firecrawl_help.py,加入完整代码。脚本分成 start 与 collect 两种操作,避免查询超时后重新提交一份可能重复计费的任务。
import argparse
import json
import os
import time
from pathlib import Path
from urllib.parse import quote, urljoin, urlsplit
from urllib.request import Request, urlopen
BASE = os.environ["FIRECRAWL_API_BASE"].rstrip("/") + "/"
API_ORIGIN = urlsplit(BASE)
if API_ORIGIN.scheme != "https" or not API_ORIGIN.netloc:
raise ValueError("FIRECRAWL_API_BASE must be an HTTPS API base")
TOKEN = os.environ["FIRECRAWL_API_KEY"]
OUT = Path("firecrawl-output")
OUT.mkdir(exist_ok=True)
def save(name, data):
(OUT / name).write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
def api(path, payload=None):
url = urljoin(BASE, path)
if urlsplit(url).scheme != "https" or urlsplit(url).netloc != API_ORIGIN.netloc:
raise ValueError("结果分页地址不属于 Firecrawl API")
request = Request(
url,
data=None if payload is None else json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
method="GET" if payload is None else "POST",
)
with urlopen(request, timeout=30) as response:
result = json.load(response)
if result.get("success") is False:
raise RuntimeError("API 返回失败,请检查请求与账户状态")
return result
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=["start", "collect"])
args = parser.parse_args()
if args.action == "start":
if (OUT / "job.json").exists():
raise SystemExit("已有 job.json;请先 collect 或为新任务使用新目录")
root = os.environ["TARGET_URL"]
pattern = os.environ["TARGET_PATH_PATTERN"]
save("map.json", api("map", {"url": root}))
created = api("crawl", {
"url": root, "limit": 10, "includePaths": [pattern],
"scrapeOptions": {"formats": ["markdown"], "onlyMainContent": True},
})
if not created.get("id"):
raise RuntimeError("创建响应缺少任务 ID")
save("job.json", {"id": created["id"], "target": root, "path_pattern": pattern})
print("任务 ID 已保存;执行 collect 查询")
else:
job = json.loads((OUT / "job.json").read_text(encoding="utf-8"))
path = "crawl/" + quote(job["id"], safe="")
for _ in range(30):
status = api(path)
save("status.json", status)
if status["status"] in {"completed", "failed", "cancelled"}:
break
time.sleep(5)
else:
raise SystemExit("本轮等待结束;保留任务 ID,稍后再次 collect")
if status["status"] != "completed":
raise SystemExit("任务未正常完成,请检查 status.json")
documents = list(status.get("data", []))
next_url = status.get("next")
visited = set()
while next_url:
if next_url in visited:
raise RuntimeError("结果分页地址重复,停止以避免循环")
visited.add(next_url)
part = api(next_url)
documents.extend(part.get("data", []))
next_url = part.get("next")
save("documents.json", documents)
index, rejected = [], []
for number, document in enumerate(documents):
meta = document.get("metadata") or {}
url = meta.get("sourceURL") or meta.get("url")
markdown = document.get("markdown")
if not url or not isinstance(markdown, str) or not markdown.strip():
rejected.append({"index": number, "metadata": meta})
continue
filename = f"page-{number:04d}.md"
(OUT / filename).write_text(markdown, encoding="utf-8")
index.append({"source_url": url, "title": meta.get("title"), "file": filename})
save("index.json", index)
save("rejected.json", rejected)
print({"returned": len(documents), "exported": len(index), "rejected": len(rejected)})先提交,再查询:
python firecrawl_help.py start
python firecrawl_help.py collectjob.json 是继续查询的依据,不能只把任务 ID 打印后丢掉。如果等待次数用完,再执行 collect 会读取同一个任务,不会再次调用创建接口。这里的等待次数加间隔不构成严格总时限,因为每次 HTTP 请求本身还可能消耗时间。
为什么要取完 next,为什么不累计每次轮询的数据
等待过程中服务可以返回部分数据。若每轮都追加整份结果,可能把先前页面反复写入。示例等待终态后再收集当前返回的数据,并沿 next 取后续结果。
next 是结果传输分页,不是目标网站的“下一页”。即使抓取任务完成,响应内容较大时仍可能需要继续读取。它的具体字段见 任务状态文档。
返回的分页地址会携带认证请求,因此代码检查其 API 来源,避免将凭据发给其他站点。真实应用还应按服务要求处理错误和重试策略;不要无条件重试 POST 创建任务,因为调用方未必知道上一次请求有没有被服务成功接收。
怎样检查输出
目录中会出现任务文件、原始文档数组、按页 Markdown,以及来源到文件的清单。下面是 index.json 的结构示例,不是实际调用输出:
[
{
"source_url": "https://example.16yun.cn/3/tutorial/index.html",
"title": "页面实际标题",
"file": "page-0000.md"
}
]检查三个方面:URL 是否符合本轮路径规则;Markdown 是否真正包含对应正文;是否达到 limit 而提前停止。达到十页上限不表示文档目录只有十页。
再对照 map.json 中符合路径的候选,检查是否还有未处理来源。Map 与 Crawl 的发现机制和范围可能不同,差集用于排查,不能直接把 Map 的数量当成应抓页面总数。
终态为 completed 或 completed 等于 total,也不能单独证明所有发现页成功。服务错误清单与结果计数应一起看;在业务发布前查看对应任务的 Crawl Errors 信息,并保留未返回页面的解释。本文脚本保存数据,不自动把结果认定为完整知识库。
失败时先判断哪个阶段
401 或 402。 前者先检查凭据,后者查看账户使用条件与额度。它们与文档页 CSS 无关,不要通过更换选择器排错。
创建成功但没有正文。 确认 collect 读取的是刚才保存的任务 ID,任务是否进入终态,以及路径规则是否连入口都排除了。检查 status 与 rejected,不要只看文件存在。
超过 limit 后文档不齐。 先确定确实需要扩充范围,再在新任务中调整上限。旧任务的 job.json 不能改几个参数就假装已经重新执行。
长时间仍是 scraping。 保留任务 ID,稍后继续查询;查看服务返回状态和错误。不要每次等待结束就 start,以免产生多份重复任务。
重复页很多。 检查语言、版本和查询参数是否混入同一范围。按来源 URL 去重后,还需要按文档版本解释相似页面,不能把所有相似正文都删除。
Markdown 空但 HTTP 看起来正常。 读取元数据错误、原页面身份和采集格式。登录提示、访问挑战、资源不可达与正文转换失败需要不同处理,换代理不是通用结论。
使用托管 API 减少了浏览器管理工作,但任务身份、结果完整性和数据含义仍属于应用。先把这三件事接好,再扩大抓取规模。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。