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.
- navcli/cli/__init__.py +5 -0
- navcli/cli/app.py +84 -0
- navcli/cli/client.py +271 -0
- navcli/cli/commands/__init__.py +0 -0
- navcli/cli/commands/base.py +46 -0
- navcli/cli/commands/control.py +119 -0
- navcli/cli/commands/explore.py +125 -0
- navcli/cli/commands/interaction.py +129 -0
- navcli/cli/commands/navigation.py +90 -0
- navcli/cli/commands/query.py +171 -0
- navcli/core/__init__.py +0 -0
- navcli/core/models/__init__.py +15 -0
- navcli/core/models/dom.py +33 -0
- navcli/core/models/element.py +22 -0
- navcli/core/models/feedback.py +24 -0
- navcli/core/models/state.py +19 -0
- navcli/server/__init__.py +86 -0
- navcli/server/app.py +48 -0
- navcli/server/browser.py +373 -0
- navcli/server/middleware/__init__.py +0 -0
- navcli/server/routes/__init__.py +11 -0
- navcli/server/routes/control.py +44 -0
- navcli/server/routes/explore.py +382 -0
- navcli/server/routes/interaction.py +317 -0
- navcli/server/routes/navigation.py +133 -0
- navcli/server/routes/query.py +303 -0
- navcli/server/routes/session.py +177 -0
- navcli/utils/__init__.py +20 -0
- navcli/utils/image.py +30 -0
- navcli/utils/js.py +13 -0
- navcli/utils/selector.py +88 -0
- navcli/utils/text.py +17 -0
- navcli/utils/time.py +46 -0
- navcli/utils/url.py +16 -0
- navcli-0.1.0.dist-info/METADATA +79 -0
- navcli-0.1.0.dist-info/RECORD +39 -0
- navcli-0.1.0.dist-info/WHEEL +5 -0
- navcli-0.1.0.dist-info/entry_points.txt +3 -0
- navcli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Interaction commands: click, type, clear, upload."""
|
|
2
|
+
|
|
3
|
+
import cmd2
|
|
4
|
+
from navcli.cli.commands.base import BaseBrowserCommand
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Parsers for aliased commands
|
|
8
|
+
_click_parser = cmd2.Cmd2ArgumentParser()
|
|
9
|
+
_click_parser.add_argument("selector", help="CSS selector")
|
|
10
|
+
_click_parser.add_argument("--force", action="store_true", help="Force click without waiting")
|
|
11
|
+
|
|
12
|
+
_type_parser = cmd2.Cmd2ArgumentParser()
|
|
13
|
+
_type_parser.add_argument("selector", help="CSS selector")
|
|
14
|
+
_type_parser.add_argument("text", help="Text to type")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InteractionCommands(BaseBrowserCommand):
|
|
18
|
+
"""Interaction command set."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, client):
|
|
21
|
+
super().__init__(client)
|
|
22
|
+
|
|
23
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
24
|
+
def do_click(self, args):
|
|
25
|
+
"""Click an element.
|
|
26
|
+
|
|
27
|
+
Usage: click <selector> [--force]
|
|
28
|
+
Example: click #submit-button
|
|
29
|
+
Example: click .login-btn --force
|
|
30
|
+
"""
|
|
31
|
+
import asyncio
|
|
32
|
+
|
|
33
|
+
async def _click():
|
|
34
|
+
return await self.client.click(args.selector, force=args.force)
|
|
35
|
+
|
|
36
|
+
result = asyncio.run(self.run_async_client(_click))
|
|
37
|
+
self.pretty_result(result)
|
|
38
|
+
|
|
39
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
40
|
+
def do_type(self, args):
|
|
41
|
+
"""Type text into an input field.
|
|
42
|
+
|
|
43
|
+
Usage: type <selector> <text>
|
|
44
|
+
Example: type #search-input hello world
|
|
45
|
+
"""
|
|
46
|
+
import asyncio
|
|
47
|
+
|
|
48
|
+
async def _type():
|
|
49
|
+
return await self.client.type(args.selector, args.text)
|
|
50
|
+
|
|
51
|
+
result = asyncio.run(self.run_async_client(_type))
|
|
52
|
+
self.pretty_result(result)
|
|
53
|
+
|
|
54
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
55
|
+
def do_dblclick(self, args):
|
|
56
|
+
"""Double-click an element.
|
|
57
|
+
|
|
58
|
+
Usage: dblclick <selector>
|
|
59
|
+
Example: dblclick #submit-button
|
|
60
|
+
"""
|
|
61
|
+
import asyncio
|
|
62
|
+
|
|
63
|
+
async def _dblclick():
|
|
64
|
+
return await self.client.dblclick(args.selector)
|
|
65
|
+
|
|
66
|
+
result = asyncio.run(self.run_async_client(_dblclick))
|
|
67
|
+
self.pretty_result(result)
|
|
68
|
+
|
|
69
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
70
|
+
def do_rightclick(self, args):
|
|
71
|
+
"""Right-click an element.
|
|
72
|
+
|
|
73
|
+
Usage: rightclick <selector>
|
|
74
|
+
Example: rightclick .context-menu
|
|
75
|
+
"""
|
|
76
|
+
import asyncio
|
|
77
|
+
|
|
78
|
+
async def _rightclick():
|
|
79
|
+
return await self.client.rightclick(args.selector)
|
|
80
|
+
|
|
81
|
+
result = asyncio.run(self.run_async_client(_rightclick))
|
|
82
|
+
self.pretty_result(result)
|
|
83
|
+
|
|
84
|
+
# Aliases
|
|
85
|
+
@cmd2.with_argparser(_click_parser)
|
|
86
|
+
def do_c(self, args):
|
|
87
|
+
"""Click an element (shortcut).
|
|
88
|
+
|
|
89
|
+
Usage: c <selector> [--force]
|
|
90
|
+
"""
|
|
91
|
+
return self.do_click(args)
|
|
92
|
+
|
|
93
|
+
@cmd2.with_argparser(_type_parser)
|
|
94
|
+
def do_t(self, args):
|
|
95
|
+
"""Type text into an input (shortcut).
|
|
96
|
+
|
|
97
|
+
Usage: t <selector> <text>
|
|
98
|
+
"""
|
|
99
|
+
return self.do_type(args)
|
|
100
|
+
|
|
101
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
102
|
+
def do_clear(self, args):
|
|
103
|
+
"""Clear an input field.
|
|
104
|
+
|
|
105
|
+
Usage: clear <selector>
|
|
106
|
+
Example: clear #search-input
|
|
107
|
+
"""
|
|
108
|
+
import asyncio
|
|
109
|
+
|
|
110
|
+
async def _clear():
|
|
111
|
+
return await self.client.clear(args.selector)
|
|
112
|
+
|
|
113
|
+
result = asyncio.run(self.run_async_client(_clear))
|
|
114
|
+
self.pretty_result(result)
|
|
115
|
+
|
|
116
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
117
|
+
def do_upload(self, args):
|
|
118
|
+
"""Upload a file to an input.
|
|
119
|
+
|
|
120
|
+
Usage: upload <selector> <file_path>
|
|
121
|
+
Example: upload input[type=file] /path/to/file.txt
|
|
122
|
+
"""
|
|
123
|
+
import asyncio
|
|
124
|
+
|
|
125
|
+
async def _upload():
|
|
126
|
+
return await self.client.upload(args.selector, args.file_path)
|
|
127
|
+
|
|
128
|
+
result = asyncio.run(self.run_async_client(_upload))
|
|
129
|
+
self.pretty_result(result)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Navigation commands: goto, back, forward, reload."""
|
|
2
|
+
|
|
3
|
+
import cmd2
|
|
4
|
+
from navcli.cli.commands.base import BaseBrowserCommand
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Alias parser for goto -> g
|
|
8
|
+
_goto_parser = cmd2.Cmd2ArgumentParser()
|
|
9
|
+
_goto_parser.add_argument("url", help="URL to navigate to")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NavigationCommands(BaseBrowserCommand):
|
|
13
|
+
"""Navigation command set."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client):
|
|
16
|
+
super().__init__(client)
|
|
17
|
+
|
|
18
|
+
@cmd2.with_argparser(_goto_parser)
|
|
19
|
+
def do_goto(self, args):
|
|
20
|
+
"""Navigate to a URL.
|
|
21
|
+
|
|
22
|
+
Usage: goto <url>
|
|
23
|
+
Example: goto https://example.com
|
|
24
|
+
"""
|
|
25
|
+
import asyncio
|
|
26
|
+
url = args.url
|
|
27
|
+
if not url.startswith(("http://", "https://")):
|
|
28
|
+
url = "https://" + url
|
|
29
|
+
|
|
30
|
+
async def _goto():
|
|
31
|
+
return await self.client.goto(url)
|
|
32
|
+
|
|
33
|
+
result = asyncio.run(self.run_async_client(_goto))
|
|
34
|
+
self.pretty_result(result)
|
|
35
|
+
|
|
36
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
37
|
+
def do_back(self, _):
|
|
38
|
+
"""Go back in history.
|
|
39
|
+
|
|
40
|
+
Usage: back
|
|
41
|
+
"""
|
|
42
|
+
import asyncio
|
|
43
|
+
|
|
44
|
+
async def _back():
|
|
45
|
+
return await self.client.back()
|
|
46
|
+
|
|
47
|
+
result = asyncio.run(self.run_async_client(_back))
|
|
48
|
+
self.pretty_result(result)
|
|
49
|
+
|
|
50
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
51
|
+
def do_forward(self, _):
|
|
52
|
+
"""Go forward in history.
|
|
53
|
+
|
|
54
|
+
Usage: forward
|
|
55
|
+
"""
|
|
56
|
+
import asyncio
|
|
57
|
+
|
|
58
|
+
async def _forward():
|
|
59
|
+
return await self.client.forward()
|
|
60
|
+
|
|
61
|
+
result = asyncio.run(self.run_async_client(_forward))
|
|
62
|
+
self.pretty_result(result)
|
|
63
|
+
|
|
64
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
65
|
+
def do_reload(self, _):
|
|
66
|
+
"""Reload the current page.
|
|
67
|
+
|
|
68
|
+
Usage: reload
|
|
69
|
+
"""
|
|
70
|
+
import asyncio
|
|
71
|
+
|
|
72
|
+
async def _reload():
|
|
73
|
+
return await self.client.reload()
|
|
74
|
+
|
|
75
|
+
result = asyncio.run(self.run_async_client(_reload))
|
|
76
|
+
self.pretty_result(result)
|
|
77
|
+
|
|
78
|
+
# Aliases
|
|
79
|
+
do_b = do_back # back -> b
|
|
80
|
+
do_f = do_forward # forward -> f
|
|
81
|
+
do_r = do_reload # reload -> r
|
|
82
|
+
|
|
83
|
+
# goto -> g (requires separate parser)
|
|
84
|
+
@cmd2.with_argparser(_goto_parser)
|
|
85
|
+
def do_g(self, args):
|
|
86
|
+
"""Navigate to a URL (shortcut).
|
|
87
|
+
|
|
88
|
+
Usage: g <url>
|
|
89
|
+
"""
|
|
90
|
+
return self.do_goto(args)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Query commands: elements, text, html, screenshot, state, url, title."""
|
|
2
|
+
|
|
3
|
+
import cmd2
|
|
4
|
+
import base64
|
|
5
|
+
from navcli.cli.commands.base import BaseBrowserCommand
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Parser for evaluate command
|
|
9
|
+
_evaluate_parser = cmd2.Cmd2ArgumentParser()
|
|
10
|
+
_evaluate_parser.add_argument("expression", help="JavaScript expression")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QueryCommands(BaseBrowserCommand):
|
|
14
|
+
"""Query command set."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, client):
|
|
17
|
+
super().__init__(client)
|
|
18
|
+
|
|
19
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
20
|
+
def do_elements(self, _):
|
|
21
|
+
"""Get interactive elements on the page.
|
|
22
|
+
|
|
23
|
+
Usage: elements
|
|
24
|
+
"""
|
|
25
|
+
import asyncio
|
|
26
|
+
|
|
27
|
+
async def _elements():
|
|
28
|
+
return await self.client.get_elements()
|
|
29
|
+
|
|
30
|
+
result = asyncio.run(self.run_async_client(_elements))
|
|
31
|
+
self.pretty_result(result)
|
|
32
|
+
if result.get("success"):
|
|
33
|
+
state = result.get("state", {})
|
|
34
|
+
elements = state.get("dom", {}).get("elements", [])
|
|
35
|
+
for elem in elements[:20]:
|
|
36
|
+
self.poutput(f" {elem.get('selector')} [{elem.get('tag')}] {elem.get('text', '')[:50]}")
|
|
37
|
+
if len(elements) > 20:
|
|
38
|
+
self.poutput(f" ... and {len(elements) - 20} more")
|
|
39
|
+
|
|
40
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
41
|
+
def do_text(self, _):
|
|
42
|
+
"""Get page text content.
|
|
43
|
+
|
|
44
|
+
Usage: text
|
|
45
|
+
"""
|
|
46
|
+
import asyncio
|
|
47
|
+
|
|
48
|
+
async def _text():
|
|
49
|
+
return await self.client.get_text()
|
|
50
|
+
|
|
51
|
+
result = asyncio.run(self.run_async_client(_text))
|
|
52
|
+
self.pretty_result(result)
|
|
53
|
+
|
|
54
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
55
|
+
def do_html(self, _):
|
|
56
|
+
"""Get page HTML content.
|
|
57
|
+
|
|
58
|
+
Usage: html
|
|
59
|
+
"""
|
|
60
|
+
import asyncio
|
|
61
|
+
|
|
62
|
+
async def _html():
|
|
63
|
+
return await self.client.get_html()
|
|
64
|
+
|
|
65
|
+
result = asyncio.run(self.run_async_client(_html))
|
|
66
|
+
self.pretty_result(result)
|
|
67
|
+
|
|
68
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
69
|
+
def do_screenshot(self, _):
|
|
70
|
+
"""Take a screenshot of the page.
|
|
71
|
+
|
|
72
|
+
Usage: screenshot [output_file]
|
|
73
|
+
Example: screenshot
|
|
74
|
+
Example: screenshot page.png
|
|
75
|
+
"""
|
|
76
|
+
import asyncio
|
|
77
|
+
import os
|
|
78
|
+
|
|
79
|
+
async def _screenshot():
|
|
80
|
+
return await self.client.get_screenshot()
|
|
81
|
+
|
|
82
|
+
result = asyncio.run(self.run_async_client(_screenshot))
|
|
83
|
+
self.pretty_result(result)
|
|
84
|
+
|
|
85
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
86
|
+
def do_state(self, _):
|
|
87
|
+
"""Get full page state.
|
|
88
|
+
|
|
89
|
+
Usage: state
|
|
90
|
+
"""
|
|
91
|
+
import asyncio
|
|
92
|
+
|
|
93
|
+
async def _state():
|
|
94
|
+
return await self.client.get_state()
|
|
95
|
+
|
|
96
|
+
result = asyncio.run(self.run_async_client(_state))
|
|
97
|
+
self.pretty_result(result)
|
|
98
|
+
|
|
99
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
100
|
+
def do_url(self, _):
|
|
101
|
+
"""Get current URL.
|
|
102
|
+
|
|
103
|
+
Usage: url
|
|
104
|
+
"""
|
|
105
|
+
import asyncio
|
|
106
|
+
|
|
107
|
+
async def _url():
|
|
108
|
+
return await self.client.get_url()
|
|
109
|
+
|
|
110
|
+
result = asyncio.run(self.run_async_client(_url))
|
|
111
|
+
self.pretty_result(result)
|
|
112
|
+
|
|
113
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
114
|
+
def do_title(self, _):
|
|
115
|
+
"""Get page title.
|
|
116
|
+
|
|
117
|
+
Usage: title
|
|
118
|
+
"""
|
|
119
|
+
import asyncio
|
|
120
|
+
|
|
121
|
+
async def _title():
|
|
122
|
+
return await self.client.get_title()
|
|
123
|
+
|
|
124
|
+
result = asyncio.run(self.run_async_client(_title))
|
|
125
|
+
self.pretty_result(result)
|
|
126
|
+
|
|
127
|
+
@cmd2.with_argparser(_evaluate_parser)
|
|
128
|
+
def do_evaluate(self, args):
|
|
129
|
+
"""Evaluate JavaScript expression.
|
|
130
|
+
|
|
131
|
+
Usage: evaluate <js_expression>
|
|
132
|
+
Example: evaluate document.title
|
|
133
|
+
Example: evaluate window.innerHeight
|
|
134
|
+
"""
|
|
135
|
+
import asyncio
|
|
136
|
+
|
|
137
|
+
async def _evaluate():
|
|
138
|
+
return await self.client.evaluate(args.expression)
|
|
139
|
+
|
|
140
|
+
result = asyncio.run(self.run_async_client(_evaluate))
|
|
141
|
+
self.pretty_result(result)
|
|
142
|
+
|
|
143
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
144
|
+
def do_links(self, _):
|
|
145
|
+
"""Get all links on the page.
|
|
146
|
+
|
|
147
|
+
Usage: links
|
|
148
|
+
"""
|
|
149
|
+
import asyncio
|
|
150
|
+
|
|
151
|
+
async def _links():
|
|
152
|
+
return await self.client.get_links()
|
|
153
|
+
|
|
154
|
+
result = asyncio.run(self.run_async_client(_links))
|
|
155
|
+
self.pretty_result(result)
|
|
156
|
+
if result.get("success"):
|
|
157
|
+
state = result.get("state", {})
|
|
158
|
+
|
|
159
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
160
|
+
def do_forms(self, _):
|
|
161
|
+
"""Get all forms on the page.
|
|
162
|
+
|
|
163
|
+
Usage: forms
|
|
164
|
+
"""
|
|
165
|
+
import asyncio
|
|
166
|
+
|
|
167
|
+
async def _forms():
|
|
168
|
+
return await self.client.get_forms()
|
|
169
|
+
|
|
170
|
+
result = asyncio.run(self.run_async_client(_forms))
|
|
171
|
+
self.pretty_result(result)
|
navcli/core/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Core models for NavCLI."""
|
|
2
|
+
|
|
3
|
+
from .element import Element
|
|
4
|
+
from .dom import DOMTree, DOMChange
|
|
5
|
+
from .state import StateSnapshot
|
|
6
|
+
from .feedback import CommandResult, Feedback
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
'Element',
|
|
10
|
+
'DOMTree',
|
|
11
|
+
'DOMChange',
|
|
12
|
+
'StateSnapshot',
|
|
13
|
+
'CommandResult',
|
|
14
|
+
'Feedback',
|
|
15
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""DOM Tree model for page DOM representation."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from .element import Element
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DOMChange(BaseModel):
|
|
10
|
+
"""Represents a SPA DOM change."""
|
|
11
|
+
|
|
12
|
+
type: str # 'added', 'removed', 'modified'
|
|
13
|
+
selector: str # CSS selector of changed element
|
|
14
|
+
html: Optional[str] = None # new HTML (for added/modified)
|
|
15
|
+
text: Optional[str] = None # new text (for modified)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DOMTree(BaseModel):
|
|
19
|
+
"""Represents the page DOM tree.
|
|
20
|
+
|
|
21
|
+
SPA Diff Strategy:
|
|
22
|
+
- First load returns full DOM
|
|
23
|
+
- Subsequent operations return incremental changes
|
|
24
|
+
- Fallback to full DOM if changes > 50%
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
version: int = 0 # incremental version number
|
|
28
|
+
full: bool = True # is full DOM (vs incremental)
|
|
29
|
+
html: str = "" # full or incremental HTML
|
|
30
|
+
changes: list[DOMChange] = Field(default_factory=list) # SPA changes
|
|
31
|
+
elements: list[Element] = Field(default_factory=list) # interactive elements
|
|
32
|
+
async_loaded: bool = False # contains async loaded content
|
|
33
|
+
loading: bool = False # currently loading
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Element model for interactive elements."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Element(BaseModel):
|
|
8
|
+
"""Represents an interactive element on the page.
|
|
9
|
+
|
|
10
|
+
Only returns "actionable + meaningful" elements:
|
|
11
|
+
- button, a (with href), input, select, textarea
|
|
12
|
+
- h1-h3, img (with alt), nav, menu
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
selector: str # CSS selector
|
|
16
|
+
tag: str # tag name
|
|
17
|
+
text: str # element text content
|
|
18
|
+
clickable: bool # is clickable
|
|
19
|
+
input: bool # is inputtable
|
|
20
|
+
type: Optional[str] = None # input type (for input elements)
|
|
21
|
+
href: Optional[str] = None # href (for anchor elements)
|
|
22
|
+
alt: Optional[str] = None # alt text (for img elements)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Feedback and CommandResult models."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from .state import StateSnapshot
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Feedback(BaseModel):
|
|
10
|
+
"""Represents the result of a command execution."""
|
|
11
|
+
|
|
12
|
+
action: str = "" # action performed
|
|
13
|
+
result: str = "" # result description
|
|
14
|
+
changes: list = Field(default_factory=list) # DOM changes
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CommandResult(BaseModel):
|
|
18
|
+
"""Standard result returned by all commands."""
|
|
19
|
+
|
|
20
|
+
success: bool = False
|
|
21
|
+
command: str = ""
|
|
22
|
+
state: StateSnapshot = Field(default_factory=StateSnapshot)
|
|
23
|
+
feedback: Feedback = Field(default_factory=Feedback)
|
|
24
|
+
error: Optional[str] = None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""StateSnapshot model for page state."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from .dom import DOMTree
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StateSnapshot(BaseModel):
|
|
10
|
+
"""Represents the current page state."""
|
|
11
|
+
|
|
12
|
+
url: str = "" # current URL
|
|
13
|
+
title: str = "" # page title
|
|
14
|
+
dom: DOMTree = Field(default_factory=DOMTree) # DOM tree
|
|
15
|
+
variables: dict = Field(default_factory=dict) # page variables
|
|
16
|
+
loading: bool = False # is still loading
|
|
17
|
+
network_idle: bool = True # network is idle
|
|
18
|
+
dom_stable: bool = True # DOM is stable
|
|
19
|
+
pending_requests: int = 0 # pending request count
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""NavCLI Browser Server."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
from uvicorn.config import Config
|
|
10
|
+
|
|
11
|
+
from navcli.server.app import create_app
|
|
12
|
+
from navcli.server.browser import start_browser, close_browser
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
# Server instance
|
|
17
|
+
_server: Optional[uvicorn.Server] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def start_server(host: str = "127.0.0.1", port: int = 8765) -> uvicorn.Server:
|
|
21
|
+
"""Start the browser server.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
host: Host to bind
|
|
25
|
+
port: Port to listen
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Uvicorn server instance
|
|
29
|
+
"""
|
|
30
|
+
global _server
|
|
31
|
+
|
|
32
|
+
# Start browser
|
|
33
|
+
await start_browser()
|
|
34
|
+
|
|
35
|
+
# Create app
|
|
36
|
+
app = create_app()
|
|
37
|
+
|
|
38
|
+
# Configure uvicorn
|
|
39
|
+
config = Config(app, host=host, port=port, log_level="info")
|
|
40
|
+
_server = uvicorn.Server(config)
|
|
41
|
+
|
|
42
|
+
# Register shutdown handler
|
|
43
|
+
def signal_handler():
|
|
44
|
+
asyncio.create_task(shutdown())
|
|
45
|
+
|
|
46
|
+
config.lifespan = "on"
|
|
47
|
+
|
|
48
|
+
# Start server
|
|
49
|
+
await _server.serve()
|
|
50
|
+
|
|
51
|
+
return _server
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def shutdown():
|
|
55
|
+
"""Shutdown the server."""
|
|
56
|
+
global _server
|
|
57
|
+
|
|
58
|
+
if _server:
|
|
59
|
+
_server.should_exit = True
|
|
60
|
+
|
|
61
|
+
# Close browser
|
|
62
|
+
await close_browser()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main():
|
|
66
|
+
"""Main entry point for nav-server command."""
|
|
67
|
+
import argparse
|
|
68
|
+
|
|
69
|
+
parser = argparse.ArgumentParser(description="NavCLI Browser Server")
|
|
70
|
+
parser.add_argument("--host", default="127.0.0.1", help="Host to bind")
|
|
71
|
+
parser.add_argument("--port", type=int, default=8765, help="Port to listen")
|
|
72
|
+
|
|
73
|
+
args = parser.parse_args()
|
|
74
|
+
|
|
75
|
+
print(f"Starting NavCLI Browser Server on {args.host}:{args.port}...")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
asyncio.run(start_server(args.host, args.port))
|
|
79
|
+
except KeyboardInterrupt:
|
|
80
|
+
print("\nShutting down...")
|
|
81
|
+
sys.exit(0)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
|
86
|
+
|
navcli/server/app.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""FastAPI application for NavCLI Browser Server."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
5
|
+
|
|
6
|
+
from navcli.server.routes import navigation, interaction, query, explore, control, session
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_app() -> FastAPI:
|
|
10
|
+
"""Create and configure the FastAPI application."""
|
|
11
|
+
app = FastAPI(
|
|
12
|
+
title="NavCLI Browser Server",
|
|
13
|
+
description="HTTP API for controlling Playwright browser",
|
|
14
|
+
version="0.1.0",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# CORS
|
|
18
|
+
app.add_middleware(
|
|
19
|
+
CORSMiddleware,
|
|
20
|
+
allow_origins=["*"],
|
|
21
|
+
allow_credentials=True,
|
|
22
|
+
allow_methods=["*"],
|
|
23
|
+
allow_headers=["*"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Include routes
|
|
27
|
+
app.include_router(navigation.router, prefix="/cmd")
|
|
28
|
+
app.include_router(interaction.router, prefix="/cmd")
|
|
29
|
+
app.include_router(query.router)
|
|
30
|
+
app.include_router(explore.router)
|
|
31
|
+
app.include_router(control.router, prefix="/cmd")
|
|
32
|
+
app.include_router(session.router, prefix="/cmd")
|
|
33
|
+
|
|
34
|
+
@app.get("/")
|
|
35
|
+
async def root():
|
|
36
|
+
"""Root endpoint."""
|
|
37
|
+
return {
|
|
38
|
+
"name": "NavCLI Browser Server",
|
|
39
|
+
"version": "0.1.0",
|
|
40
|
+
"status": "running",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@app.get("/health")
|
|
44
|
+
async def health():
|
|
45
|
+
"""Health check endpoint."""
|
|
46
|
+
return {"status": "healthy"}
|
|
47
|
+
|
|
48
|
+
return app
|