网站做优化有什么用吗,红色主题ppt免费模板,中国建设会计学会网站,网站风格对比信息表Playwright自动化测试#xff1a;从零到一搭建新闻热搜爬虫实战#xff08;含完整代码#xff09; 最近在做一个舆情监控的小项目#xff0c;需要定期抓取几个主流新闻平台的热搜榜单。一开始我尝试用传统的请求库配合解析工具#xff0c;但很快就遇到了动态加载、反爬策略…Playwright自动化测试从零到一搭建新闻热搜爬虫实战含完整代码最近在做一个舆情监控的小项目需要定期抓取几个主流新闻平台的热搜榜单。一开始我尝试用传统的请求库配合解析工具但很快就遇到了动态加载、反爬策略和页面结构频繁变动的问题。手动维护选择器的工作量巨大测试脚本的稳定性也一言难尽。后来我把目光投向了Playwright——这个微软开源的端到端测试框架。它不仅能模拟真实用户操作处理复杂的JavaScript渲染其强大的录制和代码生成功能更是为快速构建数据采集脚本打开了一扇新的大门。这篇文章我想和你分享的就是如何将Playwright从一个“测试工具”转变为一个高效的“数据采集引擎”。我们会从零开始搭建一个能够自动抓取、清洗、汇总多个新闻平台热搜数据的爬虫系统。整个过程我会穿插大量我在实际项目中踩过的坑和总结的技巧并提供可直接运行的完整代码。无论你是想为自己的项目增加一个数据源还是希望深入理解Playwright在自动化领域的应用边界相信都能有所收获。1. 环境准备与项目初始化在开始编写任何代码之前一个稳定、可复现的开发环境是高效工作的基石。与许多教程不同我强烈建议从一开始就采用项目管理的方式而不是在全局环境里随意安装。这能有效避免未来因依赖冲突导致的“在我的机器上能跑”的经典问题。1.1 核心工具链选择与安装我的技术栈选择基于几个原则主流、稳定、社区活跃。下面是我推荐的组合Node.js (LTS版本)Playwright对Node.js版本有要求使用长期支持版能获得最好的兼容性。你可以通过node -v检查现有版本。包管理工具我个人偏好pnpm它在速度和磁盘空间利用上优势明显但npm或yarn也完全没问题。代码编辑器Visual Studio Code 是不二之选其官方Playwright插件提供了无与伦比的调试和录制体验。首先我们创建一个全新的项目目录并初始化mkdir news-hotspot-crawler cd news-hotspot-crawler pnpm init -y接下来安装Playwright的核心测试库。注意我们安装的是playwright/test这是一个集成了测试运行器、断言库和Playwright浏览器驱动的完整包。pnpm add -D playwright/test安装完成后需要安装Playwright所需的浏览器二进制文件Chromium, Firefox, WebKit。这一步可能会花费一些时间因为它会下载三个浏览器。npx playwright install为了后续开发方便我们可以在package.json中添加一些脚本命令{ scripts: { test: playwright test, test:ui: playwright test --ui, test:headed: playwright test --headed, codegen: playwright codegen } }1.2 项目结构设计与配置一个清晰的项目结构能让代码维护成本大大降低。这是我为这个爬虫项目设计的结构news-hotspot-crawler/ ├── src/ │ ├── crawlers/ # 各平台爬虫具体实现 │ │ ├── baidu.crawler.js │ │ ├── toutiao.crawler.js │ │ └── qqnews.crawler.js │ ├── core/ │ │ ├── base.crawler.js # 爬虫基类封装公共方法 │ │ └── data.processor.js # 数据处理与存储逻辑 │ └── fixtures/ # 测试固件如登录态管理 ├── tests/ │ └── hotspot.spec.js # 主测试/执行文件 ├── outputs/ # 数据输出目录JSON、CSV等 ├── playwright.config.js # Playwright配置文件 └── package.json让我们先创建最关键的playwright.config.js文件。这里我们可以配置超时时间、浏览器类型、视口大小等。对于爬虫场景我通常会关闭视频录制和截图除非用于调试并设置更长的超时时间以应对网络波动。// playwright.config.js import { defineConfig, devices } from playwright/test; export default defineConfig({ timeout: 60000, // 全局超时设置为60秒 retries: 1, // 失败重试1次 fullyParallel: true, // 并行执行测试 workers: process.env.CI ? 1 : 50%, // CI环境单worker本地用一半CPU核心 reporter: [[html, { outputFolder: playwright-report }], [list]], use: { headless: true, // 爬虫通常用无头模式 viewport: { width: 1920, height: 1080 }, ignoreHTTPSErrors: true, screenshot: only-on-failure, video: off, // 关闭视频节省资源 trace: off, // 关闭Trace }, projects: [ { name: chromium, use: { ...devices[Desktop Chrome] }, }, ], });注意headless: true意味着浏览器在后台运行没有图形界面这能显著减少资源占用。但在开发调试定位元素时可以临时改为false或使用--headed参数运行以便观察浏览器行为。2. 利用录制功能快速生成爬虫骨架Playwright的录制功能Codegen是快速入门的利器。它允许你通过手动操作浏览器自动生成对应的操作代码。对于爬虫开发这能帮助我们快速理解页面结构并得到初始的选择器和操作序列。2.1 启动录制与基础操作在项目根目录下运行以下命令它会打开一个浏览器和一个代码生成器窗口。pnpm codegen https://www.baidu.com现在在打开的浏览器中你可以像普通用户一样将鼠标移动到百度首页的“热搜”区域。点击“换一换”按钮。观察右侧代码生成器窗口它会实时将你的点击、输入、滚动等操作转换为Playwright代码。录制生成的代码可能类似于这样const { chromium } require(playwright); (async () { const browser await chromium.launch({ headless: false }); const context await browser.newContext(); const page await context.newPage(); await page.goto(https://www.baidu.com/); await page.locator(span.hot-refresh-text).click(); await browser.close(); })();这段代码虽然简单但它已经包含了导航 (goto) 和元素交互 (click) 的核心操作。然而直接使用录制代码存在几个明显问题选择器脆弱生成的span.hot-refresh-text这类选择器可能依赖于具体的CSS类名一旦网站改版就会失效。缺乏等待与断言没有处理内容加载的等待逻辑在慢速网络下容易失败。代码结构单一是线性的脚本不易复用和扩展。2.2 从录制代码到可维护的爬虫类我们的目标是将录制的“动作序列”重构为结构化的、可维护的爬虫类。首先我们创建一个爬虫基类封装所有爬虫共有的逻辑比如初始化页面、公共等待方法、错误处理等。// src/core/base.crawler.js import { test } from playwright/test; /** * 爬虫基类 */ export class BaseCrawler { constructor(page) { this.page page; this.newsMap new Map(); // 用于存储和统计热搜词 } /** * 导航到目标URL并等待页面加载完成 * param {string} url * param {number} timeout */ async navigate(url, timeout 30000) { await this.page.goto(url, { waitUntil: networkidle, timeout }); // 增加一个额外等待确保主要内容区域加载 await this.page.waitForLoadState(domcontentloaded); } /** * 稳健的点击方法结合等待和重试 * param {string|import(playwright/test).Locator} selectorOrLocator * param {object} options */ async safeClick(selectorOrLocator, options {}) { const locator typeof selectorOrLocator string ? this.page.locator(selectorOrLocator).first() : selectorOrLocator; await locator.waitFor({ state: visible, timeout: 10000 }); await locator.click(options); // 点击后等待一小段时间让页面反应 await this.page.waitForTimeout(500); } /** * 向新闻Map中添加或更新热搜词 * param {string} title */ addNews(title) { if (!title || title.trim().length 0) return; const key title.trim(); this.newsMap.set(key, (this.newsMap.get(key) || 0) 1); } /** * 获取排序后的热搜列表 * returns {Array[string, number]} */ getSortedNews() { return Array.from(this.newsMap.entries()).sort((a, b) b[1] - a[1]); } /** * 核心抓取方法由子类实现 */ async crawl() { throw new Error(子类必须实现 crawl 方法); } }这个基类提供了骨架。接下来我们基于录制得到的线索实现第一个具体的爬虫百度热搜爬虫。3. 重构与优化构建健壮的百度热搜爬虫现在我们将录制得到的关于百度热搜的操作用更健壮的方式重新实现。关键在于选择稳定的定位策略和增加必要的容错机制。3.1 分析页面结构与定位策略打开百度首页使用浏览器的开发者工具F12检查“热搜”和“换一换”元素。我们发现“热搜”标题可能是一个具有特定文本或角色的元素。“换一换”按钮有多种定位方式span.hot-refresh-text文本、#hotsearch-refreshID等。每条热搜新闻位于span.title-content-title下。定位策略心得优先使用getByRole(),getByText(),getByTestId()这些语义化的定位器它们比基于CSS类名或ID的选择器更稳定因为后者是出于样式目的而设计容易改变。如果语义化定位器不可用再考虑locator()配合CSS或XPath。3.2 实现百度爬虫类基于以上分析我们创建src/crawlers/baidu.crawler.js// src/crawlers/baidu.crawler.js import { BaseCrawler } from ../core/base.crawler.js; export class BaiduHotCrawler extends BaseCrawler { constructor(page) { super(page); this.name 百度热搜; this.url https://www.baidu.com; } async crawl() { console.log(开始抓取 ${this.name}...); try { await this.navigate(this.url); // 1. 等待并确认热搜区域加载完成 const hotSearchSection this.page.getByText(百度热搜).or(this.page.getByText(热搜)); await hotSearchSection.first().waitFor({ state: visible, timeout: 10000 }); // 2. 定位“换一换”按钮 - 使用更稳健的文本定位 const refreshButton this.page.getByText(换一换, { exact: true }).first(); await expect(refreshButton).toBeVisible(); // 3. 定位热搜条目列表 // 尝试多种选择器提高兼容性 const headlineSelector this.page.locator(span.title-content-title); const itemCount await headlineSelector.count(); if (itemCount 0) { console.warn(未找到热搜条目尝试备用选择器...); // 备用选择器 const altHeadlines this.page.locator(.c-row.op-toplist1-hotitem_3S7Li); if (await altHeadlines.count() 0) { // 使用备用逻辑处理... } else { throw new Error(无法定位百度热搜条目); } } // 4. 执行多轮抓取 const MAX_REFRESH 5; for (let round 0; round MAX_REFRESH; round) { console.log(第 ${round 1} 轮抓取); // 获取当前轮次的所有标题 const currentTitles await headlineSelector.allTextContents(); for (const title of currentTitles) { this.addNews(title); console.log( - ${title}); } // 如果不是最后一轮则点击“换一换” if (round MAX_REFRESH - 1) { await refreshButton.click(); // 等待新内容加载。更优的做法是等待某个元素更新这里简单等待 await this.page.waitForTimeout(2000); // 可选等待列表数量稳定 await headlineSelector.first().waitFor({ state: attached }); } } console.log(${this.name} 抓取完成共捕获 ${this.newsMap.size} 个独立热词。); return this.getSortedNews(); } catch (error) { console.error(抓取 ${this.name} 时出错:, error.message); // 可以在这里添加截图逻辑便于调试 // await this.page.screenshot({ path: error-${this.name}-${Date.now()}.png }); throw error; // 重新抛出错误由上层处理 } } }这个实现相比原始录制代码主要增强了稳健的等待使用waitFor确保元素可见后再操作。容错处理try...catch块捕获异常并提供了备用选择器逻辑。语义化定位优先使用getByText。清晰的日志方便跟踪抓取过程。3.3 定位策略深度解析与对比在Playwright中选择正确的定位器是成功的一半。下表对比了几种常见定位策略的优缺点及适用场景定位方法示例优点缺点推荐场景getByRolepage.getByRole(button, { name: 提交 })最接近用户视角稳定性高可访问性好需要元素有正确的ARIA角色否则无法定位按钮、链接、输入框、标题等有明确语义的元素getByTextpage.getByText(登录)直观对文本内容变化敏感页面可能存在多处相同文本文本可能被CSS隐藏定位具有独特文本内容的元素如标题、标签getByTestIdpage.getByTestId(search-input)最稳定专为测试设计不受样式影响需要开发人员在元素上添加data-testid属性与开发约定用于核心交互元素是首选方案locator CSSpage.locator(.primary-btn)灵活前端开发者熟悉最不稳定CSS类名常因样式调整而改变当以上方法都无效时的备选尽量使用有语义的类名locator XPathpage.locator(//button[contains(text(), 保存)])功能强大可以表达复杂逻辑语法复杂可读性差性能可能稍差非常脆弱尽量避免使用除非处理没有其他属性的复杂DOM结构对于我们的爬虫策略是首选getByTestId(如果存在)其次getByRole/getByText最后才考虑locator。在实际项目中可以与前端团队协商为关键元素添加data-testid这能极大提升自动化脚本的健壮性。4. 扩展多平台与数据聚合分析单一平台的数据往往有局限性。要获得更全面的热点视图我们需要整合多个来源。我们将以今日头条和腾讯新闻为例展示如何快速扩展爬虫并最终将所有数据聚合分析。4.1 实现今日头条热搜爬虫今日头条的热搜区域通常也是动态加载的。我们遵循类似的模式创建src/crawlers/toutiao.crawler.js// src/crawlers/toutiao.crawler.js import { BaseCrawler } from ../core/base.crawler.js; export class ToutiaoHotCrawler extends BaseCrawler { constructor(page) { super(page); this.name 今日头条热榜; this.url https://www.toutiao.com/; } async crawl() { console.log(开始抓取 ${this.name}...); await this.navigate(this.url); // 等待热榜区域出现 await this.page.waitForSelector([data-testidhotlist], { timeout: 15000 }).catch(() { console.warn(未找到data-testid为hotlist的元素尝试文本定位...); }); // 定位“换一换”按钮 - 尝试多种方式 let refreshButton; const buttonSelectors [ () this.page.getByRole(button, { name: 换一换, exact: true }), () this.page.locator(div[aria-label换一换]), () this.page.getByText(换一换).filter({ has: this.page.locator(button, div) }), ]; for (const getSelector of buttonSelectors) { const btn getSelector(); if (await btn.count() 0) { refreshButton btn.first(); break; } } if (!refreshButton) { throw new Error(无法定位今日头条的“换一换”按钮); } await expect(refreshButton).toBeVisible(); // 定位热搜条目 - 优先使用更稳定的选择器组合 const headlineSelector this.page.locator(p.news-title, a[href*/hot] h3, div.hot-title); const MAX_REFRESH 4; // 头条可能刷新次数不同 for (let round 0; round MAX_REFRESH; round) { const titles await headlineSelector.allTextContents(); console.log(第 ${round 1} 轮抓到 ${titles.length} 条); for (const title of titles) { this.addNews(title); } if (round MAX_REFRESH - 1) { await refreshButton.click(); // 等待新列表加载通过检查列表内容是否变化来判断 await this.page.waitForFunction( (selector) { const items document.querySelectorAll(selector); return items.length 0; }, headlineSelector._selector, { timeout: 5000 } ); await this.page.waitForTimeout(1000); // 额外缓冲 } } console.log(${this.name} 抓取完成。); return this.getSortedNews(); } }4.2 实现腾讯新闻爬虫腾讯新闻的DOM结构可能又有所不同这正体现了Playwright的优势——我们只需调整定位策略核心流程不变。// src/crawlers/qqnews.crawler.js import { BaseCrawler } from ../core/base.crawler.js; export class QQNewsHotCrawler extends BaseCrawler { constructor(page) { super(page); this.name 腾讯新闻热点; this.url https://news.qq.com/; } async crawl() { console.log(开始抓取 ${this.name}...); await this.navigate(this.url); // 腾讯新闻的热点区域可能有一个特定的ID或类 await this.page.waitForSelector(.hot-list, .rank-list, { timeout: 10000 }); // 使用 :right-of 等新的定位器可能需要评估兼容性这里采用更通用的方法 // 假设热点标题在图标右侧 const listItems this.page.locator(.hot-list li, .rank-list li); const refreshButton this.page.locator(.refresh-btn, .change-btn, button:has-text(换一换)).first(); let hasRefresh await refreshButton.count() 0; const rounds hasRefresh ? 3 : 1; // 如果能换一换则多轮抓取 for (let round 0; round rounds; round) { // 获取当前所有列表项的文本 const count await listItems.count(); for (let i 0; i count; i) { const item listItems.nth(i); // 尝试获取标题可能位于子元素内 const titleElement item.locator(a, .title, p).first(); const title await titleElement.textContent().catch(() ); if (title) { this.addNews(title.trim()); } } if (hasRefresh round rounds - 1) { await refreshButton.click(); await this.page.waitForTimeout(3000); // 等待刷新 // 等待列表项数量稳定或内容更新 await listItems.first().waitFor({ state: attached }); } } console.log(${this.name} 抓取完成。); return this.getSortedNews(); } }4.3 数据聚合、存储与可视化各个爬虫返回的是排序后的数组[热词, 出现次数]。我们需要一个管理器来运行所有爬虫并整合结果。// src/core/data.processor.js import fs from fs/promises; import path from path; export class DataProcessor { constructor(outputDir ./outputs) { this.outputDir outputDir; this.allNewsMap new Map(); } /** * 合并多个爬虫的结果 * param {ArrayArray[string, number]} resultsArray 每个爬虫的排序结果数组 */ aggregate(resultsArray) { for (const result of resultsArray) { for (const [title, count] of result) { const currentCount this.allNewsMap.get(title) || 0; this.allNewsMap.set(title, currentCount count); } } return this.getSortedAggregatedResults(); } getSortedAggregatedResults() { return Array.from(this.allNewsMap.entries()).sort((a, b) b[1] - a[1]); } /** * 将结果保存为JSON文件 * param {Array[string, number]} data * param {string} filename */ async saveAsJson(data, filename hotspot-${new Date().toISOString().split(T)[0]}.json) { await fs.mkdir(this.outputDir, { recursive: true }); const filePath path.join(this.outputDir, filename); const output { generatedAt: new Date().toISOString(), data: data.map(([title, count]) ({ title, count })) }; await fs.writeFile(filePath, JSON.stringify(output, null, 2), utf-8); console.log(数据已保存至: ${filePath}); return filePath; } /** * 将结果保存为CSV文件 * param {Array[string, number]} data * param {string} filename */ async saveAsCsv(data, filename hotspot-${new Date().toISOString().split(T)[0]}.csv) { await fs.mkdir(this.outputDir, { recursive: true }); const filePath path.join(this.outputDir, filename); const csvHeader 排名,热词,出现次数\n; const csvRows data.map(([title, count], index) ${index 1},${title.replace(//g, )},${count}).join(\n); await fs.writeFile(filePath, csvHeader csvRows, utf-8); console.log(CSV数据已保存至: ${filePath}); return filePath; } /** * 打印控制台报表 * param {Array[string, number]} data * param {number} topN */ printConsoleReport(data, topN 20) { console.log(\n 全网热点聚合报告 ); console.log(统计时间: ${new Date().toLocaleString()}); console.log(总热词数: ${data.length}); console.log(--------------------------------------); console.log(Top ${topN} 热点:); data.slice(0, topN).forEach(([title, count], index) { console.log(${(index 1).toString().padStart(2)}. ${title.padEnd(40)} (出现: ${count}次)); }); console.log(\n); } }5. 整合测试与自动化调度最后我们将所有模块整合到一个Playwright测试文件中。这不仅是测试更是我们的主执行入口。利用Playwright的test.describe和test.beforeAll/test.afterAll钩子我们可以优雅地组织整个抓取流程。5.1 编写主测试执行文件创建tests/hotspot.spec.js// tests/hotspot.spec.js import { test, expect } from playwright/test; import { BaiduHotCrawler } from ../src/crawlers/baidu.crawler.js; import { ToutiaoHotCrawler } from ../src/crawlers/toutiao.crawler.js; import { QQNewsHotCrawler } from ../src/crawlers/qqnews.crawler.js; import { DataProcessor } from ../src/core/data.processor.js; test.describe(全网新闻热点抓取与聚合, () { let processor; test.beforeAll(() { processor new DataProcessor(); console.log(数据处理器初始化完成。); }); test(抓取并聚合百度、头条、腾讯新闻热点, async ({ page }) { const crawlers [ new BaiduHotCrawler(page), new ToutiaoHotCrawler(page), new QQNewsHotCrawler(page), ]; const allResults []; for (const crawler of crawlers) { // 可以为每个爬虫设置单独的页面上下文避免cookie等状态污染这里简单复用page const result await crawler.crawl(); allResults.push(result); console.log(${crawler.name} 独立热词数: ${crawler.newsMap.size}); } // 聚合所有结果 const aggregatedData processor.aggregate(allResults); // 输出报告 processor.printConsoleReport(aggregatedData, 25); // 保存数据 await processor.saveAsJson(aggregatedData); await processor.saveAsCsv(aggregatedData); // 一个简单的断言确保抓取到了数据 expect(aggregatedData.length).toBeGreaterThan(0); expect(aggregatedData[0][1]).toBeGreaterThan(0); // 最高频词至少出现一次 }); });5.2 运行与调度现在你可以通过以下命令运行整个爬虫# 以无头模式运行后台 npx playwright test # 以有头模式运行观察浏览器行为调试用 npx playwright test --headed # 使用UI模式运行有更直观的测试树和步骤跟踪 npx playwright test --ui为了定期执行例如每天上午10点你可以结合系统的定时任务如Linux的cron或Windows的任务计划程序# 一个简单的cronjob示例每天10点运行并将日志输出到文件 0 10 * * * cd /path/to/your/news-hotspot-crawler npm test /var/log/hotspot-crawler.log 215.3 高级技巧与避坑指南在实际运行中你可能会遇到一些问题。这里分享几个我总结的要点反爬应对部分网站会检测Playwright的自动化特征。可以尝试// 在playwright.config.js的use中或创建context时添加 use: { ...devices[Desktop Chrome], // 1. 使用更真实的User-Agent userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., // 2. 添加额外的HTTP头 extraHTTPHeaders: { Accept-Language: zh-CN,zh;q0.9, }, // 3. 视口设置更随机 viewport: { width: 1920, height: 1080 }, }处理动态加载对于滚动加载的内容使用page.evaluate进行滚动并配合waitForFunction等待新内容出现。超时与重试合理配置timeout和retries。对于关键操作可以自己封装带重试的逻辑。资源管理爬取多个网站后及时关闭不需要的页面或上下文防止内存泄漏。test.afterEach(async ({ page }) { await page.close(); });这个项目从录制第一个点击动作开始逐步构建了一个健壮的、可扩展的多平台新闻热点爬虫。它不仅仅是一组脚本更展示了一种将端到端测试工具创造性应用于数据采集场景的思路。代码仓库里包含了完整的、可运行的示例你可以直接克隆下来根据目标网站的实际情况调整定位策略和抓取逻辑。数据抓取的世界总是在变化网站会改版反爬策略会升级但掌握了Playwright这套以用户视角模拟交互的方法你就拥有了应对这些变化的坚实基础。