brow-cli 0.1.0__tar.gz
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.
- brow_cli-0.1.0/.gitignore +27 -0
- brow_cli-0.1.0/PKG-INFO +14 -0
- brow_cli-0.1.0/pyproject.toml +33 -0
- brow_cli-0.1.0/src/brow/__init__.py +1 -0
- brow_cli-0.1.0/src/brow/__main__.py +6 -0
- brow_cli-0.1.0/src/brow/cli.py +304 -0
- brow_cli-0.1.0/src/brow/client.py +27 -0
- brow_cli-0.1.0/src/brow/config.py +20 -0
- brow_cli-0.1.0/src/brow/daemon.py +82 -0
- brow_cli-0.1.0/src/brow/profiles.py +38 -0
- brow_cli-0.1.0/src/brow/routes/__init__.py +0 -0
- brow_cli-0.1.0/src/brow/routes/browser.py +243 -0
- brow_cli-0.1.0/src/brow/routes/eval.py +55 -0
- brow_cli-0.1.0/src/brow/routes/pages.py +50 -0
- brow_cli-0.1.0/src/brow/routes/profiles.py +55 -0
- brow_cli-0.1.0/src/brow/routes/sessions.py +37 -0
- brow_cli-0.1.0/src/brow/session.py +75 -0
- brow_cli-0.1.0/src/brow/snapshot.py +30 -0
- brow_cli-0.1.0/tests/conftest.py +19 -0
- brow_cli-0.1.0/tests/test_cli.py +55 -0
- brow_cli-0.1.0/tests/test_client.py +37 -0
- brow_cli-0.1.0/tests/test_config.py +5 -0
- brow_cli-0.1.0/tests/test_integration.py +53 -0
- brow_cli-0.1.0/tests/test_profiles.py +42 -0
- brow_cli-0.1.0/tests/test_routes_browser.py +125 -0
- brow_cli-0.1.0/tests/test_routes_eval.py +35 -0
- brow_cli-0.1.0/tests/test_routes_pages.py +65 -0
- brow_cli-0.1.0/tests/test_routes_profiles.py +35 -0
- brow_cli-0.1.0/tests/test_routes_sessions.py +43 -0
- brow_cli-0.1.0/tests/test_session.py +49 -0
- brow_cli-0.1.0/tests/test_snapshot.py +40 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.so
|
|
5
|
+
.Python
|
|
6
|
+
build/
|
|
7
|
+
develop-eggs/
|
|
8
|
+
dist/
|
|
9
|
+
downloads/
|
|
10
|
+
eggs/
|
|
11
|
+
.eggs/
|
|
12
|
+
lib/
|
|
13
|
+
lib64/
|
|
14
|
+
parts/
|
|
15
|
+
sdist/
|
|
16
|
+
var/
|
|
17
|
+
wheels/
|
|
18
|
+
*.egg-info/
|
|
19
|
+
.installed.cfg
|
|
20
|
+
*.egg
|
|
21
|
+
.venv/
|
|
22
|
+
venv/
|
|
23
|
+
ENV/
|
|
24
|
+
env/
|
|
25
|
+
.pytest_cache/
|
|
26
|
+
.coverage
|
|
27
|
+
htmlcov/
|
brow_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brow-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Standalone Playwright CLI for agent browser automation
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: fastapi>=0.104
|
|
7
|
+
Requires-Dist: httpx>=0.25
|
|
8
|
+
Requires-Dist: playwright>=1.40
|
|
9
|
+
Requires-Dist: typer>=0.9
|
|
10
|
+
Requires-Dist: uvicorn>=0.24
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: httpx>=0.25; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "brow-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Standalone Playwright CLI for agent browser automation"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"playwright>=1.40",
|
|
12
|
+
"fastapi>=0.104",
|
|
13
|
+
"uvicorn>=0.24",
|
|
14
|
+
"typer>=0.9",
|
|
15
|
+
"httpx>=0.25",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=7.0",
|
|
21
|
+
"pytest-asyncio>=0.23",
|
|
22
|
+
"httpx>=0.25",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
brow = "brow.cli:app"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/brow"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
asyncio_mode = "auto"
|
|
33
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from brow.client import BrowClient
|
|
10
|
+
from brow.config import DAEMON_HOST, DAEMON_PORT
|
|
11
|
+
from brow.daemon import daemon_running, stop_daemon
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(name="brow", no_args_is_help=True)
|
|
14
|
+
daemon_app = typer.Typer(name="daemon", no_args_is_help=True)
|
|
15
|
+
session_app = typer.Typer(name="session", no_args_is_help=True)
|
|
16
|
+
profile_app = typer.Typer(name="profile", no_args_is_help=True)
|
|
17
|
+
state_app = typer.Typer(name="state", no_args_is_help=True)
|
|
18
|
+
page_app = typer.Typer(name="page", no_args_is_help=True)
|
|
19
|
+
|
|
20
|
+
app.add_typer(daemon_app, name="daemon")
|
|
21
|
+
app.add_typer(session_app, name="session")
|
|
22
|
+
app.add_typer(profile_app, name="profile")
|
|
23
|
+
app.add_typer(state_app, name="state")
|
|
24
|
+
app.add_typer(page_app, name="page")
|
|
25
|
+
|
|
26
|
+
session_opt = typer.Option(None, "-s", "--session")
|
|
27
|
+
|
|
28
|
+
def run_async(coro):
|
|
29
|
+
return asyncio.run(coro)
|
|
30
|
+
|
|
31
|
+
def _client():
|
|
32
|
+
return BrowClient()
|
|
33
|
+
|
|
34
|
+
def _daemon_healthy():
|
|
35
|
+
import httpx
|
|
36
|
+
try:
|
|
37
|
+
r = httpx.get(f"http://{DAEMON_HOST}:{DAEMON_PORT}/status", timeout=1.0)
|
|
38
|
+
return r.status_code == 200
|
|
39
|
+
except Exception:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
def ensure_daemon():
|
|
43
|
+
if _daemon_healthy():
|
|
44
|
+
return
|
|
45
|
+
subprocess.Popen(
|
|
46
|
+
[sys.executable, "-m", "brow.daemon"],
|
|
47
|
+
stdout=subprocess.DEVNULL,
|
|
48
|
+
stderr=subprocess.DEVNULL,
|
|
49
|
+
start_new_session=True,
|
|
50
|
+
)
|
|
51
|
+
for _ in range(50):
|
|
52
|
+
time.sleep(0.3)
|
|
53
|
+
if _daemon_healthy():
|
|
54
|
+
return
|
|
55
|
+
typer.echo("Failed to start daemon", err=True)
|
|
56
|
+
raise typer.Exit(1)
|
|
57
|
+
|
|
58
|
+
@daemon_app.command("start")
|
|
59
|
+
def daemon_start(port: int = DAEMON_PORT):
|
|
60
|
+
if daemon_running():
|
|
61
|
+
typer.echo("Daemon already running")
|
|
62
|
+
return
|
|
63
|
+
subprocess.Popen(
|
|
64
|
+
[sys.executable, "-m", "brow.daemon", "--port", str(port)],
|
|
65
|
+
stdout=subprocess.DEVNULL,
|
|
66
|
+
stderr=subprocess.DEVNULL,
|
|
67
|
+
start_new_session=True,
|
|
68
|
+
)
|
|
69
|
+
typer.echo(f"Daemon starting on {DAEMON_HOST}:{port}")
|
|
70
|
+
|
|
71
|
+
@daemon_app.command("stop")
|
|
72
|
+
def daemon_stop_cmd():
|
|
73
|
+
if stop_daemon():
|
|
74
|
+
typer.echo("Daemon stopped")
|
|
75
|
+
else:
|
|
76
|
+
typer.echo("Daemon not running")
|
|
77
|
+
|
|
78
|
+
@daemon_app.command("status")
|
|
79
|
+
def daemon_status():
|
|
80
|
+
if not daemon_running():
|
|
81
|
+
typer.echo("Daemon not running")
|
|
82
|
+
return
|
|
83
|
+
c = _client()
|
|
84
|
+
result = run_async(c.get("/status"))
|
|
85
|
+
typer.echo(f"Running — {result['sessions']} active sessions")
|
|
86
|
+
|
|
87
|
+
@session_app.command("new")
|
|
88
|
+
def session_new(profile: str = "default", headed: bool = False):
|
|
89
|
+
ensure_daemon()
|
|
90
|
+
c = _client()
|
|
91
|
+
result = run_async(c.post("/sessions", json={"profile": profile, "headless": not headed}))
|
|
92
|
+
typer.echo(result["id"])
|
|
93
|
+
|
|
94
|
+
@session_app.command("list")
|
|
95
|
+
def session_list():
|
|
96
|
+
ensure_daemon()
|
|
97
|
+
c = _client()
|
|
98
|
+
sessions = run_async(c.get("/sessions"))
|
|
99
|
+
if not sessions:
|
|
100
|
+
typer.echo("No active sessions")
|
|
101
|
+
return
|
|
102
|
+
for s in sessions:
|
|
103
|
+
typer.echo(f"{s['id']}\t{s['profile']}\t{'headed' if not s['headless'] else 'headless'}\t{s['pages']} pages")
|
|
104
|
+
|
|
105
|
+
@session_app.command("delete")
|
|
106
|
+
def session_delete(sid: str):
|
|
107
|
+
ensure_daemon()
|
|
108
|
+
c = _client()
|
|
109
|
+
run_async(c.delete(f"/sessions/{sid}"))
|
|
110
|
+
typer.echo(f"Deleted session {sid}")
|
|
111
|
+
|
|
112
|
+
@app.command("navigate")
|
|
113
|
+
def navigate(url: str, s: Optional[str] = session_opt, timeout: int = 30000):
|
|
114
|
+
ensure_daemon()
|
|
115
|
+
c = _client()
|
|
116
|
+
result = run_async(c.post(f"/browser/{s}/navigate", json={"url": url, "timeout": timeout}))
|
|
117
|
+
typer.echo(f"{result['url']} [{result.get('status', '')}]")
|
|
118
|
+
|
|
119
|
+
@app.command("wait")
|
|
120
|
+
def wait_cmd(selector: Optional[str] = None, load: bool = False, s: Optional[str] = session_opt, timeout: int = 30000):
|
|
121
|
+
ensure_daemon()
|
|
122
|
+
c = _client()
|
|
123
|
+
run_async(c.post(f"/browser/{s}/wait", json={"selector": selector, "load": load, "timeout": timeout}))
|
|
124
|
+
typer.echo("Done")
|
|
125
|
+
|
|
126
|
+
@app.command("snapshot")
|
|
127
|
+
def snapshot_cmd(search: Optional[str] = None, locator: Optional[str] = None, s: Optional[str] = session_opt):
|
|
128
|
+
ensure_daemon()
|
|
129
|
+
c = _client()
|
|
130
|
+
params = {}
|
|
131
|
+
if search:
|
|
132
|
+
params["search"] = search
|
|
133
|
+
if locator:
|
|
134
|
+
params["locator"] = locator
|
|
135
|
+
result = run_async(c.get(f"/browser/{s}/snapshot", params=params))
|
|
136
|
+
typer.echo(result["tree"])
|
|
137
|
+
|
|
138
|
+
@app.command("screenshot")
|
|
139
|
+
def screenshot_cmd(full: bool = False, path: Optional[str] = None, s: Optional[str] = session_opt):
|
|
140
|
+
ensure_daemon()
|
|
141
|
+
c = _client()
|
|
142
|
+
result = run_async(c.post(f"/browser/{s}/screenshot", json={"full": full, "path": path}))
|
|
143
|
+
typer.echo(result["path"])
|
|
144
|
+
|
|
145
|
+
@app.command("html")
|
|
146
|
+
def html_cmd(locator: Optional[str] = None, search: Optional[str] = None, s: Optional[str] = session_opt):
|
|
147
|
+
ensure_daemon()
|
|
148
|
+
c = _client()
|
|
149
|
+
params = {}
|
|
150
|
+
if locator:
|
|
151
|
+
params["locator"] = locator
|
|
152
|
+
if search:
|
|
153
|
+
params["search"] = search
|
|
154
|
+
result = run_async(c.get(f"/browser/{s}/html", params=params))
|
|
155
|
+
typer.echo(result["html"])
|
|
156
|
+
|
|
157
|
+
@app.command("url")
|
|
158
|
+
def url_cmd(s: Optional[str] = session_opt):
|
|
159
|
+
ensure_daemon()
|
|
160
|
+
c = _client()
|
|
161
|
+
result = run_async(c.get(f"/browser/{s}/url"))
|
|
162
|
+
typer.echo(result["url"])
|
|
163
|
+
|
|
164
|
+
@app.command("logs")
|
|
165
|
+
def logs_cmd(search: Optional[str] = None, count: int = 50, s: Optional[str] = session_opt):
|
|
166
|
+
ensure_daemon()
|
|
167
|
+
c = _client()
|
|
168
|
+
params = {"count": count}
|
|
169
|
+
if search:
|
|
170
|
+
params["search"] = search
|
|
171
|
+
result = run_async(c.get(f"/browser/{s}/logs", params=params))
|
|
172
|
+
typer.echo(result["logs"])
|
|
173
|
+
|
|
174
|
+
@app.command("click")
|
|
175
|
+
def click_cmd(selector: str, s: Optional[str] = session_opt, timeout: int = 30000):
|
|
176
|
+
ensure_daemon()
|
|
177
|
+
c = _client()
|
|
178
|
+
run_async(c.post(f"/browser/{s}/click", json={"selector": selector, "timeout": timeout}))
|
|
179
|
+
|
|
180
|
+
@app.command("fill")
|
|
181
|
+
def fill_cmd(selector: str, value: str, s: Optional[str] = session_opt, timeout: int = 30000):
|
|
182
|
+
ensure_daemon()
|
|
183
|
+
c = _client()
|
|
184
|
+
run_async(c.post(f"/browser/{s}/fill", json={"selector": selector, "value": value, "timeout": timeout}))
|
|
185
|
+
|
|
186
|
+
@app.command("type")
|
|
187
|
+
def type_cmd(text: str, s: Optional[str] = session_opt):
|
|
188
|
+
ensure_daemon()
|
|
189
|
+
c = _client()
|
|
190
|
+
run_async(c.post(f"/browser/{s}/type", json={"text": text}))
|
|
191
|
+
|
|
192
|
+
@app.command("key")
|
|
193
|
+
def key_cmd(key: str, s: Optional[str] = session_opt):
|
|
194
|
+
ensure_daemon()
|
|
195
|
+
c = _client()
|
|
196
|
+
run_async(c.post(f"/browser/{s}/key", json={"key": key}))
|
|
197
|
+
|
|
198
|
+
@app.command("hover")
|
|
199
|
+
def hover_cmd(selector: str, s: Optional[str] = session_opt, timeout: int = 30000):
|
|
200
|
+
ensure_daemon()
|
|
201
|
+
c = _client()
|
|
202
|
+
run_async(c.post(f"/browser/{s}/hover", json={"selector": selector, "timeout": timeout}))
|
|
203
|
+
|
|
204
|
+
@app.command("scroll")
|
|
205
|
+
def scroll_cmd(pixels: int = 0, s: Optional[str] = session_opt):
|
|
206
|
+
ensure_daemon()
|
|
207
|
+
c = _client()
|
|
208
|
+
run_async(c.post(f"/browser/{s}/scroll", json={"pixels": pixels}))
|
|
209
|
+
|
|
210
|
+
@app.command("scroll-to")
|
|
211
|
+
def scroll_to_cmd(selector: str, s: Optional[str] = session_opt):
|
|
212
|
+
ensure_daemon()
|
|
213
|
+
c = _client()
|
|
214
|
+
run_async(c.post(f"/browser/{s}/scroll", json={"selector": selector}))
|
|
215
|
+
|
|
216
|
+
@app.command("drag")
|
|
217
|
+
def drag_cmd(source: str, target: str, s: Optional[str] = session_opt):
|
|
218
|
+
ensure_daemon()
|
|
219
|
+
c = _client()
|
|
220
|
+
run_async(c.post(f"/browser/{s}/drag", json={"source": source, "target": target}))
|
|
221
|
+
|
|
222
|
+
@app.command("upload")
|
|
223
|
+
def upload_cmd(selector: str, filepath: str, s: Optional[str] = session_opt):
|
|
224
|
+
ensure_daemon()
|
|
225
|
+
c = _client()
|
|
226
|
+
run_async(c.post(f"/browser/{s}/upload", json={"selector": selector, "filepath": filepath}))
|
|
227
|
+
|
|
228
|
+
@page_app.command("list")
|
|
229
|
+
def page_list(s: Optional[str] = session_opt):
|
|
230
|
+
ensure_daemon()
|
|
231
|
+
c = _client()
|
|
232
|
+
result = run_async(c.get(f"/pages/{s}"))
|
|
233
|
+
for p in result["pages"]:
|
|
234
|
+
typer.echo(f"{p['index']}\t{p['url']}")
|
|
235
|
+
|
|
236
|
+
@page_app.command("new")
|
|
237
|
+
def page_new(url: Optional[str] = None, s: Optional[str] = session_opt):
|
|
238
|
+
ensure_daemon()
|
|
239
|
+
c = _client()
|
|
240
|
+
result = run_async(c.post(f"/pages/{s}/new", json={"url": url}))
|
|
241
|
+
typer.echo(f"Page {result['index']}: {result['url']}")
|
|
242
|
+
|
|
243
|
+
@page_app.command("close")
|
|
244
|
+
def page_close(index: Optional[int] = None, s: Optional[str] = session_opt):
|
|
245
|
+
ensure_daemon()
|
|
246
|
+
c = _client()
|
|
247
|
+
run_async(c.post(f"/pages/{s}/close", params={"index": index} if index is not None else {}))
|
|
248
|
+
|
|
249
|
+
@page_app.command("switch")
|
|
250
|
+
def page_switch(index: int, s: Optional[str] = session_opt):
|
|
251
|
+
ensure_daemon()
|
|
252
|
+
c = _client()
|
|
253
|
+
result = run_async(c.post(f"/pages/{s}/switch", json={"index": index}))
|
|
254
|
+
typer.echo(f"Switched to page {result['active']}: {result['url']}")
|
|
255
|
+
|
|
256
|
+
@profile_app.command("list")
|
|
257
|
+
def profile_list():
|
|
258
|
+
ensure_daemon()
|
|
259
|
+
c = _client()
|
|
260
|
+
result = run_async(c.get("/profiles"))
|
|
261
|
+
for p in result["profiles"]:
|
|
262
|
+
typer.echo(p)
|
|
263
|
+
|
|
264
|
+
@profile_app.command("delete")
|
|
265
|
+
def profile_delete(name: str):
|
|
266
|
+
ensure_daemon()
|
|
267
|
+
c = _client()
|
|
268
|
+
run_async(c.delete(f"/profiles/{name}"))
|
|
269
|
+
typer.echo(f"Deleted profile {name}")
|
|
270
|
+
|
|
271
|
+
@state_app.command("save")
|
|
272
|
+
def state_save(name: str, s: Optional[str] = session_opt):
|
|
273
|
+
ensure_daemon()
|
|
274
|
+
c = _client()
|
|
275
|
+
run_async(c.post("/states/save", json={"name": name, "session_id": s}))
|
|
276
|
+
typer.echo(f"State saved: {name}")
|
|
277
|
+
|
|
278
|
+
@state_app.command("restore")
|
|
279
|
+
def state_restore(name: str, s: Optional[str] = session_opt):
|
|
280
|
+
ensure_daemon()
|
|
281
|
+
c = _client()
|
|
282
|
+
run_async(c.post("/states/restore", json={"name": name, "session_id": s}))
|
|
283
|
+
typer.echo(f"State restored: {name}")
|
|
284
|
+
|
|
285
|
+
@state_app.command("list")
|
|
286
|
+
def state_list():
|
|
287
|
+
ensure_daemon()
|
|
288
|
+
c = _client()
|
|
289
|
+
result = run_async(c.get("/states"))
|
|
290
|
+
for st in result["states"]:
|
|
291
|
+
typer.echo(st)
|
|
292
|
+
|
|
293
|
+
@app.command("eval")
|
|
294
|
+
def eval_cmd(code: str, s: Optional[str] = session_opt, timeout: int = 30000):
|
|
295
|
+
ensure_daemon()
|
|
296
|
+
c = _client()
|
|
297
|
+
result = run_async(c.post(f"/eval/{s}", json={"code": code, "timeout": timeout}))
|
|
298
|
+
if result.get("stdout"):
|
|
299
|
+
typer.echo(result["stdout"], nl=False)
|
|
300
|
+
if result.get("result") is not None:
|
|
301
|
+
typer.echo(result["result"])
|
|
302
|
+
|
|
303
|
+
if __name__ == "__main__":
|
|
304
|
+
app()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from brow.config import DAEMON_URL
|
|
3
|
+
|
|
4
|
+
class BrowClient:
|
|
5
|
+
def __init__(self, base_url=None):
|
|
6
|
+
self._client = httpx.AsyncClient(
|
|
7
|
+
base_url=base_url or DAEMON_URL,
|
|
8
|
+
timeout=60.0,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
async def get(self, path, **kwargs):
|
|
12
|
+
r = await self._client.get(path, **kwargs)
|
|
13
|
+
r.raise_for_status()
|
|
14
|
+
return r.json()
|
|
15
|
+
|
|
16
|
+
async def post(self, path, **kwargs):
|
|
17
|
+
r = await self._client.post(path, **kwargs)
|
|
18
|
+
r.raise_for_status()
|
|
19
|
+
return r.json()
|
|
20
|
+
|
|
21
|
+
async def delete(self, path, **kwargs):
|
|
22
|
+
r = await self._client.delete(path, **kwargs)
|
|
23
|
+
r.raise_for_status()
|
|
24
|
+
return r.json()
|
|
25
|
+
|
|
26
|
+
async def close(self):
|
|
27
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
BROW_HOME = Path(os.environ.get("BROW_HOME", Path.home() / ".brow"))
|
|
5
|
+
PROFILES_DIR = BROW_HOME / "profiles"
|
|
6
|
+
STATES_DIR = BROW_HOME / "states"
|
|
7
|
+
SCREENSHOTS_DIR = BROW_HOME / "screenshots"
|
|
8
|
+
PID_FILE = BROW_HOME / "daemon.pid"
|
|
9
|
+
LOG_FILE = BROW_HOME / "daemon.log"
|
|
10
|
+
|
|
11
|
+
DAEMON_HOST = "127.0.0.1"
|
|
12
|
+
DAEMON_PORT = int(os.environ.get("BROW_PORT", "19987"))
|
|
13
|
+
DAEMON_URL = f"http://{DAEMON_HOST}:{DAEMON_PORT}"
|
|
14
|
+
|
|
15
|
+
MAX_SESSIONS = int(os.environ.get("BROW_MAX_SESSIONS", "10"))
|
|
16
|
+
DEFAULT_TIMEOUT = 30000
|
|
17
|
+
|
|
18
|
+
def ensure_dirs():
|
|
19
|
+
for d in [BROW_HOME, PROFILES_DIR, STATES_DIR, SCREENSHOTS_DIR]:
|
|
20
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
|
|
5
|
+
import uvicorn
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from playwright.async_api import async_playwright
|
|
8
|
+
|
|
9
|
+
from brow.config import DAEMON_HOST, DAEMON_PORT, PID_FILE, ensure_dirs
|
|
10
|
+
from brow.session import SessionManager
|
|
11
|
+
from brow.profiles import ProfileManager
|
|
12
|
+
|
|
13
|
+
def create_app():
|
|
14
|
+
manager = SessionManager()
|
|
15
|
+
profiles = ProfileManager()
|
|
16
|
+
|
|
17
|
+
@asynccontextmanager
|
|
18
|
+
async def lifespan(app):
|
|
19
|
+
pw = await async_playwright().start()
|
|
20
|
+
app.state.pw = pw
|
|
21
|
+
app.state.manager = manager
|
|
22
|
+
app.state.profiles = profiles
|
|
23
|
+
yield
|
|
24
|
+
await manager.close_all()
|
|
25
|
+
await pw.stop()
|
|
26
|
+
|
|
27
|
+
app = FastAPI(lifespan=lifespan)
|
|
28
|
+
|
|
29
|
+
from brow.routes.sessions import router as sessions_router
|
|
30
|
+
from brow.routes.browser import router as browser_router
|
|
31
|
+
from brow.routes.pages import router as pages_router
|
|
32
|
+
from brow.routes.profiles import router as profiles_router
|
|
33
|
+
from brow.routes.eval import router as eval_router
|
|
34
|
+
|
|
35
|
+
app.include_router(sessions_router)
|
|
36
|
+
app.include_router(browser_router)
|
|
37
|
+
app.include_router(pages_router)
|
|
38
|
+
app.include_router(profiles_router)
|
|
39
|
+
app.include_router(eval_router)
|
|
40
|
+
|
|
41
|
+
@app.get("/status")
|
|
42
|
+
async def status():
|
|
43
|
+
return {"sessions": len(manager.sessions), "status": "running"}
|
|
44
|
+
|
|
45
|
+
return app
|
|
46
|
+
|
|
47
|
+
def run_daemon(host=None, port=None):
|
|
48
|
+
ensure_dirs()
|
|
49
|
+
host = host or DAEMON_HOST
|
|
50
|
+
port = port or DAEMON_PORT
|
|
51
|
+
PID_FILE.write_text(str(os.getpid()))
|
|
52
|
+
try:
|
|
53
|
+
uvicorn.run(create_app(), host=host, port=port, log_level="warning")
|
|
54
|
+
finally:
|
|
55
|
+
PID_FILE.unlink(missing_ok=True)
|
|
56
|
+
|
|
57
|
+
def stop_daemon():
|
|
58
|
+
if not PID_FILE.exists():
|
|
59
|
+
return False
|
|
60
|
+
pid = int(PID_FILE.read_text().strip())
|
|
61
|
+
try:
|
|
62
|
+
os.kill(pid, signal.SIGTERM)
|
|
63
|
+
except ProcessLookupError:
|
|
64
|
+
pass
|
|
65
|
+
PID_FILE.unlink(missing_ok=True)
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
def daemon_running():
|
|
69
|
+
if not PID_FILE.exists():
|
|
70
|
+
return False
|
|
71
|
+
pid = int(PID_FILE.read_text().strip())
|
|
72
|
+
try:
|
|
73
|
+
os.kill(pid, 0)
|
|
74
|
+
return True
|
|
75
|
+
except ProcessLookupError:
|
|
76
|
+
PID_FILE.unlink(missing_ok=True)
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
import sys
|
|
81
|
+
port = int(sys.argv[sys.argv.index("--port") + 1]) if "--port" in sys.argv else None
|
|
82
|
+
run_daemon(port=port)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import brow.config as cfg
|
|
4
|
+
|
|
5
|
+
class ProfileManager:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
cfg.ensure_dirs()
|
|
8
|
+
|
|
9
|
+
def get_profile_dir(self, name):
|
|
10
|
+
p = cfg.PROFILES_DIR / name
|
|
11
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
return p
|
|
13
|
+
|
|
14
|
+
def list(self):
|
|
15
|
+
if not cfg.PROFILES_DIR.exists():
|
|
16
|
+
return []
|
|
17
|
+
return [d.name for d in cfg.PROFILES_DIR.iterdir() if d.is_dir()]
|
|
18
|
+
|
|
19
|
+
def delete(self, name):
|
|
20
|
+
p = cfg.PROFILES_DIR / name
|
|
21
|
+
if not p.exists():
|
|
22
|
+
raise KeyError(f"Profile '{name}' not found")
|
|
23
|
+
shutil.rmtree(p)
|
|
24
|
+
|
|
25
|
+
def save_state(self, name, state):
|
|
26
|
+
cfg.ensure_dirs()
|
|
27
|
+
(cfg.STATES_DIR / f"{name}.json").write_text(json.dumps(state, indent=2))
|
|
28
|
+
|
|
29
|
+
def load_state(self, name):
|
|
30
|
+
path = cfg.STATES_DIR / f"{name}.json"
|
|
31
|
+
if not path.exists():
|
|
32
|
+
raise FileNotFoundError(f"State '{name}' not found")
|
|
33
|
+
return json.loads(path.read_text())
|
|
34
|
+
|
|
35
|
+
def list_states(self):
|
|
36
|
+
if not cfg.STATES_DIR.exists():
|
|
37
|
+
return []
|
|
38
|
+
return [p.stem for p in cfg.STATES_DIR.glob("*.json")]
|
|
File without changes
|