persistent-browser-bridge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pbb/__init__.py +5 -0
- pbb/browser/__init__.py +1 -0
- pbb/browser/browser_detection.py +117 -0
- pbb/browser/locator.py +103 -0
- pbb/browser/locks.py +77 -0
- pbb/browser/manager.py +218 -0
- pbb/browser/profile.py +56 -0
- pbb/browser/snapshot.py +139 -0
- pbb/cli.py +305 -0
- pbb/config.py +66 -0
- pbb/constants.py +7 -0
- pbb/daemon/__init__.py +1 -0
- pbb/daemon/app.py +137 -0
- pbb/daemon/client.py +39 -0
- pbb/daemon/server.py +36 -0
- pbb/utils/__init__.py +1 -0
- pbb/utils/paths.py +40 -0
- persistent_browser_bridge-0.1.0.dist-info/METADATA +210 -0
- persistent_browser_bridge-0.1.0.dist-info/RECORD +22 -0
- persistent_browser_bridge-0.1.0.dist-info/WHEEL +4 -0
- persistent_browser_bridge-0.1.0.dist-info/entry_points.txt +2 -0
- persistent_browser_bridge-0.1.0.dist-info/licenses/LICENSE +22 -0
pbb/browser/snapshot.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Compact, safe DOM snapshots for agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from playwright.async_api import Page
|
|
10
|
+
|
|
11
|
+
SNAPSHOT_SCRIPT = r"""
|
|
12
|
+
() => {
|
|
13
|
+
const visible = (el) => {
|
|
14
|
+
const s = getComputedStyle(el), r = el.getBoundingClientRect();
|
|
15
|
+
return s.visibility !== 'hidden' && s.display !== 'none' && s.opacity !== '0' && r.width > 0 && r.height > 0;
|
|
16
|
+
};
|
|
17
|
+
const exposed = (el) => {
|
|
18
|
+
if (!visible(el)) return false;
|
|
19
|
+
const r = el.getBoundingClientRect();
|
|
20
|
+
if (r.bottom <= 0 || r.right <= 0 || r.top >= innerHeight || r.left >= innerWidth) return false;
|
|
21
|
+
const x = Math.max(0, Math.min(innerWidth - 1, r.left + r.width / 2));
|
|
22
|
+
const y = Math.max(0, Math.min(innerHeight - 1, r.top + r.height / 2));
|
|
23
|
+
const top = document.elementFromPoint(x, y);
|
|
24
|
+
return !!top && (el.contains(top) || top.contains(el));
|
|
25
|
+
};
|
|
26
|
+
const clean = (v) => (v || '').replace(/\s+/g, ' ').trim();
|
|
27
|
+
const selector = (el) => {
|
|
28
|
+
if (el.id && CSS.escape) return '#' + CSS.escape(el.id);
|
|
29
|
+
const parts = [];
|
|
30
|
+
let node = el;
|
|
31
|
+
while (node && node.nodeType === Node.ELEMENT_NODE && node !== document.body) {
|
|
32
|
+
let part = node.tagName.toLowerCase();
|
|
33
|
+
const parent = node.parentElement;
|
|
34
|
+
if (parent) {
|
|
35
|
+
const siblings = [...parent.children].filter(x => x.tagName === node.tagName);
|
|
36
|
+
if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
|
|
37
|
+
}
|
|
38
|
+
parts.unshift(part); node = parent;
|
|
39
|
+
if (parts.length >= 6) break;
|
|
40
|
+
}
|
|
41
|
+
return parts.join(' > ');
|
|
42
|
+
};
|
|
43
|
+
const roleOf = (el) => {
|
|
44
|
+
if (el.getAttribute('role')) return el.getAttribute('role');
|
|
45
|
+
const tag = el.tagName.toLowerCase(), type = (el.getAttribute('type') || '').toLowerCase();
|
|
46
|
+
if (tag === 'a' && el.hasAttribute('href')) return 'link';
|
|
47
|
+
if (tag === 'button' || type === 'button' || type === 'submit' || type === 'reset') return 'button';
|
|
48
|
+
if (tag === 'textarea' || (tag === 'input' && !['button','submit','reset','checkbox','radio','file','hidden'].includes(type))) return 'textbox';
|
|
49
|
+
if (type === 'checkbox') return 'checkbox';
|
|
50
|
+
if (type === 'radio') return 'radio';
|
|
51
|
+
if (tag === 'select') return 'combobox';
|
|
52
|
+
return tag;
|
|
53
|
+
};
|
|
54
|
+
const nameOf = (el) => {
|
|
55
|
+
const aria = el.getAttribute('aria-label'); if (aria) return clean(aria);
|
|
56
|
+
const labelled = el.getAttribute('aria-labelledby');
|
|
57
|
+
if (labelled) return clean(labelled.split(/\s+/).map(id => document.getElementById(id)?.innerText || '').join(' '));
|
|
58
|
+
if (el.labels?.length) return clean([...el.labels].map(x => x.innerText).join(' '));
|
|
59
|
+
return clean(el.innerText || el.getAttribute('alt') || el.getAttribute('title') || el.getAttribute('name') || el.getAttribute('placeholder'));
|
|
60
|
+
};
|
|
61
|
+
const candidates = [...document.querySelectorAll('a[href],button,input:not([type=hidden]),textarea,select,[role],[contenteditable=true]')]
|
|
62
|
+
.filter(visible).slice(0, 500);
|
|
63
|
+
const elements = candidates.map((el, index) => {
|
|
64
|
+
const type = (el.getAttribute('type') || '').toLowerCase();
|
|
65
|
+
const item = {id:index + 1, role:roleOf(el), name:nameOf(el), selector:selector(el)};
|
|
66
|
+
for (const [key, value] of [['placeholder',el.getAttribute('placeholder')],['href',el.getAttribute('href')],['type',type]]) {
|
|
67
|
+
if (value) item[key] = clean(value).slice(0, 500);
|
|
68
|
+
}
|
|
69
|
+
if (type !== 'password' && ['checkbox','radio'].includes(type)) item.checked = !!el.checked;
|
|
70
|
+
if (el.disabled) item.disabled = true;
|
|
71
|
+
return item;
|
|
72
|
+
});
|
|
73
|
+
const headings = [...document.querySelectorAll('h1,h2,h3')].filter(visible).slice(0, 30)
|
|
74
|
+
.map(el => ({level:Number(el.tagName[1]), text:clean(el.innerText).slice(0, 500)})).filter(x => x.text);
|
|
75
|
+
const root = document.querySelector('main') || document.body;
|
|
76
|
+
const blocks = [...root.querySelectorAll('p,li,dt,dd,blockquote,pre,article,[role=article],[role=status],[role=alert]')]
|
|
77
|
+
.filter(exposed).map(el => clean(el.innerText)).filter(Boolean);
|
|
78
|
+
const text = clean(blocks.length ? blocks.join(' ') : root.innerText).slice(0, 20000);
|
|
79
|
+
return {elements, headings, text};
|
|
80
|
+
}
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class SnapshotStore:
|
|
86
|
+
snapshot_id: str = ""
|
|
87
|
+
page_url: str = ""
|
|
88
|
+
elements: dict[int, dict[str, Any]] | None = None
|
|
89
|
+
|
|
90
|
+
def update(self, url: str, elements: list[dict[str, Any]]) -> None:
|
|
91
|
+
digest = hashlib.sha256((url + repr(elements)).encode()).hexdigest()[:10]
|
|
92
|
+
self.snapshot_id = f"s_{digest}"
|
|
93
|
+
self.page_url = url
|
|
94
|
+
self.elements = {int(item["id"]): item for item in elements}
|
|
95
|
+
|
|
96
|
+
def get(self, element_id: int) -> dict[str, Any] | None:
|
|
97
|
+
return (self.elements or {}).get(element_id)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def capture_snapshot(page: Page, store: SnapshotStore, max_chars: int) -> dict[str, Any]:
|
|
101
|
+
raw = await page.evaluate(SNAPSHOT_SCRIPT)
|
|
102
|
+
elements = raw["elements"]
|
|
103
|
+
store.update(page.url, elements)
|
|
104
|
+
public_keys = {"id", "role", "name", "placeholder", "href", "type", "checked", "disabled"}
|
|
105
|
+
public_elements = [{k: v for k, v in item.items() if k in public_keys} for item in elements]
|
|
106
|
+
return {
|
|
107
|
+
"success": True,
|
|
108
|
+
"snapshot_id": store.snapshot_id,
|
|
109
|
+
"url": page.url,
|
|
110
|
+
"title": await page.title(),
|
|
111
|
+
"elements": public_elements,
|
|
112
|
+
"headings": raw["headings"],
|
|
113
|
+
"text": raw["text"][:max_chars],
|
|
114
|
+
"truncated": len(raw["text"]) > max_chars,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def format_snapshot(data: dict[str, Any]) -> str:
|
|
119
|
+
lines = [
|
|
120
|
+
"PAGE",
|
|
121
|
+
f"Title: {data['title']}",
|
|
122
|
+
f"URL: {data['url']}",
|
|
123
|
+
f"Snapshot: {data['snapshot_id']}",
|
|
124
|
+
"",
|
|
125
|
+
"INTERACTIVE ELEMENTS",
|
|
126
|
+
]
|
|
127
|
+
for item in data["elements"]:
|
|
128
|
+
lines.append(f"[{item['id']}] {item['role']}")
|
|
129
|
+
for key in ("name", "placeholder", "href", "type", "checked", "disabled"):
|
|
130
|
+
if key in item and item[key] not in {"", None}:
|
|
131
|
+
lines.append(f"{key}: {item[key]}")
|
|
132
|
+
lines.append("")
|
|
133
|
+
if data["headings"]:
|
|
134
|
+
lines.append("HEADINGS")
|
|
135
|
+
lines.extend(f"[H{x['level']}] {x['text']}" for x in data["headings"])
|
|
136
|
+
lines.append("")
|
|
137
|
+
if data["text"]:
|
|
138
|
+
lines.extend(("TEXT", data["text"]))
|
|
139
|
+
return "\n".join(lines).rstrip()
|
pbb/cli.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Agent-friendly command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from pbb.browser.browser_detection import detect_browsers
|
|
19
|
+
from pbb.browser.locks import ProfileLock, ProfileLockedError
|
|
20
|
+
from pbb.browser.profile import create_profile, delete_profile, list_profiles, profile_path
|
|
21
|
+
from pbb.browser.snapshot import format_snapshot
|
|
22
|
+
from pbb.config import config_path, load_settings
|
|
23
|
+
from pbb.daemon.client import DaemonClient, DaemonUnavailable
|
|
24
|
+
from pbb.utils.paths import logs_dir, profiles_dir
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(no_args_is_help=True, help="Persistent, DOM-first browser control for AI agents.")
|
|
27
|
+
profile_app = typer.Typer(help="Manage dedicated PBB browser profiles.")
|
|
28
|
+
app.add_typer(profile_app, name="profile")
|
|
29
|
+
console = Console(stderr=False)
|
|
30
|
+
error_console = Console(stderr=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def emit(data: dict[str, Any], as_json: bool, title: str | None = None) -> None:
|
|
34
|
+
if as_json:
|
|
35
|
+
# Keep agent output valid even when a Windows console uses a legacy code page.
|
|
36
|
+
sys.stdout.write(json.dumps(data, ensure_ascii=True, default=str) + "\n")
|
|
37
|
+
sys.stdout.flush()
|
|
38
|
+
if not data.get("success", False):
|
|
39
|
+
raise typer.Exit(1)
|
|
40
|
+
return
|
|
41
|
+
if not data.get("success", False):
|
|
42
|
+
error_console.print(f"[red]{data.get('error', 'error')}:[/red] {data.get('message', '')}")
|
|
43
|
+
if data.get("details"):
|
|
44
|
+
error_console.print_json(json.dumps(data["details"], default=str))
|
|
45
|
+
raise typer.Exit(1)
|
|
46
|
+
if title:
|
|
47
|
+
console.print(f"[bold]{title}[/bold]")
|
|
48
|
+
for key, value in data.items():
|
|
49
|
+
if key not in {"success", "details"} and value is not None:
|
|
50
|
+
console.print(f"{key.replace('_', ' ').title()}: {value}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def call(path: str, payload: dict[str, Any] | None, as_json: bool, title: str) -> dict[str, Any]:
|
|
54
|
+
try:
|
|
55
|
+
data = DaemonClient(load_settings()).post(path, payload)
|
|
56
|
+
except DaemonUnavailable as exc:
|
|
57
|
+
data = {"success": False, "error": "daemon_not_running", "message": str(exc), "details": {}}
|
|
58
|
+
emit(data, as_json, title)
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _port_available(host: str, port: int) -> bool:
|
|
63
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
64
|
+
try:
|
|
65
|
+
sock.bind((host, port))
|
|
66
|
+
return True
|
|
67
|
+
except OSError:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command()
|
|
72
|
+
def start(
|
|
73
|
+
profile: str = typer.Option(None, help="Dedicated PBB profile name."),
|
|
74
|
+
browser: str = typer.Option(None, help="edge, chrome, chromium, or auto."),
|
|
75
|
+
json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Start the local daemon and persistent browser."""
|
|
78
|
+
settings = load_settings()
|
|
79
|
+
selected_profile = profile or settings.default_profile
|
|
80
|
+
client = DaemonClient(settings)
|
|
81
|
+
try:
|
|
82
|
+
current = client.status()
|
|
83
|
+
current["message"] = "PBB already running"
|
|
84
|
+
emit(current, json_output, "PBB already running")
|
|
85
|
+
return
|
|
86
|
+
except DaemonUnavailable:
|
|
87
|
+
pass
|
|
88
|
+
if not _port_available(settings.daemon_host, settings.daemon_port):
|
|
89
|
+
emit(
|
|
90
|
+
{
|
|
91
|
+
"success": False,
|
|
92
|
+
"error": "port_in_use",
|
|
93
|
+
"message": f"Port {settings.daemon_port} is already in use",
|
|
94
|
+
"details": {},
|
|
95
|
+
},
|
|
96
|
+
json_output,
|
|
97
|
+
)
|
|
98
|
+
create_profile(selected_profile)
|
|
99
|
+
log_path = logs_dir() / "daemon.log"
|
|
100
|
+
log_handle = log_path.open("a", encoding="utf-8")
|
|
101
|
+
command = [sys.executable, "-m", "pbb.daemon.server", "--profile", selected_profile]
|
|
102
|
+
if browser:
|
|
103
|
+
command.extend(("--browser", browser))
|
|
104
|
+
creationflags = 0
|
|
105
|
+
if os.name == "nt":
|
|
106
|
+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
|
107
|
+
process = subprocess.Popen(
|
|
108
|
+
command,
|
|
109
|
+
stdin=subprocess.DEVNULL,
|
|
110
|
+
stdout=log_handle,
|
|
111
|
+
stderr=log_handle,
|
|
112
|
+
close_fds=True,
|
|
113
|
+
creationflags=creationflags,
|
|
114
|
+
)
|
|
115
|
+
log_handle.close()
|
|
116
|
+
deadline = time.monotonic() + 30
|
|
117
|
+
last_error = "daemon startup timed out"
|
|
118
|
+
while time.monotonic() < deadline:
|
|
119
|
+
time.sleep(0.25)
|
|
120
|
+
try:
|
|
121
|
+
data = client.status()
|
|
122
|
+
emit(data, json_output, "PBB started")
|
|
123
|
+
return
|
|
124
|
+
except DaemonUnavailable as exc:
|
|
125
|
+
last_error = str(exc)
|
|
126
|
+
if process.poll() is not None:
|
|
127
|
+
last_error = f"daemon exited with code {process.returncode}"
|
|
128
|
+
break
|
|
129
|
+
emit(
|
|
130
|
+
{
|
|
131
|
+
"success": False,
|
|
132
|
+
"error": "daemon_start_failed",
|
|
133
|
+
"message": last_error,
|
|
134
|
+
"details": {"log": str(log_path)},
|
|
135
|
+
},
|
|
136
|
+
json_output,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def status(json_output: bool = typer.Option(False, "--json")) -> None:
|
|
142
|
+
"""Show daemon, browser, profile, and current page status."""
|
|
143
|
+
try:
|
|
144
|
+
data = DaemonClient(load_settings()).status()
|
|
145
|
+
except DaemonUnavailable as exc:
|
|
146
|
+
data = {"success": False, "error": "daemon_not_running", "message": str(exc), "details": {}}
|
|
147
|
+
emit(data, json_output, "PBB Status")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command("open")
|
|
151
|
+
def open_url(url: str, json_output: bool = typer.Option(False, "--json")) -> None:
|
|
152
|
+
"""Navigate the active page to URL."""
|
|
153
|
+
call("/navigate", {"url": url}, json_output, "Page opened")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command()
|
|
157
|
+
def snapshot(json_output: bool = typer.Option(False, "--json")) -> None:
|
|
158
|
+
"""Capture a compact, safe DOM snapshot."""
|
|
159
|
+
try:
|
|
160
|
+
data = DaemonClient(load_settings()).post("/snapshot")
|
|
161
|
+
except DaemonUnavailable as exc:
|
|
162
|
+
data = {"success": False, "error": "daemon_not_running", "message": str(exc), "details": {}}
|
|
163
|
+
if data.get("success") and not json_output:
|
|
164
|
+
console.print(format_snapshot(data))
|
|
165
|
+
return
|
|
166
|
+
emit(data, json_output)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def click(target: str, json_output: bool = typer.Option(False, "--json")) -> None:
|
|
171
|
+
"""Click a snapshot reference, selector, or semantic target."""
|
|
172
|
+
call("/click", {"target": target}, json_output, "Click complete")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command()
|
|
176
|
+
def fill(target: str, value: str, json_output: bool = typer.Option(False, "--json")) -> None:
|
|
177
|
+
"""Fill an input without echoing its value in output."""
|
|
178
|
+
call("/fill", {"target": target, "value": value}, json_output, "Fill complete")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command()
|
|
182
|
+
def text(target: str, json_output: bool = typer.Option(False, "--json")) -> None:
|
|
183
|
+
"""Read visible text from a target."""
|
|
184
|
+
call("/text", {"target": target}, json_output, "Text")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command()
|
|
188
|
+
def download(
|
|
189
|
+
target: str,
|
|
190
|
+
output: Path = typer.Option(None, help="Destination directory or file."),
|
|
191
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Click a target and save the resulting Playwright download."""
|
|
194
|
+
call(
|
|
195
|
+
"/download",
|
|
196
|
+
{"target": target, "output": str(output) if output else None},
|
|
197
|
+
json_output,
|
|
198
|
+
"Download complete",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command()
|
|
203
|
+
def screenshot(
|
|
204
|
+
output: Path = typer.Argument(Path("pbb-screenshot.png")),
|
|
205
|
+
full_page: bool = typer.Option(False, "--full-page"),
|
|
206
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Save a screenshot as a secondary inspection aid."""
|
|
209
|
+
call(
|
|
210
|
+
"/screenshot",
|
|
211
|
+
{"output": str(output), "full_page": full_page},
|
|
212
|
+
json_output,
|
|
213
|
+
"Screenshot saved",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command()
|
|
218
|
+
def close(json_output: bool = typer.Option(False, "--json")) -> None:
|
|
219
|
+
"""Close the browser and stop the daemon."""
|
|
220
|
+
call("/shutdown", {}, json_output, "PBB closed")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command()
|
|
224
|
+
def shutdown(json_output: bool = typer.Option(False, "--json")) -> None:
|
|
225
|
+
"""Alias for close."""
|
|
226
|
+
close(json_output)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@app.command()
|
|
230
|
+
def recover(json_output: bool = typer.Option(False, "--json")) -> None:
|
|
231
|
+
"""Remove stale PBB-owned profile locks only."""
|
|
232
|
+
cleared: list[str] = []
|
|
233
|
+
for name in list_profiles():
|
|
234
|
+
lock = ProfileLock(profile_path(name), name)
|
|
235
|
+
if lock.clear_stale():
|
|
236
|
+
cleared.append(name)
|
|
237
|
+
emit({"success": True, "cleared_profiles": cleared}, json_output, "Recovery complete")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.command()
|
|
241
|
+
def doctor(json_output: bool = typer.Option(False, "--json")) -> None:
|
|
242
|
+
"""Check Python, browsers, directories, and daemon port."""
|
|
243
|
+
settings = load_settings()
|
|
244
|
+
browsers = [item.as_dict() for item in detect_browsers()]
|
|
245
|
+
result = {
|
|
246
|
+
"success": True,
|
|
247
|
+
"python": sys.version.split()[0],
|
|
248
|
+
"playwright": "installed",
|
|
249
|
+
"browsers": browsers,
|
|
250
|
+
"profiles_directory": str(profiles_dir()),
|
|
251
|
+
"config": str(config_path()),
|
|
252
|
+
"daemon_port": settings.daemon_port,
|
|
253
|
+
"port_available": _port_available(settings.daemon_host, settings.daemon_port),
|
|
254
|
+
}
|
|
255
|
+
emit(result, json_output, "Persistent Browser Bridge Doctor")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@profile_app.command("create")
|
|
259
|
+
def profile_create(name: str, json_output: bool = typer.Option(False, "--json")) -> None:
|
|
260
|
+
path = create_profile(name)
|
|
261
|
+
emit({"success": True, "profile": name, "path": str(path)}, json_output, "Profile ready")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@profile_app.command("list")
|
|
265
|
+
def profile_list(json_output: bool = typer.Option(False, "--json")) -> None:
|
|
266
|
+
names = list_profiles()
|
|
267
|
+
if json_output:
|
|
268
|
+
emit({"success": True, "profiles": names}, True)
|
|
269
|
+
return
|
|
270
|
+
table = Table(title="PBB Profiles")
|
|
271
|
+
table.add_column("Name")
|
|
272
|
+
table.add_column("Path")
|
|
273
|
+
for name in names:
|
|
274
|
+
table.add_row(name, str(profile_path(name)))
|
|
275
|
+
console.print(table)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@profile_app.command("path")
|
|
279
|
+
def profile_show_path(name: str) -> None:
|
|
280
|
+
console.print(str(profile_path(name)))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@profile_app.command("delete")
|
|
284
|
+
def profile_delete(
|
|
285
|
+
name: str,
|
|
286
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Confirm deletion."),
|
|
287
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
288
|
+
) -> None:
|
|
289
|
+
if not yes and not typer.confirm(f"Delete PBB profile '{name}' and its browser data?"):
|
|
290
|
+
raise typer.Abort()
|
|
291
|
+
try:
|
|
292
|
+
lock = ProfileLock(profile_path(name), name)
|
|
293
|
+
if lock.path.exists() and not lock.is_stale():
|
|
294
|
+
raise ProfileLockedError(f"Profile '{name}' is currently in use")
|
|
295
|
+
delete_profile(name)
|
|
296
|
+
emit({"success": True, "deleted": name}, json_output, "Profile deleted")
|
|
297
|
+
except (FileNotFoundError, ProfileLockedError) as exc:
|
|
298
|
+
emit(
|
|
299
|
+
{"success": False, "error": "profile_locked", "message": str(exc), "details": {}},
|
|
300
|
+
json_output,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
app()
|
pbb/config.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Configuration loading with TOML and environment overrides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator
|
|
11
|
+
|
|
12
|
+
from pbb.constants import DEFAULT_HOST, DEFAULT_PORT
|
|
13
|
+
from pbb.utils.paths import config_dir, default_download_dir, ensure_app_dirs
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Settings(BaseModel):
|
|
17
|
+
browser: str = "auto"
|
|
18
|
+
default_profile: str = "default"
|
|
19
|
+
daemon_host: str = DEFAULT_HOST
|
|
20
|
+
daemon_port: int = Field(DEFAULT_PORT, ge=1024, le=65535)
|
|
21
|
+
download_dir: Path = Field(default_factory=default_download_dir)
|
|
22
|
+
timeout: int = Field(15_000, ge=1000, le=300_000)
|
|
23
|
+
headless: bool = False
|
|
24
|
+
snapshot_max_chars: int = Field(5000, ge=500, le=100_000)
|
|
25
|
+
|
|
26
|
+
@field_validator("daemon_host")
|
|
27
|
+
@classmethod
|
|
28
|
+
def localhost_only(cls, value: str) -> str:
|
|
29
|
+
if value not in {"127.0.0.1", "localhost", "::1"}:
|
|
30
|
+
raise ValueError("v0.1 only permits a loopback daemon host")
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
@field_validator("browser")
|
|
34
|
+
@classmethod
|
|
35
|
+
def known_browser(cls, value: str) -> str:
|
|
36
|
+
normalized = value.lower()
|
|
37
|
+
if normalized not in {"edge", "chrome", "chromium", "auto"}:
|
|
38
|
+
raise ValueError("browser must be edge, chrome, chromium, or auto")
|
|
39
|
+
return normalized
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
ENV_MAP = {
|
|
43
|
+
"PBB_BROWSER": ("browser", str),
|
|
44
|
+
"PBB_PROFILE": ("default_profile", str),
|
|
45
|
+
"PBB_PORT": ("daemon_port", int),
|
|
46
|
+
"PBB_DOWNLOAD_DIR": ("download_dir", Path),
|
|
47
|
+
"PBB_TIMEOUT": ("timeout", int),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def config_path() -> Path:
|
|
52
|
+
return config_dir() / "config.toml"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_settings() -> Settings:
|
|
56
|
+
ensure_app_dirs()
|
|
57
|
+
values: dict[str, Any] = {}
|
|
58
|
+
path = config_path()
|
|
59
|
+
if path.exists():
|
|
60
|
+
with path.open("rb") as handle:
|
|
61
|
+
values.update(tomllib.load(handle))
|
|
62
|
+
for env_name, (field_name, converter) in ENV_MAP.items():
|
|
63
|
+
raw = os.getenv(env_name)
|
|
64
|
+
if raw is not None:
|
|
65
|
+
values[field_name] = converter(raw)
|
|
66
|
+
return Settings.model_validate(values)
|
pbb/constants.py
ADDED
pbb/daemon/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Local-only browser daemon."""
|
pbb/daemon/app.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""FastAPI application served only on loopback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, Request
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
from playwright.async_api import Error as PlaywrightError
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from pbb.browser.locator import LocatorResolutionError
|
|
17
|
+
from pbb.browser.manager import BrowserManager
|
|
18
|
+
from pbb.config import Settings
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NavigateRequest(BaseModel):
|
|
22
|
+
url: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TargetRequest(BaseModel):
|
|
26
|
+
target: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FillRequest(TargetRequest):
|
|
30
|
+
value: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DownloadRequest(TargetRequest):
|
|
34
|
+
output: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ScreenshotRequest(BaseModel):
|
|
38
|
+
output: str = "pbb-screenshot.png"
|
|
39
|
+
full_page: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def error(code: str, message: str, details: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
43
|
+
return {"success": False, "error": code, "message": message, "details": details or {}}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_app(settings: Settings, profile: str, browser: str | None = None) -> FastAPI:
|
|
47
|
+
manager = BrowserManager(settings, profile, browser)
|
|
48
|
+
|
|
49
|
+
@asynccontextmanager
|
|
50
|
+
async def lifespan(_app: FastAPI):
|
|
51
|
+
await manager.start()
|
|
52
|
+
try:
|
|
53
|
+
yield
|
|
54
|
+
finally:
|
|
55
|
+
await manager.close()
|
|
56
|
+
|
|
57
|
+
app = FastAPI(
|
|
58
|
+
title="Persistent Browser Bridge",
|
|
59
|
+
version="0.1.0",
|
|
60
|
+
docs_url=None,
|
|
61
|
+
redoc_url=None,
|
|
62
|
+
lifespan=lifespan,
|
|
63
|
+
)
|
|
64
|
+
app.state.manager = manager
|
|
65
|
+
app.state.server = None
|
|
66
|
+
|
|
67
|
+
@app.exception_handler(LocatorResolutionError)
|
|
68
|
+
async def locator_error(_request: Request, exc: LocatorResolutionError) -> JSONResponse:
|
|
69
|
+
return JSONResponse(error(exc.code, str(exc), exc.details), status_code=400)
|
|
70
|
+
|
|
71
|
+
@app.exception_handler(PlaywrightError)
|
|
72
|
+
async def playwright_error(_request: Request, exc: PlaywrightError) -> JSONResponse:
|
|
73
|
+
message = str(exc)
|
|
74
|
+
lowered = message.lower()
|
|
75
|
+
code = "page_closed" if "closed" in lowered else "browser_disconnected"
|
|
76
|
+
return JSONResponse(error(code, message), status_code=409)
|
|
77
|
+
|
|
78
|
+
@app.exception_handler(Exception)
|
|
79
|
+
async def internal_error(_request: Request, exc: Exception) -> JSONResponse:
|
|
80
|
+
return JSONResponse(error("internal_error", str(exc)), status_code=500)
|
|
81
|
+
|
|
82
|
+
@app.get("/status")
|
|
83
|
+
async def status() -> dict[str, Any]:
|
|
84
|
+
result = await manager.status()
|
|
85
|
+
result.update({"daemon_running": True, "pid": os.getpid()})
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
@app.post("/start")
|
|
89
|
+
async def start() -> dict[str, Any]:
|
|
90
|
+
await manager.start()
|
|
91
|
+
return await status()
|
|
92
|
+
|
|
93
|
+
@app.post("/navigate")
|
|
94
|
+
async def navigate(request: NavigateRequest) -> dict[str, Any]:
|
|
95
|
+
return await manager.navigate(request.url)
|
|
96
|
+
|
|
97
|
+
@app.post("/snapshot")
|
|
98
|
+
async def snapshot() -> dict[str, Any]:
|
|
99
|
+
return await manager.snapshot()
|
|
100
|
+
|
|
101
|
+
@app.post("/click")
|
|
102
|
+
async def click(request: TargetRequest) -> dict[str, Any]:
|
|
103
|
+
return await manager.click(request.target)
|
|
104
|
+
|
|
105
|
+
@app.post("/fill")
|
|
106
|
+
async def fill(request: FillRequest) -> dict[str, Any]:
|
|
107
|
+
return await manager.fill(request.target, request.value)
|
|
108
|
+
|
|
109
|
+
@app.post("/text")
|
|
110
|
+
async def text(request: TargetRequest) -> dict[str, Any]:
|
|
111
|
+
return await manager.text(request.target)
|
|
112
|
+
|
|
113
|
+
@app.post("/download")
|
|
114
|
+
async def download(request: DownloadRequest) -> dict[str, Any]:
|
|
115
|
+
output = Path(request.output) if request.output else None
|
|
116
|
+
return await manager.download(request.target, output)
|
|
117
|
+
|
|
118
|
+
@app.post("/screenshot")
|
|
119
|
+
async def screenshot(request: ScreenshotRequest) -> dict[str, Any]:
|
|
120
|
+
return await manager.screenshot(Path(request.output), request.full_page)
|
|
121
|
+
|
|
122
|
+
@app.post("/close")
|
|
123
|
+
async def close() -> dict[str, Any]:
|
|
124
|
+
await manager.close()
|
|
125
|
+
return {"success": True, "browser_running": False}
|
|
126
|
+
|
|
127
|
+
@app.post("/shutdown")
|
|
128
|
+
async def stop() -> dict[str, Any]:
|
|
129
|
+
async def request_exit() -> None:
|
|
130
|
+
await asyncio.sleep(0.1)
|
|
131
|
+
if app.state.server is not None:
|
|
132
|
+
app.state.server.should_exit = True
|
|
133
|
+
|
|
134
|
+
asyncio.create_task(request_exit())
|
|
135
|
+
return {"success": True, "message": "PBB daemon is shutting down"}
|
|
136
|
+
|
|
137
|
+
return app
|