转换与抽取:从原始 HTML 到 LLM-ready 数据
30 秒导读: 引擎(02 章)只把页面抓回来,交出的是一坨
rawHtml(外加可能的截图、PDF 元数据)。本章讲拿到引擎结果之后的事:一条有序的「转换器(transformer)」流水线,把rawHtml逐步加工成用户真正想要的字段——干净的markdown、links、images、metadata,以及需要调 LLM 的json(结构化抽取)、summary、answer、changeTracking。跑哪些步、跳过哪些步,完全由用户请求里的formats决定。
1. 这是什么(零基础也能懂)
一句话定义: 转换器流水线是 Firecrawl 抓取内核的「后处理厨房」——引擎买回来的是生鲜食材(rawHtml),这里按菜单(formats)把它做成一道道成品菜(markdown / json / summary…)。
它解决什么问题? 一个网页的原始 HTML 对 LLM 来说几乎没法直接用:满是导航栏、脚本、广告、base64 内联图片。用户要的从来不是 HTML,而是:
- 「给我这页干净的 markdown」
- 「 按这个 JSON schema 把商品价格抽出来」
- 「这页跟上次比变了没有(changeTracking)」
- 「总结一下这页讲了啥」
每一种要求,都对应流水线里的一个(或几个)转换器。
用起来什么样? 用户在 /scrape 请求里给一个 formats 数组,比如:
// 用户请求(示意)
{
"url": "https://example.com/product/42",
"formats": [
{ "type": "markdown" },
{ "type": "json", "schema": { /* 商品 schema */ } }
]
}
流水线看到 markdown 就跑 HTML→markdown 那步,看到 json 就跑 LLM 抽取那步;没点的格式对应的步骤直接跳过、不浪费一分钱。
一句话直觉/类比: 把它想成一条工厂流水线——rawHtml 是进料口的原料,每个工位([转换器])只在「订单上点了我这道工序」时才动手,最后一个工位([coerceFieldsToFormats])做质检:订单没点的字段一律从成品里删掉。
本节不碰代码细节。记住一件事:转换器 = 格式驱动的后处理工位;引擎负责"抓到",转换器负责"变成能用的"。
2. 顶层全景(它大概怎么转)
2.1 入口:一个有序数组 + 一个循环
整条流水线的定义极其朴素——一个有序的函数数组 transformerStack,和一个顺序 await 每个函数的循环 executeTransformers。真源码在 apps/api/src/scraper/scrapeURL/transformers/index.ts。
// 示意,非源码:流水线的本质
type Transformer = (meta, document) => Document | Promise<Document>;
const transformerStack: Transformer[] = [ /* …有序的一串工位… */ ];
for (const transformer of transformerStack) {
document = await transformer(meta, document); // 顺序执行,后者吃前者的输出
}
真实实现见 transformers/index.ts:639 executeTransformers:它逐个 await transformer(_meta, document),并给每个工位记一条 [名字, 耗时ms] 用于 debug 日志(transformers/index.ts:643-657)。注意是串行——文件顶部一句 // TODO: allow some of these to run in parallel(transformers/index.ts:612)诚实地写着:目前没有并行。
2.2 数据在管道里怎么流
每个工位读 document 上的某些字段、写另一些字段。顺序不是随意的——后面的工位依赖前面工位的产物:
引擎结果 ──▶ document.rawHtml (+ 可能已有 markdown/screenshot…)
│
┌────────────┴─────────────────────────────────────────────┐
│ transformerStack(串行,从上到下) │
│ │
│ rawHtml ─▶[deriveHTML]─▶ html │
│ html ─▶[deriveMarkdown]─▶ markdown ◀── 很多步的"源文本" │
│ markdown─▶[cleanContent/redactPII] 就地改写 markdown │
│ html ─▶[deriveLinks/deriveImages]─▶ links / images │
│ rawHtml ─▶[deriveMetadata]─▶ metadata │
│ (命中缓存条件)─▶[sendToIndex] 把结果回写索引 │
│ markdown─▶[LLMExtract/Summary/Query]─▶ json/summary/answer│
│ markdown─▶[deriveDiff]─▶ changeTracking │
│ markdown─▶[removeBase64Images] 就地清理 │
│ │
│ 最后一站 ─▶[coerceFieldsToFormats] 删掉没点的字段 │
└──────────────────── ──────────────────────────────────────┘
│
▼
LLM-ready Document
怎么读这张图: 从上到下就是执行顺序。左边是"读哪个字段",右边是"写哪个字段"。
markdown是整条管道的枢纽——一旦生成,后面 LLM 抽取、总结、问答、变更追踪、PII 脱敏全都拿它当输入。所以deriveMarkdownFromHTML必须排在这些工位前面。
2.3 各工位一句话职责
transformerStack(transformers/index.ts:613-637)的完整顺序与职责:
| 顺序 | 转换器(符号名) | 干什么 | 触发条件 |
|---|---|---|---|
| 1 | deriveHTMLFromRawHTML | rawHtml → 清洗后的 html(去脚本/广告、抽正文) | 几乎总跑 |
| 2 | deriveMarkdownFromHTML | html → markdown(核心格式转换) | 请求任何依赖 markdown 的格式 |
| 3 | performCleanContent | 让 LLM 把 markdown 洗成"只留正文" | onlyCleanContent |
| 4 | performRedactPII | 用 fire-privacy 把 markdown 里的 PII 抹掉 | redactPII |
| 5 | deriveLinksFromHTML | 从 html 抽 links(并可能喂给索引器) | links 或索引采样命中 |
| 6 | deriveImagesFromHTML | 从 html 抽 images | images |
| 7 | deriveBrandingFromActions | 从 action 的 JS 返回里取品牌信息 | branding |
| 8 | deriveMetadataFromRawHTML | 从 rawHtml 解析 <title>/OG 等元数据 | 总跑 |
| 9 | fetchProduct / fetchMenu | 调外部服务抽商品/菜单结构 | product / menu |
| 10 | sendDocumentToIndex | 命中缓存条件时把结果回写索引 | useIndex 且满足缓存策略 |
| 11 | sendDocumentToSearchIndex | 采样上报到实时搜索索引 | useSearchIndex |
| 12 | performLLMExtractUnlessNativeJson | JSON schema 结构化抽取(核心) | json |
| 13 | performDeterministicJson | 用可缓存的确定性脚本抽 JSON(非 LLM) | deterministicJson |
| 14 | performSummary | LLM 生成整页摘要 | summary |
| 15 | performQuery | LLM 就整页做问答/高亮 | question/query/highlights |
| 16 | performAttributes | 抽指定 DOM 属性 | attributes |
| 17 | performAgent | FIRE-1 agent 智能抓取(v1 agent) | internalOptions.v1Agent.prompt |
| 18 | removeBase64Images | 把 markdown 里 base64 内联图替换成占位 | removeBase64Images |
| 19 | deriveDiff | 和上次抓取比对,生成 changeTracking | changeTracking |
| 20 | fetchAudio / fetchVideo | 抽音频/视频 | audio / video |
| 21 | coerceFieldsToFormats | 质检:删掉请求没点的所有字段 | 总跑 |
sendDocumentToIndex和sendDocumentToSearchIndex用扩展运算符条件性地拼进数组:...(useIndex ? [sendDocumentToIndex] : [])(transformers/index.ts:624-625)——功能开关关掉时,这俩工位在数组里根本不存在。
3. 核心原理(逐个机制,由浅入深)
3.1 一切的开关:hasFormatOfType
它要解决的小问题: 每个工位开头都要问一句「用户到底点没点我负责的格式?」。这个"问"必须便宜、类型安全,还要能拿到格式里的附加参 数(比如 screenshot 的 fullPage: true)。
思路: v2 的 formats 是一个对象数组,每项形如 { type: "markdown" } 或 { type: "screenshot", fullPage: true }。hasFormatOfType 就是在这个数组里按 type 找,找到返回整个对象、找不到返回 undefined——于是"存不存在"和"取参数"一步完成。
真实实现: lib/format-utils.ts:14 hasFormatOfType:
// 真源码 lib/format-utils.ts:14
export function hasFormatOfType<T extends FormatObject["type"]>(
formats: FormatObject[] | undefined,
type: T,
): Extract<FormatObject, { type: T }> | undefined {
if (!formats) return undefined;
const found = formats.find(f => f.type === type);
return found as Extract<FormatObject, { type: T }> | undefined;
}
泛型 Extract<FormatObject, { type: T }> 让返回值精确窄化到那个格式的类型——调用方拿到的对象上,fullPage/schema/modes 这些字段是有类型的,不用再 as。
它如何贯穿全流程: 几乎每个工位第一行都是它。举例:
deriveImagesFromHTML开头if (hasFormatOfType(meta.options.formats, "images"))(transformers/index.ts:262)——没点就直接原样返回。performSummary开头if (hasFormatOfType(meta.options.formats, "summary"))(llmExtract.ts:1283)。deriveMarkdownFromHTML一次问 8 个格式(markdown/changeTracking/json/summary/question/highlights/query/redactPII),任何一个点了就得生成 markdown(transformers/index.ts:92-119)。
关键细节: 还有个兄弟函数 includesFormat(lib/format-utils.ts:41)兼容 v1 的字符串数组(["markdown","html"])和 v2 的对象数组——历史包袱的适配层。
3.2 从 HTML 到 markdown:三级加速 + 兜底
它要解决的小问题: HTML→markdown 是最热的一步,几乎每个请求都要跑,而且大页面很慢。慢在哪?tiktoken/Turndown 这类纯 JS 转换是同步、跑在主线程的,一个几 MB 的页面能把 Node 事件循环卡住几十秒。
思路——分层加速,越靠前越快,失败就降级:
parseMarkdown(html)
│
├─① HTML_TO_MARKDOWN_SERVICE_URL 配了?
│ → 走 Go 微服务 HTTP(html-to-markdown-client),不占 Node 线程
│ 失败 ↓
├─② USE_GO_MARKDOWN_PARSER 开了?
│ → 走本地 Go 共享库(koffi FFI 异步调用)
│ 失败/库不存在 ↓
└─③ 兜底:Turndown(纯 JS)+ joplin-gfm 插件
最后统一过一遍 postProcessMarkdown(来自 @mendable/firecrawl-rs,Rust napi 加速)
真实实现: lib/html-to-markdown.ts:54 parseMarkdown:
- ①HTTP 服务:
if (config.HTML_TO_MARKDOWN_SERVICE_URL)时调convertHTMLToMarkdownWithHttpService(html-to-markdown.ts:71-79)。这个 client 的注释直说目的是"避免用重转换阻塞 Node 事件循环"(lib/html-to-markdown-client.ts头注释)。 - ②Go 共享库:
GoMarkdownConverter(html-to-markdown.ts:14)用koffi加载本地.so,convert.async(...)异步调用 Go 的ConvertHTMLToMarkdown(html-to-markdown.ts:41-51),不阻塞主线程。 - ③Turndown 兜底:前两级都不可用/失败,才
require("turndown")纯 JS 转(html-to-markdown.ts:123-148),并注册自定义inlineLink规则、启用 GFM 插件。 - 统一后处理:三条路径都会再过一遍
postProcessMarkdown(...)(html-to-markdown.ts:78/98/146)——它来自 Rust 包@mendable/firecrawl-rs,做统一的 markdown 清理。
deriveMarkdownFromHTML 里的几个巧妙分支(transformers/index.ts:76):
| 情况 | 行为 | 位置 |
|---|---|---|
| 8 种依赖格式一个都没点 | 直接返回,不生成 markdown | index.ts:107-119 |
| 引擎/后处理器已经给了 markdown | 跳过转换(尊重 youtube 等后处理器的产物) | index.ts:122-128 |
| content-type 是 JSON | 不转,直接把 rawHtml 包进 ```json 代码块 | index.ts:130-139 |
onlyMainContent 抽正文抽成空 | 回退:重跑 deriveHTMLFromRawHTML(关掉 onlyMainContent)再转一次 | index.ts:149-175 |
那个"正文抽空就回退全文"的分支很实用:有些页面正文启发式失灵会抽出空 markdown,与其返回空不如退回全页内容。