navcli 0.1.0__py3-none-any.whl → 0.2.1__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/app.py +4 -0
- navcli/cli/client.py +34 -8
- navcli/cli/commands/control.py +16 -16
- navcli/cli/commands/explore.py +6 -6
- navcli/cli/commands/interaction.py +6 -6
- navcli/cli/commands/navigation.py +31 -9
- navcli/cli/commands/query.py +54 -10
- navcli/core/models/__init__.py +2 -1
- navcli/core/models/state.py +14 -3
- navcli/server/__init__.py +45 -3
- navcli/server/browser.py +61 -22
- navcli/server/routes/explore.py +1 -1
- navcli/server/routes/query.py +166 -7
- navcli/server/routes/session.py +38 -9
- navcli-0.2.1.dist-info/METADATA +81 -0
- {navcli-0.1.0.dist-info → navcli-0.2.1.dist-info}/RECORD +19 -19
- navcli-0.1.0.dist-info/METADATA +0 -79
- {navcli-0.1.0.dist-info → navcli-0.2.1.dist-info}/WHEEL +0 -0
- {navcli-0.1.0.dist-info → navcli-0.2.1.dist-info}/entry_points.txt +0 -0
- {navcli-0.1.0.dist-info → navcli-0.2.1.dist-info}/top_level.txt +0 -0
navcli/cli/app.py
CHANGED
|
@@ -54,6 +54,10 @@ class NavCLI(
|
|
|
54
54
|
self.poutput(" state Get full page state")
|
|
55
55
|
self.poutput(" url Get current URL")
|
|
56
56
|
self.poutput(" title Get page title")
|
|
57
|
+
self.poutput(" tables Get all tables with data")
|
|
58
|
+
self.poutput(" paragraphs Get large text paragraphs (200+ chars)")
|
|
59
|
+
self.poutput(" links Get all links")
|
|
60
|
+
self.poutput(" forms Get all forms")
|
|
57
61
|
|
|
58
62
|
self.poutput("\nExplore Commands:")
|
|
59
63
|
self.poutput(" find <text> Find element by text")
|
navcli/cli/client.py
CHANGED
|
@@ -123,6 +123,18 @@ class NavClient:
|
|
|
123
123
|
"""Get all forms on the page."""
|
|
124
124
|
return await self._request("GET", "/query/forms")
|
|
125
125
|
|
|
126
|
+
async def get_tables(self):
|
|
127
|
+
"""Get all tables on the page with their data."""
|
|
128
|
+
return await self._request("GET", "/query/tables")
|
|
129
|
+
|
|
130
|
+
async def get_paragraphs(self, min_length: int = 200):
|
|
131
|
+
"""Get large text paragraphs (200+ chars) from the page.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
min_length: Minimum paragraph length (default: 200)
|
|
135
|
+
"""
|
|
136
|
+
return await self._request("GET", f"/query/paragraphs?min_length={min_length}")
|
|
137
|
+
|
|
126
138
|
# Explore commands
|
|
127
139
|
async def find(self, text: str):
|
|
128
140
|
"""Find element by text."""
|
|
@@ -234,25 +246,39 @@ class NavClient:
|
|
|
234
246
|
|
|
235
247
|
return await self._request("POST", "/cmd/cookies/set", json=body)
|
|
236
248
|
|
|
237
|
-
async def save_session(self, path: str =
|
|
249
|
+
async def save_session(self, name: str = None, path: str = None):
|
|
238
250
|
"""Save session to file.
|
|
239
251
|
|
|
240
252
|
Args:
|
|
241
|
-
|
|
253
|
+
name: Session name (saves to ~/.navcli/sessions/<name>.json)
|
|
254
|
+
path: Full file path (alternative to name)
|
|
242
255
|
"""
|
|
243
256
|
import urllib.parse
|
|
244
|
-
encoded_path = urllib.parse.quote(path)
|
|
245
|
-
return await self._request("POST", f"/cmd/session/save?path={encoded_path}")
|
|
246
257
|
|
|
247
|
-
|
|
258
|
+
if name:
|
|
259
|
+
return await self._request("POST", f"/cmd/session/save?name={name}")
|
|
260
|
+
elif path:
|
|
261
|
+
encoded_path = urllib.parse.quote(path)
|
|
262
|
+
return await self._request("POST", f"/cmd/session/save?path={encoded_path}")
|
|
263
|
+
else:
|
|
264
|
+
return await self._request("POST", "/cmd/session/save")
|
|
265
|
+
|
|
266
|
+
async def load_session(self, name: str = None, path: str = None):
|
|
248
267
|
"""Load session from file.
|
|
249
268
|
|
|
250
269
|
Args:
|
|
251
|
-
|
|
270
|
+
name: Session name (loads from ~/.navcli/sessions/<name>.json)
|
|
271
|
+
path: Full file path (alternative to name)
|
|
252
272
|
"""
|
|
253
273
|
import urllib.parse
|
|
254
|
-
|
|
255
|
-
|
|
274
|
+
|
|
275
|
+
if name:
|
|
276
|
+
return await self._request("POST", f"/cmd/session/load?name={name}")
|
|
277
|
+
elif path:
|
|
278
|
+
encoded_path = urllib.parse.quote(path)
|
|
279
|
+
return await self._request("POST", f"/cmd/session/load?path={encoded_path}")
|
|
280
|
+
else:
|
|
281
|
+
return await self._request("POST", "/cmd/session/load")
|
|
256
282
|
|
|
257
283
|
|
|
258
284
|
async def main():
|
navcli/cli/commands/control.py
CHANGED
|
@@ -21,7 +21,7 @@ class ControlCommands(BaseBrowserCommand):
|
|
|
21
21
|
async def _quit():
|
|
22
22
|
return await self.client.quit()
|
|
23
23
|
|
|
24
|
-
result = asyncio.run(self.run_async_client(_quit))
|
|
24
|
+
result = asyncio.run(self.run_async_client(_quit()))
|
|
25
25
|
self.pretty_result(result)
|
|
26
26
|
|
|
27
27
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -35,7 +35,7 @@ class ControlCommands(BaseBrowserCommand):
|
|
|
35
35
|
async def _shutdown():
|
|
36
36
|
return await self.client.shutdown()
|
|
37
37
|
|
|
38
|
-
result = asyncio.run(self.run_async_client(_shutdown))
|
|
38
|
+
result = asyncio.run(self.run_async_client(_shutdown()))
|
|
39
39
|
self.pretty_result(result)
|
|
40
40
|
|
|
41
41
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -50,41 +50,41 @@ class ControlCommands(BaseBrowserCommand):
|
|
|
50
50
|
# Session commands
|
|
51
51
|
|
|
52
52
|
save_parser = cmd2.Cmd2ArgumentParser()
|
|
53
|
-
save_parser.add_argument("
|
|
54
|
-
|
|
53
|
+
save_parser.add_argument("name", nargs="?", default=None,
|
|
54
|
+
help="Session name (saves to ~/.navcli/sessions/<name>.json)")
|
|
55
55
|
|
|
56
56
|
@cmd2.with_argparser(save_parser)
|
|
57
57
|
def do_save_session(self, args):
|
|
58
58
|
"""Save session (cookies, storage) to file.
|
|
59
59
|
|
|
60
|
-
Usage: save_session [
|
|
61
|
-
Example: save_session
|
|
60
|
+
Usage: save_session [name]
|
|
61
|
+
Example: save_session jisilu
|
|
62
62
|
"""
|
|
63
63
|
import asyncio
|
|
64
64
|
|
|
65
65
|
async def _save():
|
|
66
|
-
return await self.client.save_session(args.
|
|
66
|
+
return await self.client.save_session(name=args.name)
|
|
67
67
|
|
|
68
|
-
result = asyncio.run(self.run_async_client(_save))
|
|
68
|
+
result = asyncio.run(self.run_async_client(_save()))
|
|
69
69
|
self.pretty_result(result)
|
|
70
70
|
|
|
71
71
|
load_parser = cmd2.Cmd2ArgumentParser()
|
|
72
|
-
load_parser.add_argument("
|
|
73
|
-
|
|
72
|
+
load_parser.add_argument("name", nargs="?", default=None,
|
|
73
|
+
help="Session name (loads from ~/.navcli/sessions/<name>.json)")
|
|
74
74
|
|
|
75
75
|
@cmd2.with_argparser(load_parser)
|
|
76
76
|
def do_load_session(self, args):
|
|
77
77
|
"""Load session (cookies, storage) from file.
|
|
78
78
|
|
|
79
|
-
Usage: load_session [
|
|
80
|
-
Example: load_session
|
|
79
|
+
Usage: load_session [name]
|
|
80
|
+
Example: load_session jisilu
|
|
81
81
|
"""
|
|
82
82
|
import asyncio
|
|
83
83
|
|
|
84
84
|
async def _load():
|
|
85
|
-
return await self.client.load_session(args.
|
|
85
|
+
return await self.client.load_session(name=args.name)
|
|
86
86
|
|
|
87
|
-
result = asyncio.run(self.run_async_client(_load))
|
|
87
|
+
result = asyncio.run(self.run_async_client(_load()))
|
|
88
88
|
self.pretty_result(result)
|
|
89
89
|
|
|
90
90
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -98,7 +98,7 @@ class ControlCommands(BaseBrowserCommand):
|
|
|
98
98
|
async def _get():
|
|
99
99
|
return await self.client.get_cookies()
|
|
100
100
|
|
|
101
|
-
result = asyncio.run(self.run_async_client(_get))
|
|
101
|
+
result = asyncio.run(self.run_async_client(_get()))
|
|
102
102
|
if result.get("success"):
|
|
103
103
|
self.poutput("[OK] got cookies")
|
|
104
104
|
else:
|
|
@@ -115,5 +115,5 @@ class ControlCommands(BaseBrowserCommand):
|
|
|
115
115
|
async def _clear():
|
|
116
116
|
return await self.client.clear_cookies()
|
|
117
117
|
|
|
118
|
-
result = asyncio.run(self.run_async_client(_clear))
|
|
118
|
+
result = asyncio.run(self.run_async_client(_clear()))
|
|
119
119
|
self.pretty_result(result)
|
navcli/cli/commands/explore.py
CHANGED
|
@@ -22,7 +22,7 @@ class ExploreCommands(BaseBrowserCommand):
|
|
|
22
22
|
async def _find():
|
|
23
23
|
return await self.client.find(args.text)
|
|
24
24
|
|
|
25
|
-
result = asyncio.run(self.run_async_client(_find))
|
|
25
|
+
result = asyncio.run(self.run_async_client(_find()))
|
|
26
26
|
self.pretty_result(result)
|
|
27
27
|
|
|
28
28
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -37,7 +37,7 @@ class ExploreCommands(BaseBrowserCommand):
|
|
|
37
37
|
async def _findall():
|
|
38
38
|
return await self.client.findall(args.text)
|
|
39
39
|
|
|
40
|
-
result = asyncio.run(self.run_async_client(_findall))
|
|
40
|
+
result = asyncio.run(self.run_async_client(_findall()))
|
|
41
41
|
self.pretty_result(result)
|
|
42
42
|
|
|
43
43
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -52,7 +52,7 @@ class ExploreCommands(BaseBrowserCommand):
|
|
|
52
52
|
async def _inspect():
|
|
53
53
|
return await self.client.inspect(args.selector)
|
|
54
54
|
|
|
55
|
-
result = asyncio.run(self.run_async_client(_inspect))
|
|
55
|
+
result = asyncio.run(self.run_async_client(_inspect()))
|
|
56
56
|
self.pretty_result(result)
|
|
57
57
|
|
|
58
58
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -73,7 +73,7 @@ class ExploreCommands(BaseBrowserCommand):
|
|
|
73
73
|
else:
|
|
74
74
|
return await self.client.wait()
|
|
75
75
|
|
|
76
|
-
result = asyncio.run(self.run_async_client(_wait))
|
|
76
|
+
result = asyncio.run(self.run_async_client(_wait()))
|
|
77
77
|
self.pretty_result(result)
|
|
78
78
|
|
|
79
79
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -89,7 +89,7 @@ class ExploreCommands(BaseBrowserCommand):
|
|
|
89
89
|
timeout = args.timeout if args.timeout else 3.0
|
|
90
90
|
return await self.client.wait_idle(timeout=timeout)
|
|
91
91
|
|
|
92
|
-
result = asyncio.run(self.run_async_client(_wait_idle))
|
|
92
|
+
result = asyncio.run(self.run_async_client(_wait_idle()))
|
|
93
93
|
self.pretty_result(result)
|
|
94
94
|
|
|
95
95
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -121,5 +121,5 @@ class ExploreCommands(BaseBrowserCommand):
|
|
|
121
121
|
else:
|
|
122
122
|
return await self.client.scroll(direction="down")
|
|
123
123
|
|
|
124
|
-
result = asyncio.run(self.run_async_client(_scroll))
|
|
124
|
+
result = asyncio.run(self.run_async_client(_scroll()))
|
|
125
125
|
self.pretty_result(result)
|
|
@@ -33,7 +33,7 @@ class InteractionCommands(BaseBrowserCommand):
|
|
|
33
33
|
async def _click():
|
|
34
34
|
return await self.client.click(args.selector, force=args.force)
|
|
35
35
|
|
|
36
|
-
result = asyncio.run(self.run_async_client(_click))
|
|
36
|
+
result = asyncio.run(self.run_async_client(_click()))
|
|
37
37
|
self.pretty_result(result)
|
|
38
38
|
|
|
39
39
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -48,7 +48,7 @@ class InteractionCommands(BaseBrowserCommand):
|
|
|
48
48
|
async def _type():
|
|
49
49
|
return await self.client.type(args.selector, args.text)
|
|
50
50
|
|
|
51
|
-
result = asyncio.run(self.run_async_client(_type))
|
|
51
|
+
result = asyncio.run(self.run_async_client(_type()))
|
|
52
52
|
self.pretty_result(result)
|
|
53
53
|
|
|
54
54
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -63,7 +63,7 @@ class InteractionCommands(BaseBrowserCommand):
|
|
|
63
63
|
async def _dblclick():
|
|
64
64
|
return await self.client.dblclick(args.selector)
|
|
65
65
|
|
|
66
|
-
result = asyncio.run(self.run_async_client(_dblclick))
|
|
66
|
+
result = asyncio.run(self.run_async_client(_dblclick()))
|
|
67
67
|
self.pretty_result(result)
|
|
68
68
|
|
|
69
69
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -78,7 +78,7 @@ class InteractionCommands(BaseBrowserCommand):
|
|
|
78
78
|
async def _rightclick():
|
|
79
79
|
return await self.client.rightclick(args.selector)
|
|
80
80
|
|
|
81
|
-
result = asyncio.run(self.run_async_client(_rightclick))
|
|
81
|
+
result = asyncio.run(self.run_async_client(_rightclick()))
|
|
82
82
|
self.pretty_result(result)
|
|
83
83
|
|
|
84
84
|
# Aliases
|
|
@@ -110,7 +110,7 @@ class InteractionCommands(BaseBrowserCommand):
|
|
|
110
110
|
async def _clear():
|
|
111
111
|
return await self.client.clear(args.selector)
|
|
112
112
|
|
|
113
|
-
result = asyncio.run(self.run_async_client(_clear))
|
|
113
|
+
result = asyncio.run(self.run_async_client(_clear()))
|
|
114
114
|
self.pretty_result(result)
|
|
115
115
|
|
|
116
116
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -125,5 +125,5 @@ class InteractionCommands(BaseBrowserCommand):
|
|
|
125
125
|
async def _upload():
|
|
126
126
|
return await self.client.upload(args.selector, args.file_path)
|
|
127
127
|
|
|
128
|
-
result = asyncio.run(self.run_async_client(_upload))
|
|
128
|
+
result = asyncio.run(self.run_async_client(_upload()))
|
|
129
129
|
self.pretty_result(result)
|
|
@@ -17,7 +17,7 @@ class NavigationCommands(BaseBrowserCommand):
|
|
|
17
17
|
|
|
18
18
|
@cmd2.with_argparser(_goto_parser)
|
|
19
19
|
def do_goto(self, args):
|
|
20
|
-
"""Navigate to a URL.
|
|
20
|
+
"""Navigate to a URL and show page summary.
|
|
21
21
|
|
|
22
22
|
Usage: goto <url>
|
|
23
23
|
Example: goto https://example.com
|
|
@@ -28,9 +28,17 @@ class NavigationCommands(BaseBrowserCommand):
|
|
|
28
28
|
url = "https://" + url
|
|
29
29
|
|
|
30
30
|
async def _goto():
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
# Navigate first
|
|
32
|
+
result = await self.client.goto(url)
|
|
33
|
+
# Then get paragraphs and merge into state
|
|
34
|
+
para_result = await self.client.get_paragraphs(min_length=200)
|
|
35
|
+
if para_result.get("success") and result.get("success"):
|
|
36
|
+
# Extract paragraphs from state and merge
|
|
37
|
+
para_data = para_result.get("state", {}).get("paragraphs", [])
|
|
38
|
+
result["state"]["paragraphs"] = para_data
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
result = asyncio.run(self.run_async_client(_goto()))
|
|
34
42
|
self.pretty_result(result)
|
|
35
43
|
|
|
36
44
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -44,7 +52,7 @@ class NavigationCommands(BaseBrowserCommand):
|
|
|
44
52
|
async def _back():
|
|
45
53
|
return await self.client.back()
|
|
46
54
|
|
|
47
|
-
result = asyncio.run(self.run_async_client(_back))
|
|
55
|
+
result = asyncio.run(self.run_async_client(_back()))
|
|
48
56
|
self.pretty_result(result)
|
|
49
57
|
|
|
50
58
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -58,7 +66,7 @@ class NavigationCommands(BaseBrowserCommand):
|
|
|
58
66
|
async def _forward():
|
|
59
67
|
return await self.client.forward()
|
|
60
68
|
|
|
61
|
-
result = asyncio.run(self.run_async_client(_forward))
|
|
69
|
+
result = asyncio.run(self.run_async_client(_forward()))
|
|
62
70
|
self.pretty_result(result)
|
|
63
71
|
|
|
64
72
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -72,7 +80,7 @@ class NavigationCommands(BaseBrowserCommand):
|
|
|
72
80
|
async def _reload():
|
|
73
81
|
return await self.client.reload()
|
|
74
82
|
|
|
75
|
-
result = asyncio.run(self.run_async_client(_reload))
|
|
83
|
+
result = asyncio.run(self.run_async_client(_reload()))
|
|
76
84
|
self.pretty_result(result)
|
|
77
85
|
|
|
78
86
|
# Aliases
|
|
@@ -80,11 +88,25 @@ class NavigationCommands(BaseBrowserCommand):
|
|
|
80
88
|
do_f = do_forward # forward -> f
|
|
81
89
|
do_r = do_reload # reload -> r
|
|
82
90
|
|
|
83
|
-
# goto -> g (
|
|
91
|
+
# goto -> g (shortcut with same logic as do_goto)
|
|
84
92
|
@cmd2.with_argparser(_goto_parser)
|
|
85
93
|
def do_g(self, args):
|
|
86
94
|
"""Navigate to a URL (shortcut).
|
|
87
95
|
|
|
88
96
|
Usage: g <url>
|
|
89
97
|
"""
|
|
90
|
-
|
|
98
|
+
import asyncio
|
|
99
|
+
url = args.url
|
|
100
|
+
if not url.startswith(("http://", "https://")):
|
|
101
|
+
url = "https://" + url
|
|
102
|
+
|
|
103
|
+
async def _goto():
|
|
104
|
+
result = await self.client.goto(url)
|
|
105
|
+
para_result = await self.client.get_paragraphs(min_length=200)
|
|
106
|
+
if para_result.get("success") and result.get("success"):
|
|
107
|
+
para_data = para_result.get("state", {}).get("paragraphs", [])
|
|
108
|
+
result["state"]["paragraphs"] = para_data
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
result = asyncio.run(self.run_async_client(_goto()))
|
|
112
|
+
self.pretty_result(result)
|
navcli/cli/commands/query.py
CHANGED
|
@@ -27,7 +27,7 @@ class QueryCommands(BaseBrowserCommand):
|
|
|
27
27
|
async def _elements():
|
|
28
28
|
return await self.client.get_elements()
|
|
29
29
|
|
|
30
|
-
result = asyncio.run(self.run_async_client(_elements))
|
|
30
|
+
result = asyncio.run(self.run_async_client(_elements()))
|
|
31
31
|
self.pretty_result(result)
|
|
32
32
|
if result.get("success"):
|
|
33
33
|
state = result.get("state", {})
|
|
@@ -48,7 +48,7 @@ class QueryCommands(BaseBrowserCommand):
|
|
|
48
48
|
async def _text():
|
|
49
49
|
return await self.client.get_text()
|
|
50
50
|
|
|
51
|
-
result = asyncio.run(self.run_async_client(_text))
|
|
51
|
+
result = asyncio.run(self.run_async_client(_text()))
|
|
52
52
|
self.pretty_result(result)
|
|
53
53
|
|
|
54
54
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -62,7 +62,7 @@ class QueryCommands(BaseBrowserCommand):
|
|
|
62
62
|
async def _html():
|
|
63
63
|
return await self.client.get_html()
|
|
64
64
|
|
|
65
|
-
result = asyncio.run(self.run_async_client(_html))
|
|
65
|
+
result = asyncio.run(self.run_async_client(_html()))
|
|
66
66
|
self.pretty_result(result)
|
|
67
67
|
|
|
68
68
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -79,7 +79,7 @@ class QueryCommands(BaseBrowserCommand):
|
|
|
79
79
|
async def _screenshot():
|
|
80
80
|
return await self.client.get_screenshot()
|
|
81
81
|
|
|
82
|
-
result = asyncio.run(self.run_async_client(_screenshot))
|
|
82
|
+
result = asyncio.run(self.run_async_client(_screenshot()))
|
|
83
83
|
self.pretty_result(result)
|
|
84
84
|
|
|
85
85
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -93,7 +93,7 @@ class QueryCommands(BaseBrowserCommand):
|
|
|
93
93
|
async def _state():
|
|
94
94
|
return await self.client.get_state()
|
|
95
95
|
|
|
96
|
-
result = asyncio.run(self.run_async_client(_state))
|
|
96
|
+
result = asyncio.run(self.run_async_client(_state()))
|
|
97
97
|
self.pretty_result(result)
|
|
98
98
|
|
|
99
99
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -107,7 +107,7 @@ class QueryCommands(BaseBrowserCommand):
|
|
|
107
107
|
async def _url():
|
|
108
108
|
return await self.client.get_url()
|
|
109
109
|
|
|
110
|
-
result = asyncio.run(self.run_async_client(_url))
|
|
110
|
+
result = asyncio.run(self.run_async_client(_url()))
|
|
111
111
|
self.pretty_result(result)
|
|
112
112
|
|
|
113
113
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -121,7 +121,7 @@ class QueryCommands(BaseBrowserCommand):
|
|
|
121
121
|
async def _title():
|
|
122
122
|
return await self.client.get_title()
|
|
123
123
|
|
|
124
|
-
result = asyncio.run(self.run_async_client(_title))
|
|
124
|
+
result = asyncio.run(self.run_async_client(_title()))
|
|
125
125
|
self.pretty_result(result)
|
|
126
126
|
|
|
127
127
|
@cmd2.with_argparser(_evaluate_parser)
|
|
@@ -137,7 +137,7 @@ class QueryCommands(BaseBrowserCommand):
|
|
|
137
137
|
async def _evaluate():
|
|
138
138
|
return await self.client.evaluate(args.expression)
|
|
139
139
|
|
|
140
|
-
result = asyncio.run(self.run_async_client(_evaluate))
|
|
140
|
+
result = asyncio.run(self.run_async_client(_evaluate()))
|
|
141
141
|
self.pretty_result(result)
|
|
142
142
|
|
|
143
143
|
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
@@ -151,7 +151,7 @@ class QueryCommands(BaseBrowserCommand):
|
|
|
151
151
|
async def _links():
|
|
152
152
|
return await self.client.get_links()
|
|
153
153
|
|
|
154
|
-
result = asyncio.run(self.run_async_client(_links))
|
|
154
|
+
result = asyncio.run(self.run_async_client(_links()))
|
|
155
155
|
self.pretty_result(result)
|
|
156
156
|
if result.get("success"):
|
|
157
157
|
state = result.get("state", {})
|
|
@@ -167,5 +167,49 @@ class QueryCommands(BaseBrowserCommand):
|
|
|
167
167
|
async def _forms():
|
|
168
168
|
return await self.client.get_forms()
|
|
169
169
|
|
|
170
|
-
result = asyncio.run(self.run_async_client(_forms))
|
|
170
|
+
result = asyncio.run(self.run_async_client(_forms()))
|
|
171
171
|
self.pretty_result(result)
|
|
172
|
+
|
|
173
|
+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
|
|
174
|
+
def do_tables(self, _):
|
|
175
|
+
"""Get all tables on the page with their data.
|
|
176
|
+
|
|
177
|
+
Usage: tables
|
|
178
|
+
"""
|
|
179
|
+
import asyncio
|
|
180
|
+
|
|
181
|
+
async def _tables():
|
|
182
|
+
return await self.client.get_tables()
|
|
183
|
+
|
|
184
|
+
result = asyncio.run(self.run_async_client(_tables()))
|
|
185
|
+
self.pretty_result(result)
|
|
186
|
+
if result.get("success"):
|
|
187
|
+
state = result.get("state", {})
|
|
188
|
+
tables = state.get("tables", [])
|
|
189
|
+
for t in tables:
|
|
190
|
+
self.poutput(f" Table: {t.get('id', 'N/A')} - {t.get('rowCount', 0)} rows")
|
|
191
|
+
|
|
192
|
+
_paragraphs_parser = cmd2.Cmd2ArgumentParser()
|
|
193
|
+
_paragraphs_parser.add_argument("min_length", nargs="?", type=int, default=200,
|
|
194
|
+
help="Minimum paragraph length (default: 200)")
|
|
195
|
+
|
|
196
|
+
@cmd2.with_argparser(_paragraphs_parser)
|
|
197
|
+
def do_paragraphs(self, args):
|
|
198
|
+
"""Get large text paragraphs (200+ chars) from the page.
|
|
199
|
+
|
|
200
|
+
Useful for extracting article content, policy documents, etc.
|
|
201
|
+
|
|
202
|
+
Usage: paragraphs [min_length]
|
|
203
|
+
Example: paragraphs
|
|
204
|
+
Example: paragraphs 500
|
|
205
|
+
"""
|
|
206
|
+
import asyncio
|
|
207
|
+
|
|
208
|
+
async def _paragraphs():
|
|
209
|
+
return await self.client.get_paragraphs(min_length=args.min_length)
|
|
210
|
+
|
|
211
|
+
result = asyncio.run(self.run_async_client(_paragraphs()))
|
|
212
|
+
self.pretty_result(result)
|
|
213
|
+
if result.get("success"):
|
|
214
|
+
state = result.get("state", {})
|
|
215
|
+
# Paragraphs are included in the feedback.result info
|
navcli/core/models/__init__.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from .element import Element
|
|
4
4
|
from .dom import DOMTree, DOMChange
|
|
5
|
-
from .state import StateSnapshot
|
|
5
|
+
from .state import StateSnapshot, Paragraph
|
|
6
6
|
from .feedback import CommandResult, Feedback
|
|
7
7
|
|
|
8
8
|
__all__ = [
|
|
@@ -10,6 +10,7 @@ __all__ = [
|
|
|
10
10
|
'DOMTree',
|
|
11
11
|
'DOMChange',
|
|
12
12
|
'StateSnapshot',
|
|
13
|
+
'Paragraph',
|
|
13
14
|
'CommandResult',
|
|
14
15
|
'Feedback',
|
|
15
16
|
]
|
navcli/core/models/state.py
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
"""StateSnapshot model for page state."""
|
|
2
2
|
|
|
3
|
-
from typing import Optional
|
|
3
|
+
from typing import Optional, List, Dict, Any
|
|
4
4
|
from pydantic import BaseModel, Field
|
|
5
5
|
|
|
6
6
|
from .dom import DOMTree
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
class Paragraph(BaseModel):
|
|
10
|
+
"""Represents a large text paragraph."""
|
|
11
|
+
|
|
12
|
+
index: int = 0
|
|
13
|
+
text: str = ""
|
|
14
|
+
length: int = 0
|
|
15
|
+
selector: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
9
18
|
class StateSnapshot(BaseModel):
|
|
10
19
|
"""Represents the current page state."""
|
|
11
20
|
|
|
@@ -15,5 +24,7 @@ class StateSnapshot(BaseModel):
|
|
|
15
24
|
variables: dict = Field(default_factory=dict) # page variables
|
|
16
25
|
loading: bool = False # is still loading
|
|
17
26
|
network_idle: bool = True # network is idle
|
|
18
|
-
dom_stable: bool = True
|
|
19
|
-
pending_requests: int = 0
|
|
27
|
+
dom_stable: bool = True # DOM is stable
|
|
28
|
+
pending_requests: int = 0 # pending request count
|
|
29
|
+
tables: List[Dict[str, Any]] = Field(default_factory=list) # extracted tables
|
|
30
|
+
paragraphs: List[Paragraph] = Field(default_factory=list) # extracted paragraphs
|
navcli/server/__init__.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""NavCLI Browser Server."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import os
|
|
4
5
|
import signal
|
|
5
6
|
import sys
|
|
6
7
|
from typing import Optional
|
|
@@ -13,16 +14,21 @@ from navcli.server.browser import start_browser, close_browser
|
|
|
13
14
|
|
|
14
15
|
__version__ = "0.1.0"
|
|
15
16
|
|
|
17
|
+
# Session directory
|
|
18
|
+
_NAVCLI_HOME = os.path.expanduser("~/.navcli")
|
|
19
|
+
_NAVCLI_SESSIONS_DIR = os.path.join(_NAVCLI_HOME, "sessions")
|
|
20
|
+
|
|
16
21
|
# Server instance
|
|
17
22
|
_server: Optional[uvicorn.Server] = None
|
|
18
23
|
|
|
19
24
|
|
|
20
|
-
async def start_server(host: str = "127.0.0.1", port: int = 8765) -> uvicorn.Server:
|
|
25
|
+
async def start_server(host: str = "127.0.0.1", port: int = 8765, headless: bool = True) -> uvicorn.Server:
|
|
21
26
|
"""Start the browser server.
|
|
22
27
|
|
|
23
28
|
Args:
|
|
24
29
|
host: Host to bind
|
|
25
30
|
port: Port to listen
|
|
31
|
+
headless: Run browser in headless mode (default: True)
|
|
26
32
|
|
|
27
33
|
Returns:
|
|
28
34
|
Uvicorn server instance
|
|
@@ -30,7 +36,7 @@ async def start_server(host: str = "127.0.0.1", port: int = 8765) -> uvicorn.Ser
|
|
|
30
36
|
global _server
|
|
31
37
|
|
|
32
38
|
# Start browser
|
|
33
|
-
await start_browser()
|
|
39
|
+
await start_browser(headless=headless)
|
|
34
40
|
|
|
35
41
|
# Create app
|
|
36
42
|
app = create_app()
|
|
@@ -65,17 +71,53 @@ async def shutdown():
|
|
|
65
71
|
def main():
|
|
66
72
|
"""Main entry point for nav-server command."""
|
|
67
73
|
import argparse
|
|
74
|
+
import os
|
|
68
75
|
|
|
69
76
|
parser = argparse.ArgumentParser(description="NavCLI Browser Server")
|
|
70
77
|
parser.add_argument("--host", default="127.0.0.1", help="Host to bind")
|
|
71
78
|
parser.add_argument("--port", type=int, default=8765, help="Port to listen")
|
|
79
|
+
parser.add_argument("--headless", action="store_true", default=True,
|
|
80
|
+
help="Run in headless mode (default)")
|
|
81
|
+
parser.add_argument("--no-headless", dest="headless", action="store_false",
|
|
82
|
+
help="Run with GUI (non-headless)")
|
|
72
83
|
|
|
73
84
|
args = parser.parse_args()
|
|
74
85
|
|
|
75
86
|
print(f"Starting NavCLI Browser Server on {args.host}:{args.port}...")
|
|
87
|
+
print(f"Headless: {args.headless}")
|
|
88
|
+
print("-" * 60)
|
|
89
|
+
|
|
90
|
+
if args.headless:
|
|
91
|
+
# Headless mode: check for saved session
|
|
92
|
+
session_files = []
|
|
93
|
+
if os.path.isdir(_NAVCLI_SESSIONS_DIR):
|
|
94
|
+
session_files = [f[:-5] for f in os.listdir(_NAVCLI_SESSIONS_DIR) if f.endswith('.json')]
|
|
95
|
+
|
|
96
|
+
if session_files:
|
|
97
|
+
print(f"Saved sessions in {_NAVCLI_SESSIONS_DIR}:")
|
|
98
|
+
for f in session_files:
|
|
99
|
+
print(f" - {f}")
|
|
100
|
+
print("\nTo load a session:")
|
|
101
|
+
print(' curl -X POST "http://localhost:8765/cmd/session/load?name=<name>"')
|
|
102
|
+
print(' nav load_session <name>')
|
|
103
|
+
else:
|
|
104
|
+
print("No saved sessions found.")
|
|
105
|
+
print("\nTo save a session after manual login:")
|
|
106
|
+
print(' curl -X POST "http://localhost:8765/cmd/session/save?name=<name>"')
|
|
107
|
+
print(' nav save_session <name>')
|
|
108
|
+
else:
|
|
109
|
+
# No-headless mode: remind to save session
|
|
110
|
+
print("Human-in-the-loop mode: browser window will open")
|
|
111
|
+
print("1. Manually log in to the website")
|
|
112
|
+
print("\nWARNING: Session will be LOST when browser closes!")
|
|
113
|
+
print("Before closing, save your session:")
|
|
114
|
+
print(' curl -X POST "http://localhost:8765/cmd/session/save?name=<name>"')
|
|
115
|
+
print(' nav save_session <name>')
|
|
116
|
+
|
|
117
|
+
print("-" * 60)
|
|
76
118
|
|
|
77
119
|
try:
|
|
78
|
-
asyncio.run(start_server(args.host, args.port))
|
|
120
|
+
asyncio.run(start_server(args.host, args.port, headless=args.headless))
|
|
79
121
|
except KeyboardInterrupt:
|
|
80
122
|
print("\nShutting down...")
|
|
81
123
|
sys.exit(0)
|
navcli/server/browser.py
CHANGED
|
@@ -19,20 +19,49 @@ _context: Optional[BrowserContext] = None
|
|
|
19
19
|
_page: Optional[Page] = None
|
|
20
20
|
_last_html: Optional[str] = None # 上一次 HTML 用于 diff
|
|
21
21
|
_dom_version: int = 0
|
|
22
|
-
|
|
22
|
+
# Default session directory
|
|
23
|
+
_NAVCLI_HOME = os.path.expanduser("~/.navcli")
|
|
24
|
+
_NAVCLI_SESSIONS_DIR = os.path.join(_NAVCLI_HOME, "sessions")
|
|
25
|
+
_storage_state_path: str = os.path.join(_NAVCLI_SESSIONS_DIR, "session.json") # Default session file
|
|
23
26
|
|
|
24
27
|
|
|
25
|
-
async def start_browser():
|
|
26
|
-
"""Start the browser.
|
|
28
|
+
async def start_browser(headless: bool = True):
|
|
29
|
+
"""Start the browser.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
headless: Whether to run in headless mode (default: True)
|
|
33
|
+
"""
|
|
27
34
|
global _browser, _context, _page
|
|
28
35
|
|
|
29
36
|
if _browser is not None:
|
|
30
37
|
return
|
|
31
38
|
|
|
32
39
|
playwright = await async_playwright().start()
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
|
|
41
|
+
# Support custom browser and user data via environment variables
|
|
42
|
+
executable_path = os.environ.get("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH")
|
|
43
|
+
user_data_dir = os.environ.get("NAVCLI_USER_DATA_DIR")
|
|
44
|
+
|
|
45
|
+
launch_kwargs = {"headless": headless}
|
|
46
|
+
if executable_path:
|
|
47
|
+
launch_kwargs["executable_path"] = executable_path
|
|
48
|
+
|
|
49
|
+
if user_data_dir:
|
|
50
|
+
# Use persistent context with user data directory
|
|
51
|
+
context_kwargs = {
|
|
52
|
+
"user_data_dir": user_data_dir,
|
|
53
|
+
"headless": headless,
|
|
54
|
+
}
|
|
55
|
+
if executable_path:
|
|
56
|
+
context_kwargs["executable_path"] = executable_path
|
|
57
|
+
context = await playwright.chromium.launch_persistent_context(**context_kwargs)
|
|
58
|
+
_page = context.pages[0] if context.pages else None
|
|
59
|
+
_browser = context.browser
|
|
60
|
+
_context = context
|
|
61
|
+
else:
|
|
62
|
+
_browser = await playwright.chromium.launch(**launch_kwargs)
|
|
63
|
+
_context = await _browser.new_context()
|
|
64
|
+
_page = await _context.new_page()
|
|
36
65
|
|
|
37
66
|
|
|
38
67
|
async def close_browser():
|
|
@@ -58,7 +87,7 @@ async def save_session(path: str = None) -> Dict[str, Any]:
|
|
|
58
87
|
"""Save session (cookies, localStorage, sessionStorage) to file.
|
|
59
88
|
|
|
60
89
|
Args:
|
|
61
|
-
path: Path to save session file (default: .
|
|
90
|
+
path: Path to save session file (default: ~/.navcli/sessions/session.json)
|
|
62
91
|
|
|
63
92
|
Returns:
|
|
64
93
|
Session data dict
|
|
@@ -70,6 +99,12 @@ async def save_session(path: str = None) -> Dict[str, Any]:
|
|
|
70
99
|
|
|
71
100
|
if path:
|
|
72
101
|
_storage_state_path = path
|
|
102
|
+
else:
|
|
103
|
+
# Use default path
|
|
104
|
+
_storage_state_path = os.path.join(_NAVCLI_SESSIONS_DIR, "session.json")
|
|
105
|
+
|
|
106
|
+
# Ensure directory exists
|
|
107
|
+
os.makedirs(os.path.dirname(_storage_state_path), exist_ok=True)
|
|
73
108
|
|
|
74
109
|
# Get storage state from Playwright
|
|
75
110
|
storage_state = await _context.storage_state()
|
|
@@ -202,7 +237,7 @@ async def wait_for_network_idle(timeout: float = 3.0) -> bool:
|
|
|
202
237
|
return False
|
|
203
238
|
|
|
204
239
|
|
|
205
|
-
def extract_interactive_elements(page: Page) -> list[Element]:
|
|
240
|
+
async def extract_interactive_elements(page: Page) -> list[Element]:
|
|
206
241
|
"""Extract interactive elements from the page.
|
|
207
242
|
|
|
208
243
|
Only returns "actionable + meaningful" elements:
|
|
@@ -227,19 +262,22 @@ def extract_interactive_elements(page: Page) -> list[Element]:
|
|
|
227
262
|
for selector in selectors:
|
|
228
263
|
try:
|
|
229
264
|
locators = page.locator(selector)
|
|
230
|
-
count = locators.count()
|
|
265
|
+
count = await locators.count()
|
|
231
266
|
for i in range(count):
|
|
232
267
|
locator = locators.nth(i)
|
|
233
268
|
try:
|
|
269
|
+
tag = selector.split("[")[0].lower()
|
|
270
|
+
is_clickable = selector not in ("input", "select", "textarea", "h1", "h2", "h3", "nav", "menu")
|
|
271
|
+
|
|
234
272
|
element = Element(
|
|
235
|
-
selector=_generate_selector(page, locator),
|
|
236
|
-
tag=
|
|
237
|
-
text=locator.inner_text().strip()[:200],
|
|
238
|
-
clickable=
|
|
273
|
+
selector=await _generate_selector(page, locator),
|
|
274
|
+
tag=tag,
|
|
275
|
+
text=(await locator.inner_text()).strip()[:200],
|
|
276
|
+
clickable=is_clickable,
|
|
239
277
|
input=selector.startswith("input") or selector == "textarea",
|
|
240
|
-
type=locator.get_attribute("type") if selector.startswith("input") else None,
|
|
241
|
-
href=locator.get_attribute("href") if selector.startswith("a") else None,
|
|
242
|
-
alt=locator.get_attribute("alt") if selector.startswith("img") else None,
|
|
278
|
+
type=await locator.get_attribute("type") if selector.startswith("input") else None,
|
|
279
|
+
href=await locator.get_attribute("href") if selector.startswith("a") else None,
|
|
280
|
+
alt=await locator.get_attribute("alt") if selector.startswith("img") else None,
|
|
243
281
|
)
|
|
244
282
|
elements.append(element)
|
|
245
283
|
except Exception:
|
|
@@ -250,18 +288,19 @@ def extract_interactive_elements(page: Page) -> list[Element]:
|
|
|
250
288
|
return elements
|
|
251
289
|
|
|
252
290
|
|
|
253
|
-
def _generate_selector(page: Page, locator) -> str:
|
|
291
|
+
async def _generate_selector(page: Page, locator) -> str:
|
|
254
292
|
"""Generate a unique selector for an element."""
|
|
255
293
|
try:
|
|
256
294
|
# Try to get id first
|
|
257
|
-
element_id = locator.get_attribute("id")
|
|
295
|
+
element_id = await locator.get_attribute("id")
|
|
258
296
|
if element_id:
|
|
259
|
-
tag = locator.evaluate_handle("el => el.tagName").lower()
|
|
297
|
+
tag = (await locator.evaluate_handle("el => el.tagName")).lower()
|
|
260
298
|
return f"#{element_id}"
|
|
261
299
|
|
|
262
300
|
# Try to get nth-child selector
|
|
263
|
-
tag = locator.evaluate_handle("el => el.tagName").lower()
|
|
264
|
-
|
|
301
|
+
tag = (await locator.evaluate_handle("el => el.tagName")).lower()
|
|
302
|
+
nth = await locator.evaluate_handle('el => {let i=0; let p=el; while(p){i++; p=p.previousElementSibling;} return i;}')
|
|
303
|
+
return f"{tag}:nth-child({nth})"
|
|
265
304
|
except Exception:
|
|
266
305
|
return ""
|
|
267
306
|
|
|
@@ -320,7 +359,7 @@ async def get_current_state(use_diff: bool = True) -> StateSnapshot:
|
|
|
320
359
|
title = await page.title()
|
|
321
360
|
|
|
322
361
|
# Extract elements
|
|
323
|
-
elements = extract_interactive_elements(page)
|
|
362
|
+
elements = await extract_interactive_elements(page)
|
|
324
363
|
|
|
325
364
|
# Get HTML
|
|
326
365
|
current_html = await page.content()
|
navcli/server/routes/explore.py
CHANGED
|
@@ -9,7 +9,7 @@ from navcli.core.models import CommandResult, Feedback, Element
|
|
|
9
9
|
from navcli.server.browser import get_page, get_current_state, start_browser, wait_for_network_idle
|
|
10
10
|
from navcli.utils import wait_for_condition
|
|
11
11
|
|
|
12
|
-
router = APIRouter()
|
|
12
|
+
router = APIRouter(prefix="/explore")
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class FindRequest(BaseModel):
|
navcli/server/routes/query.py
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
"""Query routes: elements, text, html, screenshot, state."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from fastapi import APIRouter, HTTPException
|
|
5
|
-
from pydantic import BaseModel
|
|
3
|
+
from fastapi import APIRouter
|
|
6
4
|
|
|
7
|
-
from navcli.core.models import CommandResult, Feedback
|
|
5
|
+
from navcli.core.models import CommandResult, Feedback, Paragraph
|
|
8
6
|
from navcli.server.browser import get_page, get_current_state, start_browser
|
|
9
7
|
from navcli.utils import encode_screenshot
|
|
10
8
|
|
|
11
|
-
router = APIRouter()
|
|
9
|
+
router = APIRouter(prefix="/query")
|
|
12
10
|
|
|
13
11
|
|
|
14
12
|
@router.get("/elements")
|
|
@@ -62,8 +60,118 @@ async def get_text() -> CommandResult:
|
|
|
62
60
|
)
|
|
63
61
|
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
@router.get("/paragraphs")
|
|
64
|
+
async def get_paragraphs(min_length: int = 200) -> CommandResult:
|
|
65
|
+
"""Get large text paragraphs (200+ chars) from the page.
|
|
66
|
+
|
|
67
|
+
Useful for extracting article content, policy documents, etc.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
min_length: Minimum paragraph length in characters (default: 200)
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
List of paragraphs with their text, length, and CSS selector
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
page = get_page()
|
|
77
|
+
|
|
78
|
+
# Extract paragraphs and large text blocks using JavaScript
|
|
79
|
+
paragraphs_data = await page.evaluate(f"""
|
|
80
|
+
() => {{
|
|
81
|
+
const MIN_LENGTH = {min_length};
|
|
82
|
+
const results = [];
|
|
83
|
+
|
|
84
|
+
// Get all paragraph-like elements
|
|
85
|
+
const selectors = [
|
|
86
|
+
'p',
|
|
87
|
+
'article',
|
|
88
|
+
'[class*="content"]',
|
|
89
|
+
'[class*="article"]',
|
|
90
|
+
'[class*="text"]',
|
|
91
|
+
'[class*="description"]',
|
|
92
|
+
'[class*="detail"]',
|
|
93
|
+
'div[class]'
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
let index = 0;
|
|
97
|
+
|
|
98
|
+
// Helper to clean text
|
|
99
|
+
const cleanText = (text) => {{
|
|
100
|
+
return text
|
|
101
|
+
.replace(/\\s+/g, ' ')
|
|
102
|
+
.replace(/[\\n\\r\\t]+/g, ' ')
|
|
103
|
+
.trim();
|
|
104
|
+
}};
|
|
105
|
+
|
|
106
|
+
// Process each selector
|
|
107
|
+
for (const selector of selectors) {{
|
|
108
|
+
const elements = document.querySelectorAll(selector);
|
|
109
|
+
for (const el of elements) {{
|
|
110
|
+
const text = cleanText(el.innerText);
|
|
111
|
+
if (text.length >= MIN_LENGTH) {{
|
|
112
|
+
// Generate a stable selector
|
|
113
|
+
let cssSelector = '';
|
|
114
|
+
if (el.id) {{
|
|
115
|
+
cssSelector = '#' + el.id;
|
|
116
|
+
}} else {{
|
|
117
|
+
const path = [];
|
|
118
|
+
let current = el;
|
|
119
|
+
while (current && current !== document.body) {{
|
|
120
|
+
let part = current.tagName.toLowerCase();
|
|
121
|
+
if (current.className && typeof current.className === 'string') {{
|
|
122
|
+
part += '.' + current.className.split(' ').filter(c => c).join('.');
|
|
123
|
+
}}
|
|
124
|
+
path.unshift(part);
|
|
125
|
+
current = current.parentElement;
|
|
126
|
+
}}
|
|
127
|
+
cssSelector = path.join(' > ');
|
|
128
|
+
}}
|
|
129
|
+
|
|
130
|
+
// Avoid duplicates
|
|
131
|
+
const exists = results.some(r => r.text === text);
|
|
132
|
+
if (!exists) {{
|
|
133
|
+
results.push({{
|
|
134
|
+
index: index++,
|
|
135
|
+
text: text.substring(0, 5000), // Limit to 5000 chars
|
|
136
|
+
length: text.length,
|
|
137
|
+
selector: cssSelector
|
|
138
|
+
}});
|
|
139
|
+
}}
|
|
140
|
+
}}
|
|
141
|
+
}}
|
|
142
|
+
}}
|
|
143
|
+
|
|
144
|
+
// Sort by length descending
|
|
145
|
+
results.sort((a, b) => b.length - a.length);
|
|
146
|
+
|
|
147
|
+
return results;
|
|
148
|
+
}}
|
|
149
|
+
""")
|
|
150
|
+
|
|
151
|
+
paragraphs = [
|
|
152
|
+
Paragraph(**p) for p in paragraphs_data[:50] # Limit to 50 paragraphs
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
await start_browser()
|
|
156
|
+
state = await get_current_state()
|
|
157
|
+
# Add paragraphs to state
|
|
158
|
+
state.paragraphs = paragraphs
|
|
159
|
+
|
|
160
|
+
return CommandResult(
|
|
161
|
+
success=True,
|
|
162
|
+
command="paragraphs",
|
|
163
|
+
state=state,
|
|
164
|
+
feedback=Feedback(
|
|
165
|
+
action="got paragraphs",
|
|
166
|
+
result=f"found {len(paragraphs)} paragraphs (min {min_length} chars)",
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
return CommandResult(
|
|
171
|
+
success=False,
|
|
172
|
+
command="paragraphs",
|
|
173
|
+
error=str(e),
|
|
174
|
+
)
|
|
67
175
|
|
|
68
176
|
|
|
69
177
|
@router.get("/html")
|
|
@@ -301,3 +409,54 @@ async def get_forms() -> CommandResult:
|
|
|
301
409
|
command="forms",
|
|
302
410
|
error=str(e),
|
|
303
411
|
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@router.get("/tables")
|
|
415
|
+
async def get_tables() -> CommandResult:
|
|
416
|
+
"""Get all tables on the page with their data."""
|
|
417
|
+
try:
|
|
418
|
+
await start_browser()
|
|
419
|
+
page = get_page()
|
|
420
|
+
|
|
421
|
+
tables = await page.evaluate("""
|
|
422
|
+
Array.from(document.querySelectorAll('table')).map(table => {
|
|
423
|
+
const rows = Array.from(table.querySelectorAll('tr')).map(tr => {
|
|
424
|
+
const cells = Array.from(tr.querySelectorAll('td, th'));
|
|
425
|
+
return cells.map(cell => cell.innerText.trim());
|
|
426
|
+
});
|
|
427
|
+
// Filter out empty rows
|
|
428
|
+
const dataRows = rows.filter(row => row.some(cell => cell.length > 0));
|
|
429
|
+
// Get table id and caption
|
|
430
|
+
const id = table.id || '';
|
|
431
|
+
const caption = table.querySelector('caption')?.innerText || '';
|
|
432
|
+
const headers = dataRows[0] || [];
|
|
433
|
+
return {
|
|
434
|
+
id,
|
|
435
|
+
caption,
|
|
436
|
+
headers,
|
|
437
|
+
rows: dataRows.slice(1),
|
|
438
|
+
rowCount: dataRows.length - 1
|
|
439
|
+
};
|
|
440
|
+
}).filter(t => t.rowCount > 0)
|
|
441
|
+
""")
|
|
442
|
+
|
|
443
|
+
state = await get_current_state()
|
|
444
|
+
total_rows = sum(t['rowCount'] for t in tables)
|
|
445
|
+
# Add tables to state
|
|
446
|
+
state.tables = tables
|
|
447
|
+
|
|
448
|
+
return CommandResult(
|
|
449
|
+
success=True,
|
|
450
|
+
command="tables",
|
|
451
|
+
state=state,
|
|
452
|
+
feedback=Feedback(
|
|
453
|
+
action="got tables",
|
|
454
|
+
result=f"found {len(tables)} tables, {total_rows} rows total",
|
|
455
|
+
),
|
|
456
|
+
)
|
|
457
|
+
except Exception as e:
|
|
458
|
+
return CommandResult(
|
|
459
|
+
success=False,
|
|
460
|
+
command="tables",
|
|
461
|
+
error=str(e),
|
|
462
|
+
)
|
navcli/server/routes/session.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Session routes: cookies, save_session, load_session."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
from typing import Optional, List, Dict, Any
|
|
4
5
|
from fastapi import APIRouter, HTTPException, Query
|
|
5
6
|
from pydantic import BaseModel
|
|
@@ -13,6 +14,10 @@ from navcli.server.browser import (
|
|
|
13
14
|
load_session,
|
|
14
15
|
)
|
|
15
16
|
|
|
17
|
+
# Session directory
|
|
18
|
+
_NAVCLI_HOME = os.path.expanduser("~/.navcli")
|
|
19
|
+
_NAVCLI_SESSIONS_DIR = os.path.join(_NAVCLI_HOME, "sessions")
|
|
20
|
+
|
|
16
21
|
router = APIRouter()
|
|
17
22
|
|
|
18
23
|
|
|
@@ -113,16 +118,28 @@ async def clear_cookies_route() -> CommandResult:
|
|
|
113
118
|
|
|
114
119
|
|
|
115
120
|
@router.post("/session/save")
|
|
116
|
-
async def save_session_route(
|
|
121
|
+
async def save_session_route(
|
|
122
|
+
path: str = Query(None),
|
|
123
|
+
name: str = Query(None),
|
|
124
|
+
) -> CommandResult:
|
|
117
125
|
"""Save session to file.
|
|
118
126
|
|
|
119
127
|
Saves cookies, localStorage, and sessionStorage to a JSON file.
|
|
120
128
|
|
|
121
129
|
Args:
|
|
122
|
-
path:
|
|
130
|
+
path: Full file path to save session
|
|
131
|
+
name: Session name (saves to ~/.navcli/sessions/<name>.json)
|
|
123
132
|
"""
|
|
124
133
|
try:
|
|
125
|
-
|
|
134
|
+
# Determine save path
|
|
135
|
+
if name:
|
|
136
|
+
save_path = os.path.join(_NAVCLI_SESSIONS_DIR, f"{name}.json")
|
|
137
|
+
elif path:
|
|
138
|
+
save_path = path
|
|
139
|
+
else:
|
|
140
|
+
save_path = os.path.join(_NAVCLI_SESSIONS_DIR, "session.json")
|
|
141
|
+
|
|
142
|
+
storage_state = await save_session(save_path)
|
|
126
143
|
|
|
127
144
|
cookie_count = len(storage_state.get("cookies", []))
|
|
128
145
|
origin_count = len(storage_state.get("origins", []))
|
|
@@ -132,7 +149,7 @@ async def save_session_route(path: str = Query(".navcli_session.json")) -> Comma
|
|
|
132
149
|
command="save_session",
|
|
133
150
|
feedback=Feedback(
|
|
134
151
|
action="saved session",
|
|
135
|
-
result=f"saved to {
|
|
152
|
+
result=f"saved to {save_path}: {cookie_count} cookies, {origin_count} origins",
|
|
136
153
|
),
|
|
137
154
|
)
|
|
138
155
|
except Exception as e:
|
|
@@ -144,30 +161,42 @@ async def save_session_route(path: str = Query(".navcli_session.json")) -> Comma
|
|
|
144
161
|
|
|
145
162
|
|
|
146
163
|
@router.post("/session/load")
|
|
147
|
-
async def load_session_route(
|
|
164
|
+
async def load_session_route(
|
|
165
|
+
path: str = Query(None),
|
|
166
|
+
name: str = Query(None),
|
|
167
|
+
) -> CommandResult:
|
|
148
168
|
"""Load session from file.
|
|
149
169
|
|
|
150
170
|
Loads cookies, localStorage, and sessionStorage from a JSON file.
|
|
151
171
|
|
|
152
172
|
Args:
|
|
153
|
-
path:
|
|
173
|
+
path: Full file path to load session from
|
|
174
|
+
name: Session name (loads from ~/.navcli/sessions/<name>.json)
|
|
154
175
|
"""
|
|
176
|
+
# Determine load path
|
|
177
|
+
if name:
|
|
178
|
+
load_path = os.path.join(_NAVCLI_SESSIONS_DIR, f"{name}.json")
|
|
179
|
+
elif path:
|
|
180
|
+
load_path = path
|
|
181
|
+
else:
|
|
182
|
+
load_path = os.path.join(_NAVCLI_SESSIONS_DIR, "session.json")
|
|
183
|
+
|
|
155
184
|
try:
|
|
156
|
-
await load_session(
|
|
185
|
+
await load_session(load_path)
|
|
157
186
|
|
|
158
187
|
return CommandResult(
|
|
159
188
|
success=True,
|
|
160
189
|
command="load_session",
|
|
161
190
|
feedback=Feedback(
|
|
162
191
|
action="loaded session",
|
|
163
|
-
result=f"loaded session from {
|
|
192
|
+
result=f"loaded session from {load_path}",
|
|
164
193
|
),
|
|
165
194
|
)
|
|
166
195
|
except FileNotFoundError:
|
|
167
196
|
return CommandResult(
|
|
168
197
|
success=False,
|
|
169
198
|
command="load_session",
|
|
170
|
-
error=f"Session file not found: {
|
|
199
|
+
error=f"Session file not found: {load_path}",
|
|
171
200
|
)
|
|
172
201
|
except Exception as e:
|
|
173
202
|
return CommandResult(
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: navcli
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: 可交互、可探索的浏览器命令行工具,专为 AI Agent 设计
|
|
5
|
+
Author: NavCLI Team
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/navcli/navcli
|
|
8
|
+
Project-URL: Repository, https://github.com/navcli/navcli
|
|
9
|
+
Project-URL: Issues, https://github.com/navcli/navcli/issues
|
|
10
|
+
Keywords: browser,cli,automation,ai-agent,playwright
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: playwright>=1.40.0
|
|
22
|
+
Requires-Dist: cmd2>=2.4.0
|
|
23
|
+
Requires-Dist: fastapi>=0.109.0
|
|
24
|
+
Requires-Dist: uvicorn<1.0.0,>=0.25.0
|
|
25
|
+
Requires-Dist: pydantic>=2.5.0
|
|
26
|
+
Requires-Dist: cssify>=1.0.0
|
|
27
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
32
|
+
Requires-Dist: mypy>=1.8.0; extra == "dev"
|
|
33
|
+
|
|
34
|
+
# NavCLI - Goals & Vision
|
|
35
|
+
|
|
36
|
+
## Core Goal
|
|
37
|
+
|
|
38
|
+
**Enable AI Agents to browse the web like humans.**
|
|
39
|
+
|
|
40
|
+
Existing solutions (HTTP APIs, headless browser scripts, Playwright MCP) have limitations:
|
|
41
|
+
- No support for JS-rendered SPAs
|
|
42
|
+
- No session persistence
|
|
43
|
+
- Lack of interactive exploration
|
|
44
|
+
|
|
45
|
+
NavCLI's positioning: **An interactive, explorable browser CLI**
|
|
46
|
+
|
|
47
|
+
## Core Value
|
|
48
|
+
|
|
49
|
+
| Feature | What NavCLI Solves |
|
|
50
|
+
|---------|-------------------|
|
|
51
|
+
| JS Rendering | Full SPA support |
|
|
52
|
+
| Session Persistence | Cookies, session maintained |
|
|
53
|
+
| Interactive CLI | Agent can explore while operating |
|
|
54
|
+
| Token Optimization | Lightweight elements + on-demand text/html |
|
|
55
|
+
|
|
56
|
+
## Typical Workflow
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
> g https://example.com # Navigate
|
|
60
|
+
> elements # Observe interactive elements
|
|
61
|
+
> c .btn-login # Click login
|
|
62
|
+
> t #email "test@example.com" # Type email
|
|
63
|
+
> t #password "123456" # Type password
|
|
64
|
+
> c button[type="submit"] # Submit
|
|
65
|
+
> text # Confirm result
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Agent can: **Navigate → Observe → Interact → Feedback → Continue**
|
|
69
|
+
|
|
70
|
+
## Vision
|
|
71
|
+
|
|
72
|
+
Become the **standard browser interaction layer** for AI Agents, enabling any Agent to control browsers via command-line interface:
|
|
73
|
+
- Form filling, login authentication
|
|
74
|
+
- Information scraping, content exploration
|
|
75
|
+
- Complex multi-step business processes
|
|
76
|
+
|
|
77
|
+
## Related Documentation
|
|
78
|
+
|
|
79
|
+
- [Skill File Download](https://github.com/wumu2013/navcli/blob/main/docs/skill.md)
|
|
80
|
+
- [Project Homepage](https://make.datavoid.fun/navcli/)
|
|
81
|
+
- [PRD Product Requirements](./NAVCLI_PRD.md)
|
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
navcli/cli/__init__.py,sha256=SE3EeeJ5Hku0o_rbXHVG3cPDxZaLkrLVcH57wFJOtXI,117
|
|
2
|
-
navcli/cli/app.py,sha256=
|
|
3
|
-
navcli/cli/client.py,sha256=
|
|
2
|
+
navcli/cli/app.py,sha256=39miSa9ae82UX76ZQYNTdxn6pw7DhJNHmI9XXbkF1hs,3340
|
|
3
|
+
navcli/cli/client.py,sha256=m0AJw2cx7iZb35JJqREoXfu1iamgDWhPnGeGjm80k4E,10173
|
|
4
4
|
navcli/cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
5
|
navcli/cli/commands/base.py,sha256=b8hsB43lUaD0b-5nErtunBGPOFPfKq_sdFEMmynjFRE,1596
|
|
6
|
-
navcli/cli/commands/control.py,sha256=
|
|
7
|
-
navcli/cli/commands/explore.py,sha256=
|
|
8
|
-
navcli/cli/commands/interaction.py,sha256=
|
|
9
|
-
navcli/cli/commands/navigation.py,sha256=
|
|
10
|
-
navcli/cli/commands/query.py,sha256=
|
|
6
|
+
navcli/cli/commands/control.py,sha256=72aQvPJfnihu15-HsAjKCkXQ1EXSXRDmqbRx_DjqyJQ,3274
|
|
7
|
+
navcli/cli/commands/explore.py,sha256=UTqVk1_X9Jd_XPv7vTcB9upIO8Rl99nU5LeppvHHzcA,3676
|
|
8
|
+
navcli/cli/commands/interaction.py,sha256=GtlCkq0irtJ_8CqGFbY7RXXmcTQonWrjlYgdQbXQlwc,3713
|
|
9
|
+
navcli/cli/commands/navigation.py,sha256=CLHDGwC1_D78jp5vLURWXUtHdYXTCh0bBHFP8sP9_Uw,3341
|
|
10
|
+
navcli/cli/commands/query.py,sha256=vhI8G3NRlNA-jfEnLFNPGxraoHyOTaPMk3akoB67TKM,6200
|
|
11
11
|
navcli/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
-
navcli/core/models/__init__.py,sha256
|
|
12
|
+
navcli/core/models/__init__.py,sha256=qcbXwqa5fQTpiYG6EmTTRRIJ28MqG5jueWqSXI-XrXs,323
|
|
13
13
|
navcli/core/models/dom.py,sha256=vZzEpzLEOlCnUJWBWx0-gmHj8WBxorsnPsZJgUW3UOE,1160
|
|
14
14
|
navcli/core/models/element.py,sha256=6L8PnY7h_-_MFweq6NU6gdG1g5_r_AHlW1bYTuUBNoM,747
|
|
15
15
|
navcli/core/models/feedback.py,sha256=K67nc7fCiAAfeYKDppNEnHtM41fSdF-Ai7IMhvAv7PY,689
|
|
16
|
-
navcli/core/models/state.py,sha256=
|
|
17
|
-
navcli/server/__init__.py,sha256=
|
|
16
|
+
navcli/core/models/state.py,sha256=6TBh6zMX8uhBcFICNLFgJhPKLRbET47fjZTjvt3n0Hw,1002
|
|
17
|
+
navcli/server/__init__.py,sha256=KUgDIwwAhzq-5UcUV8A2pcXW6AhPFy6mGFvFThTjvjs,3694
|
|
18
18
|
navcli/server/app.py,sha256=zNWYPGRGVWmoyiZX1g9i6LNqSagJirwzEi8eqsYUUqs,1320
|
|
19
|
-
navcli/server/browser.py,sha256=
|
|
19
|
+
navcli/server/browser.py,sha256=LAOQO157dYO1YVQe88hAnFISK0nKkq8MP96N9BRjr3I,12115
|
|
20
20
|
navcli/server/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
21
|
navcli/server/routes/__init__.py,sha256=LCd1lCcBCqJmFchr9OTvKJvCTQNLETAljdPaou-fd3I,180
|
|
22
22
|
navcli/server/routes/control.py,sha256=ysoszU2OjhVH4BW3oahJ5-28l3yheQ-vhdqI-I8EVEU,1071
|
|
23
|
-
navcli/server/routes/explore.py,sha256=
|
|
23
|
+
navcli/server/routes/explore.py,sha256=562B8DzcQhNCPwuZleOjn9edQ-rowMz7bBFWZYpCWpM,11879
|
|
24
24
|
navcli/server/routes/interaction.py,sha256=Daq-6Rp7FlWjyMK-HHYGKwQLHDBYWzC0GLq1q346Zxw,8562
|
|
25
25
|
navcli/server/routes/navigation.py,sha256=ZDcblrKnOTDn8aeBKwWgZYHQke6Hmrm_NNtTNSC3MQU,3440
|
|
26
|
-
navcli/server/routes/query.py,sha256=
|
|
27
|
-
navcli/server/routes/session.py,sha256=
|
|
26
|
+
navcli/server/routes/query.py,sha256=grKvUDnv7cQudwPx_OgQUgkwgABoqLvRVLQrZP7WyI4,13560
|
|
27
|
+
navcli/server/routes/session.py,sha256=vRTwxg19fRu1ajzn4yyIAqbQJsnRIPAYQEMae-pQQ6E,5480
|
|
28
28
|
navcli/utils/__init__.py,sha256=g3CIjq_xybpmanja9A7oyQMtf9emS6qdHJIJ6BJx6t0,521
|
|
29
29
|
navcli/utils/image.py,sha256=e30zNrOd3wi9UWesiQZJ5n62wIOfrGZnJFCMFKnFOD0,681
|
|
30
30
|
navcli/utils/js.py,sha256=-OenTrj4Nh4s-zjUvjJnN3XSncX8b03tNrgY_nhPRxo,334
|
|
@@ -32,8 +32,8 @@ navcli/utils/selector.py,sha256=E_8E5bjjL1gDn524l3bH3XzK3egn9IyoYEb7zXsTwTQ,2146
|
|
|
32
32
|
navcli/utils/text.py,sha256=wCFCKCn6Ibi8GjAeGXBHT5CfS8BCs7X9qVxDq4eR1tE,389
|
|
33
33
|
navcli/utils/time.py,sha256=bAjJ59rp02MkIfoCT1TzmFd_oGaGlOI-5RPiKSqT-kI,1181
|
|
34
34
|
navcli/utils/url.py,sha256=UG71w8ArYJlqjl2XslwAN-Kk9GJOPK3k5fg6CxXE5js,301
|
|
35
|
-
navcli-0.1.
|
|
36
|
-
navcli-0.1.
|
|
37
|
-
navcli-0.1.
|
|
38
|
-
navcli-0.1.
|
|
39
|
-
navcli-0.1.
|
|
35
|
+
navcli-0.2.1.dist-info/METADATA,sha256=3tXiF6iGntI7hBujj6yZ4axVQ2q0-FqBs_7PZBOx630,2837
|
|
36
|
+
navcli-0.2.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
37
|
+
navcli-0.2.1.dist-info/entry_points.txt,sha256=J26RwbEYUO5UbuhH0aXF_tvQFCOCatAR80blaSn5oWk,72
|
|
38
|
+
navcli-0.2.1.dist-info/top_level.txt,sha256=dwTN5Lw7STNP3zhL2-RDElX3EslzM2sbYYLfuszQSO4,7
|
|
39
|
+
navcli-0.2.1.dist-info/RECORD,,
|
navcli-0.1.0.dist-info/METADATA
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: navcli
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: 可交互、可探索的浏览器命令行工具,专为 AI Agent 设计
|
|
5
|
-
Author: NavCLI Team
|
|
6
|
-
License: MIT
|
|
7
|
-
Project-URL: Homepage, https://github.com/navcli/navcli
|
|
8
|
-
Project-URL: Repository, https://github.com/navcli/navcli
|
|
9
|
-
Project-URL: Issues, https://github.com/navcli/navcli/issues
|
|
10
|
-
Keywords: browser,cli,automation,ai-agent,playwright
|
|
11
|
-
Classifier: Development Status :: 3 - Alpha
|
|
12
|
-
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
-
Classifier: Programming Language :: Python :: 3
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
-
Requires-Python: >=3.9
|
|
20
|
-
Description-Content-Type: text/markdown
|
|
21
|
-
Requires-Dist: playwright>=1.40.0
|
|
22
|
-
Requires-Dist: cmd2>=2.4.0
|
|
23
|
-
Requires-Dist: fastapi>=0.109.0
|
|
24
|
-
Requires-Dist: uvicorn<1.0.0,>=0.25.0
|
|
25
|
-
Requires-Dist: pydantic>=2.5.0
|
|
26
|
-
Requires-Dist: cssify>=1.0.0
|
|
27
|
-
Requires-Dist: aiohttp>=3.9.0
|
|
28
|
-
Provides-Extra: dev
|
|
29
|
-
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
30
|
-
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
31
|
-
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
32
|
-
Requires-Dist: mypy>=1.8.0; extra == "dev"
|
|
33
|
-
|
|
34
|
-
# NavCLI - 目标与愿景
|
|
35
|
-
|
|
36
|
-
## 核心目标
|
|
37
|
-
|
|
38
|
-
**让 AI Agent 能够像人类一样浏览网页。**
|
|
39
|
-
|
|
40
|
-
现有方案(HTTP API、无头浏览器脚本、Playwright MCP)存在局限:
|
|
41
|
-
- 不支持 JS 渲染的 SPA
|
|
42
|
-
- 无会话状态持久化
|
|
43
|
-
- 缺乏交互式探索能力
|
|
44
|
-
|
|
45
|
-
NavCLI 的定位:**可交互、可探索的浏览器命令行工具**
|
|
46
|
-
|
|
47
|
-
## 核心价值
|
|
48
|
-
|
|
49
|
-
| 特性 | NavCLI 解决的问题 |
|
|
50
|
-
|------|------------------|
|
|
51
|
-
| JS 渲染支持 | 完整支持 SPA 应用 |
|
|
52
|
-
| 会话持久化 | cookies、session 保持 |
|
|
53
|
-
| 交互式 CLI | Agent 可边探索边操作 |
|
|
54
|
-
| Token 优化 | 轻量 elements + 按需 text/html |
|
|
55
|
-
|
|
56
|
-
## 典型工作流
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
> g https://example.com # 导航
|
|
60
|
-
> elements # 查看可操作元素
|
|
61
|
-
> c .btn-login # 点击登录
|
|
62
|
-
> t #email "test@example.com" # 输入邮箱
|
|
63
|
-
> t #password "123456" # 输入密码
|
|
64
|
-
> c button[type="submit"] # 提交
|
|
65
|
-
> text # 确认结果
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
Agent 可以:**导航 → 观察 → 交互 → 反馈 → 继续**
|
|
69
|
-
|
|
70
|
-
## 愿景
|
|
71
|
-
|
|
72
|
-
成为 AI Agent 的**标准浏览器交互层**,让任何 Agent 都能通过命令式界面控制浏览器,完成:
|
|
73
|
-
- 表单填写、登录认证
|
|
74
|
-
- 信息抓取、内容探索
|
|
75
|
-
- 复杂多步业务流程
|
|
76
|
-
|
|
77
|
-
## 相关文档
|
|
78
|
-
|
|
79
|
-
- [PRD 产品需求文档](./NAVCLI_PRD.md)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|