@mengruo/dsh-vision-toolkit 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +356 -0
- package/README.zh.md +354 -0
- package/assets/community-group-qr.png +0 -0
- package/assets/dsh-conversation-artifact.png +0 -0
- package/assets/dsh-conversation-image-qa-top.png +0 -0
- package/assets/dsh-conversation-image-qa.png +0 -0
- package/assets/dsh-conversation-pixel-diff.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging.png +0 -0
- package/assets/dsh-conversation-tool-call.png +0 -0
- package/assets/dsh-conversation-vision-trace.png +0 -0
- package/assets/dsh-view-example.png +0 -0
- package/assets/hero-v2.png +0 -0
- package/assets/logo_aihubmix.png +0 -0
- package/assets/python-bootstrap.json +48 -0
- package/assets/skill/SKILL.md +329 -0
- package/assets/skill/UPSTREAM.json +71 -0
- package/assets/skill/references/gui.md +88 -0
- package/assets/skill/references/long-screenshot-ocr.md +77 -0
- package/assets/skill/references/restore-graphic.md +84 -0
- package/assets/skill/references/restore-structure.md +45 -0
- package/assets/skill/references/restore-ui.md +202 -0
- package/assets/social-preview.png +0 -0
- package/assets/upstream/README.md +18 -0
- package/assets/upstream/focus-hint-comparison-1.webp +0 -0
- package/assets/upstream/focus-hint-comparison-2.webp +0 -0
- package/assets/upstream/infographic-reference.webp +0 -0
- package/assets/upstream/infographic-result.webp +0 -0
- package/assets/upstream/ui-fast-restore-reference.webp +0 -0
- package/assets/upstream/ui-fast-restore-result.webp +0 -0
- package/assets/upstream/ui-result.webp +0 -0
- package/assets/upstream/ui-sketch.webp +0 -0
- package/assets/vision-model-test.png +0 -0
- package/assets/vision-settings.png +0 -0
- package/assets/wechat-reward.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
- package/docs/aihubmix-gemini-vision.md +138 -0
- package/docs/aihubmix-gemini-vision.zh.md +138 -0
- package/docs/assets/aihubmix-api-keys.png +0 -0
- package/docs/assets/aihubmix-create-key.png +0 -0
- package/docs/assets/aihubmix-free-vision-model.png +0 -0
- package/docs/assets/aihubmix-home.png +0 -0
- package/docs/assets/aihubmix-sign-up.png +0 -0
- package/docs/assets/groq-console-home.png +0 -0
- package/docs/assets/groq-console-keys-login.png +0 -0
- package/docs/assets/groq-docs-quickstart.png +0 -0
- package/docs/assets/groq-docs-qwen3.6.png +0 -0
- package/docs/assets/groq-docs-vision.png +0 -0
- package/docs/assets/vision-settings.png +0 -0
- package/docs/dsh-desktop-install.md +67 -0
- package/docs/dsh-desktop-install.zh.md +67 -0
- package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
- package/docs/groq-qwen3.6-vision.md +236 -0
- package/docs/groq-qwen3.6-vision.zh.md +236 -0
- package/docs/python-runtime.i18n.yaml +6 -0
- package/docs/python-runtime.md +89 -0
- package/docs/python-runtime.zh.md +89 -0
- package/docs/requirements-traceability/README.i18n.yaml +6 -0
- package/docs/requirements-traceability/README.md +75 -0
- package/docs/requirements-traceability/README.zh.md +75 -0
- package/examples/ui-restoration/README.i18n.yaml +6 -0
- package/examples/ui-restoration/README.md +70 -0
- package/examples/ui-restoration/README.zh.md +70 -0
- package/examples/ui-restoration/assets/final-heatmap.png +0 -0
- package/examples/ui-restoration/assets/final-report.json +83 -0
- package/examples/ui-restoration/assets/implementation.png +0 -0
- package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
- package/examples/ui-restoration/assets/initial-report.json +83 -0
- package/examples/ui-restoration/assets/initial.png +0 -0
- package/examples/ui-restoration/assets/metrics.json +12 -0
- package/examples/ui-restoration/assets/reference.png +0 -0
- package/examples/ui-restoration/implementation.html +94 -0
- package/examples/ui-restoration/initial.html +57 -0
- package/lib/artifact-access.js +369 -0
- package/lib/artifact-access.js.map +1 -0
- package/lib/artifacts.js +56 -0
- package/lib/artifacts.js.map +1 -0
- package/lib/client.js +2099 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +172 -0
- package/lib/config.js.map +1 -0
- package/lib/defaults.js +6 -0
- package/lib/defaults.js.map +1 -0
- package/lib/errors.js +56 -0
- package/lib/errors.js.map +1 -0
- package/lib/evidence-cache.js +350 -0
- package/lib/evidence-cache.js.map +1 -0
- package/lib/exposure.js +247 -0
- package/lib/exposure.js.map +1 -0
- package/lib/image-input-variants.js +882 -0
- package/lib/image-input-variants.js.map +1 -0
- package/lib/index.js +105 -0
- package/lib/index.js.map +1 -0
- package/lib/paste-images.js +217 -0
- package/lib/paste-images.js.map +1 -0
- package/lib/paths.js +354 -0
- package/lib/paths.js.map +1 -0
- package/lib/plugin-update.js +1003 -0
- package/lib/plugin-update.js.map +1 -0
- package/lib/runtime-install.js +964 -0
- package/lib/runtime-install.js.map +1 -0
- package/lib/runtime-manager.js +133 -0
- package/lib/runtime-manager.js.map +1 -0
- package/lib/runtime.js +1678 -0
- package/lib/runtime.js.map +1 -0
- package/lib/skill.js +25 -0
- package/lib/skill.js.map +1 -0
- package/lib/tools.js +549 -0
- package/lib/tools.js.map +1 -0
- package/lib/types/artifact-access.d.ts +61 -0
- package/lib/types/artifact-access.d.ts.map +1 -0
- package/lib/types/artifacts.d.ts +42 -0
- package/lib/types/artifacts.d.ts.map +1 -0
- package/lib/types/client/display-config.d.ts +24 -0
- package/lib/types/client/display-config.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +350 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/model-variants-hider.d.ts +40 -0
- package/lib/types/client/model-variants-hider.d.ts.map +1 -0
- package/lib/types/client/paste-images.d.ts +126 -0
- package/lib/types/client/paste-images.d.ts.map +1 -0
- package/lib/types/config.d.ts +123 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/errors.d.ts +35 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/evidence-cache.d.ts +91 -0
- package/lib/types/evidence-cache.d.ts.map +1 -0
- package/lib/types/exposure.d.ts +50 -0
- package/lib/types/exposure.d.ts.map +1 -0
- package/lib/types/image-input-variants.d.ts +144 -0
- package/lib/types/image-input-variants.d.ts.map +1 -0
- package/lib/types/index.d.ts +19 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/paste-images.d.ts +73 -0
- package/lib/types/paste-images.d.ts.map +1 -0
- package/lib/types/paths.d.ts +117 -0
- package/lib/types/paths.d.ts.map +1 -0
- package/lib/types/plugin-update.d.ts +119 -0
- package/lib/types/plugin-update.d.ts.map +1 -0
- package/lib/types/runtime-install.d.ts +92 -0
- package/lib/types/runtime-install.d.ts.map +1 -0
- package/lib/types/runtime-manager.d.ts +60 -0
- package/lib/types/runtime-manager.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +412 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/skill.d.ts +14 -0
- package/lib/types/skill.d.ts.map +1 -0
- package/lib/types/tools.d.ts +35 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/upstream.d.ts +233 -0
- package/lib/types/upstream.d.ts.map +1 -0
- package/lib/types/version.d.ts +15 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/web-request.d.ts +11 -0
- package/lib/types/web-request.d.ts.map +1 -0
- package/lib/types/web.d.ts +118 -0
- package/lib/types/web.d.ts.map +1 -0
- package/lib/upstream.js +860 -0
- package/lib/upstream.js.map +1 -0
- package/lib/version.js +18 -0
- package/lib/version.js.map +1 -0
- package/lib/web-request.js +29 -0
- package/lib/web-request.js.map +1 -0
- package/lib/web.js +462 -0
- package/lib/web.js.map +1 -0
- package/package.json +149 -0
- package/patches/vision-tools-dsh.patch +970 -0
- package/runtime/requirements.lock +3 -0
- package/src/artifact-access.ts +386 -0
- package/src/artifacts.ts +85 -0
- package/src/client/display-config.ts +62 -0
- package/src/client/index.tsx +1580 -0
- package/src/client/model-variants-hider.ts +159 -0
- package/src/client/paste-images.tsx +747 -0
- package/src/config.ts +290 -0
- package/src/defaults.ts +5 -0
- package/src/errors.ts +62 -0
- package/src/evidence-cache.ts +418 -0
- package/src/exposure.ts +265 -0
- package/src/image-input-variants.ts +996 -0
- package/src/index.ts +141 -0
- package/src/paste-images.ts +286 -0
- package/src/paths.ts +383 -0
- package/src/plugin-update.ts +1143 -0
- package/src/runtime-install.ts +1161 -0
- package/src/runtime-manager.ts +174 -0
- package/src/runtime.ts +2189 -0
- package/src/skill.ts +35 -0
- package/src/tools.ts +691 -0
- package/src/upstream.ts +1078 -0
- package/src/version.ts +37 -0
- package/src/web-request.ts +27 -0
- package/src/web.ts +594 -0
- package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
- package/vendor/agent-vision-toolkit/LICENSE +21 -0
- package/vendor/agent-vision-toolkit/README.md +405 -0
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
- package/vendor/agent-vision-toolkit/bin/crop +90 -0
- package/vendor/agent-vision-toolkit/bin/detect +13 -0
- package/vendor/agent-vision-toolkit/bin/glance +93 -0
- package/vendor/agent-vision-toolkit/bin/ground +13 -0
- package/vendor/agent-vision-toolkit/bin/trace +129 -0
- package/vendor/agent-vision-toolkit/detect.py +60 -0
- package/vendor/agent-vision-toolkit/ground.py +267 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
- package/vendor/agent-vision-toolkit/vision_client.py +310 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Screenshot an HTML file with headless Chrome/Chromium/Edge.
|
|
3
|
+
|
|
4
|
+
The default path intentionally remains the small Chrome CLI wrapper used by
|
|
5
|
+
the original upstream tool. ``--full-page`` uses Chrome DevTools Protocol
|
|
6
|
+
instead: the requested viewport remains the layout viewport while the page's
|
|
7
|
+
CSS scroll height is measured and captured in one pass.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import socket
|
|
17
|
+
import struct
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from urllib.error import URLError
|
|
23
|
+
from urllib.parse import urlparse
|
|
24
|
+
from urllib.request import urlopen
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
CHROME_CANDIDATES = (
|
|
28
|
+
# macOS
|
|
29
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
30
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
31
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
32
|
+
# Linux
|
|
33
|
+
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
|
|
34
|
+
"microsoft-edge", "brave-browser",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_chrome() -> str | None:
|
|
39
|
+
for candidate in CHROME_CANDIDATES:
|
|
40
|
+
path = shutil.which(candidate)
|
|
41
|
+
if path:
|
|
42
|
+
return path
|
|
43
|
+
if candidate.startswith("/") and Path(candidate).is_file():
|
|
44
|
+
return candidate
|
|
45
|
+
for env in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
|
|
46
|
+
base = os.environ.get(env)
|
|
47
|
+
if not base:
|
|
48
|
+
continue
|
|
49
|
+
for sub in ("Google/Chrome/Application/chrome.exe",
|
|
50
|
+
"Microsoft/Edge/Application/msedge.exe"):
|
|
51
|
+
path = Path(base) / sub
|
|
52
|
+
if path.is_file():
|
|
53
|
+
return str(path)
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def default_output(source: str) -> str:
|
|
58
|
+
if source.startswith(("http://", "https://", "file://", "data:")):
|
|
59
|
+
stem = Path(urlparse(source).path).stem
|
|
60
|
+
return f"{stem or 'page'}.png"
|
|
61
|
+
return f"{Path(source).stem}.png"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def reserve_port() -> int:
|
|
65
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
|
|
66
|
+
listener.bind(("127.0.0.1", 0))
|
|
67
|
+
return int(listener.getsockname()[1])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def read_http_json(port: int, path: str) -> object:
|
|
71
|
+
with urlopen(f"http://127.0.0.1:{port}{path}", timeout=1.0) as response:
|
|
72
|
+
return json.loads(response.read().decode("utf-8"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DevToolsSocket:
|
|
76
|
+
"""Minimal RFC 6455 client for the Chrome DevTools Protocol."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, websocket_url: str):
|
|
79
|
+
parsed = urlparse(websocket_url)
|
|
80
|
+
if parsed.scheme != "ws" or parsed.hostname is None or parsed.port is None:
|
|
81
|
+
raise RuntimeError("invalid DevTools websocket URL")
|
|
82
|
+
self.socket = socket.create_connection((parsed.hostname, parsed.port), timeout=5.0)
|
|
83
|
+
self.socket.settimeout(5.0)
|
|
84
|
+
self.buffer = bytearray()
|
|
85
|
+
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
86
|
+
request = (
|
|
87
|
+
f"GET {parsed.path or '/'} HTTP/1.1\r\n"
|
|
88
|
+
f"Host: {parsed.hostname}:{parsed.port}\r\n"
|
|
89
|
+
"Upgrade: websocket\r\n"
|
|
90
|
+
"Connection: Upgrade\r\n"
|
|
91
|
+
f"Sec-WebSocket-Key: {key}\r\n"
|
|
92
|
+
"Sec-WebSocket-Version: 13\r\n\r\n"
|
|
93
|
+
).encode("ascii")
|
|
94
|
+
self.socket.sendall(request)
|
|
95
|
+
response = self._read_until(b"\r\n\r\n")
|
|
96
|
+
if not response.startswith(b"HTTP/1.1 101"):
|
|
97
|
+
raise RuntimeError("Chrome did not accept the DevTools websocket")
|
|
98
|
+
expected_accept = base64.b64encode(hashlib.sha1(
|
|
99
|
+
f"{key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11".encode("ascii")
|
|
100
|
+
).digest()).decode("ascii")
|
|
101
|
+
headers = response.decode("latin-1").split("\r\n")
|
|
102
|
+
accept = next((line.split(":", 1)[1].strip() for line in headers
|
|
103
|
+
if line.lower().startswith("sec-websocket-accept:")), None)
|
|
104
|
+
if accept != expected_accept:
|
|
105
|
+
raise RuntimeError("Chrome returned an invalid DevTools websocket handshake")
|
|
106
|
+
self.next_id = 0
|
|
107
|
+
|
|
108
|
+
def _read_until(self, marker: bytes) -> bytes:
|
|
109
|
+
data = bytearray()
|
|
110
|
+
while marker not in data:
|
|
111
|
+
chunk = self.socket.recv(4096)
|
|
112
|
+
if not chunk:
|
|
113
|
+
raise RuntimeError("DevTools websocket closed during handshake")
|
|
114
|
+
data.extend(chunk)
|
|
115
|
+
boundary = data.index(marker) + len(marker)
|
|
116
|
+
self.buffer.extend(data[boundary:])
|
|
117
|
+
return bytes(data[:boundary])
|
|
118
|
+
|
|
119
|
+
def _send_frame(self, opcode: int, payload: bytes) -> None:
|
|
120
|
+
mask = os.urandom(4)
|
|
121
|
+
masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
|
|
122
|
+
length = len(masked)
|
|
123
|
+
if length < 126:
|
|
124
|
+
header = struct.pack("!BB", 0x80 | opcode, 0x80 | length)
|
|
125
|
+
elif length < 65536:
|
|
126
|
+
header = struct.pack("!BBH", 0x80 | opcode, 0x80 | 126, length)
|
|
127
|
+
else:
|
|
128
|
+
header = struct.pack("!BBQ", 0x80 | opcode, 0x80 | 127, length)
|
|
129
|
+
self.socket.sendall(header + mask + masked)
|
|
130
|
+
|
|
131
|
+
def _read_frame(self) -> tuple[bool, int, bytes]:
|
|
132
|
+
header = self._read_exact(2)
|
|
133
|
+
first, second = header
|
|
134
|
+
final = (first & 0x80) != 0
|
|
135
|
+
opcode = first & 0x0F
|
|
136
|
+
length = second & 0x7F
|
|
137
|
+
masked = (second & 0x80) != 0
|
|
138
|
+
if length == 126:
|
|
139
|
+
length = struct.unpack("!H", self._read_exact(2))[0]
|
|
140
|
+
elif length == 127:
|
|
141
|
+
length = struct.unpack("!Q", self._read_exact(8))[0]
|
|
142
|
+
mask = self._read_exact(4) if masked else None
|
|
143
|
+
payload = self._read_exact(length)
|
|
144
|
+
if mask is not None:
|
|
145
|
+
payload = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
|
|
146
|
+
return final, opcode, payload
|
|
147
|
+
|
|
148
|
+
def _read_message(self) -> tuple[int, bytes]:
|
|
149
|
+
fragments: list[bytes] = []
|
|
150
|
+
message_opcode: int | None = None
|
|
151
|
+
while True:
|
|
152
|
+
final, opcode, payload = self._read_frame()
|
|
153
|
+
if opcode == 0x9:
|
|
154
|
+
self._send_frame(0xA, payload)
|
|
155
|
+
continue
|
|
156
|
+
if opcode == 0x8:
|
|
157
|
+
raise RuntimeError("DevTools websocket closed")
|
|
158
|
+
if opcode in (0x1, 0x2):
|
|
159
|
+
fragments = [payload]
|
|
160
|
+
message_opcode = opcode
|
|
161
|
+
elif opcode == 0x0 and message_opcode is not None:
|
|
162
|
+
fragments.append(payload)
|
|
163
|
+
else:
|
|
164
|
+
continue
|
|
165
|
+
if final and message_opcode is not None:
|
|
166
|
+
return message_opcode, b"".join(fragments)
|
|
167
|
+
|
|
168
|
+
def _read_exact(self, length: int) -> bytes:
|
|
169
|
+
data = bytearray()
|
|
170
|
+
if self.buffer:
|
|
171
|
+
take = min(length, len(self.buffer))
|
|
172
|
+
data.extend(self.buffer[:take])
|
|
173
|
+
del self.buffer[:take]
|
|
174
|
+
while len(data) < length:
|
|
175
|
+
chunk = self.socket.recv(length - len(data))
|
|
176
|
+
if not chunk:
|
|
177
|
+
raise RuntimeError("DevTools websocket closed unexpectedly")
|
|
178
|
+
data.extend(chunk)
|
|
179
|
+
return bytes(data)
|
|
180
|
+
|
|
181
|
+
def command(self, method: str, params: dict | None = None) -> dict:
|
|
182
|
+
self.next_id += 1
|
|
183
|
+
request_id = self.next_id
|
|
184
|
+
request = {"id": request_id, "method": method}
|
|
185
|
+
if params is not None:
|
|
186
|
+
request["params"] = params
|
|
187
|
+
self._send_frame(0x1, json.dumps(request, separators=(",", ":")).encode("utf-8"))
|
|
188
|
+
while True:
|
|
189
|
+
opcode, payload = self._read_message()
|
|
190
|
+
if opcode != 0x1:
|
|
191
|
+
continue
|
|
192
|
+
message = json.loads(payload.decode("utf-8"))
|
|
193
|
+
if message.get("id") != request_id:
|
|
194
|
+
continue
|
|
195
|
+
if "error" in message:
|
|
196
|
+
raise RuntimeError(f"{method}: {message['error'].get('message', 'CDP command failed')}")
|
|
197
|
+
return message.get("result", {})
|
|
198
|
+
|
|
199
|
+
def close(self) -> None:
|
|
200
|
+
try:
|
|
201
|
+
self._send_frame(0x8, b"")
|
|
202
|
+
except OSError:
|
|
203
|
+
pass
|
|
204
|
+
self.socket.close()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def evaluate(client: DevToolsSocket, expression: str) -> object:
|
|
208
|
+
result = client.command("Runtime.evaluate", {
|
|
209
|
+
"expression": expression,
|
|
210
|
+
"returnByValue": True,
|
|
211
|
+
"awaitPromise": True,
|
|
212
|
+
})
|
|
213
|
+
if "exceptionDetails" in result:
|
|
214
|
+
raise RuntimeError(result["exceptionDetails"].get("text", "Runtime.evaluate failed"))
|
|
215
|
+
remote = result.get("result", {})
|
|
216
|
+
if remote.get("subtype") == "error":
|
|
217
|
+
raise RuntimeError(remote.get("description", "Runtime.evaluate failed"))
|
|
218
|
+
return remote.get("value")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def wait_for_target(port: int, deadline: float) -> dict:
|
|
222
|
+
last_error: Exception | None = None
|
|
223
|
+
while time.monotonic() < deadline:
|
|
224
|
+
try:
|
|
225
|
+
targets = read_http_json(port, "/json/list")
|
|
226
|
+
if isinstance(targets, list):
|
|
227
|
+
for target in targets:
|
|
228
|
+
if isinstance(target, dict) and target.get("type") == "page" and target.get("webSocketDebuggerUrl"):
|
|
229
|
+
return target
|
|
230
|
+
except (OSError, URLError, ValueError) as error:
|
|
231
|
+
last_error = error
|
|
232
|
+
time.sleep(0.05)
|
|
233
|
+
detail = f": {last_error}" if last_error else ""
|
|
234
|
+
raise RuntimeError(f"timed out waiting for Chrome DevTools{detail}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def wait_for_ready(client: DevToolsSocket, deadline: float) -> None:
|
|
238
|
+
while time.monotonic() < deadline:
|
|
239
|
+
if evaluate(client, "document.readyState") == "complete":
|
|
240
|
+
return
|
|
241
|
+
time.sleep(0.05)
|
|
242
|
+
raise RuntimeError("timed out waiting for the HTML document")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def pause_for_frame(seconds: float) -> None:
|
|
246
|
+
time.sleep(max(0.02, seconds))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def capture_full_page(chrome: str, source: str, output: Path, width: int, height: int,
|
|
250
|
+
scale: int, wait_ms: int, max_pixels: int | None) -> int:
|
|
251
|
+
port = reserve_port()
|
|
252
|
+
guarded_profile = os.environ.get("DSH_VISION_CHROME_PROFILE")
|
|
253
|
+
profile = guarded_profile or tempfile.mkdtemp(prefix="dsh-vision-chrome-")
|
|
254
|
+
process: subprocess.Popen | None = None
|
|
255
|
+
client: DevToolsSocket | None = None
|
|
256
|
+
try:
|
|
257
|
+
command = [
|
|
258
|
+
chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars",
|
|
259
|
+
"--no-first-run", "--no-default-browser-check",
|
|
260
|
+
"--blink-settings=imagesLazyLoadingEnabled=false",
|
|
261
|
+
f"--window-size={width},{height}",
|
|
262
|
+
f"--remote-debugging-port={port}",
|
|
263
|
+
"--remote-debugging-address=127.0.0.1",
|
|
264
|
+
]
|
|
265
|
+
if guarded_profile is None:
|
|
266
|
+
command.append(f"--user-data-dir={profile}")
|
|
267
|
+
if scale != 1:
|
|
268
|
+
command.append(f"--force-device-scale-factor={scale}")
|
|
269
|
+
command.append(source)
|
|
270
|
+
process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
271
|
+
target = wait_for_target(port, time.monotonic() + 15.0)
|
|
272
|
+
client = DevToolsSocket(str(target["webSocketDebuggerUrl"]))
|
|
273
|
+
client.command("Page.enable")
|
|
274
|
+
client.command("Runtime.enable")
|
|
275
|
+
client.command("Emulation.setDeviceMetricsOverride", {
|
|
276
|
+
"width": width,
|
|
277
|
+
"height": height,
|
|
278
|
+
"deviceScaleFactor": scale,
|
|
279
|
+
"mobile": False,
|
|
280
|
+
"screenWidth": width,
|
|
281
|
+
"screenHeight": height,
|
|
282
|
+
})
|
|
283
|
+
client.command("Page.navigate", {"url": source})
|
|
284
|
+
wait_for_ready(client, time.monotonic() + 15.0)
|
|
285
|
+
if wait_ms > 0:
|
|
286
|
+
pause_for_frame(wait_ms / 1000.0)
|
|
287
|
+
|
|
288
|
+
# Keep the requested viewport fixed. Scrolling, rather than growing
|
|
289
|
+
# the viewport, wakes IO/reveal handlers without changing vh/svh.
|
|
290
|
+
evaluate(client, """
|
|
291
|
+
(() => {
|
|
292
|
+
document.documentElement.style.scrollBehavior = 'auto';
|
|
293
|
+
if (document.body) document.body.style.scrollBehavior = 'auto';
|
|
294
|
+
window.scrollTo(0, 0);
|
|
295
|
+
return true;
|
|
296
|
+
})()
|
|
297
|
+
""")
|
|
298
|
+
page_height = int(evaluate(client, """
|
|
299
|
+
Math.max(document.documentElement.scrollHeight,
|
|
300
|
+
document.body ? document.body.scrollHeight : 0)
|
|
301
|
+
""") or 0)
|
|
302
|
+
for _ in range(2):
|
|
303
|
+
measured_before_sweep = page_height
|
|
304
|
+
for position in range(0, page_height, max(1, height)):
|
|
305
|
+
evaluate(client, f"window.scrollTo(0, {position}); true")
|
|
306
|
+
pause_for_frame(0.12)
|
|
307
|
+
evaluate(client, f"window.scrollTo(0, {page_height}); true")
|
|
308
|
+
pause_for_frame(0.12)
|
|
309
|
+
page_height = int(evaluate(client, """
|
|
310
|
+
Math.max(document.documentElement.scrollHeight,
|
|
311
|
+
document.body ? document.body.scrollHeight : 0)
|
|
312
|
+
""") or 0)
|
|
313
|
+
if page_height <= measured_before_sweep:
|
|
314
|
+
break
|
|
315
|
+
evaluate(client, """
|
|
316
|
+
Promise.race([
|
|
317
|
+
Promise.all(Array.from(document.images)
|
|
318
|
+
.filter(image => !image.complete)
|
|
319
|
+
.map(image => new Promise(resolve => {
|
|
320
|
+
image.addEventListener('load', resolve, {once: true});
|
|
321
|
+
image.addEventListener('error', resolve, {once: true});
|
|
322
|
+
}))),
|
|
323
|
+
new Promise(resolve => setTimeout(resolve, 3000))
|
|
324
|
+
]).then(() => true)
|
|
325
|
+
""")
|
|
326
|
+
evaluate(client, "window.scrollTo(0, 0); true")
|
|
327
|
+
pause_for_frame(0.5)
|
|
328
|
+
page_height = int(evaluate(client, """
|
|
329
|
+
Math.max(document.documentElement.scrollHeight,
|
|
330
|
+
document.body ? document.body.scrollHeight : 0)
|
|
331
|
+
""") or 0)
|
|
332
|
+
if page_height <= 0:
|
|
333
|
+
raise RuntimeError("document has no measurable scroll height")
|
|
334
|
+
if max_pixels is not None and width * page_height * scale * scale > max_pixels:
|
|
335
|
+
raise RuntimeError(f"full-page screenshot would exceed maxImagePixels ({width * page_height * scale * scale} > {max_pixels})")
|
|
336
|
+
result = client.command("Page.captureScreenshot", {
|
|
337
|
+
"format": "png",
|
|
338
|
+
"fromSurface": True,
|
|
339
|
+
"captureBeyondViewport": True,
|
|
340
|
+
"clip": {"x": 0, "y": 0, "width": width, "height": page_height, "scale": 1},
|
|
341
|
+
})
|
|
342
|
+
data = result.get("data")
|
|
343
|
+
if not isinstance(data, str):
|
|
344
|
+
raise RuntimeError("Chrome did not return screenshot data")
|
|
345
|
+
output.write_bytes(base64.b64decode(data))
|
|
346
|
+
return page_height
|
|
347
|
+
finally:
|
|
348
|
+
if client is not None:
|
|
349
|
+
client.close()
|
|
350
|
+
if process is not None and process.poll() is None:
|
|
351
|
+
process.terminate()
|
|
352
|
+
try:
|
|
353
|
+
process.wait(timeout=2)
|
|
354
|
+
except subprocess.TimeoutExpired:
|
|
355
|
+
process.kill()
|
|
356
|
+
process.wait(timeout=2)
|
|
357
|
+
if guarded_profile is None:
|
|
358
|
+
shutil.rmtree(profile, ignore_errors=True)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def main() -> None:
|
|
362
|
+
parser = argparse.ArgumentParser(
|
|
363
|
+
prog="html_shot",
|
|
364
|
+
description="Screenshot an HTML file (or URL) to PNG with headless Chrome/Chromium/Edge",
|
|
365
|
+
)
|
|
366
|
+
parser.add_argument("source", help="path to a local .html file, or an http(s):// URL")
|
|
367
|
+
parser.add_argument("-o", "--output", help="output PNG path (default: <source-stem>.png in the current directory)")
|
|
368
|
+
parser.add_argument("--width", type=int, default=1280, help="viewport width in CSS pixels (default: 1280)")
|
|
369
|
+
parser.add_argument("--height", type=int, default=800, help="viewport height in CSS pixels (default: 800)")
|
|
370
|
+
parser.add_argument("--scale", type=int, default=1,
|
|
371
|
+
help="device scale factor: 2 makes the PNG 2x for small text (default: 1)")
|
|
372
|
+
parser.add_argument("--wait-ms", type=int, default=0,
|
|
373
|
+
help="virtual time budget in ms before capturing (default: 0, capture immediately)")
|
|
374
|
+
parser.add_argument("--full-page", action="store_true",
|
|
375
|
+
help="capture the full CSS scroll height while keeping the requested viewport")
|
|
376
|
+
parser.add_argument("--max-pixels", type=int,
|
|
377
|
+
help="reject full-page captures larger than this many output pixels")
|
|
378
|
+
args = parser.parse_args()
|
|
379
|
+
|
|
380
|
+
chrome = find_chrome()
|
|
381
|
+
if not chrome:
|
|
382
|
+
parser.exit(1, "html_shot: no Chrome/Chromium/Edge found; install one and retry\n")
|
|
383
|
+
|
|
384
|
+
source = args.source
|
|
385
|
+
if not source.startswith(("http://", "https://", "file://", "data:")):
|
|
386
|
+
path = Path(source).expanduser()
|
|
387
|
+
if not path.is_file():
|
|
388
|
+
parser.exit(1, f"html_shot: file not found: {path}\n")
|
|
389
|
+
source = path.resolve().as_uri()
|
|
390
|
+
|
|
391
|
+
output = Path(args.output).expanduser().resolve() if args.output else Path(default_output(source)).resolve()
|
|
392
|
+
if args.full_page:
|
|
393
|
+
try:
|
|
394
|
+
page_height = capture_full_page(chrome, source, output, args.width, args.height, args.scale, args.wait_ms, args.max_pixels)
|
|
395
|
+
except Exception as error:
|
|
396
|
+
parser.exit(1, f"html_shot: full-page capture failed: {error}\n")
|
|
397
|
+
print(f"wrote {output} ({args.width * args.scale}x{page_height * args.scale}; pageHeight={page_height})")
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
command = [
|
|
401
|
+
chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars",
|
|
402
|
+
"--no-first-run", "--no-default-browser-check",
|
|
403
|
+
f"--window-size={args.width},{args.height}",
|
|
404
|
+
f"--screenshot={output}",
|
|
405
|
+
]
|
|
406
|
+
if args.scale != 1:
|
|
407
|
+
command.append(f"--force-device-scale-factor={args.scale}")
|
|
408
|
+
if args.wait_ms > 0:
|
|
409
|
+
command.append(f"--virtual-time-budget={args.wait_ms}")
|
|
410
|
+
command.append(source)
|
|
411
|
+
|
|
412
|
+
result = subprocess.run(command, text=True, capture_output=True)
|
|
413
|
+
if result.returncode != 0 or not output.is_file():
|
|
414
|
+
message = result.stderr.strip() or result.stdout.strip() or f"chrome exited with code {result.returncode}"
|
|
415
|
+
parser.exit(1, f"html_shot: capture failed: {message}\n")
|
|
416
|
+
print(f"wrote {output} ({args.width * args.scale}x{args.height * args.scale})")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
if __name__ == "__main__":
|
|
420
|
+
main()
|