llama-index-tools-snaprender 0.1.0__tar.gz

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.
@@ -0,0 +1,12 @@
1
+ node_modules/
2
+ dist/
3
+ *.tgz
4
+ .DS_Store
5
+ *.pyc
6
+ __pycache__/
7
+ .venv/
8
+ *.egg-info/
9
+ drafts/
10
+ .npmrc
11
+ *.mcpb
12
+ .mcpregistry_*
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SnapRender
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.5
2
+ Name: llama-index-tools-snaprender
3
+ Version: 0.1.0
4
+ Summary: LlamaIndex tools for SnapRender: website screenshots the agent can see, and rendered pages as markdown
5
+ Project-URL: Homepage, https://snap-render.com
6
+ Project-URL: Documentation, https://snap-render.com/docs
7
+ Project-URL: Source, https://github.com/User0856/snaprender-integrations/tree/main/llamaindex
8
+ Author-email: SnapRender <support@snap-render.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,llama-index,llamaindex,markdown,screenshot,snaprender,tools,web-scraping,website-screenshot
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: <4.0,>=3.9
18
+ Requires-Dist: httpx>=0.24
19
+ Requires-Dist: llama-index-core<0.15,>=0.13.0
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
22
+ Requires-Dist: pytest>=7; extra == 'test'
23
+ Requires-Dist: respx>=0.21; extra == 'test'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # llama-index-tools-snaprender
27
+
28
+ [LlamaIndex](https://www.llamaindex.ai) tools for [SnapRender](https://snap-render.com). Your agent can take screenshots of web pages and read rendered pages as markdown. Pages load in a real Chrome browser with ads and cookie banners removed, and the screenshot goes back to the model as an image block it can look at.
29
+
30
+ - `take_screenshot`: capture a URL or raw HTML (desktop, phone or tablet, viewport or full page). Returns a text summary plus an `ImageBlock`.
31
+ - `extract_content`: read a page after JavaScript runs, as markdown, main article text, plain text, links or metadata.
32
+
33
+ Both tools have async versions, used automatically by async agents.
34
+
35
+ The free plan includes 200 captures a month. Get a key at [app.snap-render.com/auth/signup](https://app.snap-render.com/auth/signup).
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install llama-index-tools-snaprender
41
+ export SNAPRENDER_API_KEY=sk_live_...
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ```python
47
+ import asyncio
48
+
49
+ from llama_index.core.agent.workflow import FunctionAgent
50
+ from llama_index.llms.anthropic import Anthropic
51
+ from llama_index.tools.snaprender import SnapRenderToolSpec
52
+
53
+ agent = FunctionAgent(
54
+ tools=SnapRenderToolSpec().to_tool_list(),
55
+ llm=Anthropic(model="claude-sonnet-5-5"),
56
+ system_prompt="You can look at web pages with take_screenshot and read them with extract_content.",
57
+ )
58
+
59
+
60
+ async def main() -> None:
61
+ response = await agent.run(
62
+ "Take a screenshot of https://www.llamaindex.ai on an iPhone (device iphone_14) "
63
+ "and tell me what the first screen shows."
64
+ )
65
+ print(response)
66
+
67
+
68
+ asyncio.run(main())
69
+ ```
70
+
71
+ Use any LLM integration that accepts images in tool results. Note (Nov 2026): `llama-index-llms-anthropic` 0.12.3 fails with the `anthropic` SDK 2.x (`'typing.Union' object is not callable`) before any tool runs; `pip install "anthropic<2"` works until a fix ships.
72
+
73
+ Call the tools directly too:
74
+
75
+ ```python
76
+ spec = SnapRenderToolSpec()
77
+ blocks = spec.take_screenshot(url="https://example.com", full_page=True) # [TextBlock, ImageBlock]
78
+ markdown = spec.extract_content(url="https://example.com", type="article")
79
+ ```
80
+
81
+ ## Tool inputs
82
+
83
+ `take_screenshot` (pass `url` or `html`): `format` (`jpeg` default at quality 80, `png`, `webp`), `full_page`, `lazy_load`, `device` (`iphone_14`, `iphone_15_pro`, `pixel_7`, `ipad_pro`, `macbook_pro`), `width` and `height` (CSS px, default 1280x800), `dark_mode`, `delay` (ms), `quality`, `hide_selectors`, `click_selector`, `block_ads` and `block_cookie_banners` (both default true), `cache` (accept a copy from the last 24 hours, free; default false).
84
+
85
+ `extract_content`: `url`, `type` (`markdown` default, `article`, `text`, `html`, `links`, `metadata`), `selector`, `max_length` (default 100000), `delay`, `block_ads`, `block_cookie_banners`, `cache`.
86
+
87
+ Captures render at 2x device scale, so a 1280 px viewport gives a 2560 px wide image. When an image is over `max_image_bytes` (default 5 MB) or `max_image_dimension` (default 8000 px), the tool returns only the text summary and tells the model to retry with `full_page=False` or a smaller viewport.
88
+
89
+ ## Options
90
+
91
+ ```python
92
+ SnapRenderToolSpec(
93
+ api_key=None, # default: SNAPRENDER_API_KEY
94
+ base_url="https://app.snap-render.com",
95
+ timeout=90.0, # seconds
96
+ max_retries=1, # after a per-minute rate limit, waits Retry-After (up to 60 s)
97
+ max_image_bytes=5_000_000,
98
+ max_image_dimension=8000,
99
+ get_api_key=None, # callable returning a key at call time
100
+ )
101
+ ```
102
+
103
+ ## Errors
104
+
105
+ Failed requests raise `SnapRenderError` with `status`, `code` and the API's message, for example `SnapRender 422 TARGET_UNREACHABLE: The target website could not be reached...`. Agents pass the message to the model so it can explain or try another URL. Failed renders do not use a capture. Monthly quota errors are never retried.
106
+
107
+ The tools cannot reach localhost, private networks or pages behind a login. If a capture of your own site shows a security check page, see [snap-render.com/docs/allow-snaprender](https://snap-render.com/docs/allow-snaprender).
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ pip install -e ".[test]"
113
+ pytest # unit tests, no network
114
+ SNAPRENDER_TEST_KEY=sk_live_... pytest tests/test_live.py # live API tests
115
+ ```
116
+
117
+ ## Links
118
+
119
+ - API docs: [snap-render.com/docs](https://snap-render.com/docs)
120
+ - MCP server (Claude, Cursor and other MCP clients): [snaprender-mcp](https://www.npmjs.com/package/snaprender-mcp)
121
+ - Support: support@snap-render.com
122
+
123
+ MIT License
@@ -0,0 +1,98 @@
1
+ # llama-index-tools-snaprender
2
+
3
+ [LlamaIndex](https://www.llamaindex.ai) tools for [SnapRender](https://snap-render.com). Your agent can take screenshots of web pages and read rendered pages as markdown. Pages load in a real Chrome browser with ads and cookie banners removed, and the screenshot goes back to the model as an image block it can look at.
4
+
5
+ - `take_screenshot`: capture a URL or raw HTML (desktop, phone or tablet, viewport or full page). Returns a text summary plus an `ImageBlock`.
6
+ - `extract_content`: read a page after JavaScript runs, as markdown, main article text, plain text, links or metadata.
7
+
8
+ Both tools have async versions, used automatically by async agents.
9
+
10
+ The free plan includes 200 captures a month. Get a key at [app.snap-render.com/auth/signup](https://app.snap-render.com/auth/signup).
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install llama-index-tools-snaprender
16
+ export SNAPRENDER_API_KEY=sk_live_...
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ import asyncio
23
+
24
+ from llama_index.core.agent.workflow import FunctionAgent
25
+ from llama_index.llms.anthropic import Anthropic
26
+ from llama_index.tools.snaprender import SnapRenderToolSpec
27
+
28
+ agent = FunctionAgent(
29
+ tools=SnapRenderToolSpec().to_tool_list(),
30
+ llm=Anthropic(model="claude-sonnet-5-5"),
31
+ system_prompt="You can look at web pages with take_screenshot and read them with extract_content.",
32
+ )
33
+
34
+
35
+ async def main() -> None:
36
+ response = await agent.run(
37
+ "Take a screenshot of https://www.llamaindex.ai on an iPhone (device iphone_14) "
38
+ "and tell me what the first screen shows."
39
+ )
40
+ print(response)
41
+
42
+
43
+ asyncio.run(main())
44
+ ```
45
+
46
+ Use any LLM integration that accepts images in tool results. Note (Nov 2026): `llama-index-llms-anthropic` 0.12.3 fails with the `anthropic` SDK 2.x (`'typing.Union' object is not callable`) before any tool runs; `pip install "anthropic<2"` works until a fix ships.
47
+
48
+ Call the tools directly too:
49
+
50
+ ```python
51
+ spec = SnapRenderToolSpec()
52
+ blocks = spec.take_screenshot(url="https://example.com", full_page=True) # [TextBlock, ImageBlock]
53
+ markdown = spec.extract_content(url="https://example.com", type="article")
54
+ ```
55
+
56
+ ## Tool inputs
57
+
58
+ `take_screenshot` (pass `url` or `html`): `format` (`jpeg` default at quality 80, `png`, `webp`), `full_page`, `lazy_load`, `device` (`iphone_14`, `iphone_15_pro`, `pixel_7`, `ipad_pro`, `macbook_pro`), `width` and `height` (CSS px, default 1280x800), `dark_mode`, `delay` (ms), `quality`, `hide_selectors`, `click_selector`, `block_ads` and `block_cookie_banners` (both default true), `cache` (accept a copy from the last 24 hours, free; default false).
59
+
60
+ `extract_content`: `url`, `type` (`markdown` default, `article`, `text`, `html`, `links`, `metadata`), `selector`, `max_length` (default 100000), `delay`, `block_ads`, `block_cookie_banners`, `cache`.
61
+
62
+ Captures render at 2x device scale, so a 1280 px viewport gives a 2560 px wide image. When an image is over `max_image_bytes` (default 5 MB) or `max_image_dimension` (default 8000 px), the tool returns only the text summary and tells the model to retry with `full_page=False` or a smaller viewport.
63
+
64
+ ## Options
65
+
66
+ ```python
67
+ SnapRenderToolSpec(
68
+ api_key=None, # default: SNAPRENDER_API_KEY
69
+ base_url="https://app.snap-render.com",
70
+ timeout=90.0, # seconds
71
+ max_retries=1, # after a per-minute rate limit, waits Retry-After (up to 60 s)
72
+ max_image_bytes=5_000_000,
73
+ max_image_dimension=8000,
74
+ get_api_key=None, # callable returning a key at call time
75
+ )
76
+ ```
77
+
78
+ ## Errors
79
+
80
+ Failed requests raise `SnapRenderError` with `status`, `code` and the API's message, for example `SnapRender 422 TARGET_UNREACHABLE: The target website could not be reached...`. Agents pass the message to the model so it can explain or try another URL. Failed renders do not use a capture. Monthly quota errors are never retried.
81
+
82
+ The tools cannot reach localhost, private networks or pages behind a login. If a capture of your own site shows a security check page, see [snap-render.com/docs/allow-snaprender](https://snap-render.com/docs/allow-snaprender).
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ pip install -e ".[test]"
88
+ pytest # unit tests, no network
89
+ SNAPRENDER_TEST_KEY=sk_live_... pytest tests/test_live.py # live API tests
90
+ ```
91
+
92
+ ## Links
93
+
94
+ - API docs: [snap-render.com/docs](https://snap-render.com/docs)
95
+ - MCP server (Claude, Cursor and other MCP clients): [snaprender-mcp](https://www.npmjs.com/package/snaprender-mcp)
96
+ - Support: support@snap-render.com
97
+
98
+ MIT License
@@ -0,0 +1,3 @@
1
+ from llama_index.tools.snaprender.base import SnapRenderError, SnapRenderToolSpec, __version__
2
+
3
+ __all__ = ["SnapRenderToolSpec", "SnapRenderError", "__version__"]
@@ -0,0 +1,359 @@
1
+ """SnapRender tool spec: website screenshots and rendered page content for LlamaIndex agents."""
2
+
3
+
4
+ import asyncio
5
+ import os
6
+ import struct
7
+ import time
8
+ from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union
9
+
10
+ import httpx
11
+ from llama_index.core.base.llms.types import ImageBlock, TextBlock
12
+ from llama_index.core.tools.tool_spec.base import BaseToolSpec
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ DEFAULT_BASE_URL = "https://app.snap-render.com"
17
+ SIGNUP_URL = "https://app.snap-render.com/auth/signup"
18
+
19
+ Device = Literal["iphone_14", "iphone_15_pro", "pixel_7", "ipad_pro", "macbook_pro"]
20
+ ImageFormat = Literal["jpeg", "png", "webp"]
21
+ ExtractType = Literal["markdown", "text", "html", "article", "links", "metadata"]
22
+
23
+
24
+ class SnapRenderError(Exception):
25
+ """A failed SnapRender request, with the API's status, code and message."""
26
+
27
+ def __init__(self, status: int, code: str, message: str, retry_after: Optional[float] = None):
28
+ super().__init__(f"SnapRender {status} {code}: {message}")
29
+ self.status = status
30
+ self.code = code
31
+ self.message = message
32
+ self.retry_after = retry_after
33
+
34
+
35
+ def image_size(data: bytes) -> Optional[Tuple[int, int]]:
36
+ """Read (width, height) from PNG, JPEG or WebP bytes without decoding the image."""
37
+ if len(data) >= 24 and data[:8] == b"\x89PNG\r\n\x1a\n":
38
+ return struct.unpack(">II", data[16:24])
39
+ if len(data) >= 4 and data[:2] == b"\xff\xd8":
40
+ i = 2
41
+ while i + 9 < len(data):
42
+ if data[i] != 0xFF:
43
+ i += 1
44
+ continue
45
+ marker = data[i + 1]
46
+ if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
47
+ i += 2
48
+ continue
49
+ length = struct.unpack(">H", data[i + 2 : i + 4])[0]
50
+ if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
51
+ h, w = struct.unpack(">HH", data[i + 5 : i + 9])
52
+ return w, h
53
+ i += 2 + length
54
+ return None
55
+ if len(data) >= 30 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
56
+ chunk = data[12:16]
57
+ if chunk == b"VP8X":
58
+ w = 1 + int.from_bytes(data[24:27], "little")
59
+ h = 1 + int.from_bytes(data[27:30], "little")
60
+ return w, h
61
+ if chunk == b"VP8L":
62
+ b = int.from_bytes(data[21:25], "little")
63
+ return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
64
+ if chunk == b"VP8 ":
65
+ w = int.from_bytes(data[26:28], "little") & 0x3FFF
66
+ h = int.from_bytes(data[28:30], "little") & 0x3FFF
67
+ return w, h
68
+ return None
69
+
70
+
71
+ def _compact(params: Dict[str, Any]) -> Dict[str, Any]:
72
+ return {k: v for k, v in params.items() if v is not None}
73
+
74
+
75
+ def _error_from(response: httpx.Response) -> SnapRenderError:
76
+ code = f"HTTP_{response.status_code}"
77
+ message = response.reason_phrase or "Request failed."
78
+ try:
79
+ err = response.json().get("error") or {}
80
+ code = err.get("code") or code
81
+ message = err.get("message") or message
82
+ except Exception:
83
+ pass
84
+ retry_after: Optional[float] = None
85
+ try:
86
+ retry_after = float(response.headers.get("Retry-After", ""))
87
+ except ValueError:
88
+ pass
89
+ return SnapRenderError(response.status_code, code, message, retry_after)
90
+
91
+
92
+ class SnapRenderToolSpec(BaseToolSpec):
93
+ """SnapRender tools: take website screenshots the model can see, and read rendered pages.
94
+
95
+ Pages load in a real Chrome browser with ads and cookie banners removed.
96
+ The free plan includes 200 captures a month: https://app.snap-render.com/auth/signup
97
+ """
98
+
99
+ spec_functions = [
100
+ ("take_screenshot", "atake_screenshot"),
101
+ ("extract_content", "aextract_content"),
102
+ ]
103
+
104
+ def __init__(
105
+ self,
106
+ api_key: Optional[str] = None,
107
+ base_url: str = DEFAULT_BASE_URL,
108
+ timeout: float = 90.0,
109
+ max_retries: int = 1,
110
+ max_image_bytes: int = 5_000_000,
111
+ max_image_dimension: int = 8000,
112
+ get_api_key: Optional[Callable[[], str]] = None,
113
+ ) -> None:
114
+ """Create the tool spec.
115
+
116
+ Args:
117
+ api_key: SnapRender API key. Defaults to the SNAPRENDER_API_KEY environment variable.
118
+ base_url: API base URL.
119
+ timeout: Request timeout in seconds (full-page captures can be slow).
120
+ max_retries: Retries after a per-minute rate limit (HTTP 429 RATE_LIMITED), waiting as
121
+ long as Retry-After says (up to 60 s). Monthly quota errors are never retried.
122
+ max_image_bytes: Largest image sent to the model; bigger captures return a text note.
123
+ max_image_dimension: Largest width or height in pixels sent to the model.
124
+ get_api_key: Optional callable that returns a key at call time (secret stores).
125
+ """
126
+ self._api_key = api_key
127
+ self._get_api_key = get_api_key
128
+ self._base_url = base_url.rstrip("/")
129
+ self._timeout = timeout
130
+ self._max_retries = max_retries
131
+ self._max_image_bytes = max_image_bytes
132
+ self._max_image_dimension = max_image_dimension
133
+
134
+ # -- key and HTTP ---------------------------------------------------------------
135
+
136
+ def _key(self) -> str:
137
+ key = self._api_key or (self._get_api_key() if self._get_api_key else None)
138
+ key = key or os.environ.get("SNAPRENDER_API_KEY")
139
+ if key:
140
+ return key
141
+ # TODO(agent-trial): when the keyless agent trial key ships (POST /v1/agent-trial,
142
+ # returns an sk_trial_ key), mint and cache one here instead of raising.
143
+ raise SnapRenderError(
144
+ 401,
145
+ "MISSING_API_KEY",
146
+ "No API key. Set SNAPRENDER_API_KEY or pass api_key. "
147
+ f"Get a free key (200 captures a month) at {SIGNUP_URL}",
148
+ )
149
+
150
+ def _headers(self) -> Dict[str, str]:
151
+ return {
152
+ "X-API-Key": self._key(),
153
+ "Content-Type": "application/json",
154
+ "User-Agent": f"llama-index-tools-snaprender/{__version__}",
155
+ }
156
+
157
+ def _retry_wait(self, error: SnapRenderError, attempt: int) -> Optional[float]:
158
+ if error.code != "RATE_LIMITED" or attempt >= self._max_retries:
159
+ return None
160
+ wait = error.retry_after or 5.0
161
+ return wait if wait <= 60 else None
162
+
163
+ def _post(self, path: str, body: Dict[str, Any]) -> httpx.Response:
164
+ attempt = 0
165
+ while True:
166
+ with httpx.Client(timeout=self._timeout) as client:
167
+ response = client.post(f"{self._base_url}{path}", json=body, headers=self._headers())
168
+ if response.is_success:
169
+ return response
170
+ error = _error_from(response)
171
+ wait = self._retry_wait(error, attempt)
172
+ if wait is None:
173
+ raise error
174
+ attempt += 1
175
+ time.sleep(wait)
176
+
177
+ async def _apost(self, path: str, body: Dict[str, Any]) -> httpx.Response:
178
+ attempt = 0
179
+ while True:
180
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
181
+ response = await client.post(f"{self._base_url}{path}", json=body, headers=self._headers())
182
+ if response.is_success:
183
+ return response
184
+ error = _error_from(response)
185
+ wait = self._retry_wait(error, attempt)
186
+ if wait is None:
187
+ raise error
188
+ attempt += 1
189
+ await asyncio.sleep(wait)
190
+
191
+ # -- result shaping -------------------------------------------------------------
192
+
193
+ def _screenshot_body(self, **params: Any) -> Dict[str, Any]:
194
+ url, html = params.get("url"), params.get("html")
195
+ if bool(url) == bool(html):
196
+ raise ValueError("Pass exactly one of url or html.")
197
+ fmt = params.get("format") or "jpeg"
198
+ params["format"] = fmt
199
+ if params.get("quality") is None and fmt != "png":
200
+ params["quality"] = 80
201
+ params["response_type"] = "binary"
202
+ return _compact(params)
203
+
204
+ def _screenshot_blocks(self, source: str, response: httpx.Response) -> List[Union[TextBlock, ImageBlock]]:
205
+ data = response.content
206
+ media_type = response.headers.get("Content-Type", "image/jpeg").split(";")[0]
207
+ size = image_size(data)
208
+ remaining = response.headers.get("X-Remaining-Credits")
209
+ summary = f"Screenshot of {source} ("
210
+ if size:
211
+ summary += f"{size[0]}x{size[1]} px, "
212
+ summary += f"{len(data)} bytes, cache {response.headers.get('X-Cache', 'MISS')}"
213
+ if remaining is not None:
214
+ summary += f", {remaining} captures left this period"
215
+ summary += ")."
216
+ if len(data) > self._max_image_bytes:
217
+ summary += (
218
+ f" Image is over the {self._max_image_bytes} byte limit for the model and was left out. "
219
+ "Try full_page false, a smaller viewport, or format jpeg with lower quality."
220
+ )
221
+ return [TextBlock(text=summary)]
222
+ if size and max(size) > self._max_image_dimension:
223
+ summary += (
224
+ f" Image is over the {self._max_image_dimension} px limit for the model and was left out. "
225
+ "Try full_page false or a shorter viewport."
226
+ )
227
+ return [TextBlock(text=summary)]
228
+ return [TextBlock(text=summary), ImageBlock(image=data, image_mimetype=media_type)]
229
+
230
+ @staticmethod
231
+ def _extract_text(response: httpx.Response) -> str:
232
+ data = response.json()
233
+ content = data.get("content")
234
+ if not isinstance(content, str):
235
+ import json
236
+
237
+ content = json.dumps(content, indent=2, ensure_ascii=False)
238
+ meta = f"type {data.get('type')}"
239
+ if data.get("wordCount"):
240
+ meta += f", {data['wordCount']} words"
241
+ return f"{content}\n\n---\nSource: {data.get('url')} ({meta})"
242
+
243
+ # -- tools ----------------------------------------------------------------------
244
+
245
+ def take_screenshot(
246
+ self,
247
+ url: Optional[str] = None,
248
+ html: Optional[str] = None,
249
+ format: Optional[ImageFormat] = None,
250
+ full_page: Optional[bool] = None,
251
+ lazy_load: Optional[bool] = None,
252
+ device: Optional[Device] = None,
253
+ width: Optional[int] = None,
254
+ height: Optional[int] = None,
255
+ dark_mode: Optional[bool] = None,
256
+ delay: Optional[int] = None,
257
+ quality: Optional[int] = None,
258
+ hide_selectors: Optional[str] = None,
259
+ click_selector: Optional[str] = None,
260
+ block_ads: Optional[bool] = None,
261
+ block_cookie_banners: Optional[bool] = None,
262
+ cache: Optional[bool] = None,
263
+ ) -> List[Union[TextBlock, ImageBlock]]:
264
+ """Capture a screenshot of a public web page (or raw HTML) in a real Chrome browser and look at it.
265
+
266
+ Use it to check layout, visuals, a phone view (device) or a whole page (full_page).
267
+ Ads and cookie banners are removed by default. Cannot reach localhost, private
268
+ networks or pages behind a login.
269
+
270
+ Args:
271
+ url: Public http or https URL to capture. Use this or html, not both.
272
+ html: Raw HTML to render instead of a URL (max 2 MB).
273
+ format: Image format: jpeg (default, smallest), png or webp.
274
+ full_page: Capture the whole scrollable page instead of the first screen.
275
+ lazy_load: With full_page, scroll first so lazy-loaded images appear. Slower.
276
+ device: Emulate iphone_14, iphone_15_pro, pixel_7, ipad_pro or macbook_pro. Omit for desktop.
277
+ width: Viewport width in CSS pixels (320-3840, default 1280).
278
+ height: Viewport height in CSS pixels (200-10000, default 800).
279
+ dark_mode: Emulate prefers-color-scheme: dark.
280
+ delay: Extra wait in milliseconds after load (0-10000).
281
+ quality: JPEG or WebP quality (1-100).
282
+ hide_selectors: Comma-separated CSS selectors to hide before capture.
283
+ click_selector: CSS selector to click before capture.
284
+ block_ads: Block ads and trackers (default true).
285
+ block_cookie_banners: Remove cookie consent banners (default true).
286
+ cache: Accept a copy from the last 24 hours (free). Default false: render now.
287
+ """
288
+ params = {k: v for k, v in locals().items() if k != "self"}
289
+ body = self._screenshot_body(**params)
290
+ return self._screenshot_blocks(url or "html", self._post("/v1/screenshot", body))
291
+
292
+ async def atake_screenshot(
293
+ self,
294
+ url: Optional[str] = None,
295
+ html: Optional[str] = None,
296
+ format: Optional[ImageFormat] = None,
297
+ full_page: Optional[bool] = None,
298
+ lazy_load: Optional[bool] = None,
299
+ device: Optional[Device] = None,
300
+ width: Optional[int] = None,
301
+ height: Optional[int] = None,
302
+ dark_mode: Optional[bool] = None,
303
+ delay: Optional[int] = None,
304
+ quality: Optional[int] = None,
305
+ hide_selectors: Optional[str] = None,
306
+ click_selector: Optional[str] = None,
307
+ block_ads: Optional[bool] = None,
308
+ block_cookie_banners: Optional[bool] = None,
309
+ cache: Optional[bool] = None,
310
+ ) -> List[Union[TextBlock, ImageBlock]]:
311
+ """Async version of take_screenshot."""
312
+ params = {k: v for k, v in locals().items() if k != "self"}
313
+ body = self._screenshot_body(**params)
314
+ return self._screenshot_blocks(url or "html", await self._apost("/v1/screenshot", body))
315
+
316
+ def extract_content(
317
+ self,
318
+ url: str,
319
+ type: Optional[ExtractType] = None,
320
+ selector: Optional[str] = None,
321
+ max_length: Optional[int] = None,
322
+ delay: Optional[int] = None,
323
+ block_ads: Optional[bool] = None,
324
+ block_cookie_banners: Optional[bool] = None,
325
+ cache: Optional[bool] = None,
326
+ ) -> str:
327
+ """Read a public web page after it renders in a real browser and return its content.
328
+
329
+ JavaScript runs and cookie banners are removed, so it reads pages that plain HTTP
330
+ fetches get wrong.
331
+
332
+ Args:
333
+ url: Public http or https URL to read.
334
+ type: markdown (default, full page), article (main text only), text, html,
335
+ links (list of links) or metadata (title, description, Open Graph).
336
+ selector: CSS selector to read only part of the page, e.g. 'main'.
337
+ max_length: Maximum characters to return (default 100000).
338
+ delay: Extra wait in milliseconds after load (0-10000).
339
+ block_ads: Block ads and trackers (default true).
340
+ block_cookie_banners: Remove cookie consent banners (default true).
341
+ cache: Accept a cached result from the last 24 hours. Default false.
342
+ """
343
+ params = _compact({k: v for k, v in locals().items() if k != "self"})
344
+ return self._extract_text(self._post("/v1/extract", params))
345
+
346
+ async def aextract_content(
347
+ self,
348
+ url: str,
349
+ type: Optional[ExtractType] = None,
350
+ selector: Optional[str] = None,
351
+ max_length: Optional[int] = None,
352
+ delay: Optional[int] = None,
353
+ block_ads: Optional[bool] = None,
354
+ block_cookie_banners: Optional[bool] = None,
355
+ cache: Optional[bool] = None,
356
+ ) -> str:
357
+ """Async version of extract_content."""
358
+ params = _compact({k: v for k, v in locals().items() if k != "self"})
359
+ return self._extract_text(await self._apost("/v1/extract", params))
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "llama-index-tools-snaprender"
7
+ version = "0.1.0"
8
+ description = "LlamaIndex tools for SnapRender: website screenshots the agent can see, and rendered pages as markdown"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9,<4.0"
13
+ authors = [{ name = "SnapRender", email = "support@snap-render.com" }]
14
+ keywords = ["llama-index", "llamaindex", "tools", "agent", "screenshot", "website-screenshot", "web-scraping", "markdown", "snaprender"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Internet :: WWW/HTTP",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ ]
22
+ dependencies = [
23
+ "llama-index-core>=0.13.0,<0.15",
24
+ "httpx>=0.24",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ test = ["pytest>=7", "pytest-asyncio>=0.23", "respx>=0.21"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://snap-render.com"
32
+ Documentation = "https://snap-render.com/docs"
33
+ Source = "https://github.com/User0856/snaprender-integrations/tree/main/llamaindex"
34
+
35
+ [tool.hatch.build.targets.sdist]
36
+ include = ["llama_index/", "README.md", "LICENSE", "tests/"]
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["llama_index"]
40
+
41
+ [tool.pytest.ini_options]
42
+ asyncio_mode = "auto"
43
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,65 @@
1
+ """Live API tests. Run only when SNAPRENDER_TEST_KEY or SNAPRENDER_API_KEY is set (about 8 captures)."""
2
+
3
+ import os
4
+ import time
5
+
6
+ import pytest
7
+ from llama_index.core.base.llms.types import ImageBlock
8
+
9
+ from llama_index.tools.snaprender import SnapRenderError, SnapRenderToolSpec
10
+
11
+ KEY = os.environ.get("SNAPRENDER_TEST_KEY") or os.environ.get("SNAPRENDER_API_KEY")
12
+ pytestmark = pytest.mark.skipif(not KEY, reason="no SnapRender key in the environment")
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def pace():
17
+ # The free plan allows 10 requests a minute.
18
+ yield
19
+ time.sleep(6.5)
20
+
21
+
22
+ @pytest.fixture
23
+ def spec():
24
+ return SnapRenderToolSpec(api_key=KEY)
25
+
26
+
27
+ def test_screenshot_jpeg(spec):
28
+ blocks = spec.take_screenshot(url="https://example.com")
29
+ assert "2560x1600 px" in blocks[0].text
30
+ assert isinstance(blocks[1], ImageBlock) and blocks[1].image_mimetype == "image/jpeg"
31
+
32
+
33
+ def test_screenshot_device_png(spec):
34
+ blocks = spec.take_screenshot(url="https://example.com", device="pixel_7", format="png")
35
+ assert blocks[1].image_mimetype == "image/png"
36
+
37
+
38
+ def test_screenshot_html_full_page_dark(spec):
39
+ blocks = spec.take_screenshot(html="<h1>Hello from LlamaIndex</h1>", full_page=True, dark_mode=True, width=640, height=360)
40
+ assert blocks[0].text.startswith("Screenshot of html") and len(blocks) == 2
41
+
42
+
43
+ async def test_async_webp(spec):
44
+ blocks = await spec.atake_screenshot(url="https://example.com", format="webp", quality=60)
45
+ assert blocks[1].image_mimetype == "image/webp"
46
+
47
+
48
+ def test_unreachable(spec):
49
+ with pytest.raises(SnapRenderError) as info:
50
+ spec.take_screenshot(url="https://nonexistent-domain-snaprender-test-123.com")
51
+ assert info.value.code == "TARGET_UNREACHABLE"
52
+
53
+
54
+ def test_bad_key():
55
+ with pytest.raises(SnapRenderError) as info:
56
+ SnapRenderToolSpec(api_key="sk_live_invalid").take_screenshot(url="https://example.com")
57
+ assert info.value.status == 401
58
+
59
+
60
+ def test_extract(spec):
61
+ assert "documentation examples" in spec.extract_content(url="https://example.com", max_length=5000)
62
+
63
+
64
+ async def test_async_extract_links(spec):
65
+ assert "iana.org" in await spec.aextract_content(url="https://example.com", type="links")
@@ -0,0 +1,173 @@
1
+ import base64
2
+ import json
3
+ import struct
4
+
5
+ import httpx
6
+ import pytest
7
+ import respx
8
+ from llama_index.core.base.llms.types import ImageBlock, TextBlock
9
+ from llama_index.core.tools.tool_spec.base import BaseToolSpec
10
+
11
+ from llama_index.tools.snaprender import SnapRenderError, SnapRenderToolSpec
12
+ from llama_index.tools.snaprender.base import image_size
13
+
14
+ PNG = base64.b64decode(
15
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
16
+ )
17
+ API = "https://app.snap-render.com"
18
+
19
+
20
+ def png_of_size(width: int, height: int) -> bytes:
21
+ return PNG[:16] + struct.pack(">II", width, height) + PNG[24:]
22
+
23
+
24
+ def test_is_a_tool_spec_with_sync_and_async_tools():
25
+ tools = SnapRenderToolSpec(api_key="k").to_tool_list()
26
+ assert isinstance(SnapRenderToolSpec(api_key="k"), BaseToolSpec)
27
+ assert [t.metadata.name for t in tools] == ["take_screenshot", "extract_content"]
28
+ assert all(t.async_fn is not None for t in tools)
29
+ device = tools[0].metadata.get_parameters_dict()["properties"]["device"]
30
+ assert device["anyOf"][0]["enum"] == ["iphone_14", "iphone_15_pro", "pixel_7", "ipad_pro", "macbook_pro"]
31
+
32
+
33
+ @respx.mock
34
+ def test_take_screenshot_posts_defaults_and_returns_image_block():
35
+ route = respx.post(f"{API}/v1/screenshot").mock(
36
+ return_value=httpx.Response(
37
+ 200, content=PNG, headers={"Content-Type": "image/jpeg", "X-Cache": "MISS", "X-Remaining-Credits": "41"}
38
+ )
39
+ )
40
+ blocks = SnapRenderToolSpec(api_key="sk_test").take_screenshot(url="https://example.com", device="iphone_14")
41
+ request = route.calls.last.request
42
+ assert request.headers["X-API-Key"] == "sk_test"
43
+ assert request.headers["User-Agent"].startswith("llama-index-tools-snaprender/")
44
+ assert json.loads(request.content) == {
45
+ "url": "https://example.com",
46
+ "device": "iphone_14",
47
+ "format": "jpeg",
48
+ "quality": 80,
49
+ "response_type": "binary",
50
+ }
51
+ assert isinstance(blocks[0], TextBlock)
52
+ assert "1x1 px" in blocks[0].text and "41 captures left" in blocks[0].text
53
+ assert isinstance(blocks[1], ImageBlock)
54
+ assert blocks[1].image_mimetype == "image/jpeg"
55
+
56
+
57
+ @respx.mock
58
+ def test_png_sends_no_quality():
59
+ route = respx.post(f"{API}/v1/screenshot").mock(return_value=httpx.Response(200, content=PNG))
60
+ SnapRenderToolSpec(api_key="k").take_screenshot(url="https://a.test", format="png")
61
+ assert "quality" not in json.loads(route.calls.last.request.content)
62
+
63
+
64
+ def test_url_or_html_exactly_one():
65
+ spec = SnapRenderToolSpec(api_key="k")
66
+ with pytest.raises(ValueError):
67
+ spec.take_screenshot()
68
+ with pytest.raises(ValueError):
69
+ spec.take_screenshot(url="https://a.test", html="<p>")
70
+
71
+
72
+ def test_reads_env_key(monkeypatch):
73
+ monkeypatch.setenv("SNAPRENDER_API_KEY", "sk_env")
74
+ assert SnapRenderToolSpec()._key() == "sk_env"
75
+
76
+
77
+ def test_missing_key_explains_free_plan(monkeypatch):
78
+ monkeypatch.delenv("SNAPRENDER_API_KEY", raising=False)
79
+ with pytest.raises(SnapRenderError, match="200 captures a month"):
80
+ SnapRenderToolSpec()._key()
81
+
82
+
83
+ def test_get_api_key_callable(monkeypatch):
84
+ monkeypatch.delenv("SNAPRENDER_API_KEY", raising=False)
85
+ assert SnapRenderToolSpec(get_api_key=lambda: "sk_cb")._key() == "sk_cb"
86
+
87
+
88
+ @respx.mock
89
+ def test_api_error_code_and_message():
90
+ respx.post(f"{API}/v1/screenshot").mock(
91
+ return_value=httpx.Response(
92
+ 422, json={"error": {"code": "TARGET_UNREACHABLE", "message": "The target website could not be reached."}}
93
+ )
94
+ )
95
+ with pytest.raises(SnapRenderError) as info:
96
+ SnapRenderToolSpec(api_key="k").take_screenshot(url="https://nope.test")
97
+ assert info.value.status == 422 and info.value.code == "TARGET_UNREACHABLE"
98
+ assert "could not be reached" in str(info.value)
99
+
100
+
101
+ @respx.mock
102
+ def test_non_json_error():
103
+ respx.post(f"{API}/v1/screenshot").mock(return_value=httpx.Response(502, text="error code: 502"))
104
+ with pytest.raises(SnapRenderError, match="502 HTTP_502"):
105
+ SnapRenderToolSpec(api_key="k").take_screenshot(url="https://a.test")
106
+
107
+
108
+ @respx.mock
109
+ def test_retries_once_after_rate_limit(monkeypatch):
110
+ monkeypatch.setattr("time.sleep", lambda s: None)
111
+ route = respx.post(f"{API}/v1/screenshot").mock(
112
+ side_effect=[
113
+ httpx.Response(429, json={"error": {"code": "RATE_LIMITED", "message": "Slow down."}}, headers={"Retry-After": "3"}),
114
+ httpx.Response(200, content=PNG),
115
+ ]
116
+ )
117
+ blocks = SnapRenderToolSpec(api_key="k").take_screenshot(url="https://a.test")
118
+ assert route.call_count == 2 and isinstance(blocks[1], ImageBlock)
119
+
120
+
121
+ @respx.mock
122
+ def test_never_retries_quota(monkeypatch):
123
+ monkeypatch.setattr("time.sleep", lambda s: None)
124
+ route = respx.post(f"{API}/v1/screenshot").mock(
125
+ return_value=httpx.Response(429, json={"error": {"code": "QUOTA_EXCEEDED", "message": "Monthly limit."}})
126
+ )
127
+ with pytest.raises(SnapRenderError, match="QUOTA_EXCEEDED"):
128
+ SnapRenderToolSpec(api_key="k").take_screenshot(url="https://a.test")
129
+ assert route.call_count == 1
130
+
131
+
132
+ @respx.mock
133
+ def test_tall_image_left_out_with_reason():
134
+ respx.post(f"{API}/v1/screenshot").mock(return_value=httpx.Response(200, content=png_of_size(2560, 16000)))
135
+ blocks = SnapRenderToolSpec(api_key="k").take_screenshot(url="https://a.test", full_page=True)
136
+ assert len(blocks) == 1 and "8000 px limit" in blocks[0].text
137
+
138
+
139
+ @respx.mock
140
+ def test_extract_markdown_and_links():
141
+ route = respx.post(f"{API}/v1/extract").mock(
142
+ side_effect=[
143
+ httpx.Response(200, json={"url": "https://a.test", "type": "markdown", "content": "# Hi", "wordCount": 1}),
144
+ httpx.Response(200, json={"url": "https://a.test", "type": "links", "content": [{"href": "https://b.test", "text": "B"}]}),
145
+ ]
146
+ )
147
+ spec = SnapRenderToolSpec(api_key="k")
148
+ assert spec.extract_content(url="https://a.test", selector="main") == "# Hi\n\n---\nSource: https://a.test (type markdown, 1 words)"
149
+ assert json.loads(route.calls[0].request.content) == {"url": "https://a.test", "selector": "main"}
150
+ assert '"href": "https://b.test"' in spec.extract_content(url="https://a.test", type="links")
151
+
152
+
153
+ @respx.mock
154
+ async def test_async_tools():
155
+ respx.post(f"{API}/v1/screenshot").mock(return_value=httpx.Response(200, content=PNG, headers={"Content-Type": "image/png"}))
156
+ respx.post(f"{API}/v1/extract").mock(return_value=httpx.Response(200, json={"url": "u", "type": "text", "content": "x"}))
157
+ spec = SnapRenderToolSpec(api_key="k")
158
+ blocks = await spec.atake_screenshot(html="<h1>Hi</h1>")
159
+ assert blocks[0].text.startswith("Screenshot of html") and blocks[1].image_mimetype == "image/png"
160
+ assert (await spec.aextract_content(url="u")).startswith("x")
161
+
162
+
163
+ @respx.mock
164
+ async def test_function_tool_output_carries_image_block():
165
+ respx.post(f"{API}/v1/screenshot").mock(return_value=httpx.Response(200, content=PNG, headers={"Content-Type": "image/png"}))
166
+ tool = SnapRenderToolSpec(api_key="k").to_tool_list()[0]
167
+ output = await tool.acall(url="https://example.com")
168
+ assert [b.block_type for b in output.blocks] == ["text", "image"]
169
+
170
+
171
+ def test_image_size():
172
+ assert image_size(png_of_size(1280, 800)) == (1280, 800)
173
+ assert image_size(b"nope") is None