数据截至 (上游 commit 31522cf981bf)
抓取内核: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:1067 |
scrapeURLLoop | 引擎竞速:排好回退列表,瀑布式并发,选出赢家,跑后处理与转换 | scrapeURL/index.ts:660 |
scrapeURLLoopIter | 单引擎一次尝试 + 成功判定启发式 | scrapeURL/index.ts:513 |
3. Meta 对象:一次抓取的"不可变上下文"
3.1 它要解决的小问题
一次抓取要用到几十个参数:URL、用户选项、启用了哪些能力、超时控制、预取的文件、日志器、成本追踪……如果这些散落在函数参数里层层传递,代码会变成参数地狱。
Firecrawl 的做法:把它们全部塞进一个对象 Meta,一次建好,之后当作不可变在整条流水线里传递。源码注释把这个设计说得很清楚:
"The meta object is usually immutable ... Having a meta object that is treated as immutable helps the code stay clean and easily tracable" ——
scrapeURL/index.ts:268
"usually" 是关键:它几乎不可变,只有两处例外——日志数组会追加,以及外层重试要改 featureFlags(§5 会看到)。
Meta 的类型定义在 scrapeURL/index.ts:137(export type Meta),核心字段:
| 字段 | 装什么 |
|---|---|
id / url / rewrittenUrl | 抓取 ID、原始 URL、重写后的 URL |
options | 用户抓取选项(formats、proxy、timeout、actions…),已填默认值 |
internalOptions | 内部选项(teamId、forceEngine、zeroDataRetention、uploadedFile…) |
featureFlags | 由选项翻译出的能力集合(见 §3.2) |
abort | AbortManager,分层超时/取消(scrape 层、engine 层) |
pdfPrefetch / documentPrefetch / fetchPrefetch | 预取的文件(见 §3.4) |
logger / costTracking / mock | 日志器、成本追踪、录制回放 |
组装它的是 buildMetaObject(scrapeURL/index.ts:318)。
3.2 buildFeatureFlags:把"请求选项"翻译成"能力需求"
这一步是引擎选择的前提。 用户说的是"我要截图 / 我要执行动作 / 这是个 PDF",但引擎系统认的是一套统一的能力标签 FeatureFlag。buildFeatureFlags(scrapeURL/index.ts:185)就是这个翻译器:读 options,产出一个 Set<FeatureFlag>。
翻译规则举例(节选):
| 请求里出现 | 翻出的 FeatureFlag | 代码 |
|---|---|---|
actions 非空 | actions | index.ts:175 |
screenshot 格式(整页) | screenshot@fullScreen | index.ts:179-184 |
waitFor !== 0 | waitFor | index.ts:199 |
proxy === "stealth"/"enhanced" | stealthProxy | index.ts:223 |
URL 以 .pdf 结尾 | pdf | index.ts:245 |
URL 以 .docx/.xlsx/... 结尾 | document(优先于 pdf) | index.ts:231-244 |
这些 flag 之后交给 buildFallbackList(第 02 章),用来过滤和排序引擎——不支持所需能力的引擎会被排除或降权。
一个巧妙的短路:lockdown 模式直接返回空集合。
// scrapeURL/index.ts:171 —— 真实源码节选
if (options.lockdown) {
return flags; // 空集:强制只用 index 引擎,忽略一切请求时能力
}
注释解释了原因(index.ts:169):lockdown 只服务缓存,返回空 flag 才能让回退阈值不会把 index 引擎过滤掉。
3.3 urlSpecificParams 与 rewriteUrl:抓之前先"矫正"URL
buildMetaObject 开头做了两件"预处理":
① 站点专属参数覆盖。 某些域名需要特殊抓取参数,urlSpecificParams(键是去掉 www. 的 hostname)提供覆盖,直接 Object.assign 进 options / internalOptions:
// scrapeURL/index.ts:334 —— 真实源码节选
const specParams =
urlSpecificParams[new URL(url).hostname.replace(/^www\./, "")];
if (specParams !== undefined) {
options = Object.assign(options, specParams.scrapeOptions);
// ...同样 assign internalOptions
}
② URL 重写(伪重定向)。 rewriteUrl(lib/rewriteUrl.ts:3)把"人看的" Google Docs / Drive 链接改写成"可抓的"导出链接。例如把 docs.google.com/document/d/<id>/... 改写成 .../export?format=html(rewriteUrl.ts:12);已发布的 /d/e/ 链接本身就是公开 HTML,返回 undefined 不改写(rewriteUrl.ts:9)。结果存在 meta.rewrittenUrl,后续抓取优先用它(index.ts:646 的 meta.rewrittenUrl ?? meta.url)。
3.4 上传文件预取:直接投喂,跳过网络抓取
如果调用方通过 /v2/parse 上传了文件(internalOptions.uploadedFile),就没必要"抓"了——文件已经在手里。buildMetaObject 会把它写到临时文件,并塞进对应的 *Prefetch 字段,让下游引擎当作"已经抓到"来处理。
分派逻辑按文件类型走三条路(scrapeURL/index.ts:367-412):
| 判断函数 | 命中 | 写入字段 |
|---|---|---|
isPdfUpload(index.ts:288) | pdfPrefetch | |
isDocumentUpload(index.ts:298) | DOCX/XLSX/ODT/RTF… | documentPrefetch |
isHtmlUpload(index.ts:317) | HTML/XHTML | fetchPrefetch(直接存 buffer) |
| 都不是 | —— | 抛 UnsupportedFileError |
判断兼顾"扩展名 + Content-Type"两条线索,任一命中即可。写临时文件由 writeUploadedFileToTemp(index.ts:273)完成,用 randomUUID() 保证文件名不撞。
注意
*Prefetch字段的三态语义(index.ts:135注释):undefined= 还没预取,null= 预取回来是空,有值 = 预取成功。这个区分在 §5 的 anti-bot 重试里很关键——"是否已经预取过"决定了失败时是重试还是直接放弃。
4. 引擎竞速循环:瀑布式并发
这是本章的核心机制,也是 Firecrawl 抓取快而稳的关键。
4.1 它要解决的小问题
假设你有一串候选引擎 [A, B, C](A 最优先)。最朴素的策略是串行:试 A,失败/超时了再试 B,再试 C。问题是——如果 A 很慢(比如要跑 JS 等 10 秒),你得干等它,哪怕 B 其实 1 秒就能抓到。
Firecrawl 的策略叫瀑布式并发(waterfall race):先让 A 起跑;如果 A 在"合理时间"内还没出结果,不取消 A,而是把 B 也放进赛道一起跑;再过一会儿把 C 也加进来。谁先成功谁赢,赢家立刻把还在跑的其他引擎掐掉(sniped)。
直觉:不是"接力赛"(一个跑完换下一个),而是"逐渐加派选手的混合赛"——快的引擎不必等慢的,慢的引擎也没被浪费,只要它可能先到。
4.2 竞速的骨架
竞速在 scrapeURLLoop 的 while (remainingEngines.length > 0) 里(index.ts:722)。简化演示核心思想:
// 示意,非源码 —— 演示瀑布式竞速的骨架
const running = []; // 当前在赛道上的引擎
while (remainingEngines.length > 0) {
const engine = remainingEngines.shift();
running.push(startScrape(engine)); // 新引擎起跑,加入赛道
// 计算"多久后把下一个引擎也拉进来"
const waterfallDelay = maxReasonableTime(engine) + WATERFALL_DELAY_MS;
const winner = await Promise.race([
...running, // 所有在跑的引擎
timeout(waterfallDelay, "next"), // 到点就触发"加派下一个"信号
timeout(scrapeTimeout, "giveup"), // 总超时
]);
if (winner.success) break; // 有人成功,收工
// 否则:要么某引擎失败被剔除,要么 waterfall 信号到 → 循环加派下一个
}
真实实现的 Promise.race 在 index.ts:767,同时 race 三类 promise:
- 所有在跑引擎的 promise(
enginePromises.map(x => x.promise),index.ts:768); - 瀑布定时器:
waitUntilWaterfall毫秒后reject(new WaterfallNextEngineSignal())(index.ts:771-778)——这就是"该加派下一个引擎了"的信号; - 总超时定时器:到点抛
ScrapeJobTimeoutError(index.ts:780-799)。
"合理时间"怎么算?waitUntilWaterfall = getEngineMaxReasonableTime(meta, engine) + SCRAPEURL_ENGINE_WATERFALL_DELAY_MS(index.ts:726)。前者是每个引擎的"最大合理耗时"(engines/index.ts:943,按引擎查表),后者是可配置的额外延迟(默认 0,config.ts:156)。
4.3 赢家如何"狙杀"其余引擎
竞速一旦有引擎成功,Promise.race 返回赢家,跳出循环。接着:
// scrapeURL/index.ts:923 —— 真实源码
snipeAbortController.abort();
这个 snipeAbort(定义在 index.ts:699-706,tier 为 "engine")被作为子 abort 传给每个 scrapeURLLoopIter(index.ts:507 的 meta.abort.child(snipeAbort))。赢家 abort 它,还在跑的引擎就会收到取消信号,抛出 EngineSnipedError(error.ts:587)——名字很形象:被冷枪狙掉了。这样就不会浪费资源让落败引擎继续跑完。
4.4 失败引擎如何被剔除、如何触发加派
每个引擎的尝试都包在 WrappedEngineError(index.ts:631)里,好让 catch 块知道是哪个引擎出的错。竞速的 catch(index.ts:802-911)是整段最密的分支逻辑,按错误类型分流:
| 错误类型 | 处理 | 代码 |
|---|---|---|
EngineError / IndexMissError / 引擎级超时 | 记日志,剔除该引擎,继续竞速 | index.ts:809-874 |
AddFeatureError/RemoveFeatureError/SiteError/SSLError/PDF/Document antibot… | 直接向上抛(交给 §5 外层处理) | index.ts:825-842 |
x-twitter 引擎失败 | 视为致命,直接抛 | index.ts:804 |
WaterfallNextEngineSignal | 不是真错误:break 去加派下一个引擎 | index.ts:887 |
ScrapeJobTimeoutError | 总超时,直接抛 | index.ts:890 |
剔除失败引擎后有个细节(index.ts:876-879):如果赛道上一个在跑的引擎都不剩了(enginePromises.length === 0),就 break 出内层 race 循环,回到外层 while 去 shift 下一个引擎。否则继续 race 剩下的。
如果所有引擎都试完还是 result === null:lockdown 模式抛 LockdownMissError,否则抛 NoEnginesLeftError(index.ts:925-933)。
4.5 内层:一次尝试的"成功判定启发式"
scrapeURLLoopIter(index.ts:502)负责判断一次抓取算不算成功。这不是简单看状态码——反爬页面可能返回 200 但内容是空的,也可能返回 403 但其实是代理不够强。
它先把结果转成 markdown 做"内容量"检查(checkMarkdown,index.ts:536-581;大 HTML >300KB 会跳过转换以免拖慢,index.ts:538),再算三个"成功因子":
| 因子 | 含义 | 代码 |
|---|---|---|
isLongEnough | 转出的 markdown 去空白后长度 > 0(有实际内容) | index.ts:584 |
isGoodStatusCode | 状态码 2xx 或 304 | index.ts:585 |
isLikelyProxyError | 状态码是 401/403/429(疑似被反爬挡) | index.ts:589 |
判定逻辑有两条特别值得记的暗线:
① 代理不够强 → 加 stealthProxy 重来。 如果疑似代理错误、且用户用的是 proxy: "auto"、且还没上过隐身代理,就抛 AddFeatureError(["stealthProxy"])(index.ts:593-609)。这不是"失败",而是"换更强的代理再试一次"的信号,交给 §5 外层加 flag 重跑。
② 内容够长 或 状态码不好 → 就算成功。 这条判据初看反直觉:
// scrapeURL/index.ts:614 —— 真实源码
if (isLongEnough || !isGoodStatusCode) {
// 判成功:返回这次结果
return engineResult;
} else {
throw new EngineUnsuccessfulError(engine); // 判失败,换引擎
}
作者在 index.ts:611 留了注释坦白这块"很难办":状态码坏时不能只看文本(错误页文本可能很短却是"真结果")。于是逻辑变成:只要抓到了实质内容(isLongEnough),或者状态码本身就是坏的(说明这就是服务器的真实响应,没必要再换引擎去撞同一堵墙),都算"成功"返回;只有"状态码好但内容空"这种最可疑的组合才判失败换引擎。
5. 错误驱动的重试:外层 while + ScrapeRetryTracker
5.1 它要解决的小问题
竞速那层(§4)只会在候选引擎之间换。但有些失败不是"换个引擎"能解决的,而是要改变抓取策略本身——比如"该开隐身代理了""这个 PDF 被反爬挡了,得先预取""我误判了能力,该去掉某个 flag"。这类调整需要改 featureFlags 然后把整个竞速再跑一遍。
这就是 scrapeURL 里那个 while (true) 外循环干的事(index.ts:1189)。它是错误驱动的:正常情况下 scrapeURLLoop 成功就 break;某些特定错误则被 catch,改完 meta 后 continue 重跑。
5.2 外循环处理哪些错误
// scrapeURL/index.ts:1189 —— 真实源码骨架
while (true) {
try {
result = await scrapeURLLoop(meta);
break; // 成功,收工
} catch (error) {
if (error instanceof AddFeatureError && ...) {
retryTracker.record("feature_toggle", error);
meta.featureFlags = new Set([...meta.featureFlags].concat(error.featureFlags));
// 若带了 pdfPrefetch/documentPrefetch,也一并挂上
} else if (error instanceof RemoveFeatureError && ...) {
retryTracker.record("feature_removal", error);
meta.featureFlags = new Set([...meta.featureFlags].filter(x => !error.featureFlags.includes(x)));
} else if (error instanceof PDFAntibotError && ...) {
// ...
} else {
throw error; // 不认识的错误,向上抛
}
// 没 break 也没 throw → 回到 while 顶端,用新 flags 重跑竞速
}
}
四类"可恢复"错误及其调整动作:
| 错误 | 外层怎么调整 | 代码 |
|---|---|---|
AddFeatureError | 把请求的 flag 加进 featureFlags(如 stealthProxy);带预取就挂上 | index.ts:1194-1213 |
RemoveFeatureError | 把误判的 flag 从 featureFlags 移除 | index.ts:1214-1229 |
PDFAntibotError | 去掉 pdf flag,改走 chrome-cdp 预取;若已预取过还被挡则放弃 | index.ts:1230-1247 |
DocumentAntibotError | 去掉 document flag,同上逻辑 | index.ts:1248-1265 |
| 其它 | throw,交给最外层错误分类(§7) | index.ts:1266 |
注意 PDF/Document antibot 的放弃条件(index.ts:1234):如果 meta.pdfPrefetch !== undefined——即已经预取过一次还是被挡——就不再重试,直接抛错。这正是 §3.4 里三态语义的用武之地。
还有个前提:AddFeatureError / RemoveFeatureError 只在没有强制引擎(或强制引擎是数组)时才处理(index.ts:1196),因为强制单一引擎时改 flag 没意义。
5.3 ScrapeRetryTracker:防止无限重试
改 flag 重跑很好,但要防死循环——比如加了代理还失败、又要求加代理……ScrapeRetryTracker(retryTracker.ts:18)就是这道熔断闸。
每次外层调整都 record(reason, error)(retryTracker.ts:36):累加计数,任何一类超限就抛 ScrapeRetryLimitError。限额从 config 读入(index.ts:1176):
| 计数维度 | 配置项 | 默认值 | 位置 |
|---|---|---|---|
| 总尝试次数 | SCRAPE_MAX_ATTEMPTS | 6 | config.ts:159 |
| 加 feature 次数 | SCRAPE_MAX_FEATURE_TOGGLES | 3 | config.ts:160 |
| 移除 feature 次数 | SCRAPE_MAX_FEATURE_REMOVALS | 3 | config.ts:161 |
| PDF 预取次数 | SCRAPE_MAX_PDF_PREFETCHES | 2 | config.ts:162 |
| Document 预取次数 | SCRAPE_MAX_DOCUMENT_PREFETCHES | 2 | config.ts:163 |
record 里先累加 totalAttempts 并对 maxAttempts 做全局熔断(retryTracker.ts:37-40),再按具体 reason 累加分项、对分项上限熔断(retryTracker.ts:42-69)。触发时 throwLimit 会把统计快照塞进错误(retryTracker.ts:79-88),方便排障看清"到底重试了几次、哪类超了"。