Playwright爬取动态渲染页面:从原理到Canva模板库抓取实战
1. 为什么 requests 拿不到 Canva 的模板数据动态渲染页面的真相先说一个我踩过的老坑几年前我第一次想抓某个设计模板站习惯性地用 requests 把目标 URL 请求一遍然后交给 BeautifulSoup 去解析。结果发现 HTML 里干干净净别说模板卡片连模板标题都看不到只有一堆div idroot/div之类的空壳。当时一度怀疑是反爬把请求拦了后来才明白这压根不是反爬拦截而是网站压根就没把数据放在原始响应里。Canva 的模板库页面就是一个典型的动态渲染页面。整个页面是基于 React 这类前端框架构建的单页应用浏览器拿到手的 index.html 只是个空框架真正的模板数据是浏览器执行 JavaScript 脚本之后通过网络异步请求接口拿到的再在客户端渲染成一个个卡片。requests 拿不到 JS 执行结果自然什么都解析不出来。把这类页面比作餐厅就很好懂requests 只负责敲后厨的门后厨说我们菜单在客人桌上你自己去看而 Playwright 是直接派了一个真人进店坐下服务员会把菜端上来你看到了什么就是什么。这里真人就是一个真实运行的 Chromium 浏览器它会把 JavaScript 全部执行完模板卡片、缩略图、分类标签肉眼可见的都能拿到。所以用 Playwright 爬 Canva 这类动态设计模板库核心思路很简单不跟 HTTP 请求死磕而是让浏览器替你把页面演完再从渲染后的 DOM 里取数据。这也是现在动态爬虫的主流做法包括很多自动化测试工具本质上都是同一套交互驱动逻辑。这篇文章我从零开始走一遍完整流程环境搭建、浏览器启动、元素定位、懒加载滚动、数据提取存储再到限流反爬和合规边界。不管你是刚入门 Python 爬虫还是已经写过不少 requests 脚本、想往动态渲染方向进阶都可以照着这份实操记录试试。后面提到的选择器和页面结构我会标注清楚哪些是 Canva 的特征哪些是通用写法换到别的动态站也能复用小半部分。2. 环境准备Playwright 安装与浏览器内核踩坑2.1 安装步骤与常见报错先说环境。Python 版本建议 3.8 以上我用的是 3.10整个流程没有什么版本兼容的幺蛾子。pip install playwright playwright install chromium第一条命令装的是 Playwright 的 Python 库本身第二条命令会下载 Playwright 定制的 Chromium 浏览器内核。这里注意它下载的是 Chromium不是你日常用的 Chrome两者独立存在大小一般在 150MB 左右。下载速度慢是第一个常见坑尤其在国内网络环境下经常卡在某个百分比不动。我试过几种解决办法最稳妥的是设置镜像环境变量比如# Linux / macOS export PLAYWRIGHT_DOWNLOAD_HOSThttps://npmmirror.com/mirrors/playwright playwright install chromium # Windows PowerShell $env:PLAYWRIGHT_DOWNLOAD_HOSThttps://npmmirror.com/mirrors/playwright playwright install chromium第二个坑是系统依赖缺失。在纯净的 Linux 服务器上跑playwright install之后直接启动会报一堆.so文件找不到之类的错。Playwright 贴心地提供了自动安装依赖的命令playwright install-deps chromium如果你用的是 Ubuntu 这类系统它会帮你把缺的动态库全补上。Windows 和 macOS 一般没有这个问题但 mac 上首次运行可能会弹允许控制电脑之类的权限提示点允许就行。2.2 headless 与 headed 模式的选择环境就绪后启动浏览器是第一道选择题用无头模式还是带界面模式。from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://www.canva.com/templates/) page.wait_for_timeout(5000) print(page.title()) browser.close()headlessTrue是无头模式浏览器在后台跑不弹窗口适合部署到服务器上无人值守运行。headlessFalse则会把浏览器窗口弹出来鼠标键盘的动作都能肉眼看到。我的建议很实在初期调试用带界面模式代码逻辑稳定之后再切回无头模式。因为刚写爬虫时你会频繁遇到定位不到元素等待超时这类问题弹窗模式能直观看到页面当前状态事半功倍。还有一个参数值得提一下——slow_mo。它可以让每个操作之间插入固定延时单位是毫秒browser p.chromium.launch(headlessFalse, slow_mo500)相当于让浏览器慢动作播放调试时能看清楚 Playwright 每一步点击、输入、滚动是怎么执行的。等正式跑数据时再去掉它。2.3 上下文隔离别忽略的一个环节新手容易忽略context上下文这个概念。Playwright 里浏览器实例和页面之间还有一层上下文它相当于一个独立的隐身会话。同一个浏览器实例可以开多个上下文上下文之间 cookie、缓存、localStorage 完全隔离。context browser.new_context( viewport{width: 1920, height: 1080}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, localezh-CN ) page context.new_page()我建议正式爬取时不要用裸的browser.new_page()而是先创建一个带视口和 UA 的上下文。原因有两个一是 Canva 这类站点会看你的视口大小来决定加载多少模板卡片1920 宽度的页面能同时容纳更多卡片滚动触发加载也更稳定二是自定义 UA 能减少被识别成自动化的概率这个后面展开讲。3. 从页面加载到元素定位Playwright 核心操作拆解环境搭好之后真正开始跟页面打交道。这一章是全文的重头戏我把加载、等待、定位、提取这四件事逐个拆开讲每步都会结合 Canva 模板库的实际场景说明为什么这么做。3.1 页面加载状态与等待策略爬虫写多了之后你会形成一个条件反射拿到页面第一时间不是找元素而是先想页面真的加载完了吗。Playwright 提供了几种等待方式我把它们放在一起对比方式适用场景我的建议page.wait_for_timeout()粗暴延时固定睡眠仅调试阶段使用page.wait_for_load_state(networkidle)等网络请求基本停止动态站往往失效因为页面会持续发请求page.wait_for_selector()等某个元素出现在 DOM 里首选目标明确page.expect_response()等某个接口返回数据高阶用法性能最好具体到 Canva模板库页面是比较典型的无限滚动瀑布流数据是分批加载的networkidle经常等不到——你以为页面安静了下一秒滚动一下又冒出一批请求。所以我在实践中几乎不用networkidle而是用wait_for_selector配合特定的可见元素来判断首屏是否就绪page.goto(https://www.canva.com/templates/, wait_untildomcontentloaded) page.wait_for_selector(div[data-testid^template-card], timeout15000)wait_untildomcontentloaded表示只等 DOM 结构就绪不等所有资源加载完这一步通常很快。紧接着wait_for_selector是等到包含模板卡片的元素出现。Canva 的模板卡片一般会有># 先拿到所有卡片元素 cards page.locator(div[data-testid^template-card]) count cards.count() print(f当前页面卡片数量: {count}) # 取第一个卡片验证结构 first_card cards.nth(0) title first_card.locator(div).nth(1).inner_text() # 只是示例别照抄这里我强烈建议不要过度依赖nth()这种索引方式。因为卡片内部结构任何一个改版都会让你的索引整体错位。更稳的做法是利用文本定位# 按文本定位找包含模板字样的元素 category page.locator(text模板).firstget_by_text()这类基于文本的定位方式对于模板名、分类名这类人类可读内容非常有效。当然文本定位也有自己的问题同名文本太多时容易定位到不想要的元素。我的原则是能抓到>cards page.locator(div[data-testid^template-card]) for i in range(cards.count()): card cards.nth(i) name card.get_attribute(aria-label) # 很多卡片会把标题放进 aria-label img card.locator(img).first thumb img.get_attribute(src) if img else None实际上 Canva 这类模板卡片的缩略图 URL往往是可预测的 CDN 地址你甚至可以只抓aria-label再自行拼图。这里不做过多细节暴露因为具体属性会随版本变化你要掌握的核心能力是拿到一个卡片容器之后不断用locator向下钻取直到取到想要的字段。调试时多打几次inner_text()和get_attribute()看清楚实际 DOM 长什么样定位就不会瞎。3.3 提取数据的时机不要滚动一次抓一次首屏卡片定位到之后很多新手会犯一个错误页面加载一批就抓一批然后滚动再抓下一批。这种做法在动态加载页面里容易漏数据或抓重复。我推荐的做法是先把页面滚到底再统一提取。原因很简单滚动过程中上方的 DOM 节点可能会被框架回收或复用滚动前保存的引用会失效。与其维护一个复杂的增量抓取状态机不如让所有卡片都渲染出来然后一次性定位提取。后面会详细说滚动方案这里先记住结论先滚动后提取。4. 处理懒加载与无限滚动让模板库真正全部加载出来4.1 滚动策略的基础框架Canva 模板库的滚动加载机制说穿了就是页面滚动到接近底部的阈值时前端脚本触发请求下一页数据渲染新卡片。所以爬虫要模拟的只有一件事——让滚动条不断往下走。最简单的方式是直接让页面执行 JS 滚动page.evaluate(window.scrollBy(0, window.innerHeight))但这种方式有一个问题它一次只滚一屏而且滚动速度太快时部分卡片的图片还没加载出来缩略图 URL 是空的。我实测下来用鼠标滚轮的方式更接近真人操作触发加载的成功率也更高for i in range(30): page.mouse.wheel(0, 800) page.wait_for_timeout(1500) card_count page.locator(div[data-testid^template-card]).count() print(f第{i1}次滚动当前卡片数: {card_count})mouse.wheel(0, 800)表示纵向滚动 800 像素幅度适中不会一次滚过头。每次滚动后等 1.5 秒给浏览器渲染和网络请求留缓冲时间。这个延时不是固定不变的后面讲反爬时会提到优化方式。4.2 滚动终止条件别用一个死循环无限滚动页面最纠结的问题永远是什么时候停下来很多人会写一个while True循环搭配一个最大滚动次数上限比如滚 50 次就停。这个方案能跑但不优雅——模板库总条数是有限的滚动到底部之后再滚多少次也不会有新数据纯属浪费时间。我更推荐的做法是连续无新增就停止templates set() prev_count 0 no_new_count 0 while no_new_count 5: page.mouse.wheel(0, 800) page.wait_for_timeout(1500) cards page.locator(div[data-testid^template-card]).count() if cards prev_count: no_new_count 1 else: no_new_count 0 prev_count cards print(f当前卡片数: {cards}) print(f滚动结束共加载 {prev_count} 张卡片)这里有个小技巧用set去重记录已经提取过的卡片指纹比如aria-label 缩略图地址的拼接连续多次滚动后总数不再增长就认为已经到底了。连续 5 次无新增才退出是为了防止某次滚动时数据加载慢了、暂时没变化而误判。4.3 图片懒加载的兜底处理滚动加载只是第一层图片懒加载是第二层。很多模板卡片的缩略图是loadinglazy的意思是只有图片出现在视口附近时浏览器才会真正发起图片请求。所以你会发现一个很气人的情况页面结构加载完了卡片里的img标签也有了但src是空的或者是一张占位图。解决办法很粗暴让图片尽可能进入视口。滚动过程中Playwright 的scroll_into_view_if_needed()方法非常有用card.locator(img).first.scroll_into_view_if_needed() page.wait_for_timeout(500)这个方法会把元素滚动到可见区域触发懒加载。我在提取数据前会对每个卡片做一次这个操作确保缩略图 URL 是真实地址。不过也要提个醒Canva 的模板库体量很大真把每一张卡片都滚动到视口前再提取整体耗时很可观。如果只是需要模板名和链接这类文本信息根本不用等图片只有需要缩略图下载的场景才值得做这步兜底。5. 数据提取与存储从 DOM 到结构化 JSON 的完整链路5.1 提取字段与清洗逻辑模板卡片上的信息我一般只抓四个核心字段模板名、模板链接、缩略图地址、模板分类。以我实际跑过的经验Canva 卡片布局中这四个字段最稳定其他像使用次数点赞数之类的不定项会根据卡片类型变化强行提取反而容易把数据搞脏。提取阶段我个人推荐先把整个页面的卡片数据用字典组装好最后统一入库而不是每滚动一次就写入一次。这样可以避免数据重复也方便中途调试。results [] cards page.locator(div[data-testid^template-card]) for i in range(cards.count()): card cards.nth(i) # 模板链接一般在 a 标签的 href 里 link card.locator(a).first.get_attribute(href) # 模板名优先从 aria-label 取 name card.get_attribute(aria-label) # 缩略图地址从 img 的 src 取 thumb card.locator(img).first.get_attribute(src) if not name: continue results.append({ name: name.strip(), link: link, thumb: thumb, source: canva_templates })清洗时注意几个容易踩的点inner_text()取出来的文本可能带一堆换行和空格用strip()处理链接如果是相对路径要用urljoin补全缩略图地址有些是带尺寸参数的比如?w400h300如果后续要批量下载可以考虑把参数去掉来拿原图。5.2 断点续爬与去重机制数据量一大失败重来是必然的。Canva 模板库有几千上万张卡片一次爬完不太现实。我的经验是分页跑、断点续爬、数据落地三件套一起上。断点续爬最朴素的做法是每次启动时先读取已抓取的链接集合抓取时跳过已存在的链接。import json import os history_file already_done.json if os.path.exists(history_file): with open(history_file, r, encodingutf-8) as f: seen_links set(json.load(f)) else: seen_links set() # 抓取过程中 if link in seen_links: continue seen_links.add(link) # 每处理完一批就先落盘 with open(history_file, w, encodingutf-8) as f: json.dump(list(seen_links), f, ensure_asciiFalse)这个边抓边存的写盘策略看似笨但非常实用。程序中断、断网、被限流不管什么原因挂了重跑一次就能从断点继续省掉大量重复请求。如果你再用上 SQLite把已抓数据统一存在一张表里加个UNIQUE约束去重逻辑更是一劳永逸import sqlite3 conn sqlite3.connect(canva_templates.db) conn.execute( CREATE TABLE IF NOT EXISTS templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, link TEXT UNIQUE NOT NULL, thumb TEXT, source TEXT ) ) conn.commit()SQLite 单文件、零配置、支持并发读对爬虫这种量级的数据存储完全够用。之前有读者问我为什么不用 MySQL我只能说你爬个几千条数据开一个 MySQL 实例纯属杀鸡用牛刀。等到需要团队协作、数据量到百万级的时候再迁移也不迟。5.3 异常处理不要让一条坏数据毁掉整轮爬取动态网页爬虫最大的特点就是不确定性。今天能定位到的元素明天可能因为页面改版就定位不到了。所以异常处理不是可有可无的装饰而是工程化的底线。from playwright.sync_api import TimeoutError as PlaywrightTimeoutError try: cards page.locator(div[data-testid^template-card]) cards.first.wait_for(timeout10000) except PlaywrightTimeoutError: print(模板卡片元素不存在页面结构可能已改版) page.screenshot(patherror.png, full_pageTrue) raise这条建议值回票价页面结构变了、接口挂了、网络被限这些异常都会体现在某个元素等待超时上。把超时的瞬间截图存下来再配合当时的 HTML dump你就能事后分析失败根因而不是对着报错信息瞎猜。我在跑 Canva 模板库的时候就遇到过几次卡片已经定位到但提取到一半元素被回收的情况。这种动态渲染页面DOM 节点复用太常见了。处理方式是给整段提取逻辑加个重试装饰器失败则重新定位、重新提取。下面这段代码是我常用的简单重试框架import time def retry_on_failure(func, retries3, delay2): for attempt in range(retries): try: return func() except Exception as e: print(f第{attempt1}次执行失败: {e}) time.sleep(delay) raise Exception(重试次数耗尽) def extract_all_cards(page): # ... 提取逻辑 return results results retry_on_failure(lambda: extract_all_cards(page))重试不是万能的但它能屏蔽掉大部分暂时性的抖动。等经验多了你就会明白爬虫工程的稳定性不是靠代码写得完美而是靠容错兜底兜得足够多。6. 反爬与稳定性真实场景下的拦路虎与应对6.1 基础防护请求拦截与静态资源过滤聊完了提取和存储接下来是每个爬虫项目都绕不开的反爬问题。先说一个容易误解的点Canva 这类大厂网站的防护策略重点通常不在拒绝你访问而在让你访问成本变高——加载一堆无关紧要的统计脚本、字体、埋点把你的爬虫拖垮。所以我第一个优化是拦截无用请求。Playwright 的page.route()能拦截请求并选择放行、中止或改写。我的实践里图片字体广告追踪类请求全部原地掐掉因为我不需要它们def block_unnecessary(route, request): resource_type request.resource_type if resource_type in [image, media, font]: route.abort() elif request.url.startswith(https://www.google-analytics.com): route.abort() elif request.url.startswith(https://www.facebook.com): route.abort() else: route.continue_() page.route(**/*, block_unnecessary) page.goto(https://www.canva.com/templates/)这个操作立竿见影。我第一次跑完整流程时页面加载耗时几十秒拦截之后首屏加载快了几倍。注意拦截图片请求不会影响模板数据的提取——模板名称、链接这些文本信息都在 DOM 里本来就跟图片请求无关。只有你需要下载缩略图时才需要单独放行图片请求。6.2 真人化操作随机延时和变速滚动动态网页的另一个特征是网站可以通过判断操作间隔是否太规律来识别爬虫。人类的鼠标滚轮滚动不是机械匀速的而是有快有慢、有停顿。写死wait_for_timeout(1500)这种固定等待时间一长容易被后端风控盯上。我的做法是引入随机延时import random def human_pause(min_seconds0.8, max_seconds2.0): time.sleep(random.uniform(min_seconds, max_seconds)) # 滚动加载时可以这样用 for i in range(40): page.mouse.wheel(0, random.randint(500, 1200)) human_pause()滚动幅度和等待时长都变成随机的服务器端看到的操作模式更接近真实用户。这个方法不需要任何复杂的机器学习辅助但它能把请求指纹的规律性抹掉一大半。另一个容易被忽视的细节是用户代理。Playwright 默认的 UA 里带HeadlessChrome字样等于在告诉服务器我是个自动化工具。所以启动上下文时自定义 UA 是必需的这个在第 2 章已经提到过。除了 UA还可以设置locale、timezone_id、color_scheme这些属性让浏览器环境更贴近真实用户。6.3 验证与验证码的边界做爬虫的都会遇到验证页。Canva 在流量异常时会弹人机验证这个页面长得像一张带复选框或者图片选择的任务卡一旦触发正常的选择器和滚动操作都会失效。我要在这里亮明观点破解验证码不在本文讨论范围内也建议你不要去碰。原因不只是合规风险更现实的是验证码的破解方案更新极快、维护成本极高而且一旦被抓到你的 IP 会被列入黑名单连正常浏览都受影响。我在项目里遇到验证页时的处理是识别到就止损。用标题或者特定元素判断当前是否被验证拦截if 验证 in page.title() or page.locator(text验证).first.is_visible(): print(检测到验证页暂停运行等待手动处理) page.wait_for_timeout(30000) browser.close() raise SystemExit(验证拦截需要人工介入)更稳妥的做法是降低并发、拉长间隔、控制单次采集量从源头上减少触发验证的概率。爬虫不是暴力工具它是一个需要温柔以待的外交官——把频率降低到目标网站可接受的范围双方都能互不打扰地干自己的事。6.4 综合稳定性异常截图与日志留痕爬虫跑一两个小时是常态半夜崩了没人守着的情况也常发生。所以日志系统不是加分项是必需品。我习惯在每个关键步骤打印带时间戳的信息同时把失败现场留档import logging from datetime import datetime logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.FileHandler(crawl.log, encodingutf-8), logging.StreamHandler() ] ) def log_and_screenshot(page, tag): ts datetime.now().strftime(%Y%m%d_%H%M%S) page.screenshot(pathfdebug/{tag}_{ts}.png, full_pageTrue) logging.warning(f触发异常截图: {tag})排查问题的时候日志 截图比什么调试器都好使。看到滚动无新增日志连续出现 5 次你就知道到底了看到验证页拦截你就知道该歇一歇了看到定位超时大概率是页面改版了。这一套下来即便爬虫半夜出问题第二天一早看日志也能定位到大概原因。7. 合规边界与实战后的几点体会讲完技术最后必须聊合规。这也是很多新手最容易忽略的部分。爬取公开页面数据在多数场景下是没问题的但你需要搞清楚几个边界遵守网站的robots.txt和用户协议大多数大型网站会在用户协议里明确禁止未经许可的批量抓取Canva 也不例外。爬取前先去目标网站看一眼相关条款评估风险。区分公开数据和付费内容免费模板的分类名、标题、缩略图这些公开信息抓取风险相对低但如果你试图绕开付费墙抓 Premium 模板性质就变了这是明确不可为的事。数据用途决定风险等级爬下来自己研究、做个人素材整理和爬下来打包转卖、做接口收费法律风险完全不是一个量级。前者就像你在图书馆抄了点笔记后者则可能构成不正当竞争。我的建议是把这类爬虫项目定位成技术研究 个人效率工具抓取范围限制在公开数据存储在自己本地不对外提供抓取服务。这样既能练到 Playwright 的动态页面处理能力又不至于把自己置于法律风险之中。最后分享一点个人感受。现在回过头看写这个爬虫项目最大的收获不是学会了 Playwright API而是培养了一套动态页面的解题思路先搞清页面是如何渲染的再决定等待什么信号先确认元素的位置特征再设计定位策略先把滚