1. 为什么我需要一台"能截图的浏览器"
先讲个实际场景。我之前维护过一个内部报表系统,每周要生成几十份统计页面发给业务方。业务方不看在线版,一定要PDF或者图片,理由是"方便转发、方便存档、看着踏实"。最开始我用的方案是前端同事写好页面,我人工打开浏览器、逐张截图、再拼接命名,一个上午基本就耗在这件事上了。后来数据量上来,页面还要按不同参数动态生成,人工截图这条路彻底走不通。
另一个让我下决心手搓这个服务的事,是某个第三方平台限制登录,但又恰好把关键数据渲染在页面上,接口又不公开。我需要定期把页面状态"拍"下来做留痕分析。这个需求本质上是:给定一个URL或者一段HTML,自动打开、等渲染完成、截图保存,整个过程无人值守、可批量、可定时。市面上当然有现成的截图服务,但要么收费按张计,要么部署笨重,要么依赖外部网络环境。我自己手里是一台普通Linux服务器,Python环境现成,就决定用纯Python方案自己搭一个无头浏览器渲染系统。
这个系统解决的问题很直接:把HTML页面变成PNG(也能顺手转PDF),全自动,支持动态JS渲染,支持自定义视口尺寸、等待时间、截图区域,还能通过HTTP接口让别的服务调用。适合谁?适合所有需要批量网页截图、页面留痕、HTML转图片、甚至做简单视觉回归测试的人。不需要懂浏览器内核,不需要装一堆浏览器插件,一个Python脚本加几个依赖就能跑起来。
"纯Python的无头浏览器渲染系统"听起来有点唬人,其实拆开来就三件事:找对渲染内核、写对调用代码、处理好等待和异常。下面我按自己从零搭建的过程,把每一步怎么选、怎么配、踩了什么坑,全部写清楚。
2. 渲染内核选型:为什么最终选了Playwright
2.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和浏览器内核
虚拟环境激活后,装Playwright:
pip install playwright playwright install chromium这里有几个容易踩的坑,我挨个说:
坑一:playwright install chromium 下载失败或超时。Playwright默认从微软的CDN下载浏览器二进制文件,某些网络环境下很不稳定。解决办法是设置镜像环境变量:
export PLAYWRIGHT_DOWNLOAD_HOST=https://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("<h1>Hello World</h1>") page.screenshot(path="test.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_until="networkidle") page.screenshot(path=output) browser.close()这里有两个细节要说一下。
一个是wait_until="networkidle"。它的意思是等待网络空闲(通常指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_until="networkidle") page.screenshot(path=output) browser.close()注意一个细节:如果HTML里引用了外部CSS或图片,set_content默认的base_url是about:blank,所以相对路径会加载失败。这时要指定base_url:
page.set_content(html_content, wait_until="networkidle")如果页面中有大量外链资源,更好的是先page.goto("file:///path/to/html")或者传入一个真实的base URL:
page.set_content(html_content, base_url="http://127.0.0.1:8080", wait_until="networkidle")这样页面里的相对路径就能正确解析了。
4.3 全页面截图与元素截图
默认的screenshot只截当前视口范围内的内容。如果页面很长,需要滚动截全图,要加一个参数:
page.screenshot(path=output, full_page=True)full_page=True会自动滚动整个页面并拼接成一张长图。这个功能对于截取长报表、长文章特别有用。
还有种需求是只截某个元素,比如只截图表区域、只截表格。做法是先用选择器定位元素再截:
element = page.query_selector("#chart-container") element.screenshot(path="chart.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_until="networkidle") else: page.set_content(html, wait_until="networkidle") shot_bytes = page.screenshot(full_page=full_page) finally: page.close() img_base64 = base64.b64encode(shot_bytes).decode() return jsonify({"image": img_base64, "format": "png"}) if __name__ == "__main__": app.run(host="0.0.0.0", port=8765)这里有个性能关键点:我把浏览器实例做成全局复用的,而不是每次请求都重新启动浏览器。因为启动一个Chromium实例大概要几百毫秒到一两秒,而复用实例之后,单次截图耗时主要花在页面加载上,能明显提高吞吐量。
但这个方案有个并发问题:多个请求同时使用同一个browser实例没问题,因为每个请求开的是独立page,page之间天然隔离。真正要注意的是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(format="A4", print_background=True)用PDF有个好处:PDF里文字是矢量可复制的,不像PNG是纯像素。而且同样质量的文档,PDF体积往往更小。我在接口里加了format参数,传png就返回图片,传pdf就返回PDF,两行代码多一个能力,性价比很高。
5. 等待策略与动态页面:截图白屏的九成原因都在这
5.1 networkidle为什么不是万能的
最开始我信心满满,以为wait_until="networkidle"就稳了。结果上生产环境第一周就翻车:一批图表页面截图全是空白。
排查过程是这样的:我加了一堆日志,发现页面请求确实都结束了,networkidle也触发了,但截图就是不满屏。后来我单独在本地用有头浏览器打开同一个页面,发现那些图表数据是页面加载后过了两三秒才从WebSocket推送过来的,页面加载完的那一刻DOM里根本没有图表节点。
所以networkidle只代表"此刻没有网络请求",不代表"业务数据渲染完了"。很多页面是先加载框架,再通过WebSocket、EventSource、或者setTimeout触发二次更新。这类动态内容用networkidle完全判断不了。
5.2 我最终采用的综合等待方案
针对上面的问题,我的最终方案是"网络空闲 + 元素存在 + 最小等待时间"三重组合:
from playwright.sync_api import expect def wait_page_ready(page, selector=None): # 第一重:网络空闲 page.wait_for_load_state("networkidle") # 第二重:关键元素出现(如果调用方指定了) if selector: expect(page.locator(selector)).to_be_visible(timeout=10000) # 第三重:额外的业务等待时间(为了应对看不到的异步推送) page.wait_for_timeout(2000)第一个等待网络空闲,保证主体资源加载完;第二个等待关键元素可见,保证页面渲染到用户能看到的程度;第三个固定等2秒,给那些静默异步更新的数据留出时间窗口。
这个方案牺牲了一点点速度,但稳定性大幅提升。对于报表截图这种"宁可慢一点也要完整"的场景,非常划算。如果你的页面更新时机有明确标志(比如某个loading元素消失),还可以把第三重替换成监听那个元素的消失状态,效率和可靠性会更高。
5.3 如果怎么等都截不全怎么办——事件回调兜底
有些极端页面,数据是持续流式的,根本不存在"完全空闲"的时刻。遇到这种页面,networkidle会一直等不到,最终超时报错。
解决办法是监听页面console消息或网络响应,当捕获到某个特定业务信号时再截图。举个例子:
def on_console(msg): if msg.text == "RENDER_COMPLETE": page.screenshot(path="done.png") page.on("console", on_console) page.goto(url, wait_until="domcontentloaded") page.wait_for_timeout(15000)这种方案需要业务页面配合,在渲染完成时打一条标记日志。但如果你能控制目标页面的代码,这是最精确的等待方式。
6. 踩坑实录:稳定性从"勉强能用"到"半夜不报警"
6.1 内存泄漏与浏览器实例管理
服务上线跑了一周后发现一个问题:服务器内存持续上涨,最终把服务OOM干掉。
排查过程是典型的"先看监控、再逐层定位"。我先用free -h确认内存占用曲线,再用ps --sort=-%mem定位到是Chromium进程占内存最多。再细看,发现只要跑了一段时间,系统里就有大量残留的Chromium渲染进程。
根因其实是我代码里一个粗心点:全局复用的browser虽然一直不关,但每个page如果忘了close,Chromium的渲染进程就不会释放。我最初在异常分支里只关了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_state="login_state.json") page = context.new_page()第一次先用有头模式手动登录一次,然后用page.context.storage_state(path="login_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_height=4096): # 先获取总高度 total_height = page.evaluate("() => document.body.scrollHeight") scroll = 0 parts = [] while scroll < total_height: page.evaluate(f"window.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(target=worker, daemon=True) 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_ascii=False))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, dtype=int) arr2 = np.array(img2, dtype=int) diff = np.abs(arr1 - arr2).sum(axis=2) 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转图片需求,照着这个思路搭一套,是完全可以落地的。