ARTICLE DETAIL

资讯详情

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

用Python和Playwright搭建无头浏览器网页截图服务

用Python和Playwright搭建无头浏览器网页截图服务 1. 为什么我需要一台能截图的浏览器先讲个实际场景。我之前维护过一个内部报表系统每周要生成几十份统计页面发给业务方。业务方不看在线版一定要PDF或者图片理由是方便转发、方便存档、看着踏实。最开始我用的方案是前端同事写好页面我人工打开浏览器、逐张截图、再拼接命名一个上午基本就耗在这件事上了。后来数据量上来页面还要按不同参数动态生成人工截图这条路彻底走不通。另一个让我下决心手搓这个服务的事是某个第三方平台限制登录但又恰好把关键数据渲染在页面上接口又不公开。我需要定期把页面状态拍下来做留痕分析。这个需求本质上是给定一个URL或者一段HTML自动打开、等渲染完成、截图保存整个过程无人值守、可批量、可定时。市面上当然有现成的截图服务但要么收费按张计要么部署笨重要么依赖外部网络环境。我自己手里是一台普通Linux服务器Python环境现成就决定用纯Python方案自己搭一个无头浏览器渲染系统。这个系统解决的问题很直接把HTML页面变成PNG也能顺手转PDF全自动支持动态JS渲染支持自定义视口尺寸、等待时间、截图区域还能通过HTTP接口让别的服务调用。适合谁适合所有需要批量网页截图、页面留痕、HTML转图片、甚至做简单视觉回归测试的人。不需要懂浏览器内核不需要装一堆浏览器插件一个Python脚本加几个依赖就能跑起来。纯Python的无头浏览器渲染系统听起来有点唬人其实拆开来就三件事找对渲染内核、写对调用代码、处理好等待和异常。下面我按自己从零搭建的过程把每一步怎么选、怎么配、踩了什么坑全部写清楚。2. 渲染内核选型为什么最终选了Playwright2.1 网上方案那么多为什么不能直接用Selenium说到Python操作浏览器很多人第一反应是Selenium。我最早试的也是它。但实际用下来有几个痛点让我放弃了第一Selenium需要配套WebDriver而WebDriver版本必须和浏览器版本严格对应。我服务器上的Chrome是自动更新的今天写好的脚本过两周可能就因为浏览器升级导致WebDriver不匹配直接崩掉。每次都要重新下载匹配版本这在自动化场景里非常影响稳定性。第二Selenium对等待渲染完成这件事支持得比较原始。虽然也有显式等待、隐式等待但在复杂页面大量异步请求、图表库懒加载面前我还是得靠time.sleep()去猜时间。猜短了截图白屏猜长了浪费效率。第三Selenium默认是带界面的。服务器上没显示器得额外装Xvfb这类虚拟显示工具又增加一层复杂度。当然Selenium不是不能用只是它适合我就在开发机上手工跑、环境可控的场景。做服务化部署它不够省心。2.2 对比Pyppeteer和Playwright后来我把目光转向无头浏览器方案重点看了两个Pyppeteer和Playwright。Pyppeteer是Puppeteer的Python移植版底层也是走Chrome DevTools Protocol。我试过写起来确实简单但它有个致命问题项目维护节奏比较慢官方更新不勤遇到新版Chromium经常有兼容性问题。而且Pyppeteer安装时会自动下载Chromium这个下载在国内网络环境下经常半路失败很折磨人。Playwright是微软维护的项目同样基于CDP但比Pyppeteer做得彻底得多。它的特点我总结三条自带的浏览器下载机制更可靠支持从镜像源下载安装体验比Pyppeteer好一个量级。API设计更统一同步/异步都有定位元素、等待事件、截图、PDF导出一气呵成。对动态内容的等待机制非常强有Network请求空闲等待、元素状态等待、超时自动重试这些正是截图服务最需要的。2.3 我的最终选型结论选型结论就是无头浏览器用Chromium控制层用Playwright Python版。理由很简单——它把打开页面—等渲染—截图这条链路封装得最顺手而且浏览器实例复用、并发隔离这些服务化场景需要的能力它都原生支持。另外提一句如果你只是拿HTML字符串转为图片、不需要执行复杂JS也可以用imgkit依赖wkhtmltoimage这类轻量工具。但它的渲染能力和现代浏览器差太远稍微有点CSS Grid/Flexbox或者ES6语法就渲染不对。我服务的需求是忠实还原页面所以必须上真浏览器引擎。3. 环境搭建与依赖安装这步最容易翻车3.1 Python环境准备先说明我的环境Ubuntu 20.04服务器Python 3.10。理论上Python 3.8以上都能跑。我建议全程用虚拟环境不要往系统Python里直接装包。我习惯建一个项目目录专门放这个服务和它的虚拟环境mkdir -p /opt/html2shot cd /opt/html2shot python3 -m venv venv source venv/bin/activate这一步的好处是以后就算系统Python升级、或者安装了其他项目依赖也不会互相污染。特别是服务器上还可能跑着别的Python服务虚拟环境能避免依赖冲突。3.2 安装Playwright和浏览器内核虚拟环境激活后装Playwrightpip install playwright playwright install chromium这里有几个容易踩的坑我挨个说坑一playwright install chromium 下载失败或超时。Playwright默认从微软的CDN下载浏览器二进制文件某些网络环境下很不稳定。解决办法是设置镜像环境变量export PLAYWRIGHT_DOWNLOAD_HOSThttps://npmmirror.com/mirrors/playwright playwright install chromium如果你用的是Windows对应是set命令。这一步设置完下载速度会明显提升。坑二Linux服务器缺少系统依赖。如果直接跑启动浏览器时会报一堆类似libnss3.so、libatk-1.0.so.0找不到的错误。Playwright给了一个现成的补依赖命令playwright install-deps chromium它会用系统包管理器装齐所有需要的库。注意这个命令要sudo执行sudo playwright install-deps chromium坑三根用户运行Chromium会报Running as root without --no-sandbox is not supported。如果你服务器上习惯用root操作启动浏览器时必须加参数browser p.chromium.launch(args[--no-sandbox])我在开发阶段就是root跑的没加这个参数浪费了半小时查报错。不过生产环境我更建议用普通用户跑服务尽量别把--no-sandbox这种参数常态化毕竟它绕过了Chromium的安全沙箱机制。3.3 验证环境是否可用装完之后先跑一个最小脚本验证环境from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch() page browser.new_page() page.set_content(h1Hello World/h1) page.screenshot(pathtest.png) browser.close() print(setup ok)如果test.png正常生成且内容正确说明整个链路通了。这一步我很推荐在动手写完整服务前先做掉能把环境问题和代码问题分开排查。4. 核心服务设计从单个截图函数到HTTP接口4.1 最基础的截图函数先把最简单的功能实现给定一个URL截图保存到指定路径。代码如下from playwright.sync_api import sync_playwright def shot_url(url: str, output: str, width: int 1280, height: int 800): with sync_playwright() as p: browser p.chromium.launch() page browser.new_page(viewport{width: width, height: height}) page.goto(url, wait_untilnetworkidle) page.screenshot(pathoutput) browser.close()这里有两个细节要说一下。一个是wait_untilnetworkidle。它的意思是等待网络空闲通常指500ms内没有新请求才算页面加载完成。对于大部分页面这个策略比默认的load事件可靠因为load只代表页面主文档加载完不代表异步JS请求都跑完了。但networkidle也不是银弹稍后我会专门说它的问题。另一个是viewport参数。截图的分辨率取决于浏览器视口大小和显示器无关所以必须在new_page时显式设置。4.2 HTML字符串直接渲染我的服务常常需要把数据库里拼好的HTML模板直接转成图片根本没有URL。这个场景要改用page.set_content()from playwright.sync_api import sync_playwright def shot_html(html_content: str, output: str, width: int 1280, height: int 800): with sync_playwright() as p: browser p.chromium.launch() page browser.new_page(viewport{width: width, height: height}) page.set_content(html_content, wait_untilnetworkidle) page.screenshot(pathoutput) browser.close()注意一个细节如果HTML里引用了外部CSS或图片set_content默认的base_url是about:blank所以相对路径会加载失败。这时要指定base_urlpage.set_content(html_content, wait_untilnetworkidle)如果页面中有大量外链资源更好的是先page.goto(file:///path/to/html)或者传入一个真实的base URLpage.set_content(html_content, base_urlhttp://127.0.0.1:8080, wait_untilnetworkidle)这样页面里的相对路径就能正确解析了。4.3 全页面截图与元素截图默认的screenshot只截当前视口范围内的内容。如果页面很长需要滚动截全图要加一个参数page.screenshot(pathoutput, full_pageTrue)full_pageTrue会自动滚动整个页面并拼接成一张长图。这个功能对于截取长报表、长文章特别有用。还有种需求是只截某个元素比如只截图表区域、只截表格。做法是先用选择器定位元素再截element page.query_selector(#chart-container) element.screenshot(pathchart.png)元素截图会自动按元素尺寸裁剪不需要自己去算位置和大小。我在处理业务方只要表格不要整个页面的需求时用的就是这种方式。4.4 把功能包成HTTP接口脚本本身方便但要给别人用、给其他服务调用还是得提供HTTP接口。我用Flask写了最简单的服务化封装from flask import Flask, request, jsonify from io import BytesIO import base64 import uuid from playwright.sync_api import sync_playwright app Flask(__name__) playwright sync_playwright().start() browser playwright.chromium.launch() app.route(/shot, methods[POST]) def shot(): data request.get_json() url data.get(url) html data.get(html) width data.get(width, 1280) height data.get(height, 800) full_page data.get(full_page, False) if not url and not html: return jsonify({error: url or html is required}), 400 page browser.new_page(viewport{width: width, height: height}) try: if url: page.goto(url, wait_untilnetworkidle) else: page.set_content(html, wait_untilnetworkidle) shot_bytes page.screenshot(full_pagefull_page) finally: page.close() img_base64 base64.b64encode(shot_bytes).decode() return jsonify({image: img_base64, format: png}) if __name__ __main__: app.run(host0.0.0.0, port8765)这里有个性能关键点我把浏览器实例做成全局复用的而不是每次请求都重新启动浏览器。因为启动一个Chromium实例大概要几百毫秒到一两秒而复用实例之后单次截图耗时主要花在页面加载上能明显提高吞吐量。但这个方案有个并发问题多个请求同时使用同一个browser实例没问题因为每个请求开的是独立pagepage之间天然隔离。真正要注意的是sync_playwright在Flask多线程模式下是否安全。实际经验是简单场景下全局复用没问题但高并发时更稳妥的做法是启动多个浏览器实例放入连接池或者改用playwright的异步API配合FastAPI。我的服务并发量不高同步加全局复用就够了。把Flask服务跑起来之后调用方只需要发一个POST请求curl -X POST http://127.0.0.1:8765/shot \ -H Content-Type: application/json \ -d {url: https://example.com, full_page: true} \ --output result.json返回的JSON里image字段是base64编码的PNG调用方自己解码保存或展示。4.5 顺手把PDF输出也做了截图服务做完后我发现很多场景其实要的是PDF而不是图片比如给业务方发正式报告、存档审计。Playwright的page.pdf()接口可以轻松实现pdf_bytes page.pdf(formatA4, print_backgroundTrue)用PDF有个好处PDF里文字是矢量可复制的不像PNG是纯像素。而且同样质量的文档PDF体积往往更小。我在接口里加了format参数传png就返回图片传pdf就返回PDF两行代码多一个能力性价比很高。5. 等待策略与动态页面截图白屏的九成原因都在这5.1 networkidle为什么不是万能的最开始我信心满满以为wait_untilnetworkidle就稳了。结果上生产环境第一周就翻车一批图表页面截图全是空白。排查过程是这样的我加了一堆日志发现页面请求确实都结束了networkidle也触发了但截图就是不满屏。后来我单独在本地用有头浏览器打开同一个页面发现那些图表数据是页面加载后过了两三秒才从WebSocket推送过来的页面加载完的那一刻DOM里根本没有图表节点。所以networkidle只代表此刻没有网络请求不代表业务数据渲染完了。很多页面是先加载框架再通过WebSocket、EventSource、或者setTimeout触发二次更新。这类动态内容用networkidle完全判断不了。5.2 我最终采用的综合等待方案针对上面的问题我的最终方案是网络空闲 元素存在 最小等待时间三重组合from playwright.sync_api import expect def wait_page_ready(page, selectorNone): # 第一重网络空闲 page.wait_for_load_state(networkidle) # 第二重关键元素出现如果调用方指定了 if selector: expect(page.locator(selector)).to_be_visible(timeout10000) # 第三重额外的业务等待时间为了应对看不到的异步推送 page.wait_for_timeout(2000)第一个等待网络空闲保证主体资源加载完第二个等待关键元素可见保证页面渲染到用户能看到的程度第三个固定等2秒给那些静默异步更新的数据留出时间窗口。这个方案牺牲了一点点速度但稳定性大幅提升。对于报表截图这种宁可慢一点也要完整的场景非常划算。如果你的页面更新时机有明确标志比如某个loading元素消失还可以把第三重替换成监听那个元素的消失状态效率和可靠性会更高。5.3 如果怎么等都截不全怎么办——事件回调兜底有些极端页面数据是持续流式的根本不存在完全空闲的时刻。遇到这种页面networkidle会一直等不到最终超时报错。解决办法是监听页面console消息或网络响应当捕获到某个特定业务信号时再截图。举个例子def on_console(msg): if msg.text RENDER_COMPLETE: page.screenshot(pathdone.png) page.on(console, on_console) page.goto(url, wait_untildomcontentloaded) page.wait_for_timeout(15000)这种方案需要业务页面配合在渲染完成时打一条标记日志。但如果你能控制目标页面的代码这是最精确的等待方式。6. 踩坑实录稳定性从勉强能用到半夜不报警6.1 内存泄漏与浏览器实例管理服务上线跑了一周后发现一个问题服务器内存持续上涨最终把服务OOM干掉。排查过程是典型的先看监控、再逐层定位。我先用free -h确认内存占用曲线再用ps --sort-%mem定位到是Chromium进程占内存最多。再细看发现只要跑了一段时间系统里就有大量残留的Chromium渲染进程。根因其实是我代码里一个粗心点全局复用的browser虽然一直不关但每个page如果忘了closeChromium的渲染进程就不会释放。我最初在异常分支里只关了page、没在特殊情况下兜底清理。修复办法很朴素无论成功失败都要把page关掉用try/finally包裹。代码结构上做了两点改进第一明确page的生命周期边界每个请求创建的page必须在请求结束时无条件close。第二加了一层browser级的健康检查如果发现页面创建异常就重启浏览器实例import atexit def get_browser(): global browser try: browser.pages # 探测浏览器是否存活 except Exception: browser playwright.chromium.launch(args[--no-sandbox]) return browser另外我写了个定时任务每天凌晨重启一次服务。虽然不算优雅但可以彻底重置Chromium的内存碎片化问题实测非常管用。6.2 字体渲染缺失导致截图发虚有段时间业务方反馈截图里中文和数字都正常但某些特殊符号变成了方框。查下来发现是服务器上没装对应的字体库。这个问题的本质是Chromium运行在无头环境下字体渲染依赖系统字体目录。Windows和macOS天然带一大堆字体但精简版Linux服务器上可能只有很少的字体包。解决办法sudo apt install fonts-noto-cjk fonts-noto-color-emoji装完中文字体后再清一下系统字体缓存sudo fc-cache -f然后重启服务就可以了。这里建议你在部署文档里把字体依赖写清楚否则换一台新机器部署时会莫名踩坑。6.3 定时任务会话过期问题我的报表截图服务是每分钟跑一次的定时任务。有一次凌晨的定时任务突然批量失败我爬起来排查发现是目标页面的登录会话在凌晨过期了所有截图都跳转到了登录页。这个问题和截图服务本身无关但它是自动化截图场景必然会遇到的坑。解决方案我用了两种第一种给页面设置持久化登录态。Playwright支持持久化上下文把登录后的cookie和storage存下来from playwright.sync_api import BrowserContext context browser.new_context(storage_statelogin_state.json) page context.new_page()第一次先用有头模式手动登录一次然后用page.context.storage_state(pathlogin_state.json)把状态保存下来之后每次启动都用这个状态。这种方式对不需要交互验证的站点很有效。第二种如果目标页面支持API登录就写一个登录函数截图前先通过接口换取token再注入到页面里。这种方法更通用但需要目标系统提供接口。这个坑提醒了我一件事截图服务不只是打开URL按快门很多时候它背后依赖的登录态、权限、Cookie才是真正需要花时间维护的。6.4 大页面截图的像素上限还有一个冷门坑。full_page截图在一些特别长的页面上会失败或者导出被截断。原因是Chromium对纹理有大小限制不同显卡驱动的上限不一样。我在无头服务器上遇到的限制是约16384像素宽/高。超过这个尺寸合成纹理会出问题。我的解决方案是如果预测页面很长就分段截图再拼接。简单做法是滚动页面每屏截一张再用PIL拼成完整长图。脚本思路如下from PIL import Image def shot_full_page_by_parts(page, output, part_height4096): # 先获取总高度 total_height page.evaluate(() document.body.scrollHeight) scroll 0 parts [] while scroll total_height: page.evaluate(fwindow.scrollTo(0, {scroll})) page.wait_for_timeout(300) img page.screenshot(clip{x: 0, y: scroll, width: 1280, height: part_height}) parts.append(Image.open(io.BytesIO(img))) scroll part_height # 拼接 result Image.new(RGB, (1280, total_height)) y 0 for part in parts: result.paste(part, (0, y)) y part.height result.save(output)注意拼接时相邻两屏之间需要留一部分重叠区域否则页面滚动过程中如果存在懒加载图片拼接处容易有空白。具体重叠多少要看页面懒加载的距离我一般留300到500像素的冗余拼接时从上一张的末尾处切开。6.5 敏感信息干扰问题我还遇到过一个情况截图时页面右下角突然弹出客服对话窗、通知栏、或者使用Cookie的弹层把正文内容挡住。这些弹窗是页面运行时动态插入的networkidle等不到它们消失因为它们可能一直存在。我的处理方案是截图前主动隐藏或删除干扰节点page.evaluate( () { const selectors [.cookie-banner, #chat-button, .notification-popup]; selectors.forEach(sel { document.querySelectorAll(sel).forEach(el el.remove()); }); } )这个操作要放在最终截图之前并且最好只针对你知道一定会出现的干扰元素。如果页面经常变化维护这类选择器列表也是一项日常工作。7. 服务的高可用与监控实践7.1 用任务队列避免请求超时HTTP接口本身是同步的如果用户传了一个特别慢的页面请求可能要卡好几十秒。实际使用中调用方很难接受那么久的同步等待。我的做法是引入一个简单的任务队列收到请求后立刻返回task_id后台线程真正执行截图调用方轮询结果接口获取图片。实现思路用Flask加一个全局队列和几个worker线程import queue import threading import uuid task_queue queue.Queue() task_results {} def worker(): while True: task_id, url, html, width, height, full_page task_queue.get() try: # 执行截图逻辑 result do_shot(url, html, width, height, full_page) task_results[task_id] {status: done, result: result} except Exception as e: task_results[task_id] {status: error, message: str(e)} for _ in range(4): t threading.Thread(targetworker, daemonTrue) t.start() app.route(/shot_async, methods[POST]) def shot_async(): data request.get_json() task_id str(uuid.uuid4()) task_queue.put((task_id, data.get(url), data.get(html), data.get(width, 1280), data.get(height, 800), data.get(full_page, False))) return jsonify({task_id: task_id}) app.route(/result/task_id, methods[GET]) def get_result(task_id): task task_results.get(task_id) if not task: return jsonify({status: pending}) return jsonify(task)这种设计的好处很明显接口响应时间从几十秒降到几十毫秒调用方不会因为超时重试导致重复截图。worker线程的数量根据服务器CPU核数调整我机器是4核开了4个worker。7.2 截图结果的全链路日志截图服务出问题的时候最难查的就是为什么这张图是白的或者为什么这张图是登录页。所以日志记录要足够详细。我每张截图都会记录请求参数URL、视口、等待策略。页面加载耗时、截图耗时。HTTP状态码、最终URL用于检测重定向。页面关键元素的可见性。截图文件的hash值用于快速比对是否有变化。日志格式我建议用JSON方便后续接入日志分析系统。简单场景下Python的logging模块就够了。import logging, json logger logging.getLogger(shot_service) logger.setLevel(logging.INFO) def log_shot(task_id, url, status, **extra): record {task_id: task_id, url: url, status: status, **extra} logger.info(json.dumps(record, ensure_asciiFalse))7.3 探活与自动恢复最后一步是让服务能自我恢复。我写了一个简单的健康检查接口返回服务状态和最近一次截图的耗时app.route(/health) def health(): return jsonify({status: ok, latest_duration: latest_duration})然后配合系统守护进程我用的是Supervisor来监控服务的存活状态如果进程挂了就自动拉起。再配一个定时curl任务每分钟打一次健康检查接口连续失败三次就重启整个服务。这部分不复杂但对生产环境的稳定性至关重要。8. 功能扩展不只是截图还能做视觉回归8.1 改造为视觉回归工具这一节算是彩蛋。截图服务跑稳定之后我顺手把它扩展成了视觉回归测试工具。原理很简单对同一个页面在不同时间点截图比较两张图片的差异就能发现页面是否发生了意外变化——比如样式错乱、数据缺失、按钮位置偏移。我采用的是像素级对比用Pillow计算两张图的差异比例from PIL import Image, ImageChops import numpy as np def diff_ratio(img1_path, img2_path): img1 Image.open(img1_path).convert(RGB) img2 Image.open(img2_path).convert(RGB) if img1.size ! img2.size: img2 img2.resize(img1.size) arr1 np.array(img1, dtypeint) arr2 np.array(img2, dtypeint) diff np.abs(arr1 - arr2).sum(axis2) changed (diff 30).sum() total diff.shape[0] * diff.shape[1] return changed / total设置一个阈值比如差异比例超过5%就告警。这个方法不精细但对发现页面整体是否异常非常有效。我还试过用SSIM做一些感知相似度对比效果更好但计算量更大。你可以根据自己的页面特点选合适的对比算法。8.2 与CI集成更进一步可以把这个服务接入CI流程。每次前端代码合并后自动对关键页面截图然后和基准截图做对比。如果差异超阈值CI就判失败。这种用法能有效防止改了一个样式结果把布局全毁了的情况。接入方式很简单CI脚本里加上curl -X POST http://环境地址:8765/shot_async \ -H Content-Type: application/json \ -d {url: https://预发布环境/page, full_page: true}拿到截图后再调对比接口返回差异比例。8.3 并发使用的最终建议最后分享一下我对并发使用这服务的建议。如果你只是个人用、每分钟几张图那么单实例全局复用browser完全够用。如果需要支撑更多调用方建议做两层处理第一层脚本内部开多个browser实例比如4核机器开两个实例请求随机分配。第二层如果流量还要更大就部署多个服务实例前面加一层负载均衡任务分发到不同实例。不要过早引入复杂的微服务架构。一个几十行代码的Flask服务配合几个worker线程已经能够应对绝大多数内部工具的截图需求了。我在实际运维中还有一个习惯每周定期查看一次截图服务的日志统计平均耗时、失败率、以及有没有页面触发了长时间的networkidle等待。这些数据能提前暴露页面性能变化或外部系统异常。服务稳定不代表一直稳定页面一改版就可能引入新问题保持日志观察是必须的。这个项目从最开始的人工截图到现在的自动化服务化大概花了两个周末。所有代码都可以放在一个普通Python项目里依赖少、部署简单、扩展容易。如果你也有类似的网页截图、HTML转图片需求照着这个思路搭一套是完全可以落地的。
返回列表