PixelRAG + Scrapeless: 当截图平铺变为远程时会发生什么
Lead Scraping Automation Engineer
TL;DR:
- Visual RAG 将页面索引为截图瓷砖而不是解析的文本,因此瓷砖几何形状不是外观问题——它决定了嵌入模型实际看到的内容。
- PixelRAG 的渲染器设置了一个大小为 875 x 8192 的捕获视口,使用
Emulation.setDeviceMetricsOverride,然后将每个截图裁剪为 875 宽。 - 在一个覆盖请求成功并且从不应用所请求宽度的云浏览器上,结果的布局宽度从 945 到 5120 不等——有时不变,通常更宽,从未为 875。
- 裁剪被精确尊重,这使得这一点危险:你得到一个尺寸正确的瓷砖,包含一个页面的左侧切片,而该页面的布局要宽得多。
- 在一次真实捕获中,一个宽度为 875 的瓷砖用于一个宽度为 1920 的布局,包含一个产品列,而页面布局的是四个,卡片从其按钮处被切割。
rawFilePath快速路径在远程浏览器上被接受而没有错误,返回内联数据,而不写入本地文件——因此读取该文件的管道没有任何内容,而每次调用都看起来是成功的。- 在信任单个瓷砖之前,请将您请求的宽度与覆盖后的
innerWidth进行比较。 - 在 Scrapeless 免费计划 上开始,并测量您自己的捕获几何形状。
Scrapeless 数据抓取浏览器 是一个通过 CDP WebSocket 驱动的云浏览器,以下每个测量都是针对它进行的,使用了本地渲染器将使用的相同 CDP 调用。
视觉检索增强生成跳过 HTML 解析。它不是将页面转换为文本并嵌入块,而是将页面渲染为图像,嵌入图像,并让视觉模型从像素中读取答案。表格、图表和多列布局得以保留,因为没有任何东西被压缩为文本流。
这只有在图像正确时才有效。一个瓷砖渲染器做出了特定的承诺——页面以固定宽度布局,然后切割成固定高度的瓷砖——视觉索引的好坏取决于这种承诺。PixelRAG 是当前最清晰的例子,其渲染器值得仔细阅读,因为它做出的假设是每个截图管道都做出的假设。本文测量在您未启动的浏览器上,该假设会发生什么。
Tiling 应该如何工作
PixelRAG 的快速渲染器通过 WebSocket 使用原始 CDP,而不是通过驱动库。它在 render/src/pixelrag_render/backends/fast_cdp.py 的顶部设置了两个常量:
python
VIEWPORT_WIDTH = 875
TILE_HEIGHT = 8192
然后每个工作者一次应用这些作为捕获视口:
python
await conn.cdp("Emulation.setDeviceMetricsOverride", {
"width": VIEWPORT_WIDTH,
"height": tile_height,
"deviceScaleFactor": 1,
"mobile": False,
})
对于每个瓷砖,它以相同的宽度捕获一个裁剪矩形,逐步向下页面:
python
await conn.cdp("Page.captureScreenshot", {
"clip": {"x": 0, "y": t * tile_height, "width": VIEWPORT_WIDTH,
"height": clip_h, "scale": 1},
})
逻辑是合理且标准的。Emulation.setDeviceMetricsOverride 是 CDP 调用,它与 CSS 视口规范 概念对应,当您设置视口时,驱动库在底层调用它。将页面按 875 布局,切割为 8192 高的条带,嵌入条带。在您自己启动的浏览器上,这正是所发生的。
前提条件
- Python 3.10 或更高版本以及
websockets包。 - 从仪表板获取的 Scrapeless API 密钥,导出为
SCRAPELESS_KEY。 - 没有本地浏览器。下面的每个调用都通过 WebSocket 连接 发送到云浏览器。
bash
pip install "websockets==15.0.1"
export SCRAPELESS_KEY="your_api_key_here"
测量覆盖而不是信任它
覆盖返回一个没有错误的结果对象,这就是整个问题——关于响应的任何内容都表明请求未被尊重。相反,从页面中读取布局宽度。
python
import asyncio, json, os
import websockets
CDP = ("wss://browser.scrapeless.com/api/v2/browser"
f"?token={os.environ['SCRAPELESS_KEY']}&sessionTTL=120&proxyCountry=ANY")
WIDTH, HEIGHT = 875, 8192
async def call(ws, state, method, params=None, session=None):
state["id"] += 1
msg = {"id": state["id"], "method": method}
if params:
msg["params"] = params
if session:
msg["sessionId"] = session
await ws.send(json.dumps(msg))
while True:
reply = json.loads(await ws.recv())
if reply.get("id") == state["id"]:
return reply
async def one_session(run):
async with websockets.connect(CDP, max_size=50 * 1024 * 1024) as ws:
state = {"id": 0}
target = await call(ws, state, "Target.createTarget", {"url": "about:blank"})
tid = target["result"]["targetId"]
attached = await call(ws, state, "Target.attachToTarget",
{"targetId": tid, "flatten": True})
sid = attached["result"]["sessionId"]
await call(ws, state, "Page.enable", session=sid)
await call(ws, state, "Page.navigate",
{"url": "https://books.toscrape.com/"}, session=sid)
await asyncio.sleep(2.5)
async def inner_width():
r = await call(ws, state, "Runtime.evaluate",
{"expression": "innerWidth", "returnByValue": True},
session=sid)
return r["result"]["result"]["value"]
before = await inner_width()
result = await call(ws, state, "Emulation.setDeviceMetricsOverride",
{"width": WIDTH, "height": HEIGHT,
"deviceScaleFactor": 1, "mobile": False}, session=sid)
await asyncio.sleep(1)
after = await inner_width()
print(f"run{run}: error={result.get('error')} before={before} "
f"after={after} requested={WIDTH} applied={after == WIDTH}")
await call(ws, state, "Target.closeTarget", {"targetId": tid})
return after
async def main():
widths = [await one_session(run) for run in range(1, 4)]
print(f"distinct widths: {sorted(set(widths))} · requested {WIDTH} ever applied: "
f"{WIDTH in widths}")
asyncio.run(main())
text
run1: error=None before=945 after=1280 requested=875 applied=False
run2: error=None before=945 after=1600 requested=875 applied=False
run3: error=None before=945 after=945 requested=875 applied=False
distinct widths: [945, 1280, 1600] · requested 875 ever applied: False
再运行一次,after 列将显示不同的数字。这种不稳定性是结果,而不是周围的噪声,因此值得重点突出的是最后一行:请求的宽度从未是应用的宽度。
这里值得分开三件事。调用并未失败。它所达到的宽度在不同会话中有所不同——在不同的运行中,返回值为 945、1240、1280、1400、1440、1600、1680、1920、2560 和 5120。在这些会话中,没有一个是请求的 875。
注意上面的第三次运行:有时宽度根本不变,先前的 945 变为之后的 945。因此,覆盖并不可靠地是无操作的,也不可靠地是变化的。每个会话中保持的一个属性是有用的——您请求的数字不是您得到的数字。
远程浏览器拥有自己的窗口,重写重新布局是相对于该窗口而不是相对于您的数字。在本地,您拥有该过程,因此相同的调用会表现一致。
瓦片实际上包含的内容
现在,像瓦片渲染器一样捕获一个瓦片,按您认为设置的宽度进行明确裁剪。
python
import asyncio, base64, json, os, pathlib, struct
import websockets
CDP = ("wss://browser.scrapeless.com/api/v2/browser"
f"?token={os.environ['SCRAPELESS_KEY']}&sessionTTL=120&proxyCountry=ANY")
WIDTH = 875
async def call(ws, state, method, params=None, session=None):
state["id"] += 1
msg = {"id": state["id"], "method": method}
if params:
msg["params"] = params
if session:
msg["sessionId"] = session
await ws.send(json.dumps(msg))
while True:
reply = json.loads(await ws.recv())
if reply.get("id") == state["id"]:
return reply
async def main():
async with websockets.connect(CDP, max_size=100 * 1024 * 1024) as ws:
state = {"id": 0}
tid = (await call(ws, state, "Target.createTarget",
{"url": "about:blank"}))["result"]["targetId"]
sid = (await call(ws, state, "Target.attachToTarget",
{"targetId": tid, "flatten": True}))["result"]["sessionId"]
await call(ws, state, "Page.enable", session=sid)
await call(ws, state, "Page.navigate",
{"url": "https://books.toscrape.com/"}, session=sid)
await asyncio.sleep(2.5)
await call(ws, state, "Emulation.setDeviceMetricsOverride",
{"width": WIDTH, "height": 8192,
"deviceScaleFactor": 1, "mobile": False}, session=sid)
await asyncio.sleep(1)
layout = await call(ws, state, "Runtime.evaluate",
{"expression": "innerWidth", "returnByValue": True},
session=sid)
shot = await call(ws, state, "Page.captureScreenshot",
{"format": "png", "captureBeyondViewport": True,
"clip": {"x": 0, "y": 0, "width": WIDTH,
"height": 1200, "scale": 1}}, session=sid)
image = base64.b64decode(shot["result"]["data"])
png_w, png_h = struct.unpack(">II", image[16:24])
print(f"layout width: {layout['result']['result']['value']}")
print(f"tile size: {png_w}x{png_h} (requested {WIDTH}x1200)")
pathlib.Path("tile.png").write_bytes(image)
await call(ws, state, "Target.closeTarget", {"targetId": tid})
asyncio.run(main())
text
layout width: 1920
tile size: 875x1200 (requested 875x1200)
这里打印的宽度和高度是直接从图像头获取的,而不是假设的,因为 PNG 规范 将两者放在文件的第一个块中。瓦片的大小正好是请求的大小。其背后的页面布局要宽得多,落在上的宽度在每个会话中改变,因此您捕获的页面部分也不是固定的。
从此运行中保存的 tile.png 显示了这需要多少成本。在 1920 的布局宽度下,瓦片仅容纳一个产品列,而页面则布局了四个,看到的卡片穿过其“添加到购物篮”按钮被切断,标题文本超出了右边缘。页面宽度的一半多在图像外面。
文本抓取器不会在意,因为它读取的是 DOM,而 DOM 是完整的。视觉索引则完全在意:瓦片是输入,而页面的一半左右从未到达嵌入模型。检索能力会在管道中某个地方没有单个错误的情况下下降,这就是为什么将请求的宽度与 innerWidth 进行比较在每个环境中值得做一次。
想要检查自己的捕获几何形状与云浏览器的对比吗? 创建一个免费的 Scrapeless 账号并运行上面的两个代码块。
原始捕获路径消失
PixelRAG 的吞吐量技巧是跳过浏览器中的 PNG 编码。它要求 Chrome 将原始像素直接转储到共享内存中,使用 rawFilePath 参数,然后在单独的进程池中对其进行压缩。该库提供了一个专门用于此目的的 Chromium 补丁文件,压缩工作线程使用 open(raw_path, "rb") 读取文件。
在远程浏览器中,该参数被接受并悄悄忽略。
python
import asyncio, json, os, pathlib
import websockets
CDP = ("wss://browser.scrapeless.com/api/v2/browser"
f"?token={os.environ['SCRAPELESS_KEY']}&sessionTTL=120&proxyCountry=ANY")
RAW = pathlib.Path("/dev/shm/tile_probe.raw")
async def call(ws, state, method, params=None, session=None):
state["id"] += 1
msg = {"id": state["id"], "method": method}
if params:
msg["params"] = params
if session:
msg["sessionId"] = session
await ws.send(json.dumps(msg))
while True:
reply = json.loads(await ws.recv())
if reply.get("id") == state["id"]:
return reply
async def main():
RAW.unlink(missing_ok=True)
async with websockets.connect(CDP, max_size=100 * 1024 * 1024) as ws:
state = {"id": 0}
tid = (await call(ws, state, "Target.createTarget",
{"url": "about:blank"}))["result"]["targetId"]
sid = (await call(ws, state, "Target.attachToTarget",
{"targetId": tid, "flatten": True}))["result"]["sessionId"]
await call(ws, state, "Page.enable", session=sid)
await call(ws, state, "Page.navigate",
{"url": "https://books.toscrape.com/"}, session=sid)
await asyncio.sleep(2.5)
shot = await call(ws, state, "Page.captureScreenshot",
{"fromSurface": True, "optimizeForSpeed": True,
"rawFilePath": str(RAW),
"clip": {"x": 0, "y": 0, "width": 875,
"height": 1000, "scale": 1}}, session=sid)
print("error:", shot.get("error"))
print("inline data returned:", len(shot["result"].get("data", "")))
print("local file exists:", RAW.exists())
await call(ws, state, "Target.closeTarget", {"targetId": tid})
asyncio.run(main())
text
error: None
inline data returned: 245576
local file exists: False
没有错误,响应中有实际的图像数据,并且磁盘上没有任何东西。路径是关键:/dev/shm 指的是运行浏览器的机器,而这与运行您代码的机器不同。打开预期文件的工作线程会为每个瓦片引发 FileNotFoundError,如果这些失败被计算而不是引发,则运行报告写入的瓦片,而输出目录保持为空。
这是两个发现中更严峻的一个,因为这不是质量回退。它是一个不产生任何输出而声称已成功的管道。
应该做什么
这两个发现都不反对远程渲染。它们反对将本地假设带入其中。
读取几何形状而不是设置它。innerWidth 在覆盖后是您的瓦片数学应该使用的数字,而不是您传递的数字。如果您的瓦片必须是固定宽度以适应在该宽度下训练的模型,请在之后缩放或填充捕获的图像,而不是依赖浏览器按照您的数字进行布局。
选择编码的图像而不是原始路径。丢失 rawFilePath 花费 PNG 或 JPEG 编码时间,并将字节返回到您的进程中,这是浏览器远程时它们唯一有用的地方。
将瓦片几何形状视为需要验证的内容。一次检查请求的宽度与测量的宽度,当环境发生变化时运行一次,可以在建立索引之前捕获到这两个问题。两者都不会显示为错误,并且都会改变模型的视野。
结论
截图瓦片假定渲染器尊重您设置的视口,而在您启动的浏览器上这一假设是安全的。在云浏览器上,覆盖成功且布局宽度落在其他地方——在这里测量为六个会话中的六个不同值,没有一个是请求的 875——而截图裁剪被精确到像素。结果是一个错误区域的正确大小瓦片。
原始捕获路径更干净和更严重地失败:被接受,忽略,没有文件,没有错误。在信任视觉索引之前检查瓦片计数和测量的宽度,并读取几何形状而不是假设它。
关于该协议的更深入背景请参见 What Is the Chrome DevTools Protocol,连接本身涉及内容请参见 Playwright and Scraping Browser guide,计划详细信息可在 Scrapeless pricing page 中找到,而会话参数位于 Scrapeless documentation 中。
常见问题解答
问:这是否意味着 PixelRAG 无法使用云浏览器?
一点也不——这意味着它的渲染器今天假定使用的是本地浏览器。平铺逻辑、嵌入模型和索引都不受影响;需要更改的是捕获步骤,该步骤应该读取布局宽度并使用编码图像响应,而不是原始文件路径。
问:如果覆盖更改根本不应用我的值,为什么还会改变宽度?
因为它确实触发了重新布局,只是针对浏览器自己的窗口,而不是您请求的度量。在每个测量的会话中,调用之前的宽度是 945,调用之后则是不同的值,因此这个调用是有影响的——只是目标不在您的选择之内。
问:clip 参数可靠吗?
是的,这就是使不匹配容易被忽视的原因。每次捕获都精确返回请求的像素维度。正确的输出大小并不能证明下面的页面是以该大小布局的。
问:像 Playwright 这样的驱动程序库会避免这个问题吗?
不会,因为驱动程序在底层会发出相同的 CDP 命令。之前在这个端点的测量发现 Playwright 视口设置器留下的报告维度完全不变,因此症状在不同客户端之间略有不同,而原因是相同的。 WebDriver BiDi 规范 是一项标准化工作,旨在为远程浏览器控制提供一个定义的契约,这里的一些行为最终将得到确定。
问:我如何快速检查自己的设置?
设置视口,然后评估 innerWidth 并将其与您请求的内容进行比较。一行代码,一次往返,这会立即告诉您您的平铺数学运算是否在真实数字上进行还是在希望的数字上进行。
问:固定宽度裁剪实际上会损害检索吗?
它改变了输入分布。调整过的嵌入模型在特定宽度布局的页面截图上进行调整,却接收了更宽布局的裁剪,导致右侧内容缺失。图块依然将内容嵌入并返回邻近数据,因此没有任何内容显得损坏——结果只是在有用内容位于裁剪外的页面上变得更糟。
问:tiling 运行的 sessionTTL 应设置为多少?
应设置为足够长,以覆盖整个页面,因为平铺是顺序的,高页面需要在一个会话中进行多次捕获。根据您预期的最慢页面而不是平均页面来设置,并在运行完成时关闭目标。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。


