navcli 0.1.0__py3-none-any.whl → 0.2.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/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 = ".navcli_session.json"):
249
+ async def save_session(self, name: str = None, path: str = None):
238
250
  """Save session to file.
239
251
 
240
252
  Args:
241
- path: File path to save session
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
- async def load_session(self, path: str = ".navcli_session.json"):
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
- path: File path to load session from
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
- encoded_path = urllib.parse.quote(path)
255
- return await self._request("POST", f"/cmd/session/load?path={encoded_path}")
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():
@@ -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("path", nargs="?", default=".navcli_session.json",
54
- help="Path to save session file")
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 [path]
61
- Example: save_session my_session.json
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.path)
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("path", nargs="?", default=".navcli_session.json",
73
- help="Path to load session file from")
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 [path]
80
- Example: load_session my_session.json
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.path)
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)
@@ -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
- return await self.client.goto(url)
32
-
33
- result = asyncio.run(self.run_async_client(_goto))
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 (requires separate parser)
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
- return self.do_goto(args)
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)
@@ -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
@@ -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
  ]
@@ -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 # DOM is stable
19
- pending_requests: int = 0 # pending request count
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
- _storage_state_path: str = ".navcli_session.json" # 默认会话文件路径 # DOM 版本号
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
- _browser = await playwright.chromium.launch(headless=True)
34
- _context = await _browser.new_context()
35
- _page = await _context.new_page()
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: .navcli_session.json)
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=selector.split("[")[0].lower(),
237
- text=locator.inner_text().strip()[:200],
238
- clickable=selector not in ("input", "select", "textarea", "h1", "h2", "h3", "nav", "menu"),
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
- return f"{tag}:nth-child({locator.evaluate_handle('el => {let i=0; let p=el; while(p){i++; p=p.previousElementSibling;} return i;}')})"
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()
@@ -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):
@@ -1,14 +1,12 @@
1
1
  """Query routes: elements, text, html, screenshot, state."""
2
2
 
3
- from typing import Optional, List
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
- class TextRequest(BaseModel):
66
- selectors: Optional[List[str]] = None
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
+ )
@@ -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(path: str = Query(".navcli_session.json")) -> CommandResult:
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: File path to save session (default: .navcli_session.json)
130
+ path: Full file path to save session
131
+ name: Session name (saves to ~/.navcli/sessions/<name>.json)
123
132
  """
124
133
  try:
125
- storage_state = await save_session(path)
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 {path}: {cookie_count} cookies, {origin_count} origins",
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(path: str = Query(".navcli_session.json")) -> CommandResult:
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: File path to load session from (default: .navcli_session.json)
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(path)
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 {path}",
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: {path}",
199
+ error=f"Session file not found: {load_path}",
171
200
  )
172
201
  except Exception as e:
173
202
  return CommandResult(
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: navcli
3
+ Version: 0.2.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 - 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
+ - [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=wYEVsUjp4BE_MJDu2v75av1bT5x7EvF4_ssdpH2Q2VQ,3072
3
- navcli/cli/client.py,sha256=qYdMNMLnXlsMq0bkKJgAiXHpxnqTErehjAF0GRRAG7E,9138
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=JbAUNXgxZFmEO96TbhtS2CHw2jH2HZUk3EOIU_RwUBY,3253
7
- navcli/cli/commands/explore.py,sha256=p1p4i2Z-GISmFIikdpQ0mEnpPArSKVA3OIo1Xu42Njg,3664
8
- navcli/cli/commands/interaction.py,sha256=39CSeWW-Sb_z1w3i2kkmvJ515DL7WLdJPGCwUpri4aQ,3701
9
- navcli/cli/commands/navigation.py,sha256=ksIyoaCFzkF076C4K8qbl4OI-15UWOjhd47Hm7dGAkI,2265
10
- navcli/cli/commands/query.py,sha256=zWp-tiCr6xGeIlrsaK1ufoBOTk5L5n8CzGtkIkcnRAs,4630
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=-LEFzktT5xBaOMa_UXlNR_c0O9gVTfv5Bp-3IZb43Q4,295
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=iyn6GvGpONGRP6WwiE0PYird4owJleK03Gk-jWExfGg,657
17
- navcli/server/__init__.py,sha256=l7As1ZOyGRO_BX5agyhirw8XNm8Jc6qDez0ugAwXFoA,1795
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=XEqTUFgw7fLQDIjdahYn1dDDu-IlpaggEiUbH5divjk,10604
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=7leV1LgTLurqDnLIOhDJhA5XEEGBxHkUOV8SgHJxYxc,11862
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=Lgp9lA7lUgXsxvI_1G-fymkIkiCwKpTFOW7SB_NWVJk,7699
27
- navcli/server/routes/session.py,sha256=gzwQKm8CkJTXcm6-5IYfzm44JF50l7Vhk-0SaLAt754,4701
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.0.dist-info/METADATA,sha256=WyNy3vFZJjHwSR5RYa6rQZYbMR5LhYbepmg4rkztBvA,2607
36
- navcli-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
37
- navcli-0.1.0.dist-info/entry_points.txt,sha256=J26RwbEYUO5UbuhH0aXF_tvQFCOCatAR80blaSn5oWk,72
38
- navcli-0.1.0.dist-info/top_level.txt,sha256=dwTN5Lw7STNP3zhL2-RDElX3EslzM2sbYYLfuszQSO4,7
39
- navcli-0.1.0.dist-info/RECORD,,
35
+ navcli-0.2.0.dist-info/METADATA,sha256=F_QRiKsOC_u0wEPncBydWEXfyZ4wnsBmL4lYG6lNsSY,2697
36
+ navcli-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
37
+ navcli-0.2.0.dist-info/entry_points.txt,sha256=J26RwbEYUO5UbuhH0aXF_tvQFCOCatAR80blaSn5oWk,72
38
+ navcli-0.2.0.dist-info/top_level.txt,sha256=dwTN5Lw7STNP3zhL2-RDElX3EslzM2sbYYLfuszQSO4,7
39
+ navcli-0.2.0.dist-info/RECORD,,
@@ -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