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.
- llama_index_tools_snaprender-0.1.0/.gitignore +12 -0
- llama_index_tools_snaprender-0.1.0/LICENSE +21 -0
- llama_index_tools_snaprender-0.1.0/PKG-INFO +123 -0
- llama_index_tools_snaprender-0.1.0/README.md +98 -0
- llama_index_tools_snaprender-0.1.0/llama_index/tools/snaprender/__init__.py +3 -0
- llama_index_tools_snaprender-0.1.0/llama_index/tools/snaprender/base.py +359 -0
- llama_index_tools_snaprender-0.1.0/pyproject.toml +43 -0
- llama_index_tools_snaprender-0.1.0/tests/test_live.py +65 -0
- llama_index_tools_snaprender-0.1.0/tests/test_snaprender.py +173 -0
|
@@ -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,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
|