抓取内核:scrapeURL 的编排与容错
30 秒导读: 给 Firecrawl 一个 URL,它最终要还给你一份干净、可喂给大模型的
Document。中间要闯三关:选对抓取"引擎"、抓到"够好"的内容、失败了还能自动换策略重来。这一章讲的就是把这三关串起来的那根主线——scrapeURL。
本章聚焦单页抓取的编排主线:一次 scrapeURL(id, url, options, ...) 调用,从组装上下文、引擎竞速、错误重试,到最后交给转换流水线,中间发生了什么。
- 引擎怎么选、怎么打分(
buildFallbackList的能力评分与回退列表)留给 第 02 章; - 抓到原始 HTML 之后怎么变成 markdown / json / 结构化数据留给 第 03 章;
- 本章只讲编排和容错这根骨头,以及它两头的接口。
想先看全景和阅读地图,回到 index.md。
1. 先建立直觉:抓一个网页,难在哪
抓一个静态 HTML 页面很简单,fetch 一下就行。但 Firecrawl 面对的是真实世界的网页,于是有一堆"不简单":
- 有的页面是纯 HTML,有的要跑 JS 才有内容,有的是 PDF / DOCX / 表格;
- 有的站点有反爬(anti-bot),普通请求会被 403 / 429 挡回来;
- 有的站点慢,有的引擎快但能力弱,有的引擎强但贵;
- 用户还可能要求"顺便截图""执行几个点击动作""只从缓存拿"。
核心矛盾:没有任何单一抓取方式能覆盖所有情况。 所以 Firecrawl 的抓取内核不是"一个抓取器",而是一套编排逻辑:手里握着多个引擎(简单 fetch、Playwright、Fire Engine、索引缓存……),像赛马一样让它们竞速,谁先抓到"够好"的结果谁赢;赢不了就换策略、加能力、再来一轮。
scrapeURL 就是这套编排的入口函数。一句话类比:它像一个赛事总控——排好参赛引擎的出场顺序,鸣枪、掐表、判定成绩,成绩不合格就改规则重赛,最后把冠军的成果送去后厨加工。
2. 顶层全景:三层俯视图
scrapeURL 内部是三层嵌套的结构,由外到内:
scrapeURL(...) ← 外层:上下文 + robots 前置 + 错误重试外循环
├─ buildMetaObject(...) ① 组装不可变上下文 Meta(url/options/flags/abort/prefetch)
├─ shouldCheckRobots → robots.txt ② 前置合规检查(可选)
└─ while(true) { scrapeURLLoop(meta) ③ 错误驱动的重试外循环
} └ 捕获 AddFeature/RemoveFeature/Antibot → 改 flags 重跑
scrapeURLLoop(meta) ← 中层:引擎竞速
├─ buildFallbackList(meta) ④ 按能力打分,得到有序引擎列表(见第 02 章)
├─ while(engines) { Promise.race ⑤ 瀑布式并发竞速 + 定时把下一个引擎也拉进赛道
│ } └ 谁先成功谁赢,赢家 abort 掉其余(EngineSnipedError)
├─ postprocessors ⑥ 引擎结果后处理(如 YouTube)
└─ executeTransformers ⑦ 交给转换流水线 → Document(见第 03 章)
scrapeURLLoopIter(meta,engine) ← 内层:单引擎一次尝试 + 成功判定
├─ scrapeURLWithEngine(...) ⑧ 真正调某个引擎抓一次
└─ 成功判定启发式 ⑨ isLongEnough / isGoodStatusCode / isLikelyProxyError
怎么读这张图: 从上往下是"包含"关系(外层调中层、中层调内层);编号①→⑨是一次成功抓取的大致时间顺序。外层管重试,中层管竞速,内层管判定单次成绩。
Firecrawl 自己在 apps/api/src/scraper/scrapeURL/README.md 里画过一张简化的信号流图(它比真实代码旧一些,但抓住了主干):
真实代码比这张图多两条暗线:引擎不是串行"下一个",而是并发竞速(§4);"No engines left" 不是终点,外层还能改 feature flags 再来一整轮(§5)。
三个入口函数的职责
| 函数 | 职责一句话 | 位置 |
|---|---|---|
scrapeURL | 编排总控:建上下文、robots 检查、错误重试外循环、最终错误分类 | scrapeURL/index.ts:1054 |
scrapeURLLoop |