跳到主要内容

引擎系统:能力打分与回退列表

30 秒导读: 一个网页可能是普通 HTML、可能是重 JS 的 SPA、可能是 PDF、可能是被反爬墙挡住的站点。 Firecrawl 把「用什么后端去抓」抽象成一个统一的 Engine,再根据「这次请求要哪些能力」给每个引擎 打分排序,算出一条从最优到兜底的回退列表。上层抓取循环只管顺着列表挨个试——这就是 Firecrawl 号称高覆盖率的工程底座。本章只讲引擎怎么注册、怎么挑;抓取主循环怎么执行这条列表 见 01-scrape-pipeline


1. 这是什么(零基础也能懂)

一句话定义: 引擎系统 = 一套「抓网页的后端」的统一抽象 + 一个「按需选后端」的挑选算法。

它解决什么问题? 网页千奇百怪,没有一种抓法能通吃:

网页/场景单一抓法的困境
静态博客用重型浏览器抓也行,但慢、贵、浪费
React/Vue 单页应用纯 HTTP fetch 抓回来是空壳,必须跑浏览器渲染
PDF / Word 文档根本不是 HTML,要专门的解析器
有反爬墙的站点普通请求被 403,得换隐身代理(stealth proxy)
刚抓过的页面再抓一遍是浪费,应该走缓存(index)

Firecrawl 的答案: 不赌单一方案,而是养一群专才引擎,每个引擎声明「我支持哪些能力、我有多好用」, 然后按每次请求的真实需求动态排出一条回退列表。抓失败了就自动降到下一个,直到成功或列表耗尽。

一句话直觉/类比: 像医院分诊台。病人(URL)来了,先看症状(要截图?要 PDF?被墙了?), 再从一排科室(引擎)里挑出最对症的排在前、能兜底的排在后,让病人依次去看,第一个治好就走。

用起来什么样(引擎系统对外的样子): 你不会直接调某个引擎。你给 /v1/scrape 传选项 (要不要 screenshotproxy: "stealth"、URL 是不是 .pdf……),引擎系统把这些翻译成能力需求, 自动决定「先 index 查缓存 → 没有就 fire-engine 浏览器渲染 → 再不行 fetch 兜底」这样一条链。

本章要拆开的就是这个「自动决定」的黑盒。核心全在一个文件: apps/api/src/scraper/scrapeURL/engines/index.ts


2. 顶层全景(它大概怎么转)

这一节先给你一张「大盘」:引擎系统由四张并行的登记表 + 一个挑选算法 + 一个分发器组成。

2.1 五个部件,各管一摊

部件干什么engines/index.ts
Engine 联合类型枚举所有引擎的字符串标识(如 "fetch""fire-engine;chrome-cdp;stealth"):42-57
engines[] 清单按环境变量条件启用的可用引擎数组:74-92
四张登记表每个引擎的 handler / 超时估算 / 能力矩阵 / 质量分:177-548
buildFallbackList()挑选算法:输入需求,输出排好序的回退列表:577-785
scrapeURLWithEngine()分发器:拿引擎名找到 handler 真正去抓:787-812

「四张登记表」是关键设计:同一个 Engine 名字,分别在四张以引擎名为键的对象里登记它的 handler、超时、能力、质量。类型系统用 { [E in Engine]: ... } 强制每张表都覆盖每个引擎, 漏一个都编译不过。

2.2 主线走一遍(高层,不进代码)

从「一次抓取请求」到「拿到一条引擎列表」,数据这样流:

一次 scrape 请求(URL + options)

[01 抓取循环] 先把 options 翻译成 featureFlags(要哪些能力)
│ buildFeatureFlags() scrapeURL/index.ts:162

buildFallbackList(meta) ← 本章主角

┌───────────────────────┼───────────────────────────────┐
│ ① 特殊短路 │ ② 组装候选引擎 │ ③ 打分排序
│ data-layer 命中? │ 按环境变量过的 engines[] │ 能力门槛过滤
│ → 直接只返回它 │ + lockdown/index-only 收窄 │ + 质量分兜底排序
│ │ + wikipedia/x-twitter 域名特判│
└───────────────────────┴───────────────────────────────┘


[{ engine, unsupportedFeatures }, ...] ← 从最优到兜底

[01 抓取循环] 顺着列表:scrapeURLWithEngine(meta, engine)
│ engines/index.ts:787

engineHandlers[engine](meta) → 真正去抓

怎么读这张图: 左到右是「先粗筛、再排序」。buildFallbackList算出列表;真正 挨个执行、失败降级的循环在 01。本章聚焦中间那个方框。

记住两个核心概念,后面反复用到:

  • FeatureFlag(能力标志): 「这次请求需要的能力」,如 screenshotpdfstealthProxy。 由上层从 options 推导(:94-111 是全集)。
  • Engine(引擎): 「能满足能力的后端」。每个引擎在能力矩阵里声明支持哪些 FeatureFlag

挑选算法本质就是一句话:用请求要的 FeatureFlag,去匹配每个 Engine 声明支持的 FeatureFlag, 匹配得多的排前面。


3. 引擎清单:有哪些引擎、何时启用

3.1 Engine 联合类型 —— 所有引擎的名字

引擎标识是带分号的字符串,分号后是「变体修饰」。同一个底层后端可以有多个变体:

// engines/index.ts:42-57 —— Engine 联合类型(节选)
export type Engine =
| "data-layer"
| "fire-engine;chrome-cdp" // 浏览器渲染(Chrome via CDP)
| "fire-engine(retry);chrome-cdp" // 同上,但是「重试」变体
| "fire-engine;chrome-cdp;stealth" // 同上 + 隐身代理
| "fire-engine;tlsclient" // 轻量 TLS 客户端(不跑浏览器)
| "fire-engine;tlsclient;stealth"
| "playwright" | "fetch" | "pdf" | "document"
| "index" | "index;documents" // 缓存命中
| "wikipedia" | "x-twitter"; // 域名特化

读法: fire-engine;<engine>;<变体>fire-engine 是外部抓取服务的统称;chrome-cdp / tlsclient 是它内部两种抓法;stealth / (retry) 是叠加的修饰。这些字符串既是类型, 也是四张登记表的,还是日志里能直接 grep 的标识

3.2 engines[] —— 按环境变量条件启用

不是所有引擎都随时在线。engines[]布尔开关 + 展开语法把「配了才启用」的引擎拼进来:

// engines/index.ts:59-92 —— 条件启用(节选)
const useFireEngine = config.FIRE_ENGINE_BETA_URL !== "" && ... !== undefined;
const usePlaywright = config.PLAYWRIGHT_MICROSERVICE_URL !== "" && ...;
// useWikipedia 需要企业版账号密码;useXTwitter 需要 XAI_API_KEY 或开了 DB 鉴权

const engines: Engine[] = [
...(useXTwitter ? ["x-twitter" as const] : []),
...(useWikipedia ? ["wikipedia" as const] : []),
...(useIndex ? ["index" as const, "index;documents" as const] : []),
...(useFireEngine ? [ /* 6 个 fire-engine 变体 */ ] : []),
...(usePlaywright ? ["playwright" as const] : []),
"fetch", "pdf", "document", // ← 这三个永远在线,是最终兜底
];

几个要点:

  • fetch / pdf / document 无条件在列——它们不依赖外部服务,是自托管(self-hosted) 部署下也能跑的保底方案。
  • useIndexconfig.INDEX_DATABASE_URL 是否配置决定(services/index.ts:170)——没有索引库就没有缓存引擎。
  • data-layer 不在 engines[]。它是一条独立短路,只在 buildFallbackList 开头被单独判断(见 §5.1)。
  • 自托管测试环境下,即使 useFireEngine 为假,只要提供了 mock,buildFallbackList 会临时把 fire-engine 变体注入候选(:617-627),方便离线测试。

4. 能力矩阵:FeatureFlag × 引擎能力与质量分

这是引擎系统的「知识库」:每个引擎声明支持哪些能力、以及有多好用。挑选算法全靠它。

4.1 FeatureFlag 的优先级(需求有多重要)

不是每个能力一样重。featureFlagOptions 给每个 FeatureFlag 一个 priority,数值越大越「非它不可」:

FeatureFlagpriority含义
pdf / document / audio / video100内容类型硬需求——错了引擎就是抓不到
atsv / useFastMode90强特化路径
actions20需要点击/滚动等浏览器动作
stealthProxy / branding20反爬绕过 / 品牌抽取
screenshot / screenshot@fullScreen10截图
location / mobile / skipTlsVerification / disableAdblock10普通调节项
waitFor1几乎人人支持,不构成区分度

源码:featureFlagOptions,engines/index.ts:115-136。这些 priority 是后面能力门槛排序的燃料。

4.2 引擎能力矩阵 engineOptions

每个引擎登记两样东西(engines/index.ts:223-548):

  1. features —— 一张「支持哪些 FeatureFlag」的布尔表。
  2. quality —— 一个质量分,定义同等匹配下引擎的默认优先顺序

质量分从高到低(节选,每行 file:line):

引擎quality定位
data-layer2000 (:252)结构化数据层(短路命中最优)
x-twitter1500 (:546)X/Twitter 域名特化
index1000 (:273)缓存,注释明说「应总是先试」
wikipedia500 (:525)维基特化(低于 index 好让缓存先命中)
fire-engine;chrome-cdp50 (:294)浏览器渲染主力
fire-engine(retry);chrome-cdp45 (:315)重试变体
playwright20 (:399)自托管浏览器方案
fire-engine;tlsclient10 (:420)轻量 TLS 抓取
fetch5 (:462)最朴素的 HTTP 兜底
index;documents-1 (:336)缓存里的文档变体
fire-engine;chrome-cdp;stealth-2 (:357)隐身变体
fire-engine(retry);chrome-cdp;stealth-5 (:378)
fire-engine;tlsclient;stealth-15 (:441)
pdf / document-20 (:483 / :504)专才解析器

负质量分是什么意思? 源码注释点破了:

"Negative quality numbers are reserved for specialty engines, e.g. PDF, DOCX, stealth proxies" —— engines/index.ts:229

也就是说:负分不是「差」,是「特化」。 PDF 引擎质量 -20,但当请求确实是 PDF 时它照样被选中—— 因为排序时能力匹配分(supportScore)排在质量分前面(见 §5.5)。负分只保证「没人要 PDF 时, PDF 引擎不会来抢普通网页的活」。stealth 变体同理:平时靠后待命,请求隐身时才被专门捞出来(§5.4)。

举一例读懂能力矩阵: index 引擎(:254-273)features.pdf: falsefeatures.screenshot: true, quality: 1000。意思是:缓存能返回截图、但不负责 PDF;它质量最高,所以只要能命中就优先走缓存, 省掉真正的抓取。而 index;documents(:317-336)features.pdf: truefeatures.document: truequality: -1——它是缓存里专供文档/PDF的变体,平时靠后,文档请求时才有用武之地。


5. buildFallbackList:核心挑选算法

这是全章的心脏(engines/index.ts:577-785)。它接收 meta(含 URL、options、算好的 featureFlags),吐出一条 { engine, unsupportedFeatures }[] 的回退列表。下面按执行顺序拆解。

buildFallbackList 六步流水:

① data-layer 短路 ──命中──▶ 直接返回 [data-layer],结束
│未命中

② 组装候选 _engines = [...engines, (测试时注入 fire-engine)]


③ 收窄候选 lockdown / agentIndexOnly → 只留 index 系
!shouldUseIndex → 剔除 index 系
wikipedia / x-twitter → 域名特判


④ 能力门槛 算 prioritySum → 阈值 = ⌊sum/2⌋
逐引擎算 supportScore,≥阈值才留下


⑤ 专属过滤 stealthProxy 请求 → 只留支持隐身的
存在正质量非 index 引擎 → 剔除负质量特化引擎


⑥ 排序 先按 supportScore 降序,再按 quality 降序
(engpicker 命中时给 tlsclient 加权)
branding 请求但无引擎支持 → 抛错


返回排好序的回退列表

5.1 第①步:data-layer 短路

请求进来先问一句:这个 URL 能走「数据层」吗? 能就直接返回,连后面全部跳过:

// engines/index.ts:583-606 —— 短路(节选)
if (
!meta.internalOptions.agentIndexOnly &&
(await canUseDataLayerForRequest({ url, formats, actions, headers, ... }))
) {
return [{ engine: "data-layer", unsupportedFeatures: new Set() }];
}

canUseDataLayerForRequest(lib/data-layer.ts:248)是一串严格的前置校验:必须开了 enrichBeta flag、配了 FIRE_ENGINE_BETA_URL没有 actions/headers/waitFor、非隐身、 格式受支持、且 URL 在受支持域名表内——全通过才走这条最快的结构化数据路径。任何一条不满足就 return false,回退到正常流程。data-layer 引擎实现见 engines/data-layer.ts:57(scrapeURLWithDataLayer), 它直接 POST 到 fire-engine 的 /v1/data-layer/scrape

5.2 第②③步:组装并收窄候选引擎

先复制一份 engines[],测试环境按需注入 fire-engine(:614-628)。然后按几个开关收窄:

lockdown / agentIndexOnly → 只走缓存。 这两种模式强制只用 index 系引擎,并写回 forceEngine 锁死顺序:

// engines/index.ts:630-649 —— 只走 index 的两条路(节选)
if (meta.options.lockdown) {
const indexEngines = useIndex ? ["index", "index;documents"] : [];
_engines.length = 0; _engines.push(...indexEngines);
meta.internalOptions.forceEngine = indexEngines;
} else if (meta.internalOptions.agentIndexOnly) {
/* 同上:只留 index 系 */
} else if (!shouldUseIndex(meta)) {
/* 反过来:把 index、index;documents 从候选里剔除 */
}

shouldUseIndex(meta)(:550-575)决定「这次能不能用缓存」——一堆否定条件:解析模式、 自定义截图设置、变更追踪、品牌抽取、非默认 PDF 页数、maxAge === 0、带了自定义 headers/actions/profile…… 任一命中就跳过缓存,把 index 系从候选剔除。这保证「需要新鲜/定制结果」时不会误吃陈缓存。

wikipedia 域名特判(带 50% 随机): 只有当 URL 是维基媒体域名且掷硬币过半时才保留 wikipedia 引擎:

// engines/index.ts:651-656
if (!isWikimediaUrl(meta.url) || Math.random() >= 0.5) {
/* 从候选里删掉 wikipedia */
}

这个 Math.random() 是有意的灰度分流:即便是维基页面,也只有一半流量走专用引擎,另一半 走通用引擎(便于对比效果)。isWikimediaUrlengines/wikipedia/index.ts:371

x-twitter 域名特判(独占): X/Twitter 域名则相反——一旦命中就清空其它所有引擎,只留 x-twitter; 非 X 域名则把 x-twitter 删掉:

// engines/index.ts:658-666
if (isXTwitterUrl(meta.url) && _engines.includes("x-twitter")) {
_engines.length = 0; _engines.push("x-twitter"); // 独占
} else if (!isXTwitterUrl(meta.url)) {
/* 删掉 x-twitter */
}

isXTwitterUrlengines/x-twitter/index.ts:299

5.3 第④步:能力门槛过滤(prioritySum / priorityThreshold)

现在进入打分核心。先把这次请求所有 FeatureFlag 的 priority 加起来,阈值取一半:

// engines/index.ts:668-672
const prioritySum = [...meta.featureFlags].reduce(
(a, x) => a + featureFlagOptions[x].priority, 0);
const priorityThreshold = Math.floor(prioritySum / 2);

然后逐个引擎算 supportScore = 它支持的、且本次请求需要的 FeatureFlag 的 priority 之和; 只有 supportScore >= priorityThreshold 的引擎才入选(:686-709):

// engines/index.ts:686-709 —— 逐引擎打分(节选)
for (const engine of currentEngines) {
const supportedFlags = /* 请求要的 flag 里,该引擎 features===true 的那些 */;
const supportScore = [...supportedFlags].reduce(
(a, x) => a + featureFlagOptions[x].priority, 0);
const unsupportedFeatures = /* 请求要、但该引擎不支持的 flag 集合 */;
if (supportScore >= priorityThreshold) {
selectedEngines.push({ engine, supportScore, unsupportedFeatures });
}
}

为什么用「一半」当阈值? 这是一道及格线:引擎不必满足全部需求,但至少要满足「一半的分量」。

  • 请求只要 pdf(priority 100):阈值 50。只有 features.pdf: true 的引擎(pdf、index;documents) supportScore=100 过线;fetch(supportScore 0)被淘汰。
  • 请求要 screenshot(10) + waitFor(1):sum 11,阈值 5。支持截图的引擎(supportScore≥10)过线, 只支持 waitFor 的(1 分)不够。

unsupportedFeatures 会一路带到返回值里——上层抓取循环用它知道「这个引擎抓完还缺哪些能力」, 从而决定要不要继续降级补齐(01 的职责)。

currentEngines 的取值:若前面设了 forceEngine(lockdown/agentIndexOnly),就用它;否则用 收窄后的 _engines(:679-684)。

5.4 第⑤步:stealthProxy 专属过滤

如果请求显式要隐身代理,必须防止「隐身引擎因负质量被后面的质量过滤误删」。所以这里先把候选 收缩到只剩支持隐身的引擎:

// engines/index.ts:718-725 —— 隐身专属过滤(节选)
if (meta.featureFlags.has("stealthProxy")) {
const stealthCapable = selectedEngines.filter(
x => !x.unsupportedFeatures.has("stealthProxy"));
if (stealthCapable.length > 0) {
selectedEngines = stealthCapable; // 有隐身引擎才收缩
}
}

注释(:711-717)讲得很清楚:stealth 引擎全是负质量,若不加这道保护,下一步的「质量>0 过滤」 会把它们全删掉、悄悄用一个普通正质量引擎顶替,等于无视了用户的隐身请求。而 if (length>0) 的兜底:万一没有任何隐身引擎合格(如自托管没配 fire-engine),就保留原列表,别让抓取彻底崩掉。

5.5 第⑤步(续):质量过滤 + 排序

正质量优先,负质量特化引擎只在无正质量可选时保留:

// engines/index.ts:727-735 —— 质量过滤(节选)
if (selectedEngines.some(
x => engineOptions[x.engine].quality > 0 && !x.engine.startsWith("index"))) {
selectedEngines = selectedEngines.filter(
x => engineOptions[x.engine].quality > 0);
}

注意那个 !startsWith("index"): 它让 index 系不算作「触发过滤的正质量引擎」。为什么? 因为 index(缓存,quality 1000)几乎总在列表里,若它触发过滤,会把所有负质量特化引擎(pdf、 文档、stealth 变体)一并删光——那 PDF 请求就没兜底了。这个巧妙的例外保证:缓存永远排第一, 但不会挤掉后面真正干活的特化引擎。

排序(仅当没有 forceEngine 时):先能力分、后质量分:

// engines/index.ts:753-757 —— 二级排序(节选)
selectedEngines.sort((a, b) =>
b.supportScore - a.supportScore // ① 能力匹配分高的在前
|| getEffectiveQuality(b.engine) - getEffectiveQuality(a.engine)); // ② 平手看质量

这就是「负质量特化引擎照样能被选中」的机制: 排序主键是 supportScore,不是 quality。 PDF 请求时,pdf 引擎 supportScore=100 稳压 chrome-cdp(supportScore=0)排到最前,-20 的质量分 根本轮不到比较。质量分只在能力匹配打平时当二级 tiebreak——决定「一堆同样合格的通用引擎里谁先上」。

getEffectiveQuality(:740-751)还藏了一个动态调整:当 engpicker(实验性的按域名选引擎服务, shouldPrioritizeTlsClient 来自 queryEngpickerVerdict,:608-612)判定某域名「tlsclient 够用」时, 给 fire-engine;tlsclient 临时 +50(升到 60,超过 chrome-cdp 的 50),把轻量抓法提到浏览器渲染之前, 省资源。

5.6 第⑥步:branding 硬校验

最后一道关:若请求要品牌抽取(branding)但没有任何入选引擎支持,直接抛错而非静默降级:

// engines/index.ts:764-782 —— branding 校验(节选)
if (meta.featureFlags.has("branding")) {
const hasCDPEngine = selectedEngines.some(f => !f.unsupportedFeatures.has("branding"));
if (!hasCDPEngine) {
// PDF/文档场景给出更具体的报错,否则统一提示需要 Chrome CDP
throw new BrandingNotSupportedError("Branding extraction requires Chrome CDP (fire-engine).");
}
}

因为品牌抽取依赖 CDP 的 executeJavascript(能力矩阵里只有 chrome-cdp 系 features.branding: true), 无法降级到别的引擎,所以宁可早失败并给清晰错误,也不返回一个注定抓不到品牌的列表。


6. scrapeURLWithEngine:把引擎名分发到实现

回退列表算好后,上层循环拿列表里的引擎名逐个调 scrapeURLWithEngine(engines/index.ts:787-812)。 它做三件事:查表拿 handler、按需补一个 flag、调用:

// engines/index.ts:787-812 —— 分发器(节选)
export async function scrapeURLWithEngine(meta, engine): Promise<EngineScrapeResult> {
const fn = engineHandlers[engine]; // 查 handler 表
const featureFlags = new Set(meta.featureFlags);
if (engineOptions[engine].features.stealthProxy // 若引擎天生走隐身
&& !engine.startsWith("index")) { // 但 index 不强制
featureFlags.add("stealthProxy"); // 补上 stealthProxy 标志
}
return await fn({ ...meta, logger, featureFlags });
}

那个「补 stealthProxy」的细节(:798-803):有些引擎(如 pdf、document 的 features.stealthProxy: true, 注释写着 "kinda...")本身就走隐身通道,于是分发时把标志补上,好让下游代码和日志如实反映 「这次用了隐身代理」。engineHandlers(:177-195)就是引擎名 → 实现函数的映射表:

引擎名handler 函数实现文件
data-layerscrapeURLWithDataLayerengines/data-layer.ts:57
index / index;documentsscrapeURLWithIndexengines/index/index.ts:229
fire-engine;chrome-cdp(4 变体)scrapeURLWithFireEngineChromeCDPengines/fire-engine/index.ts:276
fire-engine;tlsclient(2 变体)scrapeURLWithFireEngineTLSClientengines/fire-engine/index.ts:500
playwrightscrapeURLWithPlaywrightengines/playwright/index.ts:8
fetchscrapeURLWithFetchengines/fetch/index.ts:86
pdfscrapePDFengines/pdf/index.ts:57
documentscrapeDocumentengines/document/index.ts:108
wikipediascrapeURLWithWikipediaengines/wikipedia/index.ts:229
x-twitterscrapeURLWithXTwitterengines/x-twitter/index.ts:303

另有一张 engineMRTs 表(:197-221)给每个引擎登记 MRT(Max Reasonable Time,合理最大耗时) 估算函数,getEngineMaxReasonableTime(:814-822)据此给上层做超时预算(如 fetch 15s、pdf 120s、 index 1.5s)——引擎越轻,超时越短,失败越快降级。


7. 各引擎实现职责速览

本章不深挖每个引擎内部(那属于各自子系统),只给一张「这个目录在干嘛」的导航表:

引擎目录/文件职责关键点
engines/fire-engine/调外部 Fire Engine 服务做浏览器渲染scrape.ts 提交任务、checkStatus.ts 轮询;支持 actions/截图/隐身代理;performFireEngineScrape 轮询 + 错误分类 + gzip 解压(fire-engine/index.ts:52)
engines/fetch/最朴素的 HTTP fetch 抓取undici 直连,含字符集探测(header→meta charset 回退,fetch/index.ts:13);兜底引擎
engines/playwright/自托管 Playwright 微服务渲染给自托管、无 fire-engine 的部署用的浏览器方案(playwright/index.ts:8)
engines/index/缓存命中:读写索引scrapeURLWithIndex 走「本地缓存→负缓存→Postgres→GCS」多级查找(index/index.ts:229);sendDocumentToIndex 抓取成功后异步回写(:42)
engines/pdf/PDF 解析多级回退scrapePDF 路由 Rust 原生抽取 → FirePDF → RunPod MinerU(OCR)→ pdfParse(pdf/index.ts:57)
engines/document/Office 文档转 HTML@mendable/firecrawl-rsDocumentConverter,支持 docx/doc/odt/rtf/xlsx(document/index.ts:108)
engines/data-layer.ts结构化数据层短路POST 到 fire-engine 的 data-layer 端点,命中即返回(data-layer.ts:57)
engines/wikipedia/维基媒体企业 API 特化域名特判 + 50% 灰度(wikipedia/index.ts)
engines/x-twitter/X/Twitter 特化(独占)命中即清空其它引擎(x-twitter/index.ts)

所有引擎的返回都归一到同一个 EngineScrapeResult 结构(engines/index.ts:138-175):html / markdown / json / statusCode / screenshot / proxyUsed 等。这是整套抽象的关键—— 不管底层用浏览器还是 HTTP 还是 OCR,吐出来的形状一样,上层转换器(见 03-transformers) 才能统一处理。


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

  • 能力打分 > 硬编码规则。 不写「if PDF then use pdf engine」这种 if 山,而是让引擎声明能力、 用 supportScore 出谁合适。加新引擎只需填四张登记表,挑选逻辑零改动。(engineOptions :223)

  • supportScore 主排序、quality 次排序。 一句排序表达式(:753-757)同时实现了「对症的优先」 和「同等对症下用更好的」两层意图,让负质量特化引擎在需要时自然浮到最前、不需要时自然沉底。

  • 负质量 = 特化标记。 用一个数轴的负半轴专门圈出「专才引擎」,配合 !startsWith("index") 的 过滤例外(:727-735),优雅地表达「缓存永远第一,但别挤掉兜底特化引擎」。

  • stealthProxy 先收缩再过滤(:718-725)。 预判「负质量隐身引擎会被质量过滤误杀」,提前把 候选收缩到隐身集——一个典型的「防止后续步骤破坏用户显式意图」的防御性设计。

  • 域名特判两种范式。 wikipedia 用「50% 随机灰度」做 A/B,x-twitter 用「命中即独占」做强路由—— 同一处代码演示了两种截然不同的特化策略(:651-666)。

  • 四张平行登记表 + 映射类型。 { [E in Engine]: ... } 让编译器强制「每个引擎的 handler/超时/ 能力/质量都不能漏登记」,把「加了引擎忘了配」这类错误挡在编译期。


9. 边界与局限(诚实地说)

  • 本章只讲「挑引擎」,不讲「跑引擎」。 buildFallbackList算出列表;真正挨个执行、 失败降级、把 unsupportedFeatures 拿去补齐、以及超时/中止(abort)的主循环,都在 01-scrape-pipeline

  • 能力矩阵是人工维护的。 engineOptions 里每个 features 布尔和 quality 都靠人写对。 某引擎实际支持某能力、但矩阵里写了 false,它就永远不会被选去干那活——矩阵是唯一事实来源, 代码不会自检真实能力。

  • 质量分是相对魔数。 50、45、-2、-15 这些数字之间的间距是经验调出来的(engpicker 的 +50 加权 就依赖「50 能超过 chrome-cdp 的 50」这种精确关系,:744)。改动某个数要小心它和邻居的相对次序。

  • wikipedia 的 50% 随机意味着同一个维基 URL 两次请求可能走不同引擎,结果可能有细微差异—— 这是有意的灰度,不是 bug。

  • data-layer / engpicker 是 beta。 data-layer 短路要 enrichBeta flag、engpicker 要 __experimental_engpicker;默认部署走不到这两条路径(:583-612)。


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

grep 符号名比记行号更抗漂移。以下为本章涉及的关键锚点:

主题文件路径符号名
Engine 联合类型apps/api/src/scraper/scrapeURL/engines/index.tsEngine(:42)
条件启用的引擎清单同上engines(:74)、useFireEngine / usePlaywright(:59)
FeatureFlag 全集与优先级同上featureFlags(:94)、FeatureFlagfeatureFlagOptions(:115)
引擎能力矩阵与质量分同上engineOptions(:223)
handler / 超时登记表同上engineHandlers(:177)、engineMRTs(:197)
统一返回结构同上EngineScrapeResult(:138)
是否走缓存同上shouldUseIndex(:550)
挑选算法(核心)同上buildFallbackList(:577)
分发到实现同上scrapeURLWithEngine(:787)、getEngineMaxReasonableTime(:814)
FeatureFlag 推导(上游)apps/api/src/scraper/scrapeURL/index.tsbuildFeatureFlags(:162)
data-layer 前置校验apps/api/src/lib/data-layer.tscanUseDataLayerForRequest(:248)
index 全局开关apps/api/src/services/index.tsuseIndex(:170)、queryEngpickerVerdict(:532)
fire-engine 实现apps/api/src/scraper/scrapeURL/engines/fire-engine/index.tsscrapeURLWithFireEngineChromeCDP(:276)、scrapeURLWithFireEngineTLSClient(:500)、performFireEngineScrape(:52)
fetch 实现apps/api/src/scraper/scrapeURL/engines/fetch/index.tsscrapeURLWithFetch(:86)
index 缓存实现apps/api/src/scraper/scrapeURL/engines/index/index.tsscrapeURLWithIndex(:229)、sendDocumentToIndex(:42)
pdf 多级回退apps/api/src/scraper/scrapeURL/engines/pdf/index.tsscrapePDF(:57)
document 转换apps/api/src/scraper/scrapeURL/engines/document/index.tsscrapeDocument(:108)
data-layer 实现apps/api/src/scraper/scrapeURL/engines/data-layer.tsscrapeURLWithDataLayer(:57)
域名特判engines/wikipedia/index.tsengines/x-twitter/index.tsisWikimediaUrl(:371)、isXTwitterUrl(:299)

继续读: 引擎抓回原始数据后如何转成 LLM-ready → 03-transformers; 这条回退列表如何被执行与容错 → 01-scrape-pipeline;全景与阅读地图 → index