LLM Scraper 完整教程:从不同网页模板提取统一商品数据

配置 LLM Scraper、浏览器和结构化输出,处理三种商品 HTML 模板,保存合格与失败记录,并定位材料缺失、价格歧义和模型接口错误。

亿牛云技术团队2026年10月28日7 分钟阅读

当同一类商品分散在不同网站时,维护多套 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 可以降低模板维护成本,但正确性仍来自完整材料、明确含义与来源核对。模板变化时,先检查这三个环节,再决定是否需要修改模型或提取策略。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。