webtap-tool 0.7.0__py3-none-any.whl → 0.8.0__py3-none-any.whl

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.

Potentially problematic release.


This version of webtap-tool might be problematic. Click here for more details.

webtap/commands/TIPS.md CHANGED
@@ -12,13 +12,21 @@ All commands have these pre-imported (no imports needed!):
12
12
  ## Commands
13
13
 
14
14
  ### body
15
- Fetch and analyze HTTP response bodies with Python expressions.
15
+ Fetch and analyze HTTP request or response bodies with Python expressions. Automatically detects event type.
16
16
 
17
17
  #### Examples
18
18
  ```python
19
- body(123) # Get body
20
- body(123, "json.loads(body)") # Parse JSON
19
+ # Response bodies
20
+ body(123) # Get response body
21
+ body(123, "json.loads(body)") # Parse JSON response
21
22
  body(123, "bs4(body, 'html.parser').find('title').text") # HTML title
23
+
24
+ # Request bodies (POST/PUT/PATCH)
25
+ body(456) # Get request POST data
26
+ body(456, "json.loads(body)") # Parse JSON request body
27
+ body(456, "json.loads(body)['customerId']") # Extract request field
28
+
29
+ # Analysis
22
30
  body(123, "jwt.decode(body, options={'verify_signature': False})") # Decode JWT
23
31
  body(123, "re.findall(r'/api/[^\"\\s]+', body)[:10]") # Find API endpoints
24
32
  body(123, "httpx.get(json.loads(body)['next_url']).json()") # Chain requests
@@ -26,7 +34,10 @@ body(123, "msgpack.unpackb(body)") # Binary formats
26
34
  ```
27
35
 
28
36
  #### Tips
37
+ - **Auto-detect type:** Command automatically detects request vs response events
38
+ - **Find request events:** `events({"method": "Network.requestWillBeSent", "url": "*WsJobCard/Post*"})` - POST requests
29
39
  - **Generate models:** `to_model({id}, "models/model.py", "Model")` - create Pydantic models from JSON
40
+ - **Generate types:** `quicktype({id}, "types.ts", "Type")` - TypeScript/other languages
30
41
  - **Chain requests:** `body({id}, "httpx.get(json.loads(body)['next_url']).text[:100]")`
31
42
  - **Parse XML:** `body({id}, "ElementTree.fromstring(body).find('.//title').text")`
32
43
  - **Extract forms:** `body({id}, "[f['action'] for f in bs4(body, 'html.parser').find_all('form')]")`
@@ -34,22 +45,62 @@ body(123, "msgpack.unpackb(body)") # Binary formats
34
45
  - **Find related:** `events({'requestId': request_id})` - related events
35
46
 
36
47
  ### to_model
37
- Generate Pydantic v2 models from JSON response bodies for reverse engineering APIs.
48
+ Generate Pydantic v2 models from request or response bodies.
38
49
 
39
50
  #### Examples
40
51
  ```python
41
- to_model(123, "models/product.py", "Product") # Generate from full response
42
- to_model(123, "models/customers/group.py", "CustomerGroup", "Data[0]") # Extract nested + domain structure
43
- to_model(123, "/tmp/item.py", "Item", "items[0]") # Extract array items
52
+ # Response bodies
53
+ to_model(123, "models/product.py", "Product") # Full response
54
+ to_model(123, "models/customers/group.py", "CustomerGroup", json_path="data[0]") # Extract nested
55
+
56
+ # Request bodies (POST/PUT/PATCH)
57
+ to_model(172, "models/form.py", "JobCardForm", expr="dict(urllib.parse.parse_qsl(body))") # Form data
58
+ to_model(180, "models/request.py", "CreateOrder") # JSON POST body
59
+
60
+ # Advanced transformations
61
+ to_model(123, "models/clean.py", "Clean", expr="{k: v for k, v in json.loads(body).items() if k != 'meta'}")
62
+ to_model(123, "models/merged.py", "Merged", expr="{**json.loads(body), 'url': event['params']['response']['url']}")
44
63
  ```
45
64
 
46
65
  #### Tips
47
- - **Check structure first:** `body({id})` - preview JSON before generating
48
- - **Domain organization:** Use paths like `"models/customers/group.py"` for structure
49
- - **Extract nested data:** Use `json_path="Data[0]"` to extract specific objects
50
- - **Array items:** Extract first item with `json_path="items[0]"` for model generation
51
- - **Auto-cleanup:** Generated models use snake_case fields and modern type hints (list, dict, | None)
52
- - **Edit after:** Add `Field(alias="...")` manually for API field mapping
66
+ - **Check structure:** `body({id})` - preview body before generating
67
+ - **Find requests:** `events({"method": "Network.requestWillBeSent", "url": "*api/orders*"})` - locate POST events
68
+ - **Form data:** `expr="dict(urllib.parse.parse_qsl(body))"` for application/x-www-form-urlencoded
69
+ - **Nested extraction:** `json_path="data[0]"` for JSON with wrapper objects
70
+ - **Custom transforms:** `expr` has `body` (str) and `event` (dict) variables available
71
+ - **Organization:** Paths like `"models/customers/group.py"` create directory structure automatically
72
+ - **Field mapping:** Add `Field(alias="...")` after generation for API field names
73
+
74
+ ### quicktype
75
+ Generate types from request or response bodies. Supports TypeScript, Go, Rust, Python, and 10+ other languages.
76
+
77
+ #### Examples
78
+ ```python
79
+ # Response bodies
80
+ quicktype(123, "types/User.ts", "User") # TypeScript
81
+ quicktype(123, "api.go", "ApiResponse") # Go struct
82
+ quicktype(123, "schema.json", "Schema") # JSON Schema
83
+ quicktype(123, "types.ts", "User", json_path="data[0]") # Extract nested
84
+
85
+ # Request bodies (POST/PUT/PATCH)
86
+ quicktype(172, "types/JobCard.ts", "JobCardForm", expr="dict(urllib.parse.parse_qsl(body))") # Form data
87
+ quicktype(180, "types/CreateOrder.ts", "CreateOrderRequest") # JSON POST body
88
+
89
+ # Advanced options
90
+ quicktype(123, "types.ts", "User", options={"readonly": True}) # TypeScript readonly
91
+ quicktype(123, "types.ts", "Clean", expr="{k: v for k, v in json.loads(body).items() if k != 'meta'}")
92
+ ```
93
+
94
+ #### Tips
95
+ - **Check structure:** `body({id})` - preview body before generating
96
+ - **Find requests:** `events({"method": "Network.requestWillBeSent", "url": "*api*"})` - locate POST events
97
+ - **Form data:** `expr="dict(urllib.parse.parse_qsl(body))"` for application/x-www-form-urlencoded
98
+ - **Nested extraction:** `json_path="data[0]"` for JSON with wrapper objects
99
+ - **Languages:** .ts/.go/.rs/.java/.kt/.swift/.cs/.cpp/.dart/.rb/.json extensions set language
100
+ - **Options:** Dict keys map to CLI flags: `{"readonly": True}` → `--readonly`, `{"nice_property_names": True}` → `--nice-property-names`. See `quicktype --help` for language-specific flags
101
+ - **Common options:** TypeScript: `{"readonly": True, "prefer_types": True}`, Go: `{"omit_empty": True}`, Python: `{"pydantic_base_model": True}`
102
+ - **Install:** `npm install -g quicktype` if command not found
103
+ - **Pydantic models:** Use `to_model({id}, "models/model.py", "Model")` for Pydantic v2 instead
53
104
 
54
105
  ### inspect
55
106
  Inspect CDP events with full Python debugging.
@@ -123,25 +174,54 @@ events({"level": "error"}) # Console errors
123
174
  - **Decode data:** `inspect({id}, "base64.b64decode(data.get('params', {}).get('body', ''))")`
124
175
 
125
176
  ### js
126
- Execute JavaScript in the browser with optional promise handling.
177
+ Execute JavaScript in the browser. Uses fresh scope by default to avoid redeclaration errors.
178
+
179
+ #### Scope Behavior
180
+ **Default (fresh scope)** - Each call runs in isolation:
181
+ ```python
182
+ js("const x = 1") # ✓ x isolated
183
+ js("const x = 2") # ✓ No error, fresh scope
184
+ ```
185
+
186
+ **Persistent scope** - Variables survive across calls:
187
+ ```python
188
+ js("var data = {count: 0}", persist=True) # data persists
189
+ js("data.count++", persist=True) # Modifies data
190
+ js("data.count", persist=True) # Returns 1
191
+ ```
192
+
193
+ **With browser element** - Always fresh scope:
194
+ ```python
195
+ js("element.offsetWidth", selection=1) # Use element #1
196
+ js("element.classList", selection=2) # Use element #2
197
+ ```
127
198
 
128
199
  #### Examples
129
200
  ```python
201
+ # Basic queries
130
202
  js("document.title") # Get page title
131
- js("document.body.innerText.length") # Get text length
132
203
  js("[...document.links].map(a => a.href)") # Get all links
133
- js("fetch('/api').then(r => r.json())", await_promise=True) # Async
204
+ js("document.body.innerText.length") # Text length
205
+
206
+ # Async operations
207
+ js("fetch('/api').then(r => r.json())", await_promise=True)
208
+
209
+ # DOM manipulation (no return)
134
210
  js("document.querySelectorAll('.ad').forEach(e => e.remove())", wait_return=False)
135
- js("window.fetch = new Proxy(window.fetch, {get: (t, p) => console.log(p)})", wait_return=False)
211
+
212
+ # Persistent state for multi-step operations
213
+ js("var apiData = null", persist=True)
214
+ js("fetch('/api').then(r => r.json()).then(d => apiData = d)", persist=True, await_promise=True)
215
+ js("apiData.users.length", persist=True)
136
216
  ```
137
217
 
138
218
  #### Tips
139
- - **Extract all links:** `js("[...document.links].map(a => a.href)")`
140
- - **Get page text:** `js("document.body.innerText")`
141
- - **Find data attributes:** `js("[...document.querySelectorAll('[data-id]')].map(e => e.dataset)")`
142
- - **Monitor DOM:** `js("new MutationObserver(console.log).observe(document, {childList: true, subtree: true})", wait_return=False)`
143
- - **Hook fetch:** `js("window.fetch = new Proxy(fetch, {apply: (t, _, a) => {console.log(a); return t(...a)}})", wait_return=False)`
219
+ - **Fresh scope:** Default behavior prevents const/let redeclaration errors
220
+ - **Persistent state:** Use `persist=True` for multi-step operations or global hooks
221
+ - **No return needed:** Set `wait_return=False` for DOM manipulation or hooks
222
+ - **Browser selections:** Use `selection=N` with browser() to operate on selected elements
144
223
  - **Check console:** `console()` - see logged messages from JS execution
224
+ - **Hook fetch:** `js("window.fetch = new Proxy(fetch, {apply: (t, _, a) => {console.log(a); return t(...a)}})", persist=True, wait_return=False)`
145
225
 
146
226
  ### fetch
147
227
  Control request interception for debugging and modification.
@@ -172,6 +252,29 @@ Show paused requests and responses.
172
252
  - **Modify request:** `resume({id}, modifications={'url': '...'})`
173
253
  - **Fail request:** `fail({id}, 'BlockedByClient')` - block the request
174
254
 
255
+ ### page
256
+ Get current page information and navigate.
257
+
258
+ #### Tips
259
+ - **Navigate:** `navigate("https://example.com")` - go to URL
260
+ - **Reload:** `reload()` or `reload(ignore_cache=True)` - refresh page
261
+ - **History:** `back()`, `forward()`, `history()` - navigate history
262
+ - **Execute JS:** `js("document.title")` - run JavaScript in page
263
+ - **Monitor traffic:** `network()` - see requests, `console()` - see messages
264
+ - **Switch page:** `pages()` then `connect(page=N)` - change to another tab
265
+ - **Full status:** `status()` - connection details and event count
266
+
267
+ ### pages
268
+ List available Chrome pages and manage connections.
269
+
270
+ #### Tips
271
+ - **Connect to page:** `connect(page={index})` - connect by index number
272
+ - **Connect by ID:** `connect(page_id="{page_id}")` - stable across tab reordering
273
+ - **Switch pages:** Just call `connect()` again - no need to disconnect first
274
+ - **Check status:** `status()` - see current connection and event count
275
+ - **Reconnect:** If connection lost, select page and `connect()` again
276
+ - **Find page:** Look for title/URL in table - index stays consistent
277
+
175
278
  ### selections
176
279
  Browser element selections with prompt and analysis.
177
280
 
@@ -22,7 +22,7 @@ Examples:
22
22
 
23
23
  Available builders:
24
24
  - table_response() - Tables with headers, warnings, tips
25
- - info_response() - Key-value pairs with optional heading
25
+ - info_response() - Key-value pairs with optional heading and tips
26
26
  - error_response() - Errors with suggestions
27
27
  - success_response() - Success messages with details
28
28
  - warning_response() - Warnings with suggestions
@@ -83,6 +83,7 @@ def info_response(
83
83
  title: str | None = None,
84
84
  fields: dict | None = None,
85
85
  extra: str | None = None,
86
+ tips: list[str] | None = None,
86
87
  ) -> dict:
87
88
  """Build info display with key-value pairs.
88
89
 
@@ -90,6 +91,7 @@ def info_response(
90
91
  title: Optional info title
91
92
  fields: Dict of field names to values
92
93
  extra: Optional extra content (raw markdown)
94
+ tips: Optional developer tips/guidance
93
95
  """
94
96
  elements = []
95
97
 
@@ -107,6 +109,10 @@ def info_response(
107
109
  if not elements:
108
110
  elements.append({"type": "text", "content": "_No information available_"})
109
111
 
112
+ if tips:
113
+ elements.append({"type": "heading", "content": "Next Steps", "level": 3})
114
+ elements.append({"type": "list", "items": tips})
115
+
110
116
  return {"elements": elements}
111
117
 
112
118
 
@@ -0,0 +1,110 @@
1
+ """Code generation utilities for transforming HTTP bodies into code.
2
+
3
+ Pure transformation functions with no dependencies on services or state.
4
+ Used by to_model(), quicktype(), and future code generation commands.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def parse_json(content: str) -> tuple[Any, str | None]:
13
+ """Parse JSON string into Python object.
14
+
15
+ Args:
16
+ content: JSON string to parse.
17
+
18
+ Returns:
19
+ Tuple of (parsed_data, error_message).
20
+ On success: (data, None)
21
+ On failure: (None, error_string)
22
+
23
+ Examples:
24
+ data, error = parse_json('{"key": "value"}')
25
+ if error:
26
+ return error_response(error)
27
+ """
28
+ try:
29
+ return json.loads(content), None
30
+ except json.JSONDecodeError as e:
31
+ return None, f"Invalid JSON: {e}"
32
+
33
+
34
+ def extract_json_path(data: Any, path: str) -> tuple[Any, str | None]:
35
+ """Extract nested data using simple bracket notation.
36
+
37
+ Supports paths like "data[0]", "results.users", or "data[0].items".
38
+
39
+ Args:
40
+ data: Dict or list to extract from.
41
+ path: Path using dot and bracket notation.
42
+
43
+ Returns:
44
+ Tuple of (extracted_data, error_message).
45
+ On success: (data, None)
46
+ On failure: (None, error_string)
47
+
48
+ Examples:
49
+ result, err = extract_json_path({"data": [1,2,3]}, "data[0]")
50
+ # result = 1, err = None
51
+
52
+ result, err = extract_json_path({"user": {"name": "Bob"}}, "user.name")
53
+ # result = "Bob", err = None
54
+ """
55
+ try:
56
+ parts = path.replace("[", ".").replace("]", "").split(".")
57
+ result = data
58
+ for part in parts:
59
+ if part:
60
+ if part.isdigit():
61
+ result = result[int(part)]
62
+ else:
63
+ result = result[part]
64
+ return result, None
65
+ except (KeyError, IndexError, TypeError) as e:
66
+ return None, f"JSON path '{path}' not found: {e}"
67
+
68
+
69
+ def validate_generation_data(data: Any) -> tuple[bool, str | None]:
70
+ """Validate data structure for code generation.
71
+
72
+ Code generators (Pydantic, quicktype) require dict or list structures.
73
+
74
+ Args:
75
+ data: Data to validate.
76
+
77
+ Returns:
78
+ Tuple of (is_valid, error_message).
79
+ On success: (True, None)
80
+ On failure: (False, error_string)
81
+
82
+ Examples:
83
+ is_valid, error = validate_generation_data({"key": "value"})
84
+ # is_valid = True, error = None
85
+
86
+ is_valid, error = validate_generation_data("string")
87
+ # is_valid = False, error = "Data is str, not dict or list"
88
+ """
89
+ if not isinstance(data, (dict, list)):
90
+ return False, f"Data is {type(data).__name__}, not dict or list"
91
+ return True, None
92
+
93
+
94
+ def ensure_output_directory(output: str) -> Path:
95
+ """Create output directory if needed, return resolved path.
96
+
97
+ Args:
98
+ output: Output file path (can be relative, use ~, etc.).
99
+
100
+ Returns:
101
+ Resolved absolute Path object.
102
+
103
+ Examples:
104
+ path = ensure_output_directory("~/models/user.py")
105
+ # Creates ~/models/ if it doesn't exist
106
+ # Returns Path("/home/user/models/user.py")
107
+ """
108
+ output_path = Path(output).expanduser().resolve()
109
+ output_path.parent.mkdir(parents=True, exist_ok=True)
110
+ return output_path
webtap/commands/body.py CHANGED
@@ -11,11 +11,15 @@ mcp_desc = get_mcp_description("body")
11
11
 
12
12
 
13
13
  @app.command(display="markdown", fastmcp={"type": "tool", "description": mcp_desc} if mcp_desc else {"type": "tool"})
14
- def body(state, response: int, expr: str = None, decode: bool = True, cache: bool = True) -> dict: # pyright: ignore[reportArgumentType]
15
- """Fetch and analyze response body with Python expressions.
14
+ def body(state, event: int, expr: str = None, decode: bool = True, cache: bool = True) -> dict: # pyright: ignore[reportArgumentType]
15
+ """Fetch and analyze request or response body with Python expressions.
16
+
17
+ Automatically detects event type and fetches appropriate body:
18
+ - Request events (Network.requestWillBeSent): POST/PUT/PATCH request body
19
+ - Response events (Network.responseReceived): response body
16
20
 
17
21
  Args:
18
- response: Response row ID from network() or requests()
22
+ event: Event row ID from network(), events(), or requests()
19
23
  expr: Optional Python expression with 'body' variable
20
24
  decode: Auto-decode base64 (default: True)
21
25
  cache: Use cached body (default: True)
@@ -28,7 +32,7 @@ def body(state, response: int, expr: str = None, decode: bool = True, cache: boo
28
32
 
29
33
  # Get body from service (with optional caching)
30
34
  body_service = state.service.body
31
- result = body_service.get_response_body(response, use_cache=cache)
35
+ result = body_service.get_body(event, use_cache=cache)
32
36
 
33
37
  if "error" in result:
34
38
  return error_response(result["error"])
@@ -66,7 +70,7 @@ def body(state, response: int, expr: str = None, decode: bool = True, cache: boo
66
70
  # Build markdown response with body in code block
67
71
  # DATA-LEVEL TRUNCATION for memory/performance (as per refactor plan)
68
72
  MAX_BODY_SIZE = 5000 # Keep data-level truncation for large bodies
69
- elements = [{"type": "heading", "content": "Response Body", "level": 2}]
73
+ elements = [{"type": "heading", "content": "Body", "level": 2}]
70
74
 
71
75
  # Try to detect content type and format appropriately
72
76
  content_preview = body_content[:100]
@@ -2,6 +2,7 @@
2
2
 
3
3
  from webtap.app import app
4
4
  from webtap.commands._builders import check_connection, info_response, table_response, error_response
5
+ from webtap.commands._tips import get_tips
5
6
 
6
7
 
7
8
  @app.command(display="markdown", fastmcp={"type": "tool"})
@@ -132,12 +133,32 @@ def pages(state) -> dict:
132
133
  for i, p in enumerate(pages_list)
133
134
  ]
134
135
 
136
+ # Get contextual tips
137
+ tips = None
138
+ if rows:
139
+ # Find connected page or first page
140
+ connected_row = next((r for r in rows if r["Connected"] == "Yes"), rows[0])
141
+ page_index = connected_row["Index"]
142
+
143
+ # Get page_id for the example page
144
+ connected_page = next((p for p in pages_list if str(pages_list.index(p)) == page_index), None)
145
+ page_id = connected_page.get("id", "")[:6] if connected_page else ""
146
+
147
+ tips = get_tips("pages", context={"index": page_index, "page_id": page_id})
148
+
149
+ # Build contextual warnings
150
+ warnings = []
151
+ if any(r["Connected"] == "Yes" for r in rows):
152
+ warnings.append("Already connected - call connect(page=N) to switch pages")
153
+
135
154
  # Build markdown response
136
155
  return table_response(
137
156
  title="Chrome Pages",
138
157
  headers=["Index", "Title", "URL", "ID", "Connected"],
139
158
  rows=rows,
140
159
  summary=f"{len(pages_list)} page{'s' if len(pages_list) != 1 else ''} available",
160
+ warnings=warnings if warnings else None,
161
+ tips=tips,
141
162
  )
142
163
 
143
164
 
@@ -23,39 +23,27 @@ def js(
23
23
  wait_return: bool = True,
24
24
  await_promise: bool = False,
25
25
  ) -> dict:
26
- """Execute JavaScript in the browser with optional element selection.
26
+ """Execute JavaScript in the browser.
27
+
28
+ Uses fresh scope by default to avoid redeclaration errors. Set persist=True
29
+ to keep variables across calls. Use selection=N to operate on browser elements.
27
30
 
28
31
  Args:
29
- code: JavaScript code to execute (use 'element' variable if selection provided)
30
- selection: Browser element selection number (e.g., 1 for #1) - makes element available
31
- persist: Keep variables in global scope across calls (default: False, uses fresh scope)
32
+ code: JavaScript code to execute
33
+ selection: Browser element selection number - makes 'element' variable available
34
+ persist: Keep variables in global scope across calls (default: False)
32
35
  wait_return: Wait for and return result (default: True)
33
36
  await_promise: Await promises before returning (default: False)
34
37
 
35
38
  Examples:
36
- # Default: fresh scope (no redeclaration errors)
37
- js("const x = 1") # x isolated
38
- js("const x = 2") # No error, fresh scope
39
- js("document.title") # Get page title
40
- js("[...document.links].map(a => a.href)") # Get all links
41
-
42
- # With browser element selection (always fresh scope)
43
- js("element.offsetWidth", selection=1) # Use element #1
44
- js("element.classList", selection=2) # Use element #2
45
- js("element.getBoundingClientRect()", selection=1)
46
-
47
- # Persistent scope: keep variables across calls
48
- js("var data = fetch('/api')", persist=True) # data persists
49
- js("data.json()", persist=True) # Access data from previous call
50
-
51
- # Async operations
52
- js("fetch('/api').then(r => r.json())", await_promise=True)
53
-
54
- # DOM manipulation (no return needed)
55
- js("document.querySelectorAll('.ad').forEach(e => e.remove())", wait_return=False)
39
+ js("document.title") # Fresh scope (default)
40
+ js("var data = {count: 0}", persist=True) # Persistent state
41
+ js("element.offsetWidth", selection=1) # With browser element
42
+ js("fetch('/api')", await_promise=True) # Async operation
43
+ js("element.remove()", selection=1, wait_return=False) # No return needed
56
44
 
57
45
  Returns:
58
- The evaluated result if wait_return=True, otherwise execution status
46
+ Evaluated result if wait_return=True, otherwise execution status
59
47
  """
60
48
  if error := check_connection(state):
61
49
  return error
@@ -2,6 +2,7 @@
2
2
 
3
3
  from webtap.app import app
4
4
  from webtap.commands._builders import check_connection, info_response, table_response, error_response
5
+ from webtap.commands._tips import get_tips
5
6
 
6
7
 
7
8
  @app.command(display="markdown", fastmcp={"type": "tool"})
@@ -146,6 +147,9 @@ def page(state) -> dict:
146
147
  except Exception:
147
148
  title = current.get("title", "")
148
149
 
150
+ # Get tips from TIPS.md
151
+ tips = get_tips("page")
152
+
149
153
  # Build formatted response
150
154
  return info_response(
151
155
  title=title or "Untitled Page",
@@ -154,6 +158,7 @@ def page(state) -> dict:
154
158
  "ID": current.get("id", ""),
155
159
  "Type": current.get("transitionType", ""),
156
160
  },
161
+ tips=tips,
157
162
  )
158
163
 
159
164
  return error_response("No navigation history available")