跳到主要内容

抓取内核: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引擎竞速:排好回退列表,瀑布式并发,选出赢家,跑后处理与转换scrapeURL/index.ts:643
scrapeURLLoopIter单引擎一次尝试 + 成功判定启发式scrapeURL/index.ts:502

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:257

"usually" 是关键:它几乎不可变,只有两处例外——日志数组会追加,以及外层重试要改 featureFlags(§5 会看到)。

Meta 的类型定义在 scrapeURL/index.ts:116(export type Meta),核心字段:

字段装什么
id / url / rewrittenUrl抓取 ID、原始 URL、重写后的 URL
options用户抓取选项(formats、proxy、timeout、actions…),已填默认值
internalOptions内部选项(teamId、forceEngine、zeroDataRetention、uploadedFile…)
featureFlags由选项翻译出的能力集合(见 §3.2)
abortAbortManager,分层超时/取消(scrape 层、engine 层)
pdfPrefetch / documentPrefetch / fetchPrefetch预取的文件(见 §3.4)
logger / costTracking / mock日志器、成本追踪、录制回放

组装它的是 buildMetaObject(scrapeURL/index.ts:327)。

3.2 buildFeatureFlags:把"请求选项"翻译成"能力需求"

这一步是引擎选择的前提。 用户说的是"我要截图 / 我要执行动作 / 这是个 PDF",但引擎系统认的是一套统一的能力标签 FeatureFlagbuildFeatureFlags(scrapeURL/index.ts:162)就是这个翻译器:读 options,产出一个 Set<FeatureFlag>

翻译规则举例(节选):

请求里出现翻出的 FeatureFlag代码
actions 非空actionsindex.ts:175
screenshot 格式(整页)screenshot@fullScreenindex.ts:179-184
waitFor !== 0waitForindex.ts:199
proxy === "stealth"/"enhanced"stealthProxyindex.ts:223
URL 以 .pdf 结尾pdfindex.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.assignoptions / 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:646meta.rewrittenUrl ?? meta.url)。

3.4 上传文件预取:直接投喂,跳过网络抓取

如果调用方通过 /v2/parse 上传了文件(internalOptions.uploadedFile),就没必要"抓"了——文件已经在手里。buildMetaObject 会把它写到临时文件,并塞进对应的 *Prefetch 字段,让下游引擎当作"已经抓到"来处理。

分派逻辑按文件类型走三条路(scrapeURL/index.ts:376-420):

判断函数命中写入字段
isPdfUpload(index.ts:288)PDFpdfPrefetch
isDocumentUpload(index.ts:298)DOCX/XLSX/ODT/RTF…documentPrefetch
isHtmlUpload(index.ts:317)HTML/XHTMLfetchPrefetch(直接存 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 竞速的骨架

竞速在 scrapeURLLoopwhile (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.raceindex.ts:767,同时 race 三类 promise:

  1. 所有在跑引擎的 promise(enginePromises.map(x => x.promise),index.ts:768);
  2. 瀑布定时器:waitUntilWaterfall 毫秒后 reject(new WaterfallNextEngineSignal())(index.ts:771-778)——这就是"该加派下一个引擎了"的信号;
  3. 总超时定时器:到点抛 ScrapeJobTimeoutError(index.ts:780-799)。

"合理时间"怎么算?waitUntilWaterfall = getEngineMaxReasonableTime(meta, engine) + SCRAPEURL_ENGINE_WATERFALL_DELAY_MS(index.ts:726)。前者是每个引擎的"最大合理耗时"(engines/index.ts:814,按引擎查表),后者是可配置的额外延迟(默认 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:507meta.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 循环,回到外层 whileshift 下一个引擎。否则继续 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 或 304index.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,改完 metacontinue 重跑。

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_ATTEMPTS6config.ts:159
加 feature 次数SCRAPE_MAX_FEATURE_TOGGLES3config.ts:160
移除 feature 次数SCRAPE_MAX_FEATURE_REMOVALS3config.ts:161
PDF 预取次数SCRAPE_MAX_PDF_PREFETCHES2config.ts:162
Document 预取次数SCRAPE_MAX_DOCUMENT_PREFETCHES2config.ts:163

record 里先累加 totalAttempts 并对 maxAttempts全局熔断(retryTracker.ts:37-40),再按具体 reason 累加分项、对分项上限熔断(retryTracker.ts:42-69)。触发时 throwLimit 会把统计快照塞进错误(retryTracker.ts:79-88),方便排障看清"到底重试了几次、哪类超了"。


6. robots.txt 前置检查

在进入重试外循环之前,scrapeURL 可能先做一次合规检查:这个 URL 是否被目标站点的 robots.txt 允许抓取。

是否检查shouldCheckRobots(shouldCheckRobots.ts:7)决定,两条规则:

  • options.lockdown 为真 → 一律不查(shouldCheckRobots.ts:11)。原因很讲究(见该文件顶部注释):查 robots.txt 本身就是一次对目标域名的外部请求,而 lockdown 的承诺是"绝不对外发请求",所以这个不变量是载荷性的,被特意抽到独立小文件里以便单测。
  • 否则看团队开关 teamFlags.checkRobotsOnScrape(shouldCheckRobots.ts:14)。

怎么查(index.ts:1100-1173):若 URL 本身就是 /robots.txt 则跳过;否则优先复用 crawl 里已缓存的 robots(index.ts:1116-1119),没有再 fetchRobotsTxt 拉取(lib/robots-txt)。用 createRobotsChecker + isUrlAllowedByRobots 判定;不允许就抛 CrawlDenialError(index.ts:1149)。

一个宽容设计:拉取 robots.txt 失败时默认放行(index.ts:1151-1162,catch 里除了 CrawlDenialError 都吞掉并记 debug 日志)——即"取不到规则就不拦你",避免因目标站点 robots 端点抽风而误伤正常抓取。


7. 收尾:组装 Document + 交给转换流水线

竞速选出赢家后(meta.winnerEngine = result.engine,index.ts:946),scrapeURLLoop 做三件收尾事:

① 后处理器(postprocessors)。 遍历 postprocessors,对满足 shouldRun 的逐个 run(index.ts:952-980)。目前注册的只有 youtubePostprocessor(postprocessors/index.ts:14)。后处理器EngineScrapeResultEngineScrapeResult,是"引擎结果层"的加工;失败只记 warn 不中断(index.ts:971)。

② 组装成 Document 把引擎结果的各字段(markdown / rawHtml / json / screenshot / actions…)和一堆 metadata(sourceURL、statusCode、contentType、proxyUsed、缓存命中状态…)拼成 Document 对象(index.ts:982-1018)。若赢家引擎有不支持的能力,还会往 document.warning 追加提示(index.ts:1020-1030)。

③ 交给转换流水线。 最后一步:

// scrapeURL/index.ts:1033 —— 真实源码
document = await executeTransformers(meta, document);

executeTransformers(transformers/index.ts:639)按 transformerStack(transformers/index.ts:613)顺序跑一串转换器——抽链接、抽图片、抽元数据、LLM 抽取、生成 markdown/json/summary、送索引等。这一层是第 03 章的主题,本章到"把 Document 交出去"为止。

成功路径最终返回 { success: true, document, unsupportedFeatures, dataLayer }(index.ts:1045)。

最外层的错误分类

scrapeURL 的最外层 catch(index.ts:1319-1460)把冒上来的各种错误归类打标,填进 span 属性 scrape.error_type,并按类型记不同级别日志。典型分类(节选):

错误errorType代码
NoEnginesLeftError所有引擎都失败index.ts:1362
SiteError / SSLError / DNSResolutionError站点/证书/DNS 问题index.ts:1378-1429
ScrapeRetryLimitError重试超限(带 stats)index.ts:1430
AbortManagerThrownError超时/取消:抛出内层真实错误index.ts:1436-1438
其它未知上报 Sentry(带 ZDR 检查)index.ts:1440-1447

这些错误类几乎都定义在 scrapeURL/error.ts,多数继承 TransportableError(可序列化跨进程传递,带对用户友好的详细说明文案)。失败路径统一返回 { success: false, error }(index.ts:1456),而非抛出——让调用方拿到结构化失败结果。


8. 巧妙之处(可借鉴的技术)

  • 不可变 Meta + 极少数受控可变点。 把全部上下文收进一个"几乎不可变"对象,唯一被外层重试改动的是 featureFlags。既享受 OOP 的可追溯性,又避免状态四处泄漏(设计意图见 index.ts:257 注释)。

  • 瀑布式并发而非串行回退。 Promise.race([...引擎, 瀑布定时器, 总超时])(index.ts:767)让"慢引擎不拖累、快引擎能抢跑",赢家用 EngineSnipedError(index.ts:923)狙杀其余,兼顾速度与资源。

  • 把"错误"当"控制流信号"。 WaterfallNextEngineSignalAddFeatureErrorEngineSnipedError 都是"披着异常外衣的信号":用 throw 把内层的判定结果一路冒泡到该处理它的那一层,让竞速循环和重试外循环各管一段,分层清晰。

  • 两层重试各司其职。 内层竞速换引擎,外层 while 换策略(featureFlags);ScrapeRetryTracker(retryTracker.ts:18)按维度分别熔断,防止"加代理→失败→再加代理"式死循环。

  • 成功判定的反直觉裁决。 isLongEnough || !isGoodStatusCode 判成功(index.ts:614):承认"坏状态码就是真实响应",不去无谓地换引擎撞同一堵墙,只对"200 却空白"这种最可疑组合判失败。

  • 合规不变量抽成独立可测文件。 shouldCheckRobots(shouldCheckRobots.ts)特意独立,以保证"lockdown 绝不发外部请求"这条承诺可被单测锁住。


9. 边界与局限

  • 单 URL、单页。 scrapeURL 只处理一个 URL 的一次抓取;多 URL / 爬站是上层 crawl 队列的事(见 第 04 章)。README 也明说不再支持一次多 URL。

  • actions 依赖 Fire Engine。 若请求带 actions 但没有引擎支持,直接抛 ActionsNotSupportedError(index.ts:684-693);self-hosted 无 Fire Engine 时这类能力不可用。

  • ZDR(零数据保留)模式禁用部分能力。 截图等需要落盘的能力在 ZDR 下会抛 ZDRViolationError(index.ts:654-676)。

  • 总超时硬顶。 默认最长 5 分钟(index.ts:789-797 的 300000ms 兜底),超时抛 ScrapeJobTimeoutError

  • 引擎打分/回退细节不在本章。 buildFallbackList 怎么按能力打分排序,是 第 02 章;转换器如何把 HTML 变 LLM-ready 数据,是 第 03 章


10. 代码地图(导航索引)

主题文件路径符号名
编排总入口apps/api/src/scraper/scrapeURL/index.tsscrapeURL
引擎竞速循环apps/api/src/scraper/scrapeURL/index.tsscrapeURLLoop
单引擎尝试 + 成功判定apps/api/src/scraper/scrapeURL/index.tsscrapeURLLoopIter
组装不可变上下文apps/api/src/scraper/scrapeURL/index.tsbuildMetaObject
上下文类型apps/api/src/scraper/scrapeURL/index.tsMeta(type)
选项→能力翻译apps/api/src/scraper/scrapeURL/index.tsbuildFeatureFlags
上传文件类型判断apps/api/src/scraper/scrapeURL/index.tsisPdfUpload / isDocumentUpload / isHtmlUpload
URL 伪重定向apps/api/src/scraper/scrapeURL/lib/rewriteUrl.tsrewriteUrl
robots 前置门槛apps/api/src/scraper/scrapeURL/shouldCheckRobots.tsshouldCheckRobots
重试熔断apps/api/src/scraper/scrapeURL/retryTracker.tsScrapeRetryTracker
竞速信号/错误类apps/api/src/scraper/scrapeURL/error.tsWaterfallNextEngineSignal / EngineSnipedError / AddFeatureError / RemoveFeatureError / PDFAntibotError
引擎最大合理耗时apps/api/src/scraper/scrapeURL/engines/index.tsgetEngineMaxReasonableTime
引擎回退列表(见第 02 章)apps/api/src/scraper/scrapeURL/engines/index.tsbuildFallbackList
后处理器apps/api/src/scraper/scrapeURL/postprocessors/index.tspostprocessors
转换流水线入口(见第 03 章)apps/api/src/scraper/scrapeURL/transformers/index.tsexecuteTransformers
重试/超时配置项apps/api/src/config.tsSCRAPE_MAX_ATTEMPTS