navcli 0.1.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.
@@ -0,0 +1,133 @@
1
+ """Navigation routes: goto, back, forward, reload."""
2
+
3
+ from typing import Optional
4
+ from fastapi import APIRouter, HTTPException, Query
5
+
6
+ from navcli.core.models import CommandResult, Feedback
7
+ from navcli.server.browser import get_page, get_current_state, start_browser, wait_for_network_idle
8
+ from navcli.utils import normalize_url
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/goto")
14
+ async def goto(
15
+ url: str = Query(..., description="URL to navigate to"),
16
+ wait_until: str = Query("networkidle", description="wait condition: load, domcontentloaded, networkidle")
17
+ ) -> CommandResult:
18
+ """Navigate to a URL."""
19
+ try:
20
+ if not url:
21
+ raise HTTPException(status_code=400, detail="url is required")
22
+
23
+ normalized_url = normalize_url(url)
24
+
25
+ # Start browser if not started
26
+ await start_browser()
27
+ page = get_page()
28
+
29
+ # Navigate
30
+ await page.goto(normalized_url, wait_until=wait_until)
31
+
32
+ # Wait for network idle
33
+ await wait_for_network_idle(3.0)
34
+
35
+ # Get new state
36
+ state = await get_current_state()
37
+
38
+ return CommandResult(
39
+ success=True,
40
+ command="goto",
41
+ state=state,
42
+ feedback=Feedback(
43
+ action=f"navigated to {normalized_url}",
44
+ result="page loaded",
45
+ ),
46
+ )
47
+ except Exception as e:
48
+ return CommandResult(
49
+ success=False,
50
+ command="goto",
51
+ error=str(e),
52
+ )
53
+
54
+
55
+ @router.post("/back")
56
+ async def back() -> CommandResult:
57
+ """Go back in history."""
58
+ try:
59
+ page = get_page()
60
+ await page.go_back()
61
+ await wait_for_network_idle(3.0)
62
+
63
+ state = await get_current_state()
64
+
65
+ return CommandResult(
66
+ success=True,
67
+ command="back",
68
+ state=state,
69
+ feedback=Feedback(
70
+ action="went back",
71
+ result=f"navigated to {state.url}",
72
+ ),
73
+ )
74
+ except Exception as e:
75
+ return CommandResult(
76
+ success=False,
77
+ command="back",
78
+ error=str(e),
79
+ )
80
+
81
+
82
+ @router.post("/forward")
83
+ async def forward() -> CommandResult:
84
+ """Go forward in history."""
85
+ try:
86
+ page = get_page()
87
+ await page.go_forward()
88
+ await wait_for_network_idle(3.0)
89
+
90
+ state = await get_current_state()
91
+
92
+ return CommandResult(
93
+ success=True,
94
+ command="forward",
95
+ state=state,
96
+ feedback=Feedback(
97
+ action="went forward",
98
+ result=f"navigated to {state.url}",
99
+ ),
100
+ )
101
+ except Exception as e:
102
+ return CommandResult(
103
+ success=False,
104
+ command="forward",
105
+ error=str(e),
106
+ )
107
+
108
+
109
+ @router.post("/reload")
110
+ async def reload() -> CommandResult:
111
+ """Reload the current page."""
112
+ try:
113
+ page = get_page()
114
+ await page.reload()
115
+ await wait_for_network_idle(3.0)
116
+
117
+ state = await get_current_state()
118
+
119
+ return CommandResult(
120
+ success=True,
121
+ command="reload",
122
+ state=state,
123
+ feedback=Feedback(
124
+ action="reloaded page",
125
+ result=f"reloaded {state.url}",
126
+ ),
127
+ )
128
+ except Exception as e:
129
+ return CommandResult(
130
+ success=False,
131
+ command="reload",
132
+ error=str(e),
133
+ )
@@ -0,0 +1,303 @@
1
+ """Query routes: elements, text, html, screenshot, state."""
2
+
3
+ from typing import Optional, List
4
+ from fastapi import APIRouter, HTTPException
5
+ from pydantic import BaseModel
6
+
7
+ from navcli.core.models import CommandResult, Feedback
8
+ from navcli.server.browser import get_page, get_current_state, start_browser
9
+ from navcli.utils import encode_screenshot
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.get("/elements")
15
+ async def get_elements() -> CommandResult:
16
+ """Get interactive elements on the page."""
17
+ try:
18
+ await start_browser()
19
+ state = await get_current_state()
20
+
21
+ return CommandResult(
22
+ success=True,
23
+ command="elements",
24
+ state=state,
25
+ feedback=Feedback(
26
+ action="got elements",
27
+ result=f"found {len(state.dom.elements)} interactive elements",
28
+ ),
29
+ )
30
+ except Exception as e:
31
+ return CommandResult(
32
+ success=False,
33
+ command="elements",
34
+ error=str(e),
35
+ )
36
+
37
+
38
+ @router.get("/text")
39
+ async def get_text() -> CommandResult:
40
+ """Get page text content."""
41
+ try:
42
+ page = get_page()
43
+ text = await page.inner_text("body")
44
+
45
+ await start_browser()
46
+ state = await get_current_state()
47
+
48
+ return CommandResult(
49
+ success=True,
50
+ command="text",
51
+ state=state,
52
+ feedback=Feedback(
53
+ action="got text",
54
+ result=f"text length: {len(text)} chars",
55
+ ),
56
+ )
57
+ except Exception as e:
58
+ return CommandResult(
59
+ success=False,
60
+ command="text",
61
+ error=str(e),
62
+ )
63
+
64
+
65
+ class TextRequest(BaseModel):
66
+ selectors: Optional[List[str]] = None
67
+
68
+
69
+ @router.get("/html")
70
+ async def get_html() -> CommandResult:
71
+ """Get page HTML content."""
72
+ try:
73
+ page = get_page()
74
+ html = await page.content()
75
+
76
+ await start_browser()
77
+ state = await get_current_state()
78
+
79
+ return CommandResult(
80
+ success=True,
81
+ command="html",
82
+ state=state,
83
+ feedback=Feedback(
84
+ action="got html",
85
+ result=f"html length: {len(html)} chars",
86
+ ),
87
+ )
88
+ except Exception as e:
89
+ return CommandResult(
90
+ success=False,
91
+ command="html",
92
+ error=str(e),
93
+ )
94
+
95
+
96
+ @router.get("/screenshot")
97
+ async def get_screenshot() -> CommandResult:
98
+ """Get page screenshot as base64."""
99
+ try:
100
+ page = get_page()
101
+ screenshot_bytes = await page.screenshot()
102
+ screenshot_base64 = encode_screenshot(screenshot_bytes)
103
+
104
+ await start_browser()
105
+ state = await get_current_state()
106
+
107
+ return CommandResult(
108
+ success=True,
109
+ command="screenshot",
110
+ state=state,
111
+ feedback=Feedback(
112
+ action="took screenshot",
113
+ result=f"screenshot size: {len(screenshot_bytes)} bytes",
114
+ ),
115
+ )
116
+ except Exception as e:
117
+ return CommandResult(
118
+ success=False,
119
+ command="screenshot",
120
+ error=str(e),
121
+ )
122
+
123
+
124
+ @router.get("/state")
125
+ async def get_state() -> CommandResult:
126
+ """Get full page state."""
127
+ try:
128
+ await start_browser()
129
+ state = await get_current_state()
130
+
131
+ return CommandResult(
132
+ success=True,
133
+ command="state",
134
+ state=state,
135
+ feedback=Feedback(
136
+ action="got state",
137
+ result=f"url: {state.url}, title: {state.title}",
138
+ ),
139
+ )
140
+ except Exception as e:
141
+ return CommandResult(
142
+ success=False,
143
+ command="state",
144
+ error=str(e),
145
+ )
146
+
147
+
148
+ @router.get("/url")
149
+ async def get_url() -> CommandResult:
150
+ """Get current URL."""
151
+ try:
152
+ page = get_page()
153
+ url = page.url
154
+
155
+ await start_browser()
156
+ state = await get_current_state()
157
+
158
+ return CommandResult(
159
+ success=True,
160
+ command="url",
161
+ state=state,
162
+ feedback=Feedback(
163
+ action="got url",
164
+ result=url,
165
+ ),
166
+ )
167
+ except Exception as e:
168
+ return CommandResult(
169
+ success=False,
170
+ command="url",
171
+ error=str(e),
172
+ )
173
+
174
+
175
+ @router.get("/title")
176
+ async def get_title() -> CommandResult:
177
+ """Get page title."""
178
+ try:
179
+ page = get_page()
180
+ title = await page.title()
181
+
182
+ await start_browser()
183
+ state = await get_current_state()
184
+
185
+ return CommandResult(
186
+ success=True,
187
+ command="title",
188
+ state=state,
189
+ feedback=Feedback(
190
+ action="got title",
191
+ result=title,
192
+ ),
193
+ )
194
+ except Exception as e:
195
+ return CommandResult(
196
+ success=False,
197
+ command="title",
198
+ error=str(e),
199
+ )
200
+
201
+
202
+ @router.get("/evaluate")
203
+ async def evaluate(expr: str) -> CommandResult:
204
+ """Evaluate JavaScript expression.
205
+
206
+ Usage: /query/evaluate?expr=<js_expression>
207
+ """
208
+ try:
209
+ await start_browser()
210
+ page = get_page()
211
+
212
+ result = await page.evaluate(expr)
213
+
214
+ state = await get_current_state()
215
+
216
+ return CommandResult(
217
+ success=True,
218
+ command="evaluate",
219
+ state=state,
220
+ feedback=Feedback(
221
+ action=f"evaluated: {expr[:50]}...",
222
+ result=str(result)[:100],
223
+ ),
224
+ )
225
+ except Exception as e:
226
+ return CommandResult(
227
+ success=False,
228
+ command="evaluate",
229
+ error=str(e),
230
+ )
231
+
232
+
233
+ @router.get("/links")
234
+ async def get_links() -> CommandResult:
235
+ """Get all links on the page."""
236
+ try:
237
+ await start_browser()
238
+ page = get_page()
239
+
240
+ links = await page.evaluate("""
241
+ Array.from(document.querySelectorAll('a[href]')).map(a => ({
242
+ href: a.href,
243
+ text: a.innerText.trim().substring(0, 100),
244
+ visible: a.offsetParent !== null
245
+ }))
246
+ """)
247
+
248
+ state = await get_current_state()
249
+
250
+ return CommandResult(
251
+ success=True,
252
+ command="links",
253
+ state=state,
254
+ feedback=Feedback(
255
+ action="got links",
256
+ result=f"found {len(links)} links",
257
+ ),
258
+ )
259
+ except Exception as e:
260
+ return CommandResult(
261
+ success=False,
262
+ command="links",
263
+ error=str(e),
264
+ )
265
+
266
+
267
+ @router.get("/forms")
268
+ async def get_forms() -> CommandResult:
269
+ """Get all forms on the page."""
270
+ try:
271
+ await start_browser()
272
+ page = get_page()
273
+
274
+ forms = await page.evaluate("""
275
+ Array.from(document.querySelectorAll('form')).map(form => ({
276
+ action: form.action,
277
+ method: form.method,
278
+ inputs: Array.from(form.querySelectorAll('input, select, textarea')).map(i => ({
279
+ name: i.name,
280
+ type: i.type,
281
+ id: i.id,
282
+ placeholder: i.placeholder || ''
283
+ }))
284
+ }))
285
+ """)
286
+
287
+ state = await get_current_state()
288
+
289
+ return CommandResult(
290
+ success=True,
291
+ command="forms",
292
+ state=state,
293
+ feedback=Feedback(
294
+ action="got forms",
295
+ result=f"found {len(forms)} forms",
296
+ ),
297
+ )
298
+ except Exception as e:
299
+ return CommandResult(
300
+ success=False,
301
+ command="forms",
302
+ error=str(e),
303
+ )
@@ -0,0 +1,177 @@
1
+ """Session routes: cookies, save_session, load_session."""
2
+
3
+ from typing import Optional, List, Dict, Any
4
+ from fastapi import APIRouter, HTTPException, Query
5
+ from pydantic import BaseModel
6
+
7
+ from navcli.core.models import CommandResult, Feedback
8
+ from navcli.server.browser import (
9
+ get_cookies,
10
+ set_cookie,
11
+ clear_cookies,
12
+ save_session,
13
+ load_session,
14
+ )
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ class CookieSetRequest(BaseModel):
20
+ """Request body for setting a cookie."""
21
+ name: str
22
+ value: str
23
+ domain: str
24
+ path: str = "/"
25
+ expires: Optional[int] = None
26
+ http_only: bool = False
27
+ secure: bool = False
28
+ same_site: Optional[str] = None
29
+
30
+
31
+ @router.get("/cookies")
32
+ async def query_cookies() -> CommandResult:
33
+ """Get all current cookies.
34
+
35
+ Returns list of cookies with name, value, domain, path, etc.
36
+ """
37
+ try:
38
+ cookies = await get_cookies()
39
+
40
+ return CommandResult(
41
+ success=True,
42
+ command="cookies",
43
+ feedback=Feedback(
44
+ action="got cookies",
45
+ result=f"found {len(cookies)} cookies",
46
+ ),
47
+ )
48
+ except Exception as e:
49
+ return CommandResult(
50
+ success=False,
51
+ command="cookies",
52
+ error=str(e),
53
+ )
54
+
55
+
56
+ @router.post("/cookies/set")
57
+ async def set_cookie_route(request: CookieSetRequest) -> CommandResult:
58
+ """Set a single cookie.
59
+
60
+ Usage: POST /cmd/cookies/set with JSON body
61
+ """
62
+ try:
63
+ await set_cookie(
64
+ name=request.name,
65
+ value=request.value,
66
+ domain=request.domain,
67
+ path=request.path,
68
+ expires=request.expires,
69
+ http_only=request.http_only,
70
+ secure=request.secure,
71
+ same_site=request.same_site,
72
+ )
73
+
74
+ return CommandResult(
75
+ success=True,
76
+ command="set_cookie",
77
+ feedback=Feedback(
78
+ action="set cookie",
79
+ result=f"cookie '{request.name}' set for {request.domain}",
80
+ ),
81
+ )
82
+ except Exception as e:
83
+ return CommandResult(
84
+ success=False,
85
+ command="set_cookie",
86
+ error=str(e),
87
+ )
88
+
89
+
90
+ @router.delete("/cookies/clear")
91
+ async def clear_cookies_route() -> CommandResult:
92
+ """Clear all cookies.
93
+
94
+ Returns the number of cookies that were cleared.
95
+ """
96
+ try:
97
+ count = await clear_cookies()
98
+
99
+ return CommandResult(
100
+ success=True,
101
+ command="clear_cookies",
102
+ feedback=Feedback(
103
+ action="cleared cookies",
104
+ result=f"cleared {count} cookies",
105
+ ),
106
+ )
107
+ except Exception as e:
108
+ return CommandResult(
109
+ success=False,
110
+ command="clear_cookies",
111
+ error=str(e),
112
+ )
113
+
114
+
115
+ @router.post("/session/save")
116
+ async def save_session_route(path: str = Query(".navcli_session.json")) -> CommandResult:
117
+ """Save session to file.
118
+
119
+ Saves cookies, localStorage, and sessionStorage to a JSON file.
120
+
121
+ Args:
122
+ path: File path to save session (default: .navcli_session.json)
123
+ """
124
+ try:
125
+ storage_state = await save_session(path)
126
+
127
+ cookie_count = len(storage_state.get("cookies", []))
128
+ origin_count = len(storage_state.get("origins", []))
129
+
130
+ return CommandResult(
131
+ success=True,
132
+ command="save_session",
133
+ feedback=Feedback(
134
+ action="saved session",
135
+ result=f"saved to {path}: {cookie_count} cookies, {origin_count} origins",
136
+ ),
137
+ )
138
+ except Exception as e:
139
+ return CommandResult(
140
+ success=False,
141
+ command="save_session",
142
+ error=str(e),
143
+ )
144
+
145
+
146
+ @router.post("/session/load")
147
+ async def load_session_route(path: str = Query(".navcli_session.json")) -> CommandResult:
148
+ """Load session from file.
149
+
150
+ Loads cookies, localStorage, and sessionStorage from a JSON file.
151
+
152
+ Args:
153
+ path: File path to load session from (default: .navcli_session.json)
154
+ """
155
+ try:
156
+ await load_session(path)
157
+
158
+ return CommandResult(
159
+ success=True,
160
+ command="load_session",
161
+ feedback=Feedback(
162
+ action="loaded session",
163
+ result=f"loaded session from {path}",
164
+ ),
165
+ )
166
+ except FileNotFoundError:
167
+ return CommandResult(
168
+ success=False,
169
+ command="load_session",
170
+ error=f"Session file not found: {path}",
171
+ )
172
+ except Exception as e:
173
+ return CommandResult(
174
+ success=False,
175
+ command="load_session",
176
+ error=str(e),
177
+ )
@@ -0,0 +1,20 @@
1
+ """Common utilities for NavCLI."""
2
+
3
+ from .selector import build_selector, parse_selector
4
+ from .image import decode_screenshot, encode_screenshot
5
+ from .url import normalize_url
6
+ from .js import escape_js_string
7
+ from .time import format_timeout, wait_for_condition
8
+ from .text import parse_key_value
9
+
10
+ __all__ = [
11
+ 'parse_selector',
12
+ 'build_selector',
13
+ 'decode_screenshot',
14
+ 'encode_screenshot',
15
+ 'normalize_url',
16
+ 'escape_js_string',
17
+ 'format_timeout',
18
+ 'wait_for_condition',
19
+ 'parse_key_value',
20
+ ]
navcli/utils/image.py ADDED
@@ -0,0 +1,30 @@
1
+ """Image encoding/decoding utilities."""
2
+
3
+ import base64
4
+
5
+
6
+ def decode_screenshot(base64_data: str) -> bytes:
7
+ """Decode base64 screenshot data.
8
+
9
+ Args:
10
+ base64_data: Base64 encoded image data
11
+
12
+ Returns:
13
+ Raw image bytes
14
+ """
15
+ if ',' in base64_data:
16
+ base64_data = base64_data.split(',', 1)[1]
17
+ return base64.b64decode(base64_data)
18
+
19
+
20
+ def encode_screenshot(image_bytes: bytes) -> str:
21
+ """Encode image bytes to base64 with prefix.
22
+
23
+ Args:
24
+ image_bytes: Raw image bytes
25
+
26
+ Returns:
27
+ Base64 string with data URL prefix
28
+ """
29
+ b64 = base64.b64encode(image_bytes).decode('utf-8')
30
+ return f"data:image/png;base64,{b64}"
navcli/utils/js.py ADDED
@@ -0,0 +1,13 @@
1
+ """JavaScript related utilities."""
2
+
3
+
4
+ def escape_js_string(s: str) -> str:
5
+ """Escape a string for use in JavaScript.
6
+
7
+ Args:
8
+ s: String to escape
9
+
10
+ Returns:
11
+ Escaped string safe for JS
12
+ """
13
+ return s.replace('\\', '\\\\').replace('"', '\\"').replace("'", "\\'").replace('\n', '\\n').replace('\r', '\\r')