最近在重构一个数据采集项目的时候,我又把老路子彻底推翻了一遍。以前碰到需要稳定拿数据的站点,第一反应就是用 Requests 或 Node 的 fetch 直接怼接口,然后被签名、加密参数、风控策略一顿毒打。后来切到Playwright + TypeScript这套组合,直接用浏览器帮我们完成“人机验证、登录态管理、参数生成”,再从浏览器网络层把接口响应原样接出来,数据干净、结构清晰、维护成本低。这篇文章就把我这套方案完整拆给你看,适合正在做 API 级数据采集、接口数据分析、以及想把 Playwright 从“自动化测试工具”改造成“数据采集利器”的朋友。
1. 为什么我放弃了纯 HTTP 库,转用 Playwright 做 API 级采集
1.1 纯 HTTP 方案遇到的三个无解问题
我早期的爬虫核心逻辑特别朴素:打开浏览器的开发者工具,找到 XHR 请求,复制 URL、Header、Payload,然后丢到 Python 或 Node 里重放。这个套路在早期好使,但现在越来越难用,核心是三个问题:
第一,登录态和动态签名变得越来越复杂。很多站点的接口不是简单带个 Cookie 就能过的,它们会在请求头里塞X-Sign、X-Timestamp、Device-Id,甚至某些参数是经过本地加密算法生成的,可能还依赖 WebCrypto、WASM 之类的东西。你要是纯手工逆向这些算法,工作量会非常恐怖,而且站点一旦升级加密逻辑,你的脚本基本就废了。
第二,验证码和风控策略无法绕过。滑块验证、点选验证、无感验证,这些基本都是“行为验证 + 设备指纹 + 频率风控”的组合拳。你用 Python 去模拟滑块的轨迹,即使算出了正确的缺口位置,轨迹不够拟人照样被识别。Playwright 给我带来的最大价值,就是让浏览器自己处理这些验证环节,脚本只负责等待结果和采集结果。
第三,纯 HTTP 无法应对前端加密的参数拼接逻辑。比如登录时要把密码做两次 RSA 加密后再传给后端,这种逻辑通常写在一个压缩过的 js 文件里,人工逆向成本极高。而浏览器自己就会执行这段 JS,我们在它执行完之后,轻松从网络层把最终请求和响应拦截出来,等于绕过了“逆向参数生成过程”,直接拿到最终产物。
1.2 Playwright 解决的其实不是“渲染”,而是“环境信任”
我知道很多人提到 Playwright,第一反应是“无头浏览器渲染动态页面”。这个理解没有错,但它忽略了更关键的一点:Playwright 让你拥有了一个和真实用户几乎完全一致的浏览器环境。
这个环境里包含了完整的浏览器指纹、TLS 指纹、字体列表、Canvas 渲染特征、WebGL 信息,还有正常用户访问时才会生成的 Cookie、LocalStorage、IndexedDB 等状态。当你用这个环境去请求 API 时,服务端看到的是一台真实设备,而不是一个来自 Python Requests 库的“可疑客户端”。
我在操作中把它拆成三层来用:
- 渲染层:负责打开页面、触发事件、等待 Ajax 加载完成。
- 网络层:通过事件监听拿到所有请求和响应,筛选目标 API。
- 执行层:通过
page.evaluate在页面上下文里执行 fetch、调用全局函数,甚至读取内存里缓存的加密密钥。
这三层揉在一起,让 Playwright 不仅能“看页面”,还能“调接口”,而且活得有点像浏览器内部的调试器。这也是它比 Selenium 更适合做数据采集的原因:Selenium 也能控制浏览器,但网络层监听和请求拦截能力远没有 Playwright 这套 API 设计得顺手。
1.3 这套方案适合谁,不适合谁
我从实际项目里总结了一下,这套方案的适用边界大概是这样:
| 场景 | 推荐程度 | 原因 |
|---|---|---|
| 前后端分离的 SPA 站点数据采集 | 非常推荐 | 接口数据往往是 JSON,结构化程度高,拦截后几乎零解析成本 |
| 需要稳定保持登录态的采集任务 | 非常推荐 | storageState 可以把登录态落盘,随时复用 |
| 需要处理动态签名、加密参数的接口 | 比较推荐 | 免去逆向 JS,浏览器自己算好参数 |
| 低频小批量的竞品监测、报表汇总 | 很推荐 | 开发快,维护简单 |
| 超大规模分布式采集 | 不太推荐 | 浏览器实例重,内存开销远高于纯 HTTP,量大了成本高 |
| 目标网站有明确的数据导出 API 或官方 SDK | 不推荐 | 没必要绕一圈,官方接口才是正路 |
一句话总结:当你需要“像人一样访问”才能拿到数据时,Playwright 是最好的入场券;当目标接口完全开放、纯 HTTP 能跑通时,别给自己找麻烦。
2. 项目初始化与环境准备(TypeScript + Playwright)
2.1 Node.js 环境与项目初始化
这套方案我选TypeScript而非纯 JavaScript,主要看中的是类型安全。拦截到的响应体、请求头、存储状态这些数据结构,在 TS 里定义好 interface 以后,写起来不仅补全友好,字段名错了还能在编译期直接报错,避免数据采集中最常见的“字段名写错导致长时间跑脏数据”的问题。
先用 Node.js 18 以上的版本,直接初始化项目:
mkdir api-crawler cd api-crawler npm init -y npm install typescript tsx @types/node -D npm install playwright这里我把tsx装上了,它的作用是直接运行 TypeScript 文件,不需要先编译再跑,调试的时候非常方便。然后初始化 TS 配置:
npx tsc --inittsconfig.json里把target改成ES2022,module改成ESNext,strict打开。实际跑脚本时我一般不会用tsc编译,而是直接用tsx运行:
npx tsx src/index.ts2.2 Playwright 浏览器内核安装
装完 Playwright 库之后,还需要单独下载浏览器内核,这个坑很多新手容易漏掉:
npx playwright install chromium默认情况下 Playwright 使用它自己维护的 Chromium 内核。如果你本机已经有 Chrome 或 Edge,也可以让 Playwright 直接连接系统浏览器:
import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: false, channel: 'chrome', });用channel: 'chrome'的好处是在某些只认证 Google Chrome 指纹的站点里,成功率比默认 Chromium 更高。坏处是如果你本机 Chrome 经常自动升级,可能会遇到版本不匹配的报错。我自己的建议是默认先用 Playwright 内置 Chromium,遇到指纹问题再切系统浏览器,没必要一开始就增加环境依赖。
2.3 使用 Codegen 快速抓取登录流程选择器
如果你不知道目标站点的登录按钮、输入框选择器长什么样,手动翻 HTML 太累。Playwright 自带一个 Codegen 工具,能在你手动操作浏览器的同时自动录制选择器:
npx playwright codegen https://example.com/login录制完你会得到一段包含click、fill、waitForSelector等操作的脚本,我在实际操作中一般不会直接照搬,而是把它当作“选择器字典”来用。比如登录按钮是button[type=submit],还是#login-btn,录一次就有答案了。
在写正式采集脚本前,我建议先用 Codegen 把登录流程脚本录出来,然后删掉其中和采集无关的操作,只保留登录核心步骤,这样登录部分的稳定性会高很多。
3. 核心实现:拦截浏览器流量,把 API 响应变成数据源
3.1 监听 response 事件,结构化拿数据
Playwright 里最实用的一个 API 就是page.on('response')。它能监听到页面上所有网络请求的响应,包括 XHR、Fetch、文档、图片、脚本等。我通常在事件回调里做四个判断:
- 请求资源类型是不是
xhr或fetch。 - URL 是否符合目标接口规则。
- 响应状态码是否为 200。
- 响应内容能否被解析成 JSON。
完整示例:
import { chromium } from 'playwright'; interface ProductItem { id: number; name: string; price: number; } const browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); const apiResponses: ProductItem[] = []; page.on('response', async (response) => { const url = response.url(); const resourceType = response.request().resourceType(); if (resourceType !== 'xhr' && resourceType !== 'fetch') return; if (!url.includes('/api/product/list')) return; if (!response.ok()) return; try { const data = await response.json(); if (data?.data?.list) { apiResponses.push(...data.data.list); } } catch (err) { // 非 JSON 响应直接跳过 } }); await page.goto('https://example.com/products', { waitUntil: 'networkidle' }); console.log(`采集到 ${apiResponses.length} 条商品数据`); await browser.close();注意response.json()只能调用一次,因为响应体在 Playwright 的设计里是流式的,调用了之后就没了。如果既想拿 JSON,又想拿原始文本,最好只用response.text()然后自己 JSON.parse,灵活性最高。
3.2 另一种思路:在页面上下文里直接 fetch 接口
监听response的方式适合“页面本身会调用接口”的场景。但也有一种情况,页面只在某些条件下才会加载某类数据,或者需要在页面里先处理参数再请求接口。这时候我会直接在浏览器页面的上下文里执行 fetch,让浏览器自己带上 Cookie、加上签名,然后把结果返回给 Node 进程。
const data = await page.evaluate(async (productId) => { const resp = await fetch(`/api/product/detail?id=${productId}`, { method: 'GET', headers: { 'Accept': 'application/json', }, }); return await resp.json(); }, 1024); console.log(data);使用page.evaluate有几点经验:
evaluate回调里不能直接引用外部变量,必须通过第二个参数传进去。上述代码里productId就是从外部传入的。- 页面里的 fetch 会自动携带当前域的 Cookie,所以能直接复用登录态。
- 如果你的回调里要用
await,外层回调必须声明为async,Playwright 会等待 Promise resolve 后再返回结果。
这种方式最大的优势就是你的请求用的是一等一真实浏览器的加密逻辑和身份凭证,无需逆向。缺点是不能像监听response那样精确控制“当页面自己发请求时”的时机,所以在实际项目里我会把两种方式结合:页面自动请求用监听,手动查询接口用 evaluate。
3.3 资源过滤:关掉图片、字体、CSS,只留接口请求
很多采集场景下,我们要的只是接口数据,不需要浏览器真的把图片、字体、样式全都下载下来。这会浪费带宽,甚至会被目标站点的 CDN 限速。Playwright 提供了route接口,可以拦截并中止非必要请求:
await page.route('**/*', (route, request) => { const resourceType = request.resourceType(); const url = request.url(); if (['image', 'media', 'font', 'stylesheet'].includes(resourceType)) { route.abort(); } else if (url.includes('/api/')) { route.continue(); } else { route.continue(); } });这段配置跑下来,页面上的图片、视频、字体全部不会加载,请求量经常能减少 70% 以上。但这里有个注意事项:如果目标站点的 API 接口校验了 Referer,或者某些数据要通过图片懒加载触发,粗暴地拦截图片会导致页面逻辑异常。
我的习惯是先跑一次全量请求,在控制台看哪些资源是真正影响接口调用的,再逐步过滤。不要一上来就把所有静态资源全 block 了,否则可能连 JS 文件都被拦掉,页面直接白屏,接口也不会触发。
3.4 等待策略与超时熔断
用 Playwright 采集时,最让人头痛的是页面加载速度不稳定。网络慢的时候,接口迟迟不返回;页面异常时,可能有报错弹窗挡住操作。
我的等待策略主要分三种:
- 用
page.waitForResponse精准等待目标接口。这是我最常用的方式,比waitForTimeout好用且快得多。
const [response] = await Promise.all([ page.waitForResponse((resp) => resp.url().includes('/api/product/list')), page.click('.load-more-btn'), ]); const data = await response.json();顺带解释一下Promise.all的作用:先点击按钮再等接口,会有竞态风险——万一接口在 click 的异步处理中已经返回了,等你调用waitForResponse时可能已经错过了。所以应该同时发起“点击”和“等待响应”两个异步动作。
- 当页面要发多个接口时,我会写一个等待函数,轮询检查结果是否齐全,并用超时兜底。
const deadline = Date.now() + 15000; while (Date.now() < deadline) { if (apiResponses.length >= expectedCount) break; await page.waitForTimeout(200); } if (apiResponses.length < expectedCount) { console.warn(`超时:只采集到 ${apiResponses.length} 条数据`); }- 核心操作全部设置超时,比如
page.goto(url, { timeout: 30000 }),避免某个页面卡死拖慢整个采集进程。
4. 工程化落地:登录态复用、并发控制与输出
4.1 storageState:登录一次,长期复用
这是整套方案里我个人认为含金量最高的环节。用 Playwright 登录目标站点后,浏览器里攒下了一堆 Cookie、LocalStorage、IndexedDB 数据。如果每次跑脚本都要重新登录,效率非常低,还容易被风控盯上。
Playwright 原生支持把浏览器状态保存成文件:
import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: false }); const context = await browser.newContext(); const page = await context.newPage(); // 手动或自动登录 await page.goto('https://example.com/login'); await page.fill('#username', 'your_account'); await page.fill('#password', 'your_password'); await page.click('button[type=submit]'); await page.waitForSelector('.user-info'); // 保存登录态 await context.storageState({ path: 'auth-state.json' }); await browser.close();之后再启动采集任务时,直接加载这个状态文件:
const context = await browser.newContext({ storageState: 'auth-state.json', }); const page = await context.newPage(); // 直接访问需要登录的页面,无需重新登录关于 storageState 我有三个实际经验:
- 登录态不是永久的。很多站点服务端 session 有有效期,一般 1 到 7 天不等。我会写一个检查函数,每次启动时先访问一个需要鉴权的接口,如果返回 401 就重新登录。
storageState只保存 Cookie、LocalStorage、IndexedDB 等状态,不会保存内存中变量。如果目标站点用了内存缓存来校验登录状态,重新加载后可能失效。- 不要把 auth-state.json 提交到 Git 仓库,里面包含敏感凭证。建议在
.gitignore里加上。
4.2 并发控制:不要一次性开 50 个浏览器
采集任务量大时,很多人会下意识地把任务并发起来。Playwright 支持多浏览器实例、多页面并发,但对系统资源的占用非常惊人。一个 Chromium 实例的内存通常在 200MB 到 500MB 之间,开 10 个基本就能拖垮一台普通开发机。
我的做法是在单浏览器实例内开多个 context 或页面,用并发池控制同时运行的 Task 数量。这里用p-limit这个库,代码简单清晰:
npm install p-limitimport pLimit from 'p-limit'; import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const limit = pLimit(3); const tasks = productIds.map((id) => limit(async () => { const context = await browser.newContext({ storageState: 'auth-state.json' }); const page = await context.newPage(); try { const data = await page.evaluate(async (pid) => { const resp = await fetch(`/api/product/detail?id=${pid}`); return await resp.json(); }, id); results.push(data); } finally { await context.close(); } }) ); await Promise.all(tasks); await browser.close();p-limit(3)表示同时最多 3 个采集任务。这个数字要根据目标站点允许的并发量、你本机的内存、以及采集任务的时长来调整。我的建议是先从 1 开始,逐步调到 3、5,观察接口响应速度和报错率。如果响应时间明显变长,说明已经对服务端造成压力了,应该降回去。
4.3 任务队列与断点续采
采集几万条数据时,中途失败在所难免。我的方案是维护一个任务队列文件,每条任务记录状态:pending、done、failed。每次启动脚本时,先读队列,只处理pending和failed的任务。
简化版实现:
import fs from 'fs/promises'; interface TaskItem { id: number; status: 'pending' | 'done' | 'failed'; retryCount: number; } async function loadTasks(): Promise<TaskItem[]> { try { return JSON.parse(await fs.readFile('tasks.json', 'utf-8')); } catch { return []; } } async function saveTasks(tasks: TaskItem[]) { await fs.writeFile('tasks.json', JSON.stringify(tasks, null, 2)); }这个文件就是断点续采的核心。任务跑挂了,重启脚本后它会自动跳过已经完成的,只处理剩余和失败的。配合.finally块,确保每次任务结束都会更新状态。
4.4 数据输出:JSON 落盘与数据库写入
采集到的数据最终要落库。我一般分两步走:先把原始 JSON 原样存到本地,再单独跑一个清洗脚本来解析、去重、入库。这样采集和数据处理解耦,采集挂了不影响清洗,清洗逻辑改了也不用重新采集。
输出 JSON 文件很简单:
import fs from 'fs/promises'; await fs.writeFile('data.json', JSON.stringify(results, null, 2), 'utf-8');如果要写入数据库,我习惯用better-sqlite3这种同步 API 的库,玩法简单,适合中小型采集任务:
import Database from 'better-sqlite3'; const db = new Database('crawler.db'); db.exec(` CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY, name TEXT, price REAL, crawled_at TEXT DEFAULT CURRENT_TIMESTAMP ) `); const insert = db.prepare('INSERT OR REPLACE INTO products (id, name, price) VALUES (?, ?, ?)'); for (const item of results) { insert.run(item.id, item.name, item.price); }INSERT OR REPLACE这句很关键,能保证按主键去重。跑第二次采集时,重复数据会覆盖而不是堆积。
5. 常见问题与排查技巧实录
5.1 登录态过期与 Token 版本不兼容
我用 Playwright 接管 GitLab 等开发工具的 API 采集时,经常会碰到一个错误:
login failed. check api token or gitlab version. log in via git if the version...这个报错的排查逻辑值得分享。它说的是 API Token 失效或当前 GitLab 版本不兼容客户端协议,但用 Playwright 浏览器访问网页版却一切正常。
问题通常出在:你在脚本里直接调用的 API 依赖一个已经过期或权限不足的 Token,而浏览器里的网页是走会话 Cookie 的。解决方案有两个:
- 在浏览器上下文里打开一个内部页,从 LocalStorage 或页面脚本中读取出当前会话的 Token,再注入到 API 请求里。
- 更省心的办法:不直接调 API,而是先访问页面让页面自己调接口,再通过监听
response拿数据,这样完全绕开了手动管理 Token 的问题。
这个问题给我的教训是:当“页面正常但脚本报错”时,优先检查你是不是在做“双份身份认证”。浏览器已经帮你维护了一份身份,你又额外塞了一个 Token,两套认证规则不一致时就会出奇怪的问题。
5.2 接口返回 400 或 payload 过大
有段时间我在调用一个 AI 大模型类接口时总会遇到:
api error: 400 this model's maximum context length is 1048576 tokens...这个报错的意思是:我传入的 prompt 或上下文内容超过了模型允许的 token 上限。虽然模型能接受 1048576 个 token,但“系统提示词 + 历史记录 + 当前输入”加起来超过了限制。
排查思路很简单:把请求 payload 中的文本长度分段检查,看哪一段超了。如果是采集并转发给模型的场景,可以在构建 payload 之前先做截断或摘要:
function truncateText(text, maxTokens) { const maxChars = maxTokens * 3; // 粗略按 1 token ≈ 3-4 字符估算 if (text.length <= maxChars) return text; return text.slice(0, maxChars) + '...'; }当然这个粗暴截断只适合临时调试,生产环境建议用官方 SDK 提供的 tokenizer 精确计算长度。
5.3 页面内 iframe 的接口抓不到
SPA 站点经常把部分模块塞进 iframe 里,比如地图、支付、聊天组件。用page.on('response')只能监听主 frame 的请求,iframe 里的 XHR 不会触发主 page 的 response 事件。
处理方式是用page.on('framenavigated')或监听所有 frame 的 response:
page.on('response', async (response) => { const frame = response.frame(); // 判断 frame 是否是由 iframe 创建的 if (frame !== page.mainFrame()) { // 这里是 iframe 的响应 } });实际上page.on('response')默认也会覆盖所有子 frame 的响应,但如果你发现漏了,可以挨个遍历 frame 检查:
for (const frame of page.frames()) { if (frame.url().includes('important-module')) { // 单独处理这个 frame } }如果目标接口在 iframe 里,你还需要先切换到对应 frame 才能执行 evaluate:
const frame = page.frames().find((f) => f.url().includes('/embed/')); if (frame) { const data = await frame.evaluate(() => window.someGlobalData); }5.4 动态 JavaScript 混淆与浏览器指纹检测
有朋友问我,为什么有些站点用 Playwright 跑起来会卡在验证页。这种站点通常对运行时环境做检测,包括navigator.webdriver属性、浏览器窗口尺寸、鼠标行为轨迹、是否安装了某些插件等。Playwright 会暴露出少量自动化特征的痕迹,比如navigator.webdriver为true。
我的建议是:不要在对抗检测上花太多时间,而是尽量让浏览器行为接近真人。比如:
- 使用非无头模式跑登录和复杂操作。
- 给每个 context 设置不同的 user agent、viewport、locale。
- 操作之间加入随机等待。
- 尽量减少不必要的自动化操作频率。
const context = await browser.newContext({ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', viewport: { width: 1920, height: 1080 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', });还有一个细节是headless: false时,浏览器会弹出窗口,生产环境不适合。我的方案是只在登录和调试阶段用有头模式,正式采集时尽量切到无头。如果必须无头又要过检测,那就需要额外处理 webdriver 属性,但这类方法随时可能失效,我一般不作为长期依赖。
这里也顺便说一句:所有采集行为都应该遵守目标站点的服务条款和 robots 协议,采集频次控制在对目标服务器无压力的范围内。能用官方 API 的地方,优先用官方 API。
5.5 浏览器内存泄漏与崩溃
长跑采集任务最常见的故障就是内存逐渐涨满,最终浏览器崩溃。这种情况主要是因为创建了大量 context 和 page,但释放不及时,或者单个 context 里积累了太多历史记录。
我采取的策略有三个:
- 用
browser.newContext()时为每个 Task 创建独立 context,任务结束后立即await context.close()。 - 定期重启浏览器实例。比如每处理 500 个 Task,就关闭当前 browser,重新
chromium.launch()。 - 启动时加上内存限制参数:
const browser = await chromium.launch({ args: [ '--disable-dev-shm-usage', '--memory-pressure-off', '--no-sandbox', ], });--disable-dev-shm-usage在 Linux 服务器上特别重要,否则/dev/shm太小会导致页面崩溃。
6. 与 Python 爬虫、AI 大模型结合的新方向
6.1 Playwright 能不能彻底替代 Requests 或 Scrapy?
很多初学者会纠结 Playwright 和 Python 爬虫生态怎么选。我的观点很直接:.NET 或 Node 系的 Playwright 擅长的是“浏览器环境内的采集”,而 Python 系的 Requests、Scrapy 擅长的是“纯 HTTP 链路的高效采集”。两者不是替代关系,而是互补关系。
| 对比维度 | Requests / Scrapy | Playwright |
|---|---|---|
| 请求效率 | 高,单机可并发几百上千 | 低,单实例并发个位数到十几 |
| 反爬对抗 | 弱,需要手动处理签名和验证码 | 强,浏览器自带完整环境 |
| 资源开销 | 低 | 高,每个实例几百 MB 内存 |
| 适合场景 | 开放接口、数据量大的任务 | 复杂登录、动态签名、需验证的站点 |
我在实际项目里的分工方式是:先用 Playwright 探路,把目标接口的请求参数、加密逻辑摸清楚;如果能做到纯 HTTP 复现,就迁移到 Scrapy 做大规模采集;如果复现不了,就保持 Playwright 方案但降低并发。
6.2 AI 在爬虫里的角色不是“替代”,而是“辅助”
你可能会想,现在 ChatGPT 这么强,能不能直接让它写爬虫?我的回答是:AI 能帮你快速写代码,但爬虫的本质难点从来没变过,那就是“了解目标的业务逻辑”。
在基于 Playwright 的采集方案里,AI 的切入点有三个:
- 分析 Network 面板导出的 HAR 文件,识别目标接口的参数依赖关系。
- 根据接口返回的 JSON 结构,自动生成 TypeScript interface 定义。
- 处理响应数据时,用自然语言描述清洗规则,让模型生成转换代码。
举一个我最近用的例子:导出 HAR 文件后,我让 AI 帮我总结出哪个参数是 timestamp、哪个是签名、哪个来自 Cookie,它几分钟就给了结果。这个过程换成人工坐在 DevTools 面前看,至少得半小时。
但是,AI 生成的代码不一定稳定,尤其是涉及 Playwright 这种异步事件较多的 API,建议把 AI 生成的结果当作“第一版草稿”,配合你自己的调试工具跑通后,才能正式使用。
6.3 大模型辅助数据清洗:把接口数据变成结构化知识
采集到的接口数据往往是原始 JSON,真正要在业务中用还得做清洗。我之前做过一个功能:把采集到的文章列表丢给大模型,让它提取标题、作者、发布时间、摘要、关键词,输出成结构化字段。这个过程如果用正则写,逻辑会非常脆弱;用大模型则能容忍各种格式变化。
核心调用思路:
const prompt = ` 这是从页面接口采集到的数据,请提取其中与"用户评价"相关的字段,并输出 JSON。 原始数据:${JSON.stringify(rawData)} `; const resp = await fetch('https://api.llm.example.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'your-model', messages: [{ role: 'user', content: prompt }], response_format: { type: 'json_object' }, }), });注意大模型的 context 有长度上限,原始数据过大时要先截断或分片。这个和前面提到的 token 超限问题正好衔接上。
我个人建议把 AI 清洗放在本地做,不要让采集主流程依赖外部模型接口。这样即使模型服务临时故障,原始数据也已经落盘,随时可以重跑清洗任务。
7. 最后分享一点我的踩坑心得
整套方案跑了大半年,最有价值的一个教训是:不要一上来就追求“全自动”,先把核心链路的每一步拆开单独调试。Playwright 用起来快,但一旦出问题,堆栈信息往往很抽象,尤其涉及page.evaluate里的异步逻辑时,错误往往只报一个泛泛的Evaluation failed。这时候唯一的调试办法就是分段打印日志、逐步验证。
另一个很实用的技巧是:在开发阶段把headless: false打开,看到浏览器实际操作过程。虽然会弹出一个窗口有点烦,但它能让你瞬间发现选择器选错了、页面弹了验证码、接口被拦截等问题。等确认没问题了,再切回无头模式跑批量任务。
采集工程从来不是“写一次就能永远跑”的,它更像是一个需要持续维护的数据管道。站点改版、接口换参数、登录态失效、风控策略升级,这些都会让脚本突然失灵。我的建议是:最重要的不是写出一段能跑的代码,而是设计好“出了问题后的恢复机制”。断点续采、日志记录、状态文件、超时熔断,这些工程细节才是这套方案能长期稳定的根本。