ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python+Playwright实战:动态渲染站点增量爬虫设计

Python+Playwright实战:动态渲染站点增量爬虫设计 前阵子在做一个艺术类素材归档的小项目需要定期抓取DeviantArt的每日精选作品。这站点其实是个很有意思的目标页面里全是高质量插画和摄影数据量不算极大但更新规律清晰非常适合拿来练手增量爬虫。不过一开始我用 requests 写了个试水脚本请求首页源码一看傻眼了——HTML 里只有一堆 div 壳子和 JSON 拼装脚本真正的作品列表根本不在服务端渲染结果里。这篇文章就围绕这件事展开怎么用 Python Playwright 把 DeviantArt 的每日精选数据稳定抓下来并且做成增量模式——每天只抓新增部分不重复劳动。这次不是搬运一段能跑的代码就完事而是把我在实际过程中踩过的坑、想清楚的取舍、以及最后怎么设计去重和断点续爬的完整思路全部拆开讲。适合有一定 Python 基础、想搞明白动态渲染站点爬虫怎么破、或者正在纠结到底要不要上 Playwright的朋友。1. 为什么我放弃 requests 方案转投 Playwright1.1 先看第一个坑直接请求只拿到空壳DeviantArt 这个站点在 2021 年之后改成了一套全新的前端架构。首页、作品瀑布流、每日精选等页面很多区域都是前端动态渲染的。你用 requests 去拿https://www.deviantart.com/daily-deviations这个地址返回的 HTML 里确实有一些初始数据但每日精选的区域是一个异步加载的容器真正的作品列表是要靠浏览器执行 JavaScript 之后才填充进去的。我当时的测试结果是requests 拿到的 HTML 里整个精选区域是空的最多只有几个模板标签和占位符。你想用正则抽数据连数据都没有抽个寂寞。随后我去抓接口发现每日精选的数据确实有对应的 XHR 请求但问题在于这个接口有比较严格的前端签名校验一段 JS 会把一堆参数动态拼进 URL而且这些参数每隔一段时间就会变一次。你要是纯用 requests 硬闯要么 403要么拿到的响应体是一段加密字符串还得先逆向 JS。这就是典型的重度前端渲染 接口签名站点。不是说 requests 一定搞不定但你要花大量时间去逆向签名算法、模拟浏览器头部信息、维护过期的 Cookie。而对于每天定时增量抓取这种场景我更想要的是稳定、能快速上线、并且抗页面改版能力强一点的方案。1.2 Playwright 的仿真浏览器逻辑以及它的代价Playwright 本质上是把整个浏览器当成了一个可控的机器人。它启动一个真实的 Chromium 或 Firefox 内核替你渲染页面、执行 JavaScript、触发滚动和点击事件然后你从渲染完毕的 DOM 里提取数据。这样做的好处很直接站点前端怎么改只要最终渲染出来的 HTML 结构还能看到作品数据爬虫就不会被签名算法卡死。你不用关心 XHR 请求怎么加密不用逆向代码不用手动维护 Cookie。从某种角度讲你从模拟服务器交互降维到了模拟用户看网页。代价是什么——速度。浏览器渲染比纯 HTTP 请求慢几个数量级内存占用也大。我实测抓 100 个精选作品纯 requests 如果走得通可能只要十几秒Playwright 滚动加载加渲染怎么也得一两分钟。所以我的结论是像 DeviantArt 这种非得上浏览器不可的站点Playwright 是投入产出比最高的选择。但你要清楚它的定位——它不是为了高性能而生的而是为了能爬到和维护省事。1.3 爬虫方案选型什么时候该用 Playwright什么时候不该用结合这次实践我总结了一套很简单的选型逻辑如果目标页面是服务端渲染数据直接嵌在 HTML 里用 requests 是最好的快且省资源。如果是前端异步加载但你能轻松找到并模拟它的 XHR 接口而且签名不复杂requests 也可以配合 Session 管理 Cookie 就行。如果接口有动态签名、页面结构复杂、或者数据分散在多个异步加载中那别折腾了直接上 Playwright。这次项目里DeviantArt 的签名校验和动态渲染并存我当然选了第三条路。之后你会在第 4 节看到Playwright 这套方案从写代码到跑通首版我用了不到一天后续跑了一周都很稳。这个效率是用 requests 逆向接口没法比的。2. 增量爬虫的设计核心去重表与断点续爬2.1 全量爬虫和增量爬虫最大的差别在状态很多朋友写爬虫都是一次性逻辑从头爬到尾把结果存下来结束。但一旦你面对的是一个每天更新的站点连续跑几天之后你就会发现一个很尴尬的问题重复数据越来越多。同一张作品图今天在精选里明天可能还是热门推荐你用全量逻辑去抓每次都会重复下载和入库。增量爬虫跟全量爬虫的本质区别就是它维护了一个状态什么东西已经抓过了什么东西是新的。这个状态通常用一个持久化存储来保存——最简单的就是一个数据库表里面记着每次抓取的作品 ID。下次抓取前先查一遍数据库过滤掉已经存在的记录只处理新的。听起来很朴素但这是整个系统稳定性的基石。如果不做增量你每天跑全量不仅浪费流量和 CPU还容易触发站点限流。增量做对了单次请求量基本是稳定的不会随着数据积累爆炸。2.2 作品 ID 是最稳的去重主键去重用什么字段很多人第一反应是作品标题。大忌。DeviantArt 上作品标题极其容易重复同一天可能有 20 幅画都叫 Untitled。如果拿标题当去重依据你会漏掉大量真实作品。正确做法是使用作品在站内的唯一标识符。DeviantArt 的每件作品都有一个独立链接形如https://www.deviantart.com/用户名/art/作品名-作品编号其中作品编号就是这个作品的稳定 ID。哪怕标题改了编号不变哪怕同标题不同画编号也不同。所以我在设计的时候把编号作为第一去重键抓取后在数据库里建了唯一索引。如果你抓的站点没有这么清晰的数字 ID也可以自己拼一个链接 作者名的复合键。原则只有一个必须找到那个在站点生命周期内不会变化的标识。2.3 断点续爬中断了不能从头再来增量爬虫还有一个很实际的需求是断点续爬。每天定时任务跑的时候不可能保证网络不抖动、程序不崩。如果爬到第 50 条突然报错退出下一次运行应该从第 50 条之后继续而不是重新开始。实现断点续爬有两个关键点已经成功入库的数据就是天然的断点标记。因为每次都会先查库去重所以中断后重新启动已入库的作品会被跳过剩下的会被继续抓取。这就是我要在数据库里做唯一索引的原因——就算程序逻辑出 bug 重复处理了数据库层面也能兜底。未入库但已写入失败的数据需要一个失败重试队列。我在项目里加了一个简单的crawl_log表记录每次任务的抓取时间、成功条数、失败条数和失败的链接列表。下次跑的时候如果发现上次有任务中断且失败批次还在就先把失败列表跑一遍再跑正常批次。这套设计让增量爬虫真正具备了无人值守的底气。我后面配置了定时任务之后基本上就是每周看一次日志确认健康度日常完全不用管。3. 从零搭建环境依赖安装与项目骨架3.1 Python 虚拟环境与依赖安装建议创建独立的虚拟环境别把依赖都装在系统 Python 里不然项目多了之后就是地狱。我这次用的是 Python 3.11加虚拟环境依赖文件长这样playwright1.40.0 sqlalchemy2.0.0 requests2.31.0 beautifulsoup44.12.0 schedule1.2.0安装步骤# 创建并进入虚拟环境Windows 用户用 venv\Scripts\activate python -m venv venv source venv/bin/activate # 安装依赖 pip install -r requirements.txt然后安装 Playwright 的浏览器内核这一步是灵魂所在playwright install chromium这个命令会下载指定版本的 Chromium。注意别看了这一步很容易卡在下载速度上。如果你在公司内网或者网络受限环境可以先下载 Playwright 的浏览器包放到本地再配置PLAYWRIGHT_BROWSERS_PATH环境变量指向它。有些代理环境下 chromium 下载失败的话换一个稳定网络源基本能解决。3.2 Playwright 同步 API 还是异步 APIPlaywright 提供两套 APIsync_playwright和async_playwright。我强烈建议新手直接使用同步方式理由就一个——代码逻辑更直观哪里等待、哪里提取一路写下来不会把人绕晕。异步版本适合在大型并发爬虫里用但那种项目中你会面临更多复杂的调度问题不是这篇要讲的主要内容。一个最基础的同步启动流程长这样from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch( headlessTrue, # 无头模式不弹出浏览器窗口 args[--disable-blink-featuresAutomationControlled] # 降低自动化特征 ) page browser.new_page( user_agentMozilla/5.0 ... Chrome/119.0.0.0 Safari/537.36 ) page.goto(https://www.deviantart.com/daily-deviations, timeout60000) page.wait_for_selector(a[href*/art/], timeout30000) print(page.title()) browser.close()这个脚本只要你跑通了后面基本就是往里面填业务逻辑。我建议把浏览器启动和关闭封装成上下文管理器这样每次任务或者每轮重试的时候都能干净地释放资源。3.3 项目目录结构把爬虫当成工程而不是脚本这次项目我用了非常传统的分层结构但特别适合学习借鉴deviant_spider/ ├── main.py # 入口负责调度 ├── config.py # 全局配置URL、浏览器参数、DB地址 ├── models.py # SQLAlchemy 数据模型 ├── spider/ │ ├── __init__.py │ ├── robot.py # Playwright 核心抓取逻辑 │ ├── parser.py # DOM 数据解析层 │ └── handle.py # 去重、入库、失败重试 ├── logs/ │ └── spider.log # 运行日志 └── requirements.txt为什么拆这么细因为爬虫最怕页面一改全盘重写。如果你把页面解析逻辑和存储逻辑写在一个 200 行的函数里改版一次你就要在屎山上找哪里是提取、哪里是入库。分层之后页面结构变了只改parser.py数据库调整只动models.py方案的可维护性完全不一样。4. 核心实战DeviantArt 每日精选页面的数据提取4.1 页面结构与元素定位的完整方法论DeviantArt 的每日精选页面Daily Deviations结构是一个瀑布流网格每个作品卡片里包含缩略图、作品标题、作者昵称、点赞数等。以我的抓取目标为例每件作品我需要的字段是作品 ID、标题、作者、作品页链接、缩略图链接、标签列表、点赞数。关键问题来了怎么定位这些元素很多人喜欢上网搜DeviantArt 爬虫 CSS 选择器之类的现成答案。但站点改版很快你复制来的选择器很可能已经失效。最顶用的方法永远是打开浏览器的 DevTools手动审查元素。具体流程我一般这样做用 Playwright 启动一个非无头浏览器手动访问每日精选页面。在页面里找到一张作品卡片右键检查。先从卡片最外层开始观察 class 命名规律。DeviantArt 的卡片普遍有元素属性例如链接都带/art/前缀缩略图在img标签里。在 Console 里测试选择器比如运行document.querySelectorAll(a[href*/art/]).length。我最终使用的核心选择器大约是作品链接用a[href*/art/]作者信息在链接附近的span或者a[href*/用户]里。由于我不能保证你现在打开页面时结构没变所以这里更想传递的方法是先定位作品卡片最外层容器再一层一层找数据。不要一上来就写一个很长很脆的选择器。顺便说一个实用技巧如果你发现某个数据在 DOM 里看得到但不好定位可以直接在 DevTools 的 Elements 面板里右键元素把它的outerHTML复制出来放到本地分析结构。这一步能省掉大量盲试选择器的功夫。4.2 用 Playwright 写第一版抓取脚本下面我给出一个已经精简过但完全可跑的示例演示怎么从页面中提取作品信息。核心思路是先把所有卡片元素通过一个宽泛的选择器取到再逐一解析def fetch_daily_deviations(page, max_items50): results [] page.goto(https://www.deviantart.com/daily-deviations, timeout60000) # 等待首批卡片渲染完成 page.wait_for_selector(a[href*/art/], timeout30000) # 滚动页面懒加载更多卡片 for _ in range(10): page.keyboard.press(End) page.wait_for_timeout(1500) cards page.query_selector_all(a[href*/art/]) if len(cards) max_items: break for card in cards: try: href card.get_attribute(href) if not href or /art/ not in href: continue # 在卡片内寻找标题和作者 title_el card.query_selector(span[title], h1, h2) # 可能随改版变化 author_el card.query_selector(a[href*/art/] span, a[href*/journal/]) results.append({ url: href, title: title_el.inner_text().strip() if title_el else , author: author_el.inner_text().strip() if author_el else , }) except Exception as e: # 单卡片解析失败不影响整体 print(f解析单卡片失败: {e}) continue return results这个方法有个明显缺点query_selector_all返回的是所有匹配链接滚动加载过程中会不断收集到重复卡片。不过没关系因为我们后面会用作品 ID 去重来过滤爬虫界的大扫把其实是在数据库那层。4.3 滚动加载与翻页的两种处理方式DeviantArt 的每日精选页是无限滚动Infinite Scroll的下拉到页面底部自动加载更多。处理方式有两种第一种模拟键盘End键触发滚动滚一下等一会儿再滚再等直到达到预期数量或没有新内容出现。上面示例用的就是这种简单直接。第二种使用page.mouse.wheel做更细粒度的滚轮控制。鼠标滚轮对某些站点更接近真实用户行为例如for i in range(20): page.mouse.wheel(0, 3000) page.wait_for_timeout(1000)两种方式我都试过对于这个页面来说差别不大。重要的是等待时机的控制每次滚动后要等新数据渲染出来再用wait_for_selector确认新卡片出现了。千万不要无脑快滚否则浏览器进程可能来不及渲染后面采集到的列表就是残缺的。5. SQLAlchemy 数据持久化把抓到的数据存进 SQLite5.1 为什么选 SQLAlchemy 而不是裸 sqlite3数据量不大用sqlite3直接操作当然也可以但一旦表结构变复杂、又要做增量判断又要做失败重试裸 sqlite3 的 SQL 语句就会被大量字符串拼接占据维护成本急剧上升。SQLAlchemy 的好处是把数据库表映射成 Python 类你用操作对象的方式来读写数据同时它内部帮你弱化了不同数据库之间的语法差异。今天你用 SQLite改天要上 MySQL只需要把create_engine的地址改一下模型代码基本不用动。我是这样建引擎的from sqlalchemy import create_engine from sqlalchemy.orm import declarative_base, sessionmaker DATABASE_URL sqlite:///deviant_spider.db engine create_engine(DATABASE_URL, echoFalse, futureTrue) SessionLocal sessionmaker(bindengine, autoflushFalse) Base declarative_base()5.2 表结构设计一张表就够了吗我的第一版设计就是一张主表artworks记录作品基础信息。跑了两天后发现不行一旦抓取任务在中间失败你根本不知道哪些链接没抓到。所以我又加了一张任务日志表crawl_tasks记录每次任务批次、状态。两张表足够了。artworks表结构大概这样class Artwork(Base): __tablename__ artworks id Column(Integer, primary_keyTrue, autoincrementTrue) deviant_id Column(String(100), uniqueTrue, indexTrue, nullableFalse) title Column(String(255), default) author Column(String(100), default) artwork_url Column(String(500), default) thumb_url Column(String(500), default) tags Column(JSON, defaultlist) fav_count Column(Integer, default0) created_at Column(DateTime, defaultdatetime.utcnow)crawl_tasks表记录每次任务批次class CrawlTask(Base): __tablename__ crawl_tasks id Column(Integer, primary_keyTrue, autoincrementTrue) task_time Column(DateTime, defaultdatetime.utcnow) total_found Column(Integer, default0) new_count Column(Integer, default0) fail_count Column(Integer, default0) failed_ids Column(JSON, defaultlist) # 失败的链接列表下次补跑5.3 写入去重数据库唯一约束与代码双重校验去重是本篇的灵魂。我在数据库层面给deviant_id加了唯一约束这样就算程序有并发或者重复处理数据库也不会产生重复记录。但如果你直接执行INSERT遇到重复会抛异常所以写入时建议使用先查后写加异常兜底的双重策略def save_artwork(session, item): # 先查一次库 exists session.query(Artwork).filter_by(deviant_iditem[deviant_id]).first() if exists: return False record Artwork(**item) session.add(record) try: session.commit() except IntegrityError: # 发生唯一约束冲突说明是并发或之前已存在回滚即可 session.rollback() return False return True这里有一个小细节session.query(Artwork).filter_by(...).first()每次都要跑一条 SELECT对于几千条数据量完全没问题。如果你以后数据量到了几十万级别再考虑批量判断——用一次SELECT ... WHERE deviant_id IN (...)把已有 ID 全部拉出来集合判断去重能省很多次往返查询。目前我们这个场景用不着。6. 实测运行我在跑这个爬虫时踩到的 5 个坑6.1 等待元素sleep(3)为什么不够最早我写的是打开页面time.sleep(3)然后开始抓。听着合理但实际运行中经常抓到空列表。原因很简单3 秒是我假设的渲染时间并不等于真实渲染时间。网络慢一秒、图片加载卡两秒你的 sleep 就不够用了。而且 sleep 是绝对等待普通情况下多余等待也会拖慢整体速度。正解是用 Playwright 的wait_for_selector去等待关键节点出现。page.wait_for_selector(a[href*/art/], timeout30000)这句话的意思是给我 30 秒上限但如果 2 秒就出现了马上继续往下走。这比我手动 sleep 高效得多而且几乎不会误判页面尚未加载好。6.2 点击加载更多失效的真凶隐藏遮罩层DeviantArt 页面有时候会在底部弹出一个登录推荐或者Cookie 政策之类的浮层这个浮层会像一堵墙一样挡住页面底部的按钮导致模拟点击没反应。我调试了很久发现单纯用page.click()去点Load More是点不到的因为元素在视觉上被遮罩覆盖了。解决方法是先强制隐藏遮罩层page.evaluate( () { // 找到所有能遮挡的元素直接移除或隐藏 const banners document.querySelectorAll(div[class*banner], div[class*modal], div[class*toast]); banners.forEach(el el.remove()); } )这段 JS 不算优雅但在实战里特别管用。或者你也可以用 Playwright 的click(forceTrue)去强制触发点击事件完全绕过可见性检测page.click(textLoad More, forceTrue)6.3 请求频率控制别把自己的 IP 坑了Playwright 因为是真实浏览器所以触发的请求都是真实浏览器流量。但这不代表你可以疯狂刷新和滚动。DeviantArt 对流量异常是有风控的短时间内大量翻页、大量图片加载很容易被判定为机器人。我给出的建议是把headlessTrue时仍然正常设置一个真实的移动/桌面 UA。每次页面滚动之间至少等待 1.5 到 2 秒模拟人看图的节奏。不要连续长时间高强度抓取每抓 5 分钟休息 10 秒。我实际运行后感觉频率控制好这个爬虫连续跑两周没有出现明显的验证码或封 IP 问题。记住爬虫不是越快越好稳定压倒一切。6.4 数据异常与容错坏数据不可怕可怕的是中断有时候页面上某些卡片没有缩略图或者作者栏是个空链接。这些单点异常如果在循环里直接raise整个任务就中断了。我的做法是在解析单张卡片的逻辑里包上try...except把它当成跳过处理并把异常信息写进日志。这样坏数据不影响批次完成。另一个容易被忽视的坑是内存泄漏。Playwright 长时间运行在无头模式下如果你不断开新的页面或者不断创建新浏览器实例内存曲线会一路上涨。我的建议是每个任务批次都重新启动一个浏览器实例抓完就关闭不要复用同一个browser跨批次运行。6.5 日志与可视化进度不要等到崩了才后悔爬虫是需要看日志的。我开始跑的第一周靠print输出到控制台周六才发现某个晚上任务崩了而崩溃就发生在周二的凌晨。从那以后我加了标准 Python logging 模块将日志输出到文件并且记录每次批次的成功数和失败数。import logging logging.basicConfig( filenamelogs/spider.log, levellogging.INFO, format%(asctime)s - %(name)s - %(levelname)s - %(message)s )每一批次跑完以后我会把新增数量和失败链接列表打出来这样哪天数据偏少我能立刻从日志里发现异常而不是让任务在里面安静地错误。7. 定时增量运行与后续扩展7.1 用 schedule 做每日定时任务开发环境验证通过之后就要把它变成无人值守的定时任务。我用的是schedule这个轻量库代码简单到不用解释import schedule import time def job(): print(开始抓取每日精选...) run_spider() print(本次抓取完成) # 每天凌晨 3 点跑一次 schedule.every().day.at(03:00).do(job) while True: schedule.run_pending() time.sleep(60)如果你在生产环境建议用系统的 crontab 或者系统服务来跑可靠性更高。把schedule挂在本地进程里进程一退出就没了。我实际部署时用的是服务器上的 crontab这样每天自动执行重启都不怕。由于增量逻辑的存在定时任务跑得非常快。我每天的数据量大概新增 20~80 条跑一次可能只需要 2~3 分钟。相比全量爬虫动不动 20 分钟省出来的时间可以干很多别的事。7.2 扩展方向素材归档、标签聚类、热度排序这个爬虫跑起来之后我还顺手做了几个扩展。最实用的一个是把缩略图按作者归档下载到本地目录之后做素材库的时候直接按作者查图非常方便。另一个是把标签字段拆出来做了简单的词频统计看看最近一个月每日精选里哪些风格标签出现频率最高——比如 dark fantasy、surrealism、digital painting 这类热词。再往深走可以在artworks表上按fav_count排序做每日精选的热度王榜单。由于增量抓取持续积累你手里的数据会越来越值钱。我估计再过一两个月就可以拿这些数据做趋势分析和风格聚类了。爬虫接到数据这步只是开始真正好玩的是数据积累到一定量级之后的二次利用。我个人建议拿到稳定爬虫之后一定要留出一部分时间做数据分析和素材归档否则数据躺着不利用爬虫代码就成了自娱自乐的工具。还有一个细节想提一下DeviantArt 的作品版权归作者本人所有抓取数据用于个人学习或素材归档没问题但如果要做公开的数据集或者二次分发务必确认授权和版权边界。爬虫本身不违法关键在于你拿数据去做什么。我把这条写在最后也是希望看到这篇文章的人能合理使用这个能力而不是去伤害内容创作者的利益。
返回列表