navvi 2.0.0
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.
- package/LICENSE +201 -0
- package/README.md +179 -0
- package/bin/navvi.js +150 -0
- package/container/Dockerfile +63 -0
- package/container/marionette.py +147 -0
- package/container/navvi-server.py +652 -0
- package/container/requirements.txt +2 -0
- package/container/start.sh +126 -0
- package/docs/navvi-logo.png +0 -0
- package/mcp/server.mjs +1278 -0
- package/package.json +41 -0
- package/personas/default.yaml +5 -0
- package/personas/dev.yaml +15 -0
- package/personas/fry.yaml +18 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal Firefox Marionette TCP client.
|
|
3
|
+
|
|
4
|
+
Marionette is Firefox's built-in remote protocol (enabled with --marionette).
|
|
5
|
+
It speaks a simple length-prefixed JSON protocol on port 2828.
|
|
6
|
+
|
|
7
|
+
We only need: connect, newSession, navigate, getURL, getTitle, executeJS.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import socket
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MarionetteError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Marionette:
|
|
20
|
+
def __init__(self, host="127.0.0.1", port=2828, timeout=10):
|
|
21
|
+
self.host = host
|
|
22
|
+
self.port = port
|
|
23
|
+
self.timeout = timeout
|
|
24
|
+
self.sock = None
|
|
25
|
+
self.session_id = None
|
|
26
|
+
|
|
27
|
+
def connect(self, retries=10, delay=1.0):
|
|
28
|
+
"""Connect to Firefox Marionette, retrying until ready."""
|
|
29
|
+
for attempt in range(retries):
|
|
30
|
+
try:
|
|
31
|
+
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
32
|
+
self.sock.settimeout(self.timeout)
|
|
33
|
+
self.sock.connect((self.host, self.port))
|
|
34
|
+
# Read server hello
|
|
35
|
+
self._recv()
|
|
36
|
+
return
|
|
37
|
+
except (ConnectionRefusedError, OSError):
|
|
38
|
+
if self.sock:
|
|
39
|
+
self.sock.close()
|
|
40
|
+
self.sock = None
|
|
41
|
+
if attempt < retries - 1:
|
|
42
|
+
time.sleep(delay)
|
|
43
|
+
raise MarionetteError(
|
|
44
|
+
f"Could not connect to Marionette at {self.host}:{self.port} "
|
|
45
|
+
f"after {retries} attempts"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def close(self):
|
|
49
|
+
if self.sock:
|
|
50
|
+
try:
|
|
51
|
+
self.sock.close()
|
|
52
|
+
except OSError:
|
|
53
|
+
pass
|
|
54
|
+
self.sock = None
|
|
55
|
+
|
|
56
|
+
def _send(self, msg):
|
|
57
|
+
"""Send a length-prefixed JSON message."""
|
|
58
|
+
data = json.dumps(msg)
|
|
59
|
+
packet = f"{len(data)}:{data}"
|
|
60
|
+
self.sock.sendall(packet.encode("utf-8"))
|
|
61
|
+
|
|
62
|
+
def _recv(self):
|
|
63
|
+
"""Read a length-prefixed JSON response."""
|
|
64
|
+
# Read length prefix until ':'
|
|
65
|
+
length_str = b""
|
|
66
|
+
while True:
|
|
67
|
+
ch = self.sock.recv(1)
|
|
68
|
+
if not ch:
|
|
69
|
+
raise MarionetteError("Connection closed")
|
|
70
|
+
if ch == b":":
|
|
71
|
+
break
|
|
72
|
+
length_str += ch
|
|
73
|
+
|
|
74
|
+
length = int(length_str)
|
|
75
|
+
data = b""
|
|
76
|
+
while len(data) < length:
|
|
77
|
+
chunk = self.sock.recv(length - len(data))
|
|
78
|
+
if not chunk:
|
|
79
|
+
raise MarionetteError("Connection closed during read")
|
|
80
|
+
data += chunk
|
|
81
|
+
|
|
82
|
+
return json.loads(data.decode("utf-8"))
|
|
83
|
+
|
|
84
|
+
def _command(self, name, params=None):
|
|
85
|
+
"""Send a Marionette command and return the result."""
|
|
86
|
+
msg = [0, self._next_id(), name, params or {}]
|
|
87
|
+
self._send(msg)
|
|
88
|
+
resp = self._recv()
|
|
89
|
+
# Response format: [1, id, error, result]
|
|
90
|
+
if len(resp) >= 4:
|
|
91
|
+
if resp[2]: # error present
|
|
92
|
+
err = resp[2]
|
|
93
|
+
raise MarionetteError(
|
|
94
|
+
f"{err.get('error', 'unknown')}: {err.get('message', '')}"
|
|
95
|
+
)
|
|
96
|
+
return resp[3]
|
|
97
|
+
return resp
|
|
98
|
+
|
|
99
|
+
_id_counter = 0
|
|
100
|
+
|
|
101
|
+
def _next_id(self):
|
|
102
|
+
Marionette._id_counter += 1
|
|
103
|
+
return Marionette._id_counter
|
|
104
|
+
|
|
105
|
+
def new_session(self):
|
|
106
|
+
"""Create a new Marionette session."""
|
|
107
|
+
result = self._command("WebDriver:NewSession", {
|
|
108
|
+
"capabilities": {
|
|
109
|
+
"alwaysMatch": {
|
|
110
|
+
"acceptInsecureCerts": True
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
self.session_id = result.get("sessionId") if isinstance(result, dict) else None
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
def navigate(self, url):
|
|
118
|
+
"""Navigate to a URL."""
|
|
119
|
+
return self._command("WebDriver:Navigate", {"url": url})
|
|
120
|
+
|
|
121
|
+
def get_url(self):
|
|
122
|
+
"""Get current page URL."""
|
|
123
|
+
result = self._command("WebDriver:GetCurrentURL")
|
|
124
|
+
return result.get("value", "") if isinstance(result, dict) else str(result)
|
|
125
|
+
|
|
126
|
+
def get_title(self):
|
|
127
|
+
"""Get current page title."""
|
|
128
|
+
result = self._command("WebDriver:GetTitle")
|
|
129
|
+
return result.get("value", "") if isinstance(result, dict) else str(result)
|
|
130
|
+
|
|
131
|
+
def execute_script(self, script, args=None):
|
|
132
|
+
"""Execute JavaScript in the page context."""
|
|
133
|
+
result = self._command("WebDriver:ExecuteScript", {
|
|
134
|
+
"script": script,
|
|
135
|
+
"args": args or [],
|
|
136
|
+
})
|
|
137
|
+
return result.get("value") if isinstance(result, dict) else result
|
|
138
|
+
|
|
139
|
+
def execute_async_script(self, script, args=None, timeout_ms=30000):
|
|
140
|
+
"""Execute async JavaScript (with callback)."""
|
|
141
|
+
# Set script timeout first
|
|
142
|
+
self._command("WebDriver:SetTimeouts", {"script": timeout_ms})
|
|
143
|
+
result = self._command("WebDriver:ExecuteAsyncScript", {
|
|
144
|
+
"script": script,
|
|
145
|
+
"args": args or [],
|
|
146
|
+
})
|
|
147
|
+
return result.get("value") if isinstance(result, dict) else result
|