Firecrawl 使用教程:站点发现、批量提取与结果导出

使用 Firecrawl v2 API 发现文档链接、提交有范围限制的抓取任务、保存任务 ID、轮询状态并取完分页结果,导出可核对的页面数据。

亿牛云技术团队2026年10月28日6 分钟阅读

如果你希望把网页采集接入应用,又不想在应用里管理浏览器进程,可以通过 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 collect

job.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 减少了浏览器管理工作,但任务身份、结果完整性和数据含义仍属于应用。先把这三件事接好,再扩大抓取规模。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。