LLM Scraper 完整教程:从不同网页模板提取统一商品数据
配置 LLM Scraper、浏览器和结构化输出,处理三种商品 HTML 模板,保存合格与失败记录,并定位材料缺失、价格歧义和模型接口错误。
当同一类商品分散在不同网站时,维护多套 CSS 选择器可能比提取本身更费时间。LLM Scraper 可以让模型按同一份字段定义读取不同页面,但模型仍需要完整材料、明确的字段含义和应用侧检查。
本文用三种在正文中提供的商品 HTML 作为输入,提取名称、当前价格、币种和库存状态。它们的布局不同,却表达同样的数据。最终得到合格记录与失败记录两个 JSON 文件。示例会调用读者配置的模型服务;配置写在文章里,不需要作者或读者向他人提供密钥。
适合用在什么地方
如果一个站点只有稳定的两种模板,确定性选择器通常更容易解释和维护。LLM 提取更适合模板多、字段描述清楚、单页材料不大而且允许逐条核对的任务。
它不代替链接发现、分页调度或业务数据校验。本文只处理已经取得的页面材料。批量目录发现可以使用 Crawlee,但不要把“找到了 URL”与“模型读对了价格”合并成同一个成功状态。
安装与模型配置
准备 Node.js 24。在新目录安装 LLM Scraper 2 与其相关依赖,再下载 Chromium:
mkdir product-extraction
cd product-extraction
npm init -y
npm install llm-scraper@2.0.0 ai@6 @ai-sdk/openai@3 zod@4 playwright
npx playwright install chromium保留 package-lock.json。这套例子使用 AI SDK 6 的 Output.object,不能直接复制旧版只传 Zod Schema 的调用方式。官方主路径见 LLM Scraper 使用说明。
创建本地 .env,配置你自己的模型服务。下面使用 OpenAI 兼容 Chat Completions 接口;兼容服务不一定完整支持工具调用或结构化输出,需检查服务能力。
MODEL_BASE_URL=https://api.example.16yun.cn/v1
MODEL_API_KEY=replace-with-your-key
MODEL_NAME=replace-with-an-available-model上面的地址和模型名都是占位符,不是可访问的演示服务。将它们替换成实际服务提供的值;.env 不提交版本库。若使用其他协议或供应商,改用对应的 AI SDK provider,不能只更换 URL 就假设请求协议相同。
用三种材料明确“当前价格”
第一个模板用商品卡片,第二个用表格,第三个用描述列表。第二个模板额外包含原价,便于检验字段描述是否排除了它。
新建 extract.mts,先加入输入和字段定义:
import { writeFile } from 'node:fs/promises';
import { chromium } from 'playwright';
import { createOpenAI } from '@ai-sdk/openai';
import { Output } from 'ai';
import { z } from 'zod';
import LLMScraper from 'llm-scraper';
const inputs = [
{ id: 'card', html: '<main><h1>山谷红茶 500g</h1><p>当前价 CNY 12.90</p><p>有货</p></main>' },
{ id: 'table', html: '<main><h1>山谷红茶 500g</h1><table><tr><th>原价</th><td>CNY 16.00</td></tr><tr><th>当前价</th><td>CNY 12.90</td></tr><tr><th>库存</th><td>有货</td></tr></table></main>' },
{ id: 'description', html: '<main><h1>山谷红茶 500g</h1><dl><dt>现售金额</dt><dd>12.90</dd><dt>币种</dt><dd>CNY</dd><dt>库存状态</dt><dd>有货</dd></dl></main>' },
];
const schema = z.object({
name: z.string().min(1).describe('完整商品名称,不添加页面没有的型号'),
price: z.string().regex(/^\d+\.\d{2}$/).nullable()
.describe('当前公开售卖价格,只返回十进制金额;排除原价、运费和会员价,缺失时为 null'),
currency: z.enum(['CNY', 'USD', 'GBP']).nullable()
.describe('页面明确提供的币种,不根据语言或网站域名猜测'),
availability: z.enum(['in_stock', 'out_of_stock', 'unknown'])
.describe('由页面明确库存文案判断,无法判断时为 unknown'),
});三份 HTML 是教学材料,不是已部署站点,也不是模型实测输出。已知材料中的正确价格都是 12.90 CNY;这个事实来自正文输入,可以拿来核对模型是否把原价 16.00 误当成现价。
价格字段允许 null,是为了表达确实没有读到价格。接下来应用会将缺少价格的记录放入失败文件,而不是让模型为了满足必填字段猜一个数。
加上完整调用、保存和资源释放
将以下代码追加到同一个 extract.mts:
const { MODEL_BASE_URL, MODEL_API_KEY, MODEL_NAME } = process.env;
if (!MODEL_BASE_URL || !MODEL_API_KEY || !MODEL_NAME) {
throw new Error('请配置 MODEL_BASE_URL、MODEL_API_KEY、MODEL_NAME');
}
const provider = createOpenAI({
baseURL: MODEL_BASE_URL,
apiKey: MODEL_API_KEY,
});
const scraper = new LLMScraper(provider.chat(MODEL_NAME));
const accepted: unknown[] = [];
const failed: unknown[] = [];
const browser = await chromium.launch({ headless: true });
try {
for (const input of inputs) {
const page = await browser.newPage();
try {
await page.setContent(input.html);
const { data } = await scraper.run(
page,
Output.object({ schema }),
{ format: 'html' },
);
const product = schema.parse(data);
if (product.price === null || product.currency === null) {
throw new Error('当前价格或币种缺失');
}
accepted.push({
source_id: input.id,
source_url: null,
...product,
});
} catch (error) {
failed.push({
source_id: input.id,
error: error instanceof Error ? error.name : 'UnknownError',
stage: 'load_or_extract_or_validate',
});
} finally {
await page.close();
}
}
} finally {
await browser.close();
}
await writeFile('accepted.json', JSON.stringify(accepted, null, 2));
await writeFile('failed.json', JSON.stringify(failed, null, 2));
console.log({ accepted: accepted.length, failed: failed.length });
if (failed.length) process.exitCode = 1;在同一目录运行:
node --env-file=.env extract.mts示例按顺序处理三份材料,每次关闭页面,最后关闭浏览器。一条提取失败不会让后两条完全没有处理机会。失败文件保留输入 ID 与错误类别,不直接写可能包含请求详情的异常文本;排错时在受控环境查看脱敏错误,区分接口、页面与数据问题。
输入使用 page.setContent,所以来源 URL 为 null,source_id 对应正文材料。不能把 about:blank 当成真实商品来源,也不能让模型编造网址。
如何把材料示例换成真实网页
将输入改为包含 URL 的对象,保留稳定 ID;把加载行替换成以下局部代码:
await page.goto(input.url, { waitUntil: 'domcontentloaded' });
await page.locator('main h1').waitFor();
const sourceUrl = page.url();这要求同时把输入类型改为 { id, url },并在保存记录时把 source_url: null 改成 source_url: sourceUrl。等待选择器应按照真实页面修改;如果价格晚于标题加载,需等待价格自身的就绪标记,不能只等标题。
自动跳转后还要检查最终来源是否仍属于允许范围,再把内容交给模型。页面里的文字只是待提取材料,不是允许改变模型服务配置或执行额外操作的指令。
选择输入格式,而不是把所有材料都塞给模型
| 当前问题 | 优先尝试 | 需要确认 |
|---|---|---|
| 商品字段在普通 DOM 中 | html | 清理后仍保留字段关系 |
| 清理过程丢失必要属性或结构 | raw_html | 更大输入是否带入无关脚本 |
| 正文和标题足以表达字段 | markdown | 表格与价格上下文是否还完整 |
| 信息只在图片中 | 支持图像的模型与 image | 多模态能力和读取成本 |
text 模式偏向可读正文提取,不能假设适合所有商品布局。先检查送入模型的材料是否包含所需字段,再调整提示词;缺失的 DOM 信息无法靠更强的“请认真提取”补回来。
检查结果:类型正确还要语义正确
下面是期望的单条记录结构,不是调用结果:
{
"source_id": "table",
"source_url": null,
"name": "山谷红茶 500g",
"price": "12.90",
"currency": "CNY",
"availability": "in_stock"
}先检查三种 source_id 是否各有一个去向,再核对价格是否来自当前价,而非原价。Schema 能拒绝错误类型,却不能独自判断一个格式合法的 16.00 是不是取错了字段。
对真实页面建立少量人工核对样本,覆盖原价与现价并存、缺价格、缺库存和不同币种。单看三条示例都成功,不能推出任意新模板都可靠。
常见问题与处理
浏览器可以启动,但模型请求报 401 或 404。 先区分密钥鉴权错误与接口路径错误。确认 MODEL_BASE_URL 是服务要求的基础路径、模型名确实可用。不要更换页面选择器来修复服务端鉴权。
返回普通文本,结构化输出失败。 检查模型与兼容服务是否支持该 provider 使用的结构化方式。切换匹配的 provider 或可用模型;不要从任意回复中截取第一个花括号就当成可靠数据。
字段一直为 null。 查看页面实际材料是否含字段,确认加载完成、格式清理未丢失语义。如果页面确实没有公开价格,失败记录是合理结果,不应要求模型补值。
提取到了原价。 保留有冲突价格的样本,明确当前价定义。必要时先把商品主区域裁剪成自定义材料,再评估结果;不能只把正则放宽到所有数字。
调用越来越慢。 分开看页面加载与模型等待。减少无关材料、逐步扩大批量并记录失败类别;本文串行处理便于排错,改并发前先确认服务配额与单页资源占用。
出现部分流式字段。 流式中间结果只用于展示进度。只有完整输出经过同一套字段与业务检查,才应进入合格文件。
用统一 Schema 可以降低模板维护成本,但正确性仍来自完整材料、明确含义与来源核对。模板变化时,先检查这三个环节,再决定是否需要修改模型或提取策略。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。