用Playwright打造新闻热搜自动采集爬虫:从环境搭建到定时运行
我平时有收集新闻热搜做数据分析的习惯早先靠手一个个复制粘贴点一次页面就得停下整理一次后来实在受不了这个重复劳动就写了这个基于 Playwright 的自动化爬虫脚本。如果你也在做内容监测、竞品动态跟踪或者单纯想攒一份属于自己的历史热搜数据集这篇文章可以帮你把整个链路理顺从环境搭建、页面解析到数据落盘、定时运行都有完整代码可以直接抄。写这个脚本之前我也犹豫过用什么方案。一开始想用 requests 直接请求页面结果返回的 HTML 里根本找不到热搜列表——因为现在的热搜榜普遍是 JavaScript 动态渲染的直接请求等于拿到一张没有内容的空壳。后来转向 Playwright问题一下解决了它能真实打开浏览器、执行页面脚本、等待目标内容出现在 DOM 里然后再把数据抓出来。下面我把从选型到上线的完整过程展开讲。1. 为什么选 Playwright 做热搜抓取1.1 requests 方案痛在哪里如果你只是抓一个服务端渲染的静态网页requests 当然又轻又方便。但今天的新闻热搜页基本都是前端异步渲染热搜数据是页面加载完成后脚本再向接口请求并渲染到 DOM 上的。我第一次用 requests 去拿某个资讯站点的热搜页返回的 HTML 里只有一个骨架和一堆 JavaScript 文件引用真正的榜单内容一个都看不到。有朋友可能会说那我顺着页面脚本去挖那个异步数据接口不就行了这个思路理论上成立但实践下来维护成本很高。接口地址、请求参数、返回结构随时可能调整前端一改版你的抓取逻辑就全线作废。我遇到过某平台连续几天更新接口逻辑那几天排查成本比重新写爬虫还高。浏览器自动化工具就没有这个问题。Playwright 模拟的是真实用户打开浏览器、等待页面渲染完成的完整过程页面上能看到什么脚本就能读到什么完全不依赖后端接口的内部结构。从长期维护角度看只要页面展示结构不调整爬虫就能一直稳定运行这是 requests 方案给不了的。1.2 Playwright 的三个硬核优势第一天然支持 JavaScript 渲染。前面说过浏览器自动化工具会完整执行页面脚本等数据真正渲染出来再抓取这就把动态页面抓取的第一大难题直接消掉了。第二自动等待机制做得很聪明。很多初学者写的时候习惯 time.sleep(3) 硬等几秒但网络一波动等够了还是不行。Playwright 的 wait_for_selector 会以极短间隔轮询 DOM让 CSS 选择器能定位到目标元素后立刻往下执行。网络快就快跑网络慢就多等一会效率和稳定性都好很多。第三调试体验完整。Playwright 自带代码录制器、页面截图、trace 文件记录等功能。脚本跑挂的时候直接打开 trace 就能回放当时的页面状态、Console 输出和网络请求定位问题比 Selenium 那套额外配置省心太多了。1.3 同类工具横评决定用 Playwright 之前我也简单横向比较过 Selenium 和 Puppeteer这里给你一个参考。工具支持的浏览器安装复杂度自动等待调试工具链SeleniumChrome、Firefox、Edge、Safari 等需要单独下载 WebDriver版本要匹配浏览器经常踩坑需要显式 Wait 才比较好用否则容易误用 sleep依赖第三方扩展配置麻烦Puppeteer仅 Chromium 系npm 安装自带 Chromium 下载不算费事DOM 等待需要手写稍微有点繁琐有内置截图和性能分析但缺少 trace 级别的定位手段PlaywrightChromium、Firefox、WebKitpip install 加 playwright install chromium 两步搞定原生 wait_for_selector 轮询机制录制器、截图、trace 全都有排查效率很高Selenium 是老牌工具生态大但说实话配置 WebDriver 这件事在团队里经常轮番踩坑。Puppeteer 和 Playwright 同代能力接近只是语言生态上 Python 使用者更愿意用 Playwright。我自己在 Python 技术栈所以选了 Playwright。2. 环境准备与工程骨架2.1 安装 Python 依赖搭建环境其实就两条命令。我假设你已经装好了 Python 3.8 以上版本建议顺手建一个虚拟环境避免和系统里的其他包打架。python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install playwright playwright install chromiumplaywright install 这个步骤很容易被忽略但它是必须的。它会下载对应的浏览器二进制文件如果漏掉这一步直接运行代码会在启动浏览器时直接报错。如果是部署在 Linux 服务器上还可能需要操作系统的依赖库。保险起见可以直接跑下面的命令它会自动安装缺失的系统动态库。playwright install --with-deps chromium如果网络不好pip 可以换国内镜像源浏览器二进制文件的下载也有镜像配置方式。不过这些都属于环境调试的细节等遇到具体报错再按提示处理也行。2.2 工程目录怎么摆代码量不大但目录结构一定要从一开始理清楚后面跑历史增量、看日志、找截图都会方便很多。我常用的结构是这样hot_search_crawler/ ├── crawler.py # 爬虫主逻辑 ├── config.py # URL、UA、选择器等全局配置 ├── requirements.txt # 依赖清单 ├── data/ │ ├── hot_search.json # 最近一次抓取结果 │ ├── hot_search_history.csv # 历史数据累计 │ └── last_page.png # 每次抓取后的页面截图 └── logs/ └── run.log # 运行日志把配置单独放到 config.py 里是个好习惯。热搜页的 DOM 结构会变选择器和 URL 要改时只动一个文件就行不用在主脚本里到处翻。2.3 关键启动参数的含义先看一段最基础的浏览器启动代码with sync_playwright() as p: browser p.chromium.launch( headlessTrue, args[--disable-blink-featuresAutomationControlled, --no-sandbox] )headlessTrue 表示无头模式即浏览器不弹出图形界面适合服务器和定时任务。如果你在调试阶段可以临时改成 headlessFalse亲眼看看页面到底渲染成了什么样排查定位问题特别有效。--disable-blink-featuresAutomationControlled 是个使用率极高的参数主要用来关闭 Chromium 的自动化控制标记从源头减少页面脚本检测到“这是机器人”的概率。--no-sandbox 是在 root 用户或部分容器环境里必须加的不加的话浏览器可能直接崩溃。3. 核心 API 与开发细节3.1 配置浏览器实例的讲究一个容易被忽略但很关键的思路是让 Playwright 打开的浏览器尽量像一个真实用户在用。热搜站点不需要登录态但 UA、视口大小、时区这些指纹信息会暴露自动化操作。我在配置里会这么写context browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, viewport{width: 1440, height: 900}, localezh-CN, timezone_idAsia/Shanghai )locale 和 timezone_id 主要是让页面按照中国的习惯返回语言格式和时区信息同时让指纹更像本地用户。viewport 设为 1440x900 是大多数普通用户的屏幕尺寸能避免某些页面在窄视口下触发移动端布局影响选择器稳定性。还有一个细节现代模板渲染引擎经常使用无头浏览器的 UA 做差异处理默认的 HeadlessChrome UA 会暴露自动化特征所以显式设置一个正常 Chrome UA 非常有必要。3.2 页面跳转与等待策略跳转页面时wait_until 的取值决定了页面要到什么状态才继续执行。我一般推荐 domcontentloaded 而不是 networkidle原因很实在很多新闻页有持续的数据埋点、轮询刷新、广告请求网络永远不会完全“空闲”如果你死等 networkidle脚本超时是家常便饭。page.goto(HOT_URL, wait_untildomcontentloaded, timeout30000) page.wait_for_selector(div.hot-list li.hot-item, timeout15000)wait_for_selector 是 Playwright 里我使用频率最高的 API。它会在循环中反复检查页面里是否出现了指定的 CSS 选择器出现了立刻往下走超时则抛出异常。比起 time.sleep 硬等 5 秒再看效率能提升好几倍而且不会因为某次网络慢导致漏抓。处理动态加载榜单时还有一个小技巧页面首屏往往只渲染前十几条数据更多热搜需要滚动页面才会继续加载。遇到这种情况可以在等待首屏内容后模拟几次滚轮操作for _ in range(3): page.mouse.wheel(0, 1200) page.wait_for_timeout(300)滚动之后等待新元素出现再批量读取。这个操作能让脚本拿到更完整的数据尤其是页面做了懒加载的情况下效果非常明显。3.3 热搜元素的定位与解析定位策略直接影响脚本的稳定性。我的建议是尽量避开那种动态生成的 class 类名因为前端每次构建都可能变化。优先用稳定的语义属性、data 属性或者结构层级。比如这样items page.locator(div.hot-list li.hot-item) count items.count() for i in range(count): item items.nth(i) rank item.locator(.rank).inner_text() title item.locator(.title).inner_text() heat item.locator(.heat).inner_text().locator() 返回的是一个元素句柄调用 .count() 可以获得匹配数量再用 .nth(i) 逐个拿元素这样的写法比一次性 all_text_contents() 更灵活因为每个热搜条目还要分别提取序号、标题和热度三个字段。解析时常见的坑是元素隐藏或不可见。Playwright 默认会检查元素的可操作性有些隐藏节点通过 inner_text 拿出来的可能是空字符串。真遇到这种情况可以配合 is_visible() 做过滤只保留真实展示出来的条目。3.4 数据清洗与落盘从页面上拿到的原始文本通常带有大量空白字符和广告角标直接存 MySQL 或 CSV 都显得很脏。我常用的清洗手段是先把字符串做规范化处理把换行和多余空格全部去掉再用正则把序号、标题、热度值分开。热度数值也常出现“万”“亿”这类中文单位入库前建议统一转成纯数字便于后续的排序和统计。处理方式可以简单封装一个函数以“万”结尾就乘以一万以“亿”结尾就乘以一亿。这个细节在做历史趋势分析时会省下很多力气。数据存成什么格式看你的下游需求。预览最近一次抓取结果用 JSON 就行结构清楚随手能打开看需要积累历史数据做趋势曲线CSV 追加写入是最省事的选择。两者不冲突我现在的脚本就是同时维护两份。4. 完整实战代码与运行流程4.1 核心脚本逐行解析下面是一份可以直接复用的核心脚本注意示例中的 URL 和 DOM 结构是通用占位实际部署时替换成你目标站点的真实值即可。代码整体分四个模块配置、清洗、抓取、保存。import csv import json import os import random import re import time from datetime import datetime from playwright.sync_api import sync_playwright # 配置区 HOT_URL https://sample-news-site.example/hot-list UA_LIST [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36, ] JSON_PATH data/hot_search.json CSV_PATH data/hot_search_history.csv # 清洗函数 def clean_text(raw: str) - str: 去掉多余空白字符以及标题左侧可能残留的排名数字 text re.sub(r\s, , raw) text re.sub(r^\d, , text) return text.strip() def parse_heat(raw: str) - int: 把带万/亿的中文热度值统一转成可排序的整数 text re.sub(r\s, , raw).replace(热度, ) if text.endswith(亿) and text[:-1].replace(., ).isdigit(): return int(float(text[:-1]) * 100000000) if text.endswith(万) and text[:-1].replace(., ).isdigit(): return int(float(text[:-1]) * 10000) return int(text) if text.isdigit() else 0 # 核心抓取 def crawl_once(): with sync_playwright() as p: browser p.chromium.launch( headlessTrue, args[--disable-blink-featuresAutomationControlled, --no-sandbox] ) context browser.new_context( user_agentrandom.choice(UA_LIST), viewport{width: 1440, height: 900}, localezh-CN, timezone_idAsia/Shanghai, ) page context.new_page() # 访问热搜页等待核心列表出现 page.goto(HOT_URL, wait_untildomcontentloaded, timeout30000) page.wait_for_selector(div.hot-list li.hot-item, timeout15000) # 页面懒加载时模拟滚动让更多数据渲染出来 for _ in range(3): page.mouse.wheel(0, 1200) page.wait_for_timeout(300) # 逐个解析条目 items page.locator(div.hot-list li.hot-item) count items.count() results [] for i in range(count): item items.nth(i) rank item.locator(.rank).inner_text() title item.locator(.title).inner_text() heat item.locator(.heat).inner_text() results.append({ rank: int(clean_text(rank)), title: clean_text(title), heat: parse_heat(heat), crawled_at: datetime.now().strftime(%Y-%m-%d %H:%M:%S), }) # 按排名顺序整理 results.sort(keylambda x: x[rank]) # 保存 JSON 快照 os.makedirs(data, exist_okTrue) with open(JSON_PATH, w, encodingutf-8) as f: json.dump(results, f, ensure_asciiFalse, indent2) # 截图留档方便人工核对 page.screenshot(pathdata/last_page.png) browser.close() print(f[OK] 本次抓到 {len(results)} 条数据) return results # CSV 增量存储 def load_existing_keys(path: str) - set: 读取历史 CSV返回 (日期, 标题) 的集合用于去重 if not os.path.exists(path): return set() with open(path, newline, encodingutf-8-sig) as f: reader csv.DictReader(f) return {(row[crawled_at][:10], row[title]) for row in reader} def append_to_csv(results: list, path: str CSV_PATH) - int: 增量写入 CSV避免重复数据堆积 existing load_existing_keys(path) fieldnames [rank, title, heat, crawled_at] new_rows [] for row in results: key (row[crawled_at][:10], row[title]) if key not in existing: new_rows.append(row) if not new_rows: return 0 is_new_file not os.path.exists(path) or os.path.getsize(path) 0 with open(path, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) if is_new_file: writer.writeheader() writer.writerows(new_rows) return len(new_rows) if __name__ __main__: data crawl_once() added append_to_csv(data) print(f[CSV] 新增 {added} 条历史记录)这份代码我实际跑过的路径是启动无头浏览器、加载动态列表、滚动触发懒加载、解析条目、存 JSON、增量存 CSV全程没有问题。第一次运行建议先打开可视化模式确保选择器真实匹配你的目标页面再切到 headless 模式跑定时任务。4.2 CSV 增量去重与历史留存append_to_csv 函数解决了一个实际问题CSV 文件越攒越多重复抓取数据会导致历史记录迅速膨胀最后的统计图全是噪声。去重逻辑其实很简单用“日期 标题”拼接成一个唯一键把历史 CSV 里的所有键加载到内存然后筛选新抓结果里不存在的记录追加写入。一天之内同一个标题再次出现在榜单上时不会重复入库第二天又出现又能按新日期正常记录下来。这样既保留了历史变化又不会让数据冗余失控。编码上选择 utf-8-sig 是刻意的。直接用 utf-8 写 CSV再用 Excel 打开很容易中文乱码utf-8-sig 带 BOMExcel 识别编码更稳定。如果你下游是 Pandas 做分析utf-8-sig 也没问题read_csv 默认能处理。4.3 定时运行任务爬虫写好之后手动执行显然不合适热搜榜白天变化快但也不需要分钟级跟踪保持耐心反而是更稳妥的策略。我采用了简单的定时策略每隔 4 小时跑一次每次自动抓取并写入 CSV。最简单的定时方式是在主脚本里加一个循环import schedule import time schedule.every(4).hours.do(run_task) while True: schedule.run_pending() time.sleep(60)schedule 是轻量级调度库如果说你不想引入这个依赖直接用 time.sleep(14400) 包一个 while 循环也能达到效果。不过 schedule 的优势在于可以同时注册多个任务以后如果想扩展别的采集逻辑扩展起来方便很多。服务器上长期跑定时任务还有一个建议输出日志统一重定向到 logs/run.log这样哪天数据断了可以先看日志确认是网络问题、选择器问题还是调度问题排查效率会高很多。5. 常见问题与排查技巧实录5.1 定位不到热搜元素这是 Playwright 爬虫里出现频率最高的问题每次报错基本都是 TimeoutError 或者找不到元素。排查时我一般按顺序检查等待时间是否够长把 wait_for_selector 的 timeout 调大比如 15000 毫秒别用默认值。热搜榜数据量大时渲染时间可能超过 5 秒。选择器是否写对很多前端框架会生成动态 class 名比如 .hot-item-a1b2c3下次构建就变了。优先选稳定属性比如>