从去年开始我一直在做数据大屏的自动化巡检,每天定时把页面上十几个关键指标卡片截图存档,用来回溯数据变化。DrissionPage整页截图很简单,但一落到“单独截某个卡片区域”就出问题了——原生元素截图遇到canvas图表直接白屏,页面滚到一半时截出来还是残缺的。折腾了两周,最后发现正解是“用JS定位、用CDP裁剪”,也就是标题里说的js区域截图方案。今天把这套思路完整梳理一遍,从坐标原理到复杂场景再到踩坑排查,给同样在做网页区域截图的朋友一个可以直接抄作业的版本。
1. 为什么原生元素截图会翻车,才需要JS介入
1.1 DrissionPage原生截图能做什么
先说清楚底子。DrissionPage 4.x里已经内置了比较完整的截图API,常用的是这三个:
python复制# 整页/当前视口截图
page.get_screenshot(path='full.png')
# 元素截图
ele = page.ele('#target')
ele.get_screenshot(path='ele.png')
# 指定滚动容器截图
page.get_screenshot(path='container.png', scroll_to_center=True)
这些方法在日常场景下非常省事,不需要操作CDP命令,内部已经处理了视口宽度、滚动位置等一堆逻辑。我最早做日志页面存档时,直接 page.get_screenshot() 一把梭,一天截上百张图都稳定。
那么问题来了:既然原生API都有元素级截图,为什么还要自己去折腾JS区域截图?
1.2 原生元素截图在哪些场景翻车
我实际遇到的翻车场景,整理成一张表:
| 场景 | 现象 | 根因 |
|---|---|---|
| 截canvas图表(echarts等) | 得到一张透明底或空白图片 | 元素截图的CDP参数对canvas的合成帧抓取不稳定 |
| 目标区域在视口外 | 截出来是空背景或错位 | 原生实现没有自动滚动或坐标换算 |
| 元素宽度带小数、页面有缩放 | 截图边缘被裁掉 | CDP clip参数在使用浮点坐标时跨版本表现不一致 |
| 懒加载图片未触发 | 截图里只有占位图 | 原生的等待逻辑集中在元素存在,而不是内容渲染完成 |
最典型的翻车案例是截echarts折线图。我用 ele.get_screenshot() 截一个canvas渲染的图表,得到的图片尺寸是对的,但内容区域要么透明、要么只有部分网格线,数据曲线完全消失。原因在于:canvas的实际绘制内容是独立于DOM的位图,getScreenshot 走CDP裁剪时,对canvas这种合成层的捕获时机和坐标换算并不是在所有版本里都可靠,尤其在页面有动画或滚动未停稳时更明显。
当时我给DrissionPage提issue也没等到完美解法,干脆放弃原生元素截图,自己控制整个过程:先用JS读元素的位置和尺寸,再自己调CDP的截图命令按坐标裁剪。从那以后,所有区域截图问题都归结为“坐标算得准不准”和“渲染等没等完”,可控性完全不一样。
1.3 JS介入的核心价值
换个角度看,所谓的“JS区域截图”,本质不是用JS去截屏,而是用JS解决三个前置问题:
- 精确定位:
getBoundingClientRect()能在任意时刻拿到元素相对视口的精确矩形,不受框架限制。 - 渲染等待:在JS里可以监听图片加载、字体加载、DOM变化,确保截图时目标区域内容已经画完。
- 复杂场景穿透:iframe、shadow DOM、滚动容器内的元素,用一行选择器就能定位到,再配合坐标换算公式把位置映射到顶层页面。
搞清楚这一点,后面所有代码都围绕这三件事展开,思路就清晰了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 区域截图前必须搞懂的三个坐标概念
2.1 getBoundingClientRect返回的到底是什么
这是区域截图最核心的API,但很多人对它的理解停在“拿到元素的宽高位置”这个层面。实际上它返回的是元素相对当前可视视口(viewport)的坐标,不是相对文档的坐标。
javascript复制const r = document.querySelector('#target').getBoundingClientRect();
// r.top、r.left、r.right、r.bottom、r.width、r.height
注意:当页面发生滚动时,r.top 和 r.left 会跟着变。元素固定在文档里不动,滚动条往下拉100像素,r.top 就减少100。这个特性决定了截图指令里的坐标必须和当前视口状态匹配,否则就会截偏。
我用一个生活化类比来帮助记忆:你在剧场里给舞台上的演员拍照,getBoundingClientRect 拿到的是“演员此刻在镜头画面里的位置”,而不是“演员在舞台上的绝对坐标”。镜头移动了(页面滚动了),画面里的位置就变了。
2.2 视口坐标、页面坐标、物理像素的换算关系
做CDP裁剪时,有三个坐标系容易混:
| 坐标系 | 参照物 | 获取方式 | 用于什么 |
|---|---|---|---|
| 视口坐标 | 当前浏览器可视区域 | getBoundingClientRect() |
CDP clip默认坐标 |
| 页面坐标 | 整个文档左上角 | rect + window.scrollX/scrollY |
需要截屏视口外内容 |
| 物理像素 | 屏幕实际像素点 | CSS像素 × devicePixelRatio | 决定输出图片的分辨率 |
换算公式就两个:
javascript复制// 视口坐标 -> 页面坐标
pageX = rect.left + window.scrollX;
pageY = rect.top + window.scrollY;
// CSS宽高 -> 物理像素宽高
physicalWidth = rect.width * window.devicePixelRatio;
physicalHeight = rect.height * window.devicePixelRatio;
实际操作中,如果目标元素就在当前视口内,直接用 rect.left 和 rect.top 就能截准。如果元素在视口外,需要先把 window.scrollX 和 scrollY 叠加进去,或者在截图的CDP命令里开启“允许截取视口外区域”的参数。我建议代码里统一用页面坐标加 captureBeyondViewport 参数,少踩很多坑。
2.3 CDP的Page.captureScreenshot裁剪参数
要让DrissionPage调用Chrome DevTools Protocol的截图能力,核心命令是 Page.captureScreenshot。其中和区域截图相关的参数:
json复制{
"format": "png",
"clip": {
"x": 0,
"y": 0,
"width": 800,
"height": 600,
"scale": 1
},
"captureBeyondViewport": false,
"fromSurface": true
}
几个容易忽略的细节:
clip的x、y默认是视口坐标,如果captureBeyondViewport设为true,它们就变成页面坐标。scale是缩放系数,设为1时按当前设备像素比输出,屏幕是2倍屏时,截出来的PNG宽度会是clip.width × 2。想要输出图片尺寸恰好等于CSS像素尺寸,可以手动把scale设为1 / window.devicePixelRatio,但不同内核版本对这个参数的解释不完全一致,实战中我更推荐让截图自然输出,再用PIL统一做尺寸处理。fromSurface在大多数页面保持true即可,它控制是否从合成器表面捕获图像。如果截到空白,可以试试把它改成false。
3. 核心代码:用JS定位配合CDP完成精准裁剪
3.1 第一步:等待目标元素和内容都就绪
区域截图最大的坑不是坐标算错,而是东西还没渲染完就截图。尤其是数据大屏这种页面,很多图表是异步加载的,元素在DOM里存在,但canvas里的图形才画到一半。
我习惯的做法是两层等待:
python复制from DrissionPage import ChromiumPage
page = ChromiumPage()
page.get('https://example.com/dashboard')
# 第一层:等元素出现在DOM且可见
page.wait.ele_displayed('#kpi-card', timeout=10)
# 第二层:用JS确认图片、字体、异步内容都完成
page.run_js("""
const imgs = document.querySelectorAll('#kpi-card img');
const promises = Array.from(imgs).map(img => img.complete ? Promise.resolve()
: new Promise(resolve => { img.onload = resolve; img.onerror = resolve; }));
return Promise.all(promises).then(() => document.fonts.ready);
""")
如果页面里图表数据由接口返回,更稳妥的做法是先等网络请求结束。DrissionPage提供 page.wait.load_start() 之类的等待,但在单页应用里不一定可靠。我常用一个笨但有效的方法:轮询目标文本或数字出现,比如等卡片里的数值从 -- 变成具体数字。
python复制page.wait.condition(lambda: '--' not in page.ele('#kpi-value').text, timeout=15)
3.2 第二步:读取坐标与尺寸
这一步是JS的主场。注意两点:把视口坐标、页面滚动量、设备像素比一起返回;判断元素是否隐藏或尺寸为0。
python复制rect_info = page.run_js("""
const el = document.querySelector('#kpi-card');
if (!el) return null;
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return { hidden: true };
return {
x: Math.round(r.left + window.scrollX),
y: Math.round(r.top + window.scrollY),
width: Math.round(r.width),
height: Math.round(r.height),
viewportX: Math.round(r.left),
viewportY: Math.round(r.top),
dpr: window.devicePixelRatio,
scrollX: window.scrollX,
scrollY: window.scrollY,
hidden: false
};
""")
为什么这里强调 Math.round?这是我踩过的一个实坑:Chrome某些版本对带小数的clip坐标会静默生成一张错位或边缘被裁的图,对浮点坐标的舍入处理并不统一。所以拿到坐标后统一取整,宁可损失0.5像素,也不赌内核的边界行为。如果对边缘质量要求极高,可以在取整后额外向外扩展1像素,再在后期裁剪时去掉。
3.3 第三步:调用CDP完成裁剪保存
拿到坐标后,直接用DrissionPage的 run_cdp 调原生命令:
python复制import base64
result = page.run_cdp('Page.captureScreenshot', **{
'format': 'png',
'clip': {
'x': rect_info['x'],
'y': rect_info['y'],
'width': rect_info['width'],
'height': rect_info['height'],
'scale': 1
},
'captureBeyondViewport': True,
'fromSurface': True,
})
with open('output.png', 'wb') as f:
f.write(base64.b64decode(result['data']))
这里 captureBeyondViewport=True 配合页面坐标,即使目标元素被滚到视口外也能正确截取。我统一这么写,省得每次判断元素在不在视口里。
补充一句:如果用的是DrissionPage 3.x版本,run_cdp 方法名不一样,需要改成 page.driver.page.send_cmd('Page.captureScreenshot', params),效果相同。
3.4 封装成通用函数
把上面三步合起来,封装成一个可复用的方法:
python复制import base64
import time
from pathlib import Path
def shot_region(page, selector, path, wait_selector=None, extra_js=None):
"""DrissionPage JS区域截图通用函数
:param page: ChromiumPage 实例
:param selector: 目标元素的CSS选择器
:param path: 截图保存路径
:param wait_selector: 可选,截图前等待的元素选择器
:param extra_js: 可选,截图前执行的自定义JS(用于等待渲染完成)
"""
target = wait_selector or selector
page.wait.ele_displayed(target, timeout=10)
if extra_js:
page.run_js(extra_js)
time.sleep(0.2)
rect_info = page.run_js(f"""
const el = document.querySelector('{selector}');
if (!el) return null;
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return {{ hidden: true }};
return {{
x: Math.round(r.left + window.scrollX),
y: Math.round(r.top + window.scrollY),
width: Math.round(r.width),
height: Math.round(r.height),
dpr: window.devicePixelRatio,
hidden: false
}};
""")
if not rect_info or rect_info.get('hidden'):
raise RuntimeError(f'目标元素不可见或不存在: {selector}')
result = page.run_cdp('Page.captureScreenshot', **{
'format': 'png',
'clip': {
'x': rect_info['x'],
'y': rect_info['y'],
'width': rect_info['width'],
'height': rect_info['height'],
'scale': 1
},
'captureBeyondViewport': True,
'fromSurface': True,
})
Path(path).write_bytes(base64.b64decode(result['data']))
return path
这个函数我在多个项目里直接复用,参数设计遵循“能少就让调用方少传”的原则。wait_selector 单独拆出来,是因为很多场景下要等的元素和要截的元素不是同一个——比如等一个loading遮罩消失,再截图底下的卡片。
4. 复杂页面里的区域截图:滚动、iframe、canvas、shadow DOM
4.1 视口外或滚动容器内的元素
通用函数里我用了页面坐标加 captureBeyondViewport,理论上已经覆盖了视口外的情况。但有一个前置条件:元素必须已经渲染出来。有些页面的滚动容器做了懒加载,元素要滚到附近才会真正挂载到DOM。
遇到这种页面,需要在截图前执行滚动:
python复制page.run_js("""
const el = document.querySelector('#target');
el.scrollIntoView({block: 'center'});
""")
# 等待滚动后可能触发的懒加载
page.wait.ele_displayed('#target img', timeout=5)
用 block: 'center' 而不是默认的 start,是让元素出现在视口中间,避开顶部固定导航栏的遮挡。这个细节在数据大屏页面尤其重要,因为很多大屏顶部有一层不透明的标题栏。
4.2 iframe里的元素怎么截
iframe是区域截图里最容易踩坑的场景。难点不在定位,而在坐标换算:iframe内部元素的 getBoundingClientRect() 是相对于iframe自身文档的视口坐标,和顶层页面的坐标系是两个世界。
换算公式为:
code复制元素在顶层页面的视口X = iframe元素在顶层页面的left + 内部元素相对于iframe视口的left
元素在顶层页面的视口Y = iframe元素在顶层页面的top + 内部元素相对于iframe视口的top
用代码实现:
python复制# 先拿到iframe元素在顶层的坐标
iframe_info = page.run_js("""
const frame = document.querySelector('#main-frame');
const r = frame.getBoundingClientRect();
return { x: r.left, y: r.top, width: r.width, height: r.height };
""")
# 切换到iframe里执行JS,拿到内部元素相对iframe视口的坐标
frame = page.get_frame('#main-frame')
inner_info = frame.run_js("""
const el = document.querySelector('#chart-inside');
const r = el.getBoundingClientRect();
return { x: r.left, y: r.top, width: r.width, height: r.height };
""")
final_clip = {
'x': round(iframe_info['x'] + inner_info['x'] + page.run_js('return window.scrollX') if use_page_coord else 0),
'y': round(iframe_info['y'] + inner_info['y'] + page.run_js('return window.scrollY') if use_page_coord else 0),
'width': round(inner_info['width']),
'height': round(inner_info['height']),
}
注意:如果iframe内部也滚动了,getBoundingClientRect 得到的坐标已经包含了内部滚动偏移,不需要额外加内部 scrollX。只需要加顶层页面的滚动量(使用页面坐标时)。
4.3 canvas图表区域:最稳的处理方式
回到开头提到的echarts图表空白问题。用JS区域截图方案后,CDP裁剪直接作用于合成后的页面帧,canvas上已经画好的内容会像普通图片一样被截进PNG,不再出现透明空白。
不过针对canvas图表,还有一个更轻量且像素级准确的替代方案——直接读取canvas本身:
python复制base64_str = page.run_js("""
const canvas = document.querySelector('#chart-canvas');
return canvas.toDataURL('image/png');
""")
# base64_str 形如 data:image/png;base64,.....
这种方式拿到的是canvas内部像素精度的原始内容,不受CSS缩放影响,常用于图表下载功能。但它有两个限制:一是跨域图片会污染canvas导致 toDataURL 抛异常;二是只能截canvas元素本身,不能连带周围的标题、图例等DOM一起截。
所以我的建议是:默认用CDP区域截图,兼容性最好;只有当目标是纯canvas且需要最高保真度时,才考虑 toDataURL 方案。
4.4 shadow DOM中的元素
shadow DOM在数据大屏里不算常见,但在一些现代化组件库里会遇到。普通 querySelector 无法穿透shadow root,需要用 shadowRoot 链式查找。
python复制rect_info = page.run_js("""
const host = document.querySelector('#widget-host');
const el = host.shadowRoot.querySelector('.content');
const r = el.getBoundingClientRect();
return {
x: Math.round(r.left + window.scrollX),
y: Math.round(r.top + window.scrollY),
width: Math.round(r.width),
height: Math.round(r.height),
hidden: false
};
""")
如果shadow DOM嵌套多层,就不断链式 .shadowRoot.querySelector 往下找。这个场景DrissionPage自带的元素定位也能处理,但JS方式更通用,复制到浏览器控制台就能快速验证选择器是否正确。
5. 实战:批量截取数据大屏的关键指标卡片
5.1 明确页面结构和需求
假设要做巡检的数据大屏长这样,页面里若干张指标卡片:
html复制<div class="card" id="card-revenue">
<div class="card-title">今日营收</div>
<div class="card-chart" id="revenue-chart"></div>
</div>
<div class="card" id="card-users">
<div class="card-title">活跃用户</div>
<div class="card-chart" id="users-chart"></div>
</div>
<div class="card" id="card-orders">
<div class="card-title">订单量</div>
<div class="card-chart" id="orders-chart"></div>
</div>
需求是每天定时把每张卡片单独截成图片,按日期归档。卡片数量会变,所以不能写死三个截图调用,要用循环扫描。
5.2 循环截取所有卡片
python复制card_ids = page.run_js("""
return Array.from(document.querySelectorAll('.card')).map(el => el.id);
""")
for card_id in card_ids:
selector = f'#{card_id}'
# 等卡片内部图表渲染完成
page.wait.ele_displayed(f'{selector} canvas', timeout=10)
time.sleep(0.5)
shot_region(
page,
selector=selector,
path=f'output/{card_id}.png',
extra_js=f"""
// 等待卡片内图表动画绘制完成
const chartCanvas = document.querySelector('{selector} canvas');
if (chartCanvas && chartCanvas.__chart__ && chartCanvas.__chart__.getDataURL) {{
// 有的图表库可以强制结束动画
}}
"""
)
这里有个经验:图表动画未结束时,截图里的数值可能停在中间状态。echarts默认动画时长在300到1000毫秒之间,我在截图前固定 time.sleep(0.5) 等待动画完成。如果你对时间敏感,可以监听图表实例的 finished 事件,但为了通用性,固定延时更简单。
5.3 统一输出规格,避免尺寸参差不齐
不同卡片的尺寸可能不一致,如果下游需要统一宽度(比如拼接到报告里),建议截图后做一次后处理。我用Pillow做中心裁剪和缩放:
python复制from PIL import Image
def normalize_screenshot(path, target_width=800, target_height=400):
img = Image.open(path)
# 先按目标宽高比中心裁剪
target_ratio = target_width / target_height
src_ratio = img.width / img.height
if src_ratio > target_ratio:
new_width = round(img.height * target_ratio)
offset = (img.width - new_width) // 2
img = img.crop((offset, 0, offset + new_width, img.height))
else:
new_height = round(img.width / target_ratio)
offset = (img.height - new_height) // 2
img = img.crop((0, offset, img.width, offset + new_height))
img = img.resize((target_width, target_height), Image.LANCZOS)
img.save(path)
为什么不直接在CDP的clip里硬控宽高?因为目标元素的CSS宽高是页面布局决定的,强行改clip宽高会导致内容被拉伸或截断。后期统一裁剪缩放是最稳妥的做法,尤其在高DPI屏幕上,先截原始清晰大图再缩小,比直接截小图再放大的画质好得多。
5.4 和DrissionPage其它能力配合
实际巡检流程里,我不会让脚本裸奔等页面加载,而是组合DrissionPage的等待能力让整个流程更稳:
python复制page.get('https://dashboard.example.com')
page.wait.doc_loaded()
# 等待首屏某个关键数据出现
page.wait.ele_displayed('#kpi-value', timeout=15)
# 可选:等待请求空闲
page.wait.network_idle(timeout=10) # 高版本DrissionPage可用
network_idle 方法在不同版本里方法名可能不同,如果没有这个API,就用循环查 document.readyState 加轮询数值的方式替代,效果差不多。
6. 踩坑记录:空白、偏移、模糊的完整排查链路
6.1 截出来全空白,先按这个顺序排查
这是我被折磨最久的问题。前前后后花了两天,最后发现是三个因素叠加。整理成统一的排查链路:
第一步:确认元素真的渲染了
python复制# 先整页截图看看
page.get_screenshot(path='debug_full.png')
打开整页截图,如果元素位置就是空白,说明问题在渲染等待,而不是截图代码。优先检查是不是懒加载、数据请求未返回、canvas动画未绘制。
第二步:打印坐标,人工核对
python复制print(rect_info)
如果打印出来的 x、y 是负数或者远大于页面宽度,说明坐标计算有问题。常见原因:页面使用了CSS transform缩放、元素在iframe里、滚动位置计算错误。
第三步:去掉clip参数,跑一次全页CDP截图
python复制result = page.run_cdp('Page.captureScreenshot', format='png')
如果全页截图正常,说明CDP通路没问题,问题在clip参数本身。这时检查 captureBeyondViewport 和坐标体系是否匹配,并确认所有数值都取了整。
我用这套链路定位过多个看似“玄学”的空白问题,最后都能归结到上面三类原因之一。
6.2 图片模糊或尺寸不对,多半是DPR问题
区域截图在Retina屏上会默认得到CSS尺寸2倍的PNG。这不算bug,反而是好事——截图更清晰。但如果下游系统只接受固定尺寸的图片,就会出现尺寸和预期对不上的问题。
我的处理方式是在封装函数里额外返回实际输出尺寸:
python复制from PIL import Image
img = Image.open(path)
print(img.size) # 可能是 1600x800,而不是目标元素的 800x400
统一输出规格就用前面提到的 normalize_screenshot 函数处理。如果一定想让CDP直接输出CSS像素尺寸,可以把clip里的 scale 设为 1 / rect_info['dpr'],但这个方法在部分内核版本上对半透明元素的边缘质量有影响,所以我只在实际需要时用。
6.3 目标被弹层遮挡,截图里混入无关内容
页面里有个悬浮的客服按钮,恰好悬浮层覆盖在目标区域一角,截出来的图右上角总会带上一小片阴影。解决思路是截图前判断遮挡:
python复制covered = page.run_js("""
const el = document.querySelector('#target');
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
// elementFromPoint 会返回最顶层的元素
const topEl = document.elementFromPoint(cx, cy);
return topEl === el || el.contains(topEl);
""")
如果返回 False,说明目标区域被其他元素盖住了,可以在截图前先隐藏可能的遮挡层。我常用的做法是给遮罩元素加 display: none,截图后再恢复:
python复制page.run_js("""
const el = document.querySelector('.suspension-btn');
if (el) { el.dataset.originalDisplay = el.style.display; el.style.display = 'none'; }
""")
shot_region(...)
page.run_js("""
const el = document.querySelector('.suspension-btn');
if (el && el.dataset.originalDisplay !== undefined) { el.style.display = el.dataset.originalDisplay; }
""")
6.4 滚动页面后坐标失效,主因是滚动偏移重复计算
这个问题在我用 captureBeyondViewport=False 时反复出现。场景是:页面已经滚到底部,我直接读 getBoundingClientRect,再用 x + scrollX 换算页面坐标,结果截图位置比实际靠下很多。
原因很直接:当元素滚动到视口内时,getBoundingClientRect 的 top 已经是视口相对值,再加上 scrollY 才是页面绝对坐标;但如果前面已经执行过 scrollIntoView 导致滚动位置变化,而我还用旧的 scrollY 计算,坐标自然错位。
所以我最终的代码统一在读取坐标的同时读取 scrollX/scrollY,保证两个值是同一个时间点的快照。不要先拿一次滚动量,过一会儿再拿元素坐标,中间一旦发生滚动就全对不上。
这套方案我在实际项目里跑了将近半年,从每天几十张截图到上千张卡片截图都验证过稳定性。区域截图这件事,核心不在截图命令本身,而在于把“元素定位”、“渲染等待”、“坐标换算”这三环做扎实。如果你也遇到DrissionPage原生截图不够精准的问题,不妨直接换成这套JS+CDP方案,代码量多不了几行,但对各种页面的掌控力完全不在一个量级。最后留一个小技巧:同一个页面批量截多个区域时,尽量把坐标读取和截图动作连在一起执行,减少中间被其它逻辑触发重排的概率,截出来的图会更稳定。
