diff --git a/.github/workflows/refresh-llms.yml b/.github/workflows/refresh-llms.yml
index ff5171b44..86de393df 100644
--- a/.github/workflows/refresh-llms.yml
+++ b/.github/workflows/refresh-llms.yml
@@ -63,12 +63,12 @@ jobs:
# The generator reports a failed spec fetch on stderr and still exits 0,
# so stderr is captured to a file and inspected after the run.
status=0
- make docs-llms >/tmp/generate-llms.out 2>/tmp/generate-llms.err || status=$?
+ make docs-llms-refresh >/tmp/generate-llms.out 2>/tmp/generate-llms.err || status=$?
cat /tmp/generate-llms.out
cat /tmp/generate-llms.err >&2
if [ "$status" -ne 0 ]; then
- echo "::error::make docs-llms failed with exit code $status."
+ echo "::error::make docs-llms-refresh failed with exit code $status."
exit "$status"
fi
@@ -106,7 +106,7 @@ jobs:
DATE=$(date -u +%Y-%m-%d)
TITLE="docs: refresh generated llms.txt"
BODY=$(printf '%s\n\n%s\n\n%s\n\n%s\n' \
- "Automated daily regeneration of \`$TARGET\` via \`make docs-llms\`, which builds the API section from the live OpenAPI spec." \
+ "Automated daily regeneration of \`$TARGET\` via \`make docs-llms-refresh\`, which builds the API section from the live OpenAPI spec." \
"Updated $DATE (UTC): $STAT" \
"No hand edits. The run fails instead of committing if the spec cannot be fetched or comes back incomplete." \
"Opened by .github/workflows/refresh-llms.yml. Merges automatically once the required checks pass.")
diff --git a/docs/src/docs.json b/docs/src/docs.json
index 2acbf7798..a149c1ba6 100644
--- a/docs/src/docs.json
+++ b/docs/src/docs.json
@@ -249,6 +249,7 @@
"sdk-reference/remotesession/observe",
"sdk-reference/remotesession/execute",
"sdk-reference/remotesession/scrape",
+"sdk-reference/remotesession/fetch",
"sdk-reference/remotesession/replay",
"sdk-reference/remotesession/cdp_url",
"sdk-reference/remotesession/set_cookies",
diff --git a/docs/src/features/sessions/browser-controls.mdx b/docs/src/features/sessions/browser-controls.mdx
index 5e6eba224..4177a19bb 100644
--- a/docs/src/features/sessions/browser-controls.mdx
+++ b/docs/src/features/sessions/browser-controls.mdx
@@ -22,6 +22,7 @@ import Click from "/snippets/browser-controls/click.mdx";
import Fill from "/snippets/browser-controls/fill.mdx";
import Check from "/snippets/browser-controls/check.mdx";
import EvaluateJs from "/snippets/browser-controls/eval_js.mdx";
+import Fetch from "/snippets/browser-controls/fetch.mdx";
import SelectDropdownOption from "/snippets/browser-controls/select_dropdown_option.mdx";
import PressKey from "/snippets/browser-controls/press_key.mdx";
import ScrollUp from "/snippets/browser-controls/scroll_up.mdx";
@@ -205,6 +206,25 @@ Evaluate JavaScript code on the current page and return the result. `session.eva
---
+### Fetch
+
+Issue an HTTP request from the page the session is on. `session.fetch(url)` runs the browser's own `fetch()` inside the current page, so the request carries the page's cookies, the session's proxy and the browser's network fingerprint. A relative URL resolves against the current page, which also keeps it same-origin; a cross-origin URL is subject to CORS as in any browser tab, so `goto` the target origin first. The result is a standard `requests.Response`: `status_code`, `ok`, `headers`, `text`, `url`, `json()` and `raise_for_status()` work as usual.
+
+
+
+**Parameters:**
+- `url` (str): The URL to request, absolute or relative to the current page
+- `method` (str): HTTP method, `GET` by default
+- `headers` (dict): Extra request headers
+- `params` (dict): Query parameters appended to the URL
+- `json` (Any): Body serialised as JSON with an `application/json` content type
+- `data` (str | dict): Body sent verbatim, or form-encoded when a dict
+- `timeout` (float): Seconds before the request is aborted
+
+**Use for:** Calling a site's own JSON endpoints with the session's cookies and IP, without leaving the browser
+
+---
+
## Scrolling Actions
### ScrollUp
diff --git a/docs/src/llms.txt b/docs/src/llms.txt
index 7410c7505..93d8a8a44 100644
--- a/docs/src/llms.txt
+++ b/docs/src/llms.txt
@@ -203,6 +203,8 @@ The SDK docs below are for generated-code editing and reference. They are not th
## APIs
+
+
## Agents
- [POST Agent Start](https://docs.notte.cc/api-reference/agents/agent-start.md)
@@ -301,7 +303,10 @@ The SDK docs below are for generated-code editing and reference. They are not th
## Sessions
+- [DELETE Delete Session File](https://docs.notte.cc/api-reference/sessions/delete-session-file.md)
+- [GET Download Session File](https://docs.notte.cc/api-reference/sessions/download-session-file.md)
- [GET Get Session Script](https://docs.notte.cc/api-reference/sessions/get-session-script.md)
+- [GET List Session Files](https://docs.notte.cc/api-reference/sessions/list-session-files.md)
- [GET List Sessions](https://docs.notte.cc/api-reference/sessions/list-sessions.md)
- [POST Page Execute](https://docs.notte.cc/api-reference/sessions/page-execute.md)
- [POST Page Observe](https://docs.notte.cc/api-reference/sessions/page-observe.md)
@@ -316,15 +321,7 @@ The SDK docs below are for generated-code editing and reference. They are not th
- [POST Session Start](https://docs.notte.cc/api-reference/sessions/session-start.md)
- [GET Session Status](https://docs.notte.cc/api-reference/sessions/session-status.md)
- [DELETE Session Stop](https://docs.notte.cc/api-reference/sessions/session-stop.md)
-
-## Storage
-
-- [GET File Download](https://docs.notte.cc/api-reference/storage/file-download.md)
-- [GET File Download Uploaded File](https://docs.notte.cc/api-reference/storage/file-download-uploaded-file.md)
-- [GET File List Downloads](https://docs.notte.cc/api-reference/storage/file-list-downloads.md)
-- [GET File List Uploads](https://docs.notte.cc/api-reference/storage/file-list-uploads.md)
-- [POST File Upload](https://docs.notte.cc/api-reference/storage/file-upload.md)
-- [POST File Upload Downloaded File](https://docs.notte.cc/api-reference/storage/file-upload-downloaded-file.md)
+- [POST Upload Session File](https://docs.notte.cc/api-reference/sessions/upload-session-file.md)
## Usage
@@ -345,6 +342,8 @@ The SDK docs below are for generated-code editing and reference. They are not th
- [DELETE Vault Delete](https://docs.notte.cc/api-reference/vaults/vault-delete.md)
- [PATCH Vault Update](https://docs.notte.cc/api-reference/vaults/vault-update.md)
+
+
## SDK
@@ -368,6 +367,7 @@ The SDK docs below are for generated-code editing and reference. They are not th
- [observe](https://docs.notte.cc/sdk-reference/remotesession/observe.md): Observes the current session page
- [execute](https://docs.notte.cc/sdk-reference/remotesession/execute.md): Executes an action on the current session page
- [scrape](https://docs.notte.cc/sdk-reference/remotesession/scrape.md): Scrape the current page data
+- [fetch](https://docs.notte.cc/sdk-reference/remotesession/fetch.md): Issue an HTTP request from the page the session is on and return the response
- [replay](https://docs.notte.cc/sdk-reference/remotesession/replay.md): Get presigned URLs for the session replay
- [cdp_url](https://docs.notte.cc/sdk-reference/remotesession/cdp_url.md): Get the Chrome DevTools Protocol WebSocket URL for the session
- [set_cookies](https://docs.notte.cc/sdk-reference/remotesession/set_cookies.md): Uploads cookies to the session
diff --git a/docs/src/scripts/generate_llms.py b/docs/src/scripts/generate_llms.py
index 21be4b031..b59c55a5e 100644
--- a/docs/src/scripts/generate_llms.py
+++ b/docs/src/scripts/generate_llms.py
@@ -5,12 +5,19 @@
Each page is emitted as a bullet with title + description pulled from
the page's YAML frontmatter.
+The API section is rendered from the OpenAPI spec and kept between marker
+comments. By default it is reused verbatim from the existing llms.txt so the
+generator is deterministic and offline, which is what the pre-commit hook
+needs; pass --refresh-openapi to fetch the live spec and rebuild it, which is
+what the daily refresh workflow does.
+
Run from anywhere:
- python3 src/scripts/generate_llms.py
+ python3 src/scripts/generate_llms.py [--refresh-openapi]
"""
from __future__ import annotations
+import argparse
import json
import re
import sys
@@ -168,6 +175,41 @@ def render_openapi(spec: dict) -> list[str]:
return lines
+OPENAPI_END_MARKER = ""
+
+
+def openapi_begin_marker(url: str) -> str:
+ return f""
+
+
+def cached_openapi_section(existing: str, url: str) -> list[str] | None:
+ """Return the marker-delimited API block for `url` from a previous llms.txt, or None."""
+ begin = openapi_begin_marker(url)
+ start = existing.find(begin)
+ if start == -1:
+ return None
+ end = existing.find(OPENAPI_END_MARKER, start)
+ if end == -1:
+ return None
+ block = existing[start : end + len(OPENAPI_END_MARKER)]
+ return block.split("\n")
+
+
+def openapi_section(url: str, *, refresh: bool, existing: str) -> list[str]:
+ """The API block for `url`: reused from `existing` unless refreshing or absent."""
+ if not refresh:
+ cached = cached_openapi_section(existing, url)
+ if cached is not None:
+ return cached + [""]
+ print(f" warning: no cached openapi section for {url}, fetching live", file=sys.stderr)
+ try:
+ spec = fetch_openapi(url)
+ except Exception as e:
+ print(f" warning: failed to fetch openapi {url}: {e}", file=sys.stderr)
+ return [f"- OpenAPI spec: {url}", ""]
+ return [openapi_begin_marker(url), ""] + render_openapi(spec) + [OPENAPI_END_MARKER, ""]
+
+
def read_frontmatter(page_path: str) -> dict:
"""Return parsed frontmatter dict for a nav page path."""
for ext in (".mdx", ".md"):
@@ -226,7 +268,16 @@ def render_pages(pages: list, depth: int) -> list[str]:
return lines
-def main() -> int:
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument(
+ "--refresh-openapi",
+ action="store_true",
+ help="fetch the live OpenAPI spec and rebuild the API section instead of reusing the committed one",
+ )
+ args = parser.parse_args(argv)
+ existing = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else ""
+
config = json.loads(DOCS_JSON.read_text())
site_name = config.get("name", "Docs")
@@ -253,21 +304,11 @@ def main() -> int:
for group in tab.get("groups", []):
out += [f"## {group['group']}", ""]
if "openapi" in group:
- try:
- spec = fetch_openapi(group["openapi"])
- out += render_openapi(spec)
- except Exception as e:
- print(f" warning: failed to fetch openapi {group['openapi']}: {e}", file=sys.stderr)
- out += [f"- OpenAPI spec: {group['openapi']}", ""]
+ out += openapi_section(group["openapi"], refresh=args.refresh_openapi, existing=existing)
out += render_pages(group.get("pages", []), depth=3)
out += [""]
if "openapi" in tab:
- try:
- spec = fetch_openapi(tab["openapi"])
- out += render_openapi(spec)
- except Exception as e:
- print(f" warning: failed to fetch openapi {tab['openapi']}: {e}", file=sys.stderr)
- out += [f"- OpenAPI spec: {tab['openapi']}", ""]
+ out += openapi_section(tab["openapi"], refresh=args.refresh_openapi, existing=existing)
OUTPUT.write_text("\n".join(out).rstrip() + "\n")
print(f"wrote {OUTPUT.relative_to(SRC_DIR.parent)} ({OUTPUT.stat().st_size} bytes)")
diff --git a/docs/src/sdk-reference/misc/remotesession.mdx b/docs/src/sdk-reference/misc/remotesession.mdx
index c0126d86c..f4ffeb30e 100644
--- a/docs/src/sdk-reference/misc/remotesession.mdx
+++ b/docs/src/sdk-reference/misc/remotesession.mdx
@@ -89,6 +89,20 @@ Result containing execution details, any errors, and the updated session state.
---
+### fetch
+
+```python
+fetch(url: , method: = GET, headers: collections.abc.Mapping[str, str] | None = None, params: collections.abc.Mapping[str, typing.Any] | None = None, json: typing.Any = None, data: str | collections.abc.Mapping[str, typing.Any] | None = None, timeout: float | None = None) ->
+```
+
+Issue an HTTP request from the page the session is on and return the response
+
+**Returns:**
+
+[`Response`](/sdk-reference/misc/response)[`Response`](/sdk-reference/misc/response.md)
+
+---
+
### get_cookies
```python
diff --git a/docs/src/sdk-reference/misc/response.mdx b/docs/src/sdk-reference/misc/response.mdx
new file mode 100644
index 000000000..3e43554ab
--- /dev/null
+++ b/docs/src/sdk-reference/misc/response.mdx
@@ -0,0 +1,65 @@
+---
+title: "Response"
+description: "The :class:`Response ` object, which contains a"
+---
+
+
+server's response to an HTTP request
+
+## Methods
+
+### close
+
+```python
+close()
+```
+
+Releases the connection back to the pool
+
+---
+
+### iter_content
+
+```python
+iter_content(chunk_size = 1, decode_unicode = False)
+```
+
+Iterates over the response data
+
+---
+
+### iter_lines
+
+```python
+iter_lines(chunk_size = 512, decode_unicode = False, delimiter = None)
+```
+
+Iterates over the response data, one line at a time
+
+---
+
+### json
+
+```python
+json(kwargs)
+```
+
+Decodes the JSON response body (if any) as a Python object
+
+---
+
+### raise_for_status
+
+```python
+raise_for_status()
+```
+
+Raises :class:`HTTPError`, if one occurred
+
+---
+
+
+
+## Module
+
+`requests.models`
diff --git a/docs/src/sdk-reference/remotesession/fetch.mdx b/docs/src/sdk-reference/remotesession/fetch.mdx
new file mode 100644
index 000000000..a8244b3d6
--- /dev/null
+++ b/docs/src/sdk-reference/remotesession/fetch.mdx
@@ -0,0 +1,53 @@
+---
+title: "fetch"
+description: "Issue an HTTP request from the page the session is on and return the response"
+---
+import AgentMdNotice from '/partials/agent-md-notice.mdx';
+
+
+
+The request runs inside the browser through `fetch()`, so it carries the
+page's cookies, the session's proxy and the browser's own network
+fingerprint. A relative `url` resolves against the current page, which
+also makes it same-origin; a cross-origin URL is subject to CORS exactly
+as in a browser tab, so `goto` the target origin first. Redirects are
+followed and the final URL is on `response.url`. The result is a standard
+`requests.Response`: a non-2xx status is returned, not raised, and
+`response.raise_for_status()` raises `requests.HTTPError`. A network
+failure surfaces as the JavaScript error.
+
+`json` is serialised as the body with an `application/json` content type,
+`data` as a form body when it is a mapping or verbatim when it is a string.
+
+```python
+session.execute(type="goto", url="https://en.wikipedia.org/wiki/Main_Page")
+summary = session.fetch("/api/rest_v1/page/summary/Main_Page").json()
+```
+
+
+## Parameters
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Returns
+
+[`Response`](/sdk-reference/misc/response)[`Response`](/sdk-reference/misc/response.md)
diff --git a/docs/src/sdk-reference/remotesession/index.mdx b/docs/src/sdk-reference/remotesession/index.mdx
index 3d92d99ea..4dbcd52e8 100644
--- a/docs/src/sdk-reference/remotesession/index.mdx
+++ b/docs/src/sdk-reference/remotesession/index.mdx
@@ -109,6 +109,24 @@ Attributes:
Executes an action on the current session page
+
+
+ Issue an HTTP request from the page the session is on and return the response
+
+
+
+
+ Issue an HTTP request from the page the session is on and return the response
+
+
str | Exec
"""
return asyncio.run(self.aevaluate_js(code, raise_on_failure=raise_on_failure))
+ async def afetch(
+ self,
+ url: str,
+ *,
+ method: str = "GET",
+ headers: Mapping[str, str] | None = None,
+ params: Mapping[str, Any] | None = None,
+ json: Any = None,
+ data: FetchData | None = None,
+ timeout: float | None = None,
+ ) -> requests.Response:
+ """
+ Issue an HTTP request from the page the session is on and return the response.
+
+ The request runs inside the browser through `fetch()`, so it carries the
+ page's cookies, the session's proxy and the browser's own network
+ fingerprint. A relative `url` resolves against the current page, which
+ also makes it same-origin; a cross-origin URL is subject to CORS exactly
+ as in a browser tab, so `goto` the target origin first. Redirects are
+ followed and the final URL is on `response.url`. The result is a standard
+ `requests.Response`: a non-2xx status is returned, not raised, and
+ `response.raise_for_status()` raises `requests.HTTPError`. A network
+ failure surfaces as the JavaScript error.
+
+ `json` is serialised as the body with an `application/json` content type,
+ `data` as a form body when it is a mapping or verbatim when it is a string.
+ """
+ script = build_fetch_script(
+ url, method=method, headers=headers, params=params, json_body=json, data=data, timeout=timeout
+ )
+ return response_from_evaluated(await self.aevaluate_js(script))
+
+ def fetch(
+ self,
+ url: str,
+ *,
+ method: str = "GET",
+ headers: Mapping[str, str] | None = None,
+ params: Mapping[str, Any] | None = None,
+ json: Any = None,
+ data: FetchData | None = None,
+ timeout: float | None = None,
+ ) -> requests.Response:
+ """
+ Synchronous version of afetch.
+ """
+ return asyncio.run(
+ self.afetch(url, method=method, headers=headers, params=params, json=json, data=data, timeout=timeout)
+ )
+
@overload
async def ascrape(self, /, *, only_images: Literal[True], raise_on_failure: bool = True) -> list[ImageData]: ...
diff --git a/packages/notte-core/src/notte_core/data/fetch.py b/packages/notte-core/src/notte_core/data/fetch.py
new file mode 100644
index 000000000..6356e955e
--- /dev/null
+++ b/packages/notte-core/src/notte_core/data/fetch.py
@@ -0,0 +1,167 @@
+"""Issue an HTTP request from the page a session is on.
+
+`session.fetch()` runs the browser's own `fetch()` inside the current page, so
+the request carries the page's cookies, the session's proxy and the browser's
+network fingerprint. This module builds the script and reads the result back
+into a standard `requests.Response`; it is shared by the remote SDK session and
+the local browser session.
+"""
+
+from __future__ import annotations
+
+import base64
+import io
+import json
+from collections.abc import Mapping
+from http import HTTPStatus
+from typing import Any, cast
+from urllib.parse import urlencode, urlsplit, urlunsplit
+
+import requests
+from requests.structures import CaseInsensitiveDict
+
+from notte_core.errors.actions import FetchResponseDecodeError
+
+FetchData = str | Mapping[str, Any]
+
+_CONTENT_TYPE = "content-type"
+
+
+def _has_header(headers: Mapping[str, str], name: str) -> bool:
+ return any(key.lower() == name for key in headers)
+
+
+def build_fetch_script(
+ url: str,
+ *,
+ method: str = "GET",
+ headers: Mapping[str, str] | None = None,
+ params: Mapping[str, Any] | None = None,
+ json_body: Any = None,
+ data: FetchData | None = None,
+ timeout: float | None = None,
+) -> str:
+ """Return the JavaScript that performs the request and serialises the response.
+
+ The script is an async IIFE, which `evaluate_js` awaits. It returns a JSON
+ string so the value survives the evaluate round-trip unchanged.
+ """
+ if json_body is not None and data is not None:
+ raise ValueError("pass either json or data, not both")
+ if timeout is not None and timeout <= 0:
+ raise ValueError("timeout must be positive")
+
+ request_url = url
+ if params:
+ # insert before any fragment: the browser strips `#...` before sending,
+ # so parameters appended after it would be silently dropped
+ parts = urlsplit(url)
+ query = urlencode(params, doseq=True)
+ if parts.query:
+ query = f"{parts.query}&{query}"
+ request_url = urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
+
+ request_headers: dict[str, str] = dict(headers or {})
+ body: str | None = None
+ if json_body is not None:
+ body = json.dumps(json_body)
+ if not _has_header(request_headers, _CONTENT_TYPE):
+ request_headers["Content-Type"] = "application/json"
+ elif isinstance(data, Mapping):
+ body = urlencode(data, doseq=True)
+ if not _has_header(request_headers, _CONTENT_TYPE):
+ request_headers["Content-Type"] = "application/x-www-form-urlencoded"
+ elif data is not None:
+ body = data
+
+ init: dict[str, Any] = {
+ "method": method.upper(),
+ "headers": request_headers,
+ "credentials": "include",
+ "redirect": "follow",
+ }
+ if body is not None:
+ if init["method"] in {"GET", "HEAD"}:
+ # browser fetch() rejects these outright, so fail before the round-trip
+ raise ValueError(f"{init['method']} requests cannot have a body")
+ init["body"] = body
+
+ abort = ""
+ if timeout is not None:
+ abort = (
+ "const controller = new AbortController();"
+ f"setTimeout(() => controller.abort(), {int(timeout * 1000)});"
+ "init.signal = controller.signal;"
+ )
+ return (
+ "(async () => {"
+ f"const init = {json.dumps(init)};"
+ f"{abort}"
+ f"const response = await fetch({json.dumps(request_url)}, init);"
+ # ship the raw bytes as base64: `response.text()` would decode with
+ # replacement and lose any non-UTF-8 or binary body for good
+ "const bytes = new Uint8Array(await response.arrayBuffer());"
+ "let binary = '';"
+ "for (let i = 0; i < bytes.length; i += 0x8000) {"
+ " binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));"
+ "}"
+ "const headers = {};"
+ "response.headers.forEach((value, key) => { headers[key] = value; });"
+ "return JSON.stringify({status: response.status, url: response.url, headers: headers, body_b64: btoa(binary)});"
+ "})()"
+ )
+
+
+def _encoding_for(headers: Mapping[str, str], content: bytes) -> str | None:
+ """The charset the response declares, else utf-8 when the bytes are valid utf-8.
+
+ Returning None leaves `requests` to detect the encoding from the bytes, which
+ is the right call for the odd legacy page that declares nothing.
+ """
+ content_type = next((value for key, value in headers.items() if key.lower() == _CONTENT_TYPE), "")
+ for param in content_type.split(";")[1:]:
+ name, _, value = param.strip().partition("=")
+ if name.strip().lower() == "charset" and value:
+ return value.strip().strip("\"'")
+ try:
+ _ = content.decode("utf-8")
+ except UnicodeDecodeError:
+ return None
+ return "utf-8"
+
+
+def response_from_evaluated(raw: str) -> requests.Response:
+ """Turn the envelope `build_fetch_script` returns into a `requests.Response`.
+
+ A non-2xx status is a response, not an error; `raise_for_status()` raises
+ `requests.HTTPError` as usual. `url` is the final URL after redirects.
+ `content` holds the exact bytes the server sent, so binary bodies survive;
+ `text` decodes them with the declared charset, or utf-8 when none is given.
+ """
+ try:
+ payload: Any = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise FetchResponseDecodeError(reason=str(exc)) from exc
+ if not isinstance(payload, dict):
+ raise FetchResponseDecodeError(reason="envelope is not an object")
+ envelope = cast(dict[str, Any], payload)
+ try:
+ status_code = int(envelope["status"])
+ raw_headers: Any = envelope.get("headers") or {}
+ headers = {str(key): str(value) for key, value in dict(raw_headers).items()}
+ content = base64.b64decode(str(envelope.get("body_b64", "")), validate=True)
+ url = str(envelope.get("url", ""))
+ except (KeyError, TypeError, ValueError) as exc:
+ raise FetchResponseDecodeError(reason=str(exc)) from exc
+
+ response = requests.Response()
+ response.status_code = status_code
+ response.headers = CaseInsensitiveDict(headers)
+ response.encoding = _encoding_for(headers, content)
+ response.raw = io.BytesIO(content)
+ response.url = url
+ try:
+ response.reason = HTTPStatus(status_code).phrase
+ except ValueError:
+ response.reason = ""
+ return response
diff --git a/packages/notte-core/src/notte_core/errors/actions.py b/packages/notte-core/src/notte_core/errors/actions.py
index 60d7f81ad..8ef8d3789 100644
--- a/packages/notte-core/src/notte_core/errors/actions.py
+++ b/packages/notte-core/src/notte_core/errors/actions.py
@@ -32,6 +32,17 @@ def __init__(self) -> None:
)
+class FetchResponseDecodeError(ActionError):
+ def __init__(self, reason: str) -> None:
+ message = f"fetch script returned an unreadable response envelope: {reason}"
+ super().__init__(
+ dev_message=message,
+ user_message=f"{message}.",
+ agent_message=message,
+ should_notify_team=True,
+ )
+
+
class NotEnoughActionsListedError(ActionError):
def __init__(self, n_trials: int, n_actions: int, threshold: float) -> None:
super().__init__(
diff --git a/packages/notte-sdk/src/notte_sdk/endpoints/sessions.py b/packages/notte-sdk/src/notte_sdk/endpoints/sessions.py
index 2bfc3fec4..d7ce1619a 100644
--- a/packages/notte-sdk/src/notte_sdk/endpoints/sessions.py
+++ b/packages/notte-sdk/src/notte_sdk/endpoints/sessions.py
@@ -1,11 +1,12 @@
import time
-from collections.abc import Sequence
+from collections.abc import Mapping, Sequence
from enum import StrEnum
from pathlib import Path
from types import TracebackType
from typing import TYPE_CHECKING, Any, Literal, Unpack, overload
from webbrowser import open as open_browser
+import requests
from notte_core.actions import BaseAction, InteractionActionUnion
from notte_core.actions.typedicts import (
CaptchaSolveActionDict,
@@ -43,6 +44,7 @@
from notte_core.common.logging import logger
from notte_core.common.resource import SyncResource
from notte_core.common.telemetry import track_usage
+from notte_core.data.fetch import FetchData, build_fetch_script, response_from_evaluated
from notte_core.data.space import ImageData, StructuredData, TBaseModel
from notte_core.errors.actions import EvaluateJsNoDataError
from notte_core.errors.base import NotteBaseError
@@ -1636,3 +1638,40 @@ def evaluate_js(self, code: str, *, raise_on_failure: bool = True) -> str | Exec
# an API build that predates the eval-js fix reports success without data
raise EvaluateJsNoDataError()
return result.data.markdown
+
+ def fetch(
+ self,
+ url: str,
+ *,
+ method: str = "GET",
+ headers: Mapping[str, str] | None = None,
+ params: Mapping[str, Any] | None = None,
+ json: Any = None,
+ data: FetchData | None = None,
+ timeout: float | None = None,
+ ) -> requests.Response:
+ """
+ Issue an HTTP request from the page the session is on and return the response.
+
+ The request runs inside the browser through `fetch()`, so it carries the
+ page's cookies, the session's proxy and the browser's own network
+ fingerprint. A relative `url` resolves against the current page, which
+ also makes it same-origin; a cross-origin URL is subject to CORS exactly
+ as in a browser tab, so `goto` the target origin first. Redirects are
+ followed and the final URL is on `response.url`. The result is a standard
+ `requests.Response`: a non-2xx status is returned, not raised, and
+ `response.raise_for_status()` raises `requests.HTTPError`. A network
+ failure surfaces as the JavaScript error.
+
+ `json` is serialised as the body with an `application/json` content type,
+ `data` as a form body when it is a mapping or verbatim when it is a string.
+
+ ```python
+ session.execute(type="goto", url="https://en.wikipedia.org/wiki/Main_Page")
+ summary = session.fetch("/api/rest_v1/page/summary/Main_Page").json()
+ ```
+ """
+ script = build_fetch_script(
+ url, method=method, headers=headers, params=params, json_body=json, data=data, timeout=timeout
+ )
+ return response_from_evaluated(self.evaluate_js(script))
diff --git a/tests/sdk/test_fetch_helper.py b/tests/sdk/test_fetch_helper.py
new file mode 100644
index 000000000..25d27d6d6
--- /dev/null
+++ b/tests/sdk/test_fetch_helper.py
@@ -0,0 +1,183 @@
+"""Remote `fetch()`: the request runs in the page via `evaluate_js` and comes back as a `requests.Response`."""
+
+import base64
+import datetime as dt
+import json
+
+import pytest
+import requests
+from notte_core.actions import EvaluateJsAction
+from notte_core.browser.observation import ExecutionResult
+from notte_core.data.fetch import build_fetch_script
+from notte_core.data.space import DataSpace
+from notte_core.errors.actions import FetchResponseDecodeError
+
+from tests.sdk.test_execute_raise_on_failure import over_the_wire, remote_session
+
+
+def envelope(
+ status: int = 200,
+ text: str = '{"ok": true}',
+ url: str = "https://example.com/api",
+ content_type: str = "application/json",
+ body: bytes | None = None,
+) -> str:
+ payload = body if body is not None else text.encode("utf-8")
+ return json.dumps(
+ {
+ "status": status,
+ "url": url,
+ "headers": {"content-type": content_type},
+ "body_b64": base64.b64encode(payload).decode("ascii"),
+ }
+ )
+
+
+def eval_result(markdown: str) -> ExecutionResult:
+ now = dt.datetime.now(dt.timezone.utc)
+ return ExecutionResult(
+ action=EvaluateJsAction(code="fetch"),
+ success=True,
+ message="ok",
+ data=DataSpace(markdown=markdown),
+ started_at=now,
+ ended_at=now,
+ )
+
+
+# --- the script -----------------------------------------------------------------
+
+
+def test_script_defaults_to_a_credentialed_get_with_no_body() -> None:
+ script = build_fetch_script("/api")
+
+ assert script.startswith("(async () => {")
+ assert '"method": "GET"' in script
+ assert '"credentials": "include"' in script
+ assert 'fetch("/api", init)' in script
+ assert '"body"' not in script
+ assert "AbortController" not in script
+
+
+def test_script_appends_params_to_the_query_string() -> None:
+ assert 'fetch("/api?page=2&q=a+b", init)' in build_fetch_script("/api", params={"page": 2, "q": "a b"})
+ assert 'fetch("/api?x=1&page=2", init)' in build_fetch_script("/api?x=1", params={"page": 2})
+ # before the fragment, which the browser strips before sending
+ assert 'fetch("/items?page=2#results", init)' in build_fetch_script("/items#results", params={"page": 2})
+ assert 'fetch("https://a.test/p?x=1&page=2#f", init)' in build_fetch_script(
+ "https://a.test/p?x=1#f", params={"page": 2}
+ )
+
+
+def test_script_serialises_a_json_body_and_sets_the_content_type() -> None:
+ script = build_fetch_script("/graphql", method="post", json_body={"query": "{ me }"})
+
+ assert '"method": "POST"' in script
+ assert '"Content-Type": "application/json"' in script
+ assert json.dumps(json.dumps({"query": "{ me }"})) in script
+
+
+def test_script_keeps_a_caller_content_type() -> None:
+ script = build_fetch_script(
+ "/x", method="POST", json_body={}, headers={"content-type": "application/graphql-response+json"}
+ )
+
+ assert script.count("ontent-") == 1
+ assert "application/graphql-response+json" in script
+
+
+def test_script_form_encodes_a_mapping_and_passes_a_string_through() -> None:
+ form = build_fetch_script("/login", method="POST", data={"user": "a b", "pw": "c"})
+ assert '"body": "user=a+b&pw=c"' in form
+ assert '"Content-Type": "application/x-www-form-urlencoded"' in form
+
+ raw = build_fetch_script("/raw", method="PUT", data="")
+ assert '"body": ""' in raw
+ assert "Content-Type" not in raw
+
+
+def test_script_rejects_json_and_data_together() -> None:
+ with pytest.raises(ValueError, match="either json or data"):
+ _ = build_fetch_script("/x", json_body={}, data="y")
+
+
+def test_script_rejects_a_body_on_get_and_head() -> None:
+ with pytest.raises(ValueError, match="GET requests cannot have a body"):
+ _ = build_fetch_script("/x", json_body={"a": 1})
+ with pytest.raises(ValueError, match="HEAD requests cannot have a body"):
+ _ = build_fetch_script("/x", method="head", data="y")
+
+
+def test_script_aborts_after_the_timeout() -> None:
+ script = build_fetch_script("/slow", timeout=2.5)
+
+ assert "controller.abort(), 2500" in script
+ assert "init.signal = controller.signal" in script
+ with pytest.raises(ValueError, match="positive"):
+ _ = build_fetch_script("/slow", timeout=0)
+
+
+# --- the response ----------------------------------------------------------------
+
+
+def test_fetch_returns_a_requests_response() -> None:
+ session = remote_session(over_the_wire(eval_result(envelope())))
+
+ response = session.fetch("/api")
+
+ assert isinstance(response, requests.Response)
+ assert response.status_code == 200
+ assert response.ok
+ assert response.reason == "OK"
+ assert response.json() == {"ok": True}
+ assert response.text == '{"ok": true}'
+ assert response.content == b'{"ok": true}'
+ # browsers lowercase header names; requests keeps the lookup case-insensitive
+ assert response.headers["Content-Type"] == "application/json"
+ assert response.url == "https://example.com/api"
+ response.raise_for_status()
+
+
+def test_fetch_returns_http_errors_and_raises_only_when_asked() -> None:
+ session = remote_session(over_the_wire(eval_result(envelope(status=403, text="denied"))))
+
+ response = session.fetch("/api")
+
+ assert response.status_code == 403
+ assert not response.ok
+ assert response.text == "denied"
+ with pytest.raises(requests.HTTPError, match="403 Client Error: Forbidden") as raised:
+ response.raise_for_status()
+ assert raised.value.response is response
+
+
+def test_fetch_preserves_binary_bodies_byte_for_byte() -> None:
+ payload = b"caf\xe9\x00\xff"
+ session = remote_session(
+ over_the_wire(eval_result(envelope(content_type="application/octet-stream", body=payload)))
+ )
+
+ response = session.fetch("/blob")
+
+ assert response.content == payload
+ # undeclared charset and not valid utf-8: left to requests' detection, never forced
+ assert response.encoding is None
+
+
+def test_fetch_decodes_text_with_the_declared_charset() -> None:
+ payload = "caf\u00e9".encode("latin-1")
+ session = remote_session(
+ over_the_wire(eval_result(envelope(content_type="text/html; charset=ISO-8859-1", body=payload)))
+ )
+
+ response = session.fetch("/page")
+
+ assert response.encoding == "ISO-8859-1"
+ assert response.text == "caf\u00e9"
+
+
+def test_fetch_rejects_an_unreadable_envelope() -> None:
+ session = remote_session(over_the_wire(eval_result("not json")))
+
+ with pytest.raises(FetchResponseDecodeError, match="unreadable"):
+ _ = session.fetch("/api")
diff --git a/tests/test_fetch_helper.py b/tests/test_fetch_helper.py
new file mode 100644
index 000000000..8ec9000a3
--- /dev/null
+++ b/tests/test_fetch_helper.py
@@ -0,0 +1,59 @@
+"""`afetch()` runs the browser's `fetch()` inside the page and returns the response."""
+
+import pytest
+from notte_browser.session import NotteSession
+from notte_core.errors.actions import ActionExecutionError
+
+
+@pytest.mark.asyncio
+async def test_afetch_resolves_a_relative_url_against_the_page() -> None:
+ async with NotteSession(headless=True) as session:
+ _ = await session.aexecute(type="goto", url="https://www.example.com/")
+
+ response = await session.afetch("/")
+
+ assert response.status_code == 200
+ assert response.ok
+ assert "Example Domain" in response.text
+ assert response.url.startswith("https://www.example.com/")
+ assert "content-type" in response.headers
+
+
+@pytest.mark.asyncio
+async def test_afetch_json_reads_the_body() -> None:
+ async with NotteSession(headless=True) as session:
+ _ = await session.aexecute(type="goto", url="https://www.example.com/")
+
+ response = await session.afetch("data:application/json,%7B%22a%22%3A1%7D")
+
+ assert response.json() == {"a": 1}
+
+
+@pytest.mark.asyncio
+async def test_afetch_returns_binary_bodies_intact() -> None:
+ async with NotteSession(headless=True) as session:
+ _ = await session.aexecute(type="goto", url="https://www.example.com/")
+
+ # a 1x1 PNG served from a data URL: bytes that are not valid utf-8
+ response = await session.afetch(
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
+ )
+
+ assert response.status_code == 200
+ assert response.headers["Content-Type"] == "image/png"
+ assert response.content.startswith(b"\x89PNG\r\n\x1a\n")
+ assert len(response.content) == 70
+
+
+@pytest.mark.asyncio
+async def test_afetch_network_failure_raises_the_js_error() -> None:
+ async with NotteSession(headless=True) as session:
+ _ = await session.aexecute(type="goto", url="https://www.example.com/")
+
+ with pytest.raises(ActionExecutionError, match="JavaScript evaluation failed"):
+ _ = await session.afetch("https://nonexistent.invalid/")
+
+
+# NOTE: no sync-variant test here on purpose, for the same reason as
+# test_evaluate_js_helper.py: a sync NotteSession in this process breaks the
+# next async browser launch. The sync wrapper is a one-line delegation to afetch.
diff --git a/tests/test_generate_llms_offline.py b/tests/test_generate_llms_offline.py
new file mode 100644
index 000000000..7d71aa45e
--- /dev/null
+++ b/tests/test_generate_llms_offline.py
@@ -0,0 +1,108 @@
+"""`generate_llms.py` reuses the committed API section unless asked to refresh it.
+
+The pre-commit hook regenerates llms.txt on every run, so the default path must
+not depend on the live OpenAPI spec: any drift in the API between a local run and
+CI would otherwise fail the hook on unrelated pull requests.
+"""
+
+import importlib.util
+from pathlib import Path
+from types import ModuleType
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parent.parent / "docs" / "src" / "scripts" / "generate_llms.py"
+URL = "https://api.example.test/openapi.json"
+
+
+@pytest.fixture(scope="module")
+def generate_llms() -> ModuleType:
+ spec = importlib.util.spec_from_file_location("generate_llms", SCRIPT)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def existing_llms(generate_llms: ModuleType) -> str:
+ return "\n".join(
+ [
+ "## APIs",
+ "",
+ generate_llms.openapi_begin_marker(URL),
+ "",
+ "## Sessions",
+ "",
+ "- [POST Session Start](https://docs.notte.cc/api-reference/sessions/session-start.md)",
+ "",
+ generate_llms.OPENAPI_END_MARKER,
+ "",
+ "## SDK",
+ ]
+ )
+
+
+def test_default_run_reuses_the_committed_section_without_fetching(
+ generate_llms: ModuleType, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ def no_network(url: str) -> dict:
+ raise AssertionError(f"fetched {url} in offline mode")
+
+ monkeypatch.setattr(generate_llms, "fetch_openapi", no_network)
+
+ section = generate_llms.openapi_section(URL, refresh=False, existing=existing_llms(generate_llms))
+
+ assert section[0] == generate_llms.openapi_begin_marker(URL)
+ assert "- [POST Session Start](https://docs.notte.cc/api-reference/sessions/session-start.md)" in section
+ assert section[-2] == generate_llms.OPENAPI_END_MARKER
+
+
+def test_refresh_rebuilds_the_section_from_the_live_spec(
+ generate_llms: ModuleType, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ spec = {
+ "paths": {
+ "/sessions/start": {
+ "post": {"tags": ["sessions"], "summary": "Session Start", "operationId": "session_start"}
+ }
+ }
+ }
+ monkeypatch.setattr(generate_llms, "fetch_openapi", lambda url: spec)
+
+ section = generate_llms.openapi_section(URL, refresh=True, existing="stale text without markers")
+
+ assert section[0] == generate_llms.openapi_begin_marker(URL)
+ assert "- [POST Session Start](https://docs.notte.cc/api-reference/sessions/session-start.md)" in section
+ assert generate_llms.OPENAPI_END_MARKER in section
+
+
+def test_missing_cached_section_falls_back_to_fetching(
+ generate_llms: ModuleType, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ calls: list[str] = []
+
+ def fetch(url: str) -> dict:
+ calls.append(url)
+ return {"paths": {}}
+
+ monkeypatch.setattr(generate_llms, "fetch_openapi", fetch)
+
+ section = generate_llms.openapi_section(URL, refresh=False, existing="no markers here")
+
+ assert calls == [URL]
+ assert section[0] == generate_llms.openapi_begin_marker(URL)
+
+
+def test_failed_fetch_keeps_the_stub_line_for_the_refresh_workflow(
+ generate_llms: ModuleType, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+ def fetch(url: str) -> dict:
+ raise OSError("offline")
+
+ monkeypatch.setattr(generate_llms, "fetch_openapi", fetch)
+
+ section = generate_llms.openapi_section(URL, refresh=True, existing="")
+
+ # refresh-llms.yml greps stderr for this phrase to refuse a degraded file
+ assert "failed to fetch openapi" in capsys.readouterr().err
+ assert section == [f"- OpenAPI spec: {URL}", ""]