1. 项目概述与核心价值
如果你和我一样,经常需要从各种网站上抓取数据,那你肯定对传统爬虫的“脆弱性”深有体会。今天要聊的这个llm-scraper,是我最近在数据采集工具箱里发现的一件“神器”。简单来说,它是一个基于 TypeScript 的库,但它的核心能力在于,利用大语言模型(LLM)的“理解”能力,从任何网页中提取结构化的数据。这听起来可能有点抽象,我举个例子:你想从 Hacker News 首页抓取前 5 条新闻的标题、作者、分数和评论链接。传统方法你需要写选择器(比如document.querySelectorAll(‘.athing’)),然后一层层解析 DOM 结构。一旦网站改版,你的选择器可能就全失效了,你得重新分析页面结构,调试代码。而llm-scraper的思路是,我把整个网页的内容(可以是 HTML、Markdown 或纯文本)扔给 GPT-4、Claude 或 Gemini 这样的 LLM,然后告诉它:“请按照我定义的格式,把里面的关键信息给我找出来。” 它就像一个能看懂网页的智能助手,帮你把非结构化的网页内容,变成规整的 JSON 数据。
这个项目的价值,在于它解决了传统爬虫的几个核心痛点。第一是健壮性。只要网页上的文字信息没变,即使 HTML 结构天翻地覆,LLM 依然能根据语义理解找到你要的数据。第二是开发效率。你不需要再花大量时间去研究复杂的 DOM 树和 CSS 选择器,只需要用 Zod 或 JSON Schema 定义好你期望的数据结构,剩下的交给 LLM 去“理解”和“匹配”。第三是灵活性。它支持多种内容格式输入(原始 HTML、预处理后的 HTML、Markdown、提取的文本、甚至截图),也支持市面上几乎所有主流的 LLM 提供商(OpenAI, Anthropic, Google, Groq, Ollama 等),让你可以根据成本、速度和准确度自由选择模型。对于前端开发者、数据分析师、或者任何需要快速构建稳定数据采集流程的人来说,这无疑打开了一扇新的大门。
2. 核心设计思路与技术选型解析
2.1 为什么是“LLM + Playwright”的组合?
llm-scraper的技术栈选择非常务实,体现了开发者对实际应用场景的深刻理解。它的底层浏览器自动化框架选择了Playwright,而不是更早流行的 Puppeteer 或 Selenium。这里面的考量有几个层面。首先,Playwright 由微软维护,支持 Chromium、Firefox 和 WebKit 三大浏览器引擎,跨浏览器一致性更好,这对于需要应对不同网站渲染差异的爬虫场景很重要。其次,Playwright 的 API 设计非常现代和友好,自动等待、网络拦截、设备模拟等高级功能开箱即用,能大大简化处理动态加载、反爬检测等复杂情况的代码。最后,Playwright 的性能和资源管理也相当出色,这对于需要并发处理大量页面的爬虫任务至关重要。
而将 LLM 作为“解析引擎”而非“爬取引擎”,是一个聪明的架构分离。Playwright 负责“导航”和“获取”——它能模拟真实用户点击、滚动、填写表单,拿到最终的页面内容(无论是服务端渲染还是客户端渲染)。然后,llm-scraper将获取到的内容,根据配置转换成 LLM 更容易处理的格式(比如用 Readability.js 提取纯净文本,或者转换成 Markdown),再连同我们定义好的数据模式(Schema)一起,发送给 LLM。LLM 的任务是纯粹的“理解与提取”,它不关心页面是怎么加载出来的,只关心给它的文本里有什么,以及如何按照要求格式化输出。这种职责分离让整个系统更清晰、也更易于维护和扩展。
2.2 类型安全与模式定义:Zod 的核心作用
作为一个 TypeScript 项目,类型安全是llm-scraper的一大亮点。它深度集成了Zod这个流行的模式声明与验证库。你不仅仅是用 Zod 来定义期望的数据形状,更重要的是,这个定义会同时用于指导 LLM 的输出和验证/类型推断最终结果。
当你用z.object({...})定义一个模式时,你实际上是在做三件事:
- 给 LLM 的指令:
describe()方法里的描述(如‘Top 5 stories on Hacker News’)会作为自然语言提示的一部分,帮助 LLM 更准确地理解你要找什么。字段名(title,points)和类型(z.string(),z.number())也给了 LLM 明确的输出格式约束。 - 给 TypeScript 的类型信息:Zod 能自动推断出 TypeScript 类型。当你最后调用
schema.parse(result)时,如果验证成功,data变量的类型就会是{ top: Array<{ title: string, points: number, by: string, commentsURL: string }> },你在后续代码中可以获得完美的智能提示和类型检查。 - 运行时的数据验证:即使 LLM 大部分时候很靠谱,也可能产生格式不符的“幻觉”输出。
schema.parse()会严格校验数据是否符合模式,如果points字段返回了字符串“105”而不是数字105,解析会抛出错误,让你能及时捕获和处理异常数据,保证数据管道的可靠性。
这种“一处定义,多处生效”的方式,极大地提升了开发体验和数据质量。当然,项目也支持原生的 JSON Schema,为不使用 Zod 的开发者提供了备选方案。
3. 从零开始:环境搭建与基础使用详解
3.1 项目初始化与依赖安装
让我们从一个最基础的例子开始,亲手体验一下llm-scraper的工作流程。首先,你需要一个 Node.js 环境(建议版本 18 或以上)。创建一个新的项目目录并初始化:
mkdir my-llm-scraper && cd my-llm-scraper npm init -y接下来,安装核心依赖。根据官方文档,你需要三个包:llm-scraper本身、浏览器自动化工具playwright、以及模式定义库zod。
npm install llm-scraper playwright zod安装 Playwright 时,它会自动下载所需的浏览器二进制文件(Chromium, Firefox, WebKit),这可能需要一些时间。为了后续编写代码方便,我们还需要安装 TypeScript 和相关的类型定义,当然,如果你直接用 JavaScript 也可以。
npm install -D typescript @types/node ts-node npx tsc --init3.2 配置 LLM 提供商与 API 密钥
llm-scraper本身不绑定任何特定的 LLM,它通过 Vercel AI SDK 来对接各种模型。这意味着你需要根据你想用的模型,安装对应的提供商包并配置 API 密钥。
以 OpenAI 的 GPT-4o 为例:
- 安装 OpenAI 提供商包:
npm install @ai-sdk/openai - 在项目根目录创建
.env文件,填入你的 OpenAI API Key(你需要在 OpenAI 官网申请):OPENAI_API_KEY=sk-your-actual-api-key-here - 在代码中初始化 LLM。创建一个名为
scrape-hn.ts的文件:import { openai } from '@ai-sdk/openai'; import dotenv from 'dotenv'; // 加载环境变量 dotenv.config(); // 检查 API Key 是否存在 if (!process.env.OPENAI_API_KEY) { throw new Error('OPENAI_API_KEY is not set in .env file'); } // 初始化 LLM,这里使用 gpt-4o,平衡了速度、成本和能力 const llm = openai('gpt-4o');注意:在实际生产环境中,请务必妥善保管你的 API Key,不要将其硬编码在代码中或提交到版本控制系统。
.env文件应该被添加到.gitignore中。
其他主流 LLM 提供商配置示例:
如果你追求极致的性价比和速度,处理大量文本时,Anthropic 的 Claude 3 Haiku或Google 的 Gemini 1.5 Flash是非常好的选择。它们的上下文窗口大,价格低廉。
// 使用 Anthropic Claude import { anthropic } from '@ai-sdk/anthropic'; // 安装: npm install @ai-sdk/anthropic // 环境变量: ANTHROPIC_API_KEY const llm = anthropic('claude-3-haiku-20240307'); // 使用 Google Gemini import { google } from '@ai-sdk/google'; // 安装: npm install @ai-sdk/google // 环境变量: GOOGLE_GENERATIVE_AI_API_KEY const llm = google('gemini-1.5-flash');对于希望完全本地运行、数据不出私域的场景,Ollama是绝佳选择。你需要先在本地安装并运行 Ollama,然后拉取一个模型如llama3.1:8b。
// 使用 Ollama (本地) import { ollama } from 'ollama-ai-provider-v2'; // 安装: npm install ollama-ai-provider-v2 // 无需 API Key,但需确保 Ollama 服务在本地运行 (默认 http://localhost:11434) const llm = ollama('llama3.1:8b');3.3 第一个可运行的爬虫脚本
现在,让我们把 LLM 配置、Playwright 浏览器启动和llm-scraper的核心流程串联起来,写一个完整的、可执行的 Hacker News 爬虫。
// scrape-hn.ts import { chromium } from 'playwright'; import { z } from 'zod'; import { Output } from 'ai'; import { openai } from '@ai-sdk/openai'; import LLMScraper from 'llm-scraper'; import dotenv from 'dotenv'; dotenv.config(); async function scrapeHackerNewsTopStories() { // 1. 启动浏览器。headless: true 表示无头模式,不显示GUI,适合服务器。 // 新版本 Playwright 推荐使用 `chromium.launch()` 而非 `playwright.chromium.launch()` const browser = await chromium.launch({ headless: true }); // 2. 初始化 LLM const llm = openai('gpt-4o'); // 3. 创建 LLMScraper 实例 const scraper = new LLMScraper(llm); // 4. 打开新页面并导航到目标网址 const page = await browser.newPage(); // 设置一个合理的超时和视口大小,模拟真实设备 await page.setViewportSize({ width: 1280, height: 800 }); await page.goto('https://news.ycombinator.com', { waitUntil: 'networkidle' }); // 等待网络空闲 // 5. 定义我们想要提取的数据模式 const schema = z.object({ top: z .array( z.object({ title: z.string(), points: z.number(), by: z.string(), commentsURL: z.string(), }) ) .length(5) // 明确告诉 LLM 我们要正好 5 条 .describe('Top 5 stories on Hacker News'), }); // 6. 运行爬虫! // `format: 'html'` 表示将页面内容作为预处理后的HTML传递给LLM,这是最常用的格式。 const { data } = await scraper.run(page, Output.object({ schema }), { format: 'html', }); // 7. 输出结果 console.log('成功抓取到 Hacker News 头条新闻:'); console.log(JSON.stringify(data.top, null, 2)); // 美化输出 // 8. 清理资源 await page.close(); await browser.close(); return data.top; } // 执行函数,并处理可能的错误 scrapeHackerNewsTopStories().catch(console.error);运行这个脚本:
npx ts-node scrape-hn.ts如果一切顺利,你将在控制台看到一个包含 5 个对象的数组,每个对象都有title,points,by,commentsURL字段,数据就是从 Hacker News 首页实时抓取并解析出来的。
4. 高级功能与实战技巧
4.1 六种内容格式化模式的深度解析与选型
llm-scraper提供了format选项,这决定了它把什么样的“原材料”喂给 LLM。选择不同的格式,会对结果准确性、处理速度和 API 成本产生直接影响。
html(默认/推荐): 这是最常用也是我个人最推荐的模式。它会对原始 HTML 进行预处理,移除脚本、样式等无关标签,清理多余的空白和属性,得到一个更干净、更专注于内容结构的 HTML。这能显著减少 Token 消耗(从而降低成本),并帮助 LLM 更专注于正文内容,避免被导航栏、广告等干扰。适用于绝大多数信息型网站。raw_html: 传递原始的、未经处理的 HTML。只有在html模式预处理过程中意外破坏了某些关键结构(比如一些用复杂div布局的数据表格),导致 LLM 无法正确解析时,才考虑使用此模式。注意:Token 消耗大,成本高,且容易被无关元素干扰。markdown: 将 HTML 转换为 Markdown 格式。Markdown 的层次结构(标题、列表)对 LLM 非常友好,特别适合抓取博客文章、文档、论坛帖子等以阅读性文本为主的内容。转换过程可能会丢失一些原始的 HTML 语义信息。text: 使用 Mozilla 的 Readability.js 算法提取页面的核心文本内容,完全剥离 HTML 标签。这是提取纯文章正文的利器,比如新闻详情页。对于需要从长篇大论中提取摘要、关键词或情感的分析任务,这个模式非常高效。但它会丢失所有的结构化信息(如哪个文本是标题,哪个是链接)。image(多模态): 对页面进行截图,将图片传给支持视觉识别的多模态 LLM(如 GPT-4V, Gemini 1.5 Pro)。这是应对反爬虫技术(如 Canvas 绘图、复杂 CSS 隐藏)的终极方案,因为截图就是用户最终看到的样子。成本极高,速度慢,通常作为最后的手段。custom: 允许你传入一个自定义函数,对页面内容进行任意处理后再交给 LLM。这提供了最大的灵活性。例如,你可以先用 Playwright 执行一些交互操作(点击“加载更多”),然后提取特定容器的innerHTML,再手动清理。
选型心得:
- 起步和通用场景,无脑选
html。 - 抓取新闻、博客正文,用
text或markdown。 - 处理单页应用(SPA)且数据是动态渲染的,确保先用 Playwright 等待足够时间或等待特定元素出现,再用
html模式。 - 只有在前几种模式都失败,且怀疑是页面结构过于特殊时,才尝试
raw_html。 image模式是杀手锏,但也是成本炸弹,慎用。
4.2 流式输出与代码生成:应对复杂场景
流式输出 (stream)当你要提取一个很长的列表(比如电商网站的 100 个商品)或一篇很长的文章时,等待 LLM 一次性生成全部结果可能会超时,或者你希望尽快看到部分结果。这时可以使用stream方法。
// ... 前面的浏览器、LLM、scraper初始化代码相同 ... const schema = z.object({ items: z.array(z.object({ name: z.string(), price: z.string() })).describe('商品列表'), }); // 使用 stream 方法替代 run const { stream } = await scraper.stream(page, Output.object({ schema }), { format: 'html', }); console.log('开始流式接收数据:'); for await (const chunk of stream) { // chunk 是一个部分完成的对象,可能包含 items 数组的一部分 if (chunk.items) { chunk.items.forEach(item => console.log(`- ${item.name}: ${item.price}`)); } } console.log('流式接收完成。');流式输出不仅改善了用户体验,对于处理大量数据也更可靠。
代码生成 (generate)这是llm-scraper一个非常有趣的功能。它不直接让 LLM 返回数据,而是让 LLM 根据你的 Schema 和页面内容,生成一段纯粹的 Playwright 选择器代码。然后你可以用page.evaluate()在浏览器上下文中执行这段代码来获取数据。
// ... 初始化代码 ... const { code } = await scraper.generate(page, Output.object({ schema })); console.log('生成的 Playwright 选择器代码:'); console.log(code); // 在页面上下文中执行生成的代码 const result = await page.evaluate(code); // 用 Zod Schema 验证结果 const data = schema.parse(result); console.log(data);这个功能的价值在于:
- 可解释性与调试:你可以看到 LLM 是如何“思考”并定位数据的(它用了哪些 CSS 选择器),如果结果不对,你可以检查生成的代码来理解问题。
- 性能与复用:一旦生成了正确的选择器代码,你可以把它保存下来,以后直接运行这段高效的本地代码来抓取数据,完全绕过 LLM,实现零成本、毫秒级的数据提取。这相当于让 LLM 当了一回“爬虫代码编写助手”。
- 绕过速率限制:对于需要频繁抓取的页面,首次用 LLM 分析生成代码,后续用生成的代码直接抓取,完美避开 LLM API 的调用频率和成本限制。
4.3 模式定义进阶:复杂结构、可选字段与数据清洗
在实际项目中,你要抓取的数据结构 rarely 是扁平和完美的。Zod 提供了强大的模式组合能力。
嵌套对象与数组:
const productSchema = z.object({ name: z.string(), sku: z.string(), price: z.object({ current: z.number(), original: z.number().optional(), // 原价可能不存在 currency: z.string().default('USD'), // 默认值 }), attributes: z.array(z.string()).optional(), // 可选属性数组 reviews: z.array( z.object({ author: z.string(), rating: z.number().min(1).max(5), text: z.string(), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // 用正则验证日期格式 }) ).optional(), }).describe('产品详情信息');数据后处理与转换:你可以在 Zod 模式中使用.transform()在验证后对数据进行清洗。
const schema = z.object({ priceText: z.string(), }).transform((data) => ({ // 将 "$29.99" 这样的字符串转换为数字 29.99 price: parseFloat(data.priceText.replace(/[^0-9.]/g, '')), }));但要注意,.transform()是在 Zod 解析阶段执行的,LLM 看到的提示仍然是原始的priceText: string。更佳实践是让 LLM 直接输出数字,如果网站格式统一,可以在 Schema 中定义price: z.number(),LLM 通常能很好地理解并转换。
提供更详细的描述:清晰的描述是引导 LLM 准确输出的关键。不要吝啬你的提示词。
const schema = z.object({ publicationDate: z.string().describe('文章的发布日期,通常位于文章标题下方或正文开头,格式类似“2023-10-27”或“October 27, 2023”。'), author: z.string().describe('文章作者姓名,如果找不到则返回空字符串。'), tags: z.array(z.string()).describe('文章关联的标签或分类,通常是一组关键词。'), });5. 生产环境部署、优化与成本控制
5.1 错误处理与健壮性增强
直接上线的脚本是脆弱的。我们必须为网络错误、页面结构变化、LLM 输出异常等情况做好准备。
import { chromium } from 'playwright'; import { z } from 'zod'; import { Output } from 'ai'; import { openai } from '@ai-sdk/openai'; import LLMScraper from 'llm-scraper'; async function robustScraper(url: string, schema: z.ZodSchema, maxRetries = 3) { const browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); // 设置请求超时和重试逻辑 page.setDefaultTimeout(30000); // 30秒超时 let retries = 0; while (retries < maxRetries) { try { // 导航,并监听失败请求 const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); if (!response || !response.ok()) { throw new Error(`页面加载失败,状态码: ${response?.status()}`); } // 可选:等待关键内容出现,增加成功率 await page.waitForSelector('body', { state: 'attached' }); // 例如,对于Hacker News,可以等待故事列表出现 // await page.waitForSelector('.athing', { timeout: 10000 }); const llm = openai('gpt-4o'); const scraper = new LLMScraper(llm); const { data } = await scraper.run(page, Output.object({ schema }), { format: 'html', // 可以设置 LLM 调用的超时和重试 // maxRetries: 2, }); await browser.close(); return data; // 成功则返回数据 } catch (error) { retries++; console.error(`第 ${retries} 次尝试失败:`, error.message); if (retries === maxRetries) { await browser.close(); throw new Error(`抓取失败,已重试 ${maxRetries} 次: ${error.message}`); } // 等待一段时间后重试 await new Promise(resolve => setTimeout(resolve, 2000 * retries)); // 刷新页面或重新导航 await page.reload({ waitUntil: 'domcontentloaded' }); } } } // 使用示例 const schema = z.object({ /* ... */ }); try { const data = await robustScraper('https://example.com', schema); console.log('抓取成功:', data); } catch (error) { console.error('最终失败:', error); // 这里可以触发告警,如发送邮件、Slack消息等 }5.2 性能优化与成本控制策略
LLM API 调用是按 Token 计费的,而 Token 数量直接取决于你发送给它的内容长度。成本控制是生产应用必须考虑的。
1. 内容切片与精准抓取:不要总是把整个document.body.innerHTML扔给 LLM。先用 Playwright 定位到包含目标数据的最小容器。
// 假设我们只需要抓取 id 为 `product-list` 的 div 里的内容 const productListHandle = await page.$('#product-list'); if (productListHandle) { const innerHTML = await productListHandle.innerHTML(); // 将 innerHTML 传递给 scraper,需要使用 `custom` 格式 const { data } = await scraper.run(page, Output.object({ schema }), { format: 'custom', content: () => innerHTML, // 自定义函数返回处理后的内容 }); }2. 模型选型与降级:
- 开发调试阶段:使用能力最强的模型(如 GPT-4o),以获得最准确的结果和更好的提示词反馈。
- 生产环境批量运行:切换到更便宜、更快的模型(如 GPT-3.5-Turbo, Claude Haiku, Gemini Flash)。可以先用小批量数据测试目标模型的准确率是否可接受。
- 本地模型:对于数据敏感或长期成本考量,Ollama + Llama 3.1 等本地模型是终极解决方案,尽管初始设置稍复杂。
3. 缓存与去重:
- 对请求的 URL 和参数进行哈希,将结果缓存到数据库(如 Redis)或磁盘。设定合理的过期时间。
- 在调用 LLM 前,可以先检查本地是否有可用的、未过期的解析结果或生成的选择器代码。
4. 并发控制与速率限制:Playwright 可以启动多个浏览器上下文(Context)甚至多个浏览器实例来实现并发。但同时,要严格遵守目标网站和 LLM API 的速率限制。
import { chromium } from 'playwright'; import { createPool } from 'generic-pool'; // 使用连接池管理浏览器实例 const browserPool = createPool({ create: () => chromium.launch({ headless: true }), destroy: (browser) => browser.close(), }, { max: 5 }); // 最大 5 个浏览器实例 async function scrapeWithConcurrency(urls: string[], schema) { const promises = urls.map(async (url) => { const browser = await browserPool.acquire(); const page = await browser.newPage(); try { // ... 抓取逻辑 ... return data; } finally { await page.close(); await browserPool.release(browser); } }); // 控制并发数,例如一次只处理3个URL const results = await Promise.allSettled(promises.map(p => p.catch(e => e))); // 处理 results... }5.3 监控、日志与数据质量校验
在生产系统中,你需要知道它是否在正常工作。
- 结构化日志:使用 Winston、Pino 等日志库,记录每次抓取任务的 URL、开始结束时间、使用的模型、消耗的 Token、是否成功、错误信息等。这有助于后续分析和计费。
- 数据质量检查点:在 Zod
schema.parse()之后,可以添加额外的业务逻辑校验。例如,检查抓取的价格是否在合理范围内,日期格式是否异常等。 - 定期回归测试:为重要的数据源编写测试用例,定期运行,确保当网站改版或 LLM 行为漂移时,你能第一时间收到警报。
- 人工审核样本:对于关键任务,定期对抓取结果进行人工抽样审核,评估准确率。
6. 常见问题、故障排查与避坑指南
在实际使用中,你肯定会遇到各种各样的问题。下面是我踩过的一些坑和解决方案。
6.1 LLM 输出不符合 Schema 格式
问题现象:schema.parse()抛出 Zod 验证错误,提示类型不匹配或缺少字段。排查思路:
- 检查提示清晰度:首先检查你的 Schema 描述是否足够清晰。
describe()里的文字是给 LLM 的关键指令。模糊的描述会导致模糊的输出。 - 检查输入内容:在调用
scraper.run()之前,先把format设置为‘html’或‘text’,然后手动将页面内容打印或保存到本地文件,看看 LLM 到底“看到”了什么。是不是广告、导航栏等噪音太多,淹没了目标数据?如果是,考虑使用custom模式或先用 Playwright 进行预处理。 - 简化 Schema:如果抓取一个复杂对象失败,先尝试只抓取一个最简单的字段(比如只要标题)。成功后再逐步增加字段,以定位是哪个字段引起了问题。
- 使用更强大的模型:如果使用
gpt-3.5-turbo经常出错,可以暂时切换到gpt-4o测试,如果后者成功,说明可能是模型能力问题,需要考虑升级模型或优化提示。 - 启用 JSON 模式(如果提供商支持):像 OpenAI 的 API 可以设置
response_format: { type: “json_object” },Vercel AI SDK 的Output.object内部应该已经处理。这能强制 LLM 输出合法的 JSON。
6.2 抓取动态加载内容失败
问题现象:页面已经打开,但抓取到的内容是空的或者是不完整的初始状态。解决方案:
- 确保页面加载完成:
page.goto使用waitUntil: ‘networkidle’或waitUntil: ‘domcontentloaded’。对于重度 SPA,‘networkidle’更可靠。 - 主动等待元素:在调用 scraper 前,使用 Playwright 的
page.waitForSelector()或page.waitForFunction()等待包含目标数据的特定元素出现在 DOM 中。 - 模拟交互:有些页面需要滚动、点击“加载更多”按钮才能显示全部内容。用 Playwright 先执行这些操作。
await page.goto(url); // 滚动到页面底部,触发懒加载 await page.evaluate(async () => { await new Promise((resolve) => { let totalHeight = 0; const distance = 100; const timer = setInterval(() => { const scrollHeight = document.body.scrollHeight; window.scrollBy(0, distance); totalHeight += distance; if (totalHeight >= scrollHeight) { clearInterval(timer); resolve(); } }, 100); }); }); // 然后再进行抓取
6.3 处理分页与列表数据
问题场景:需要抓取一个列表的所有分页。策略:
- 用 LLM 识别“下一页”链接:首先抓取第一页,在 Schema 中定义一个
nextPageUrl: z.string().optional()字段,让 LLM 帮你找出“下一页”的按钮或链接地址。 - 循环抓取:拿到
nextPageUrl后,用 Playwright 导航到该 URL,重复抓取过程,直到nextPageUrl为空或达到页数限制。 - 注意去重:列表数据可能跨页重复,需要根据唯一标识(如 ID)进行去重。
6.4 应对网站反爬机制
问题:IP 被封、请求被拒绝、出现验证码。应对措施(需谨慎,遵守网站robots.txt和服务条款):
- 尊重
robots.txt:首先检查目标网站的robots.txt,确认是否允许爬取目标路径。 - 降低请求频率:在请求间添加随机延迟(
page.waitForTimeout(2000 + Math.random() * 3000))。 - 使用代理轮询:通过代理池轮换 IP 地址。
- 模拟真人行为:使用 Playwright 模拟更真实的用户行为序列(如随机移动鼠标、在不同时间间隔点击)。
- 终极方案:如前所述,使用
format: ‘image’模式,让多模态 LLM “看”截图来提取数据。但这成本极高,且可能违反服务条款。
6.5 内存泄漏与资源管理
问题:长时间运行后,Node.js 进程内存持续增长。根源:Playwright 的浏览器实例、页面和上下文如果没有正确关闭,会持续占用资源。最佳实践:
- 使用
try...catch...finally块确保在任何情况下(成功或失败)都关闭页面和浏览器。 - 在循环中抓取时,考虑复用浏览器实例,但为每个任务创建新的上下文(Context)和页面(Page),任务结束后及时清理。
- 监控 Node.js 进程的内存使用情况,必要时重启进程。
最后,再分享一个我个人的小技巧:对于固定结构且变化不频繁的网站,优先使用generate功能生成选择器代码。把生成的、经过验证的代码保存起来,建立一个“选择器代码库”。下次抓取时,先尝试使用库中的代码,如果失败(可能因为网站改版),再回退到使用 LLM 实时解析或重新生成代码。这种混合策略能在长期内大幅降低成本和提升效率。llm-scraper不是一个银弹,但它将 LLM 的智能与传统爬虫的稳定高效结合,为我们处理非结构化网络数据提供了一个强大而优雅的新范式。