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/__init__.py
ADDED
pbb/browser/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Browser management primitives."""
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Detect installed Edge, Chrome, or Chromium browsers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class BrowserInstall:
|
|
14
|
+
name: str
|
|
15
|
+
channel: str | None
|
|
16
|
+
executable: str | None
|
|
17
|
+
|
|
18
|
+
def as_dict(self) -> dict[str, str | None]:
|
|
19
|
+
return asdict(self)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _windows_candidates() -> list[BrowserInstall]:
|
|
23
|
+
local = Path(os.environ.get("LOCALAPPDATA", ""))
|
|
24
|
+
return [
|
|
25
|
+
BrowserInstall(
|
|
26
|
+
"Microsoft Edge",
|
|
27
|
+
"msedge",
|
|
28
|
+
str(
|
|
29
|
+
Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)"))
|
|
30
|
+
/ "Microsoft/Edge/Application/msedge.exe"
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
BrowserInstall(
|
|
34
|
+
"Microsoft Edge",
|
|
35
|
+
"msedge",
|
|
36
|
+
str(
|
|
37
|
+
Path(os.environ.get("PROGRAMFILES", "C:/Program Files"))
|
|
38
|
+
/ "Microsoft/Edge/Application/msedge.exe"
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
BrowserInstall(
|
|
42
|
+
"Microsoft Edge", "msedge", str(local / "Microsoft/Edge/Application/msedge.exe")
|
|
43
|
+
),
|
|
44
|
+
BrowserInstall(
|
|
45
|
+
"Google Chrome",
|
|
46
|
+
"chrome",
|
|
47
|
+
str(
|
|
48
|
+
Path(os.environ.get("PROGRAMFILES", "C:/Program Files"))
|
|
49
|
+
/ "Google/Chrome/Application/chrome.exe"
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
BrowserInstall(
|
|
53
|
+
"Google Chrome",
|
|
54
|
+
"chrome",
|
|
55
|
+
str(
|
|
56
|
+
Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)"))
|
|
57
|
+
/ "Google/Chrome/Application/chrome.exe"
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
BrowserInstall(
|
|
61
|
+
"Google Chrome", "chrome", str(local / "Google/Chrome/Application/chrome.exe")
|
|
62
|
+
),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def detect_browsers() -> list[BrowserInstall]:
|
|
67
|
+
candidates: list[BrowserInstall] = []
|
|
68
|
+
if sys.platform == "win32":
|
|
69
|
+
candidates.extend(_windows_candidates())
|
|
70
|
+
elif sys.platform == "darwin":
|
|
71
|
+
candidates.extend(
|
|
72
|
+
[
|
|
73
|
+
BrowserInstall(
|
|
74
|
+
"Microsoft Edge",
|
|
75
|
+
"msedge",
|
|
76
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
77
|
+
),
|
|
78
|
+
BrowserInstall(
|
|
79
|
+
"Google Chrome",
|
|
80
|
+
"chrome",
|
|
81
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
82
|
+
),
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
for name, channel, command in (
|
|
87
|
+
("Microsoft Edge", "msedge", "microsoft-edge"),
|
|
88
|
+
("Google Chrome", "chrome", "google-chrome"),
|
|
89
|
+
("Chromium", None, "chromium"),
|
|
90
|
+
("Chromium", None, "chromium-browser"),
|
|
91
|
+
):
|
|
92
|
+
candidates.append(BrowserInstall(name, channel, shutil.which(command)))
|
|
93
|
+
seen: set[tuple[str, str]] = set()
|
|
94
|
+
found: list[BrowserInstall] = []
|
|
95
|
+
for item in candidates:
|
|
96
|
+
if item.executable and Path(item.executable).is_file():
|
|
97
|
+
key = (item.name, str(Path(item.executable).resolve()).lower())
|
|
98
|
+
if key not in seen:
|
|
99
|
+
seen.add(key)
|
|
100
|
+
found.append(item)
|
|
101
|
+
return found
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def select_browser(preference: str) -> BrowserInstall:
|
|
105
|
+
installed = detect_browsers()
|
|
106
|
+
aliases = {"edge": "Microsoft Edge", "chrome": "Google Chrome", "chromium": "Chromium"}
|
|
107
|
+
if preference != "auto":
|
|
108
|
+
wanted = aliases[preference]
|
|
109
|
+
for browser in installed:
|
|
110
|
+
if browser.name == wanted:
|
|
111
|
+
return browser
|
|
112
|
+
if preference == "chromium":
|
|
113
|
+
return BrowserInstall("Playwright Chromium", None, None)
|
|
114
|
+
raise FileNotFoundError(f"Requested browser '{preference}' was not found")
|
|
115
|
+
if installed:
|
|
116
|
+
return installed[0]
|
|
117
|
+
return BrowserInstall("Playwright Chromium", None, None)
|
pbb/browser/locator.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Conservative target resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from playwright.async_api import Error as PlaywrightError
|
|
10
|
+
from playwright.async_api import Locator, Page
|
|
11
|
+
|
|
12
|
+
from pbb.browser.snapshot import SnapshotStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LocatorResolutionError(RuntimeError):
|
|
16
|
+
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.code = code
|
|
19
|
+
self.details = details or {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ResolvedLocator:
|
|
24
|
+
locator: Locator
|
|
25
|
+
metadata: dict[str, Any]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _unique(locator: Locator, label: str) -> ResolvedLocator | None:
|
|
29
|
+
count = await locator.count()
|
|
30
|
+
if count == 1:
|
|
31
|
+
return ResolvedLocator(locator.first, {"strategy": label})
|
|
32
|
+
if count > 1:
|
|
33
|
+
matches: list[dict[str, str]] = []
|
|
34
|
+
for index in range(min(count, 10)):
|
|
35
|
+
item = locator.nth(index)
|
|
36
|
+
matches.append(
|
|
37
|
+
{
|
|
38
|
+
"tag": await item.evaluate("el => el.tagName.toLowerCase()"),
|
|
39
|
+
"text": (await item.inner_text())[:200],
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
raise LocatorResolutionError(
|
|
43
|
+
"ambiguous_target",
|
|
44
|
+
f"Target matched {count} elements using {label}",
|
|
45
|
+
{"matches": matches},
|
|
46
|
+
)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def resolve_locator(page: Page, store: SnapshotStore, target: str) -> ResolvedLocator:
|
|
51
|
+
match = re.fullmatch(r"(?:(s_[0-9a-f]+):)?@(\d+)", target)
|
|
52
|
+
if match:
|
|
53
|
+
explicit_snapshot, raw_id = match.groups()
|
|
54
|
+
if explicit_snapshot and explicit_snapshot != store.snapshot_id:
|
|
55
|
+
raise LocatorResolutionError(
|
|
56
|
+
"stale_snapshot", "Snapshot reference is no longer current"
|
|
57
|
+
)
|
|
58
|
+
item = store.get(int(raw_id))
|
|
59
|
+
if item is None:
|
|
60
|
+
raise LocatorResolutionError(
|
|
61
|
+
"element_not_found", f"Snapshot element {target} was not found"
|
|
62
|
+
)
|
|
63
|
+
if page.url != store.page_url:
|
|
64
|
+
raise LocatorResolutionError("stale_snapshot", "Page URL changed after the snapshot")
|
|
65
|
+
locator = page.locator(item["selector"])
|
|
66
|
+
if await locator.count() != 1:
|
|
67
|
+
raise LocatorResolutionError(
|
|
68
|
+
"stale_snapshot", "Snapshot element changed or disappeared"
|
|
69
|
+
)
|
|
70
|
+
current = await locator.first.evaluate(
|
|
71
|
+
"el => ({name:(el.getAttribute('aria-label') || el.innerText || el.getAttribute('name') || el.getAttribute('placeholder') || '').replace(/\\s+/g,' ').trim(), role:el.getAttribute('role') || ''})"
|
|
72
|
+
)
|
|
73
|
+
expected_name = item.get("name", "")
|
|
74
|
+
if expected_name and current["name"] and expected_name != current["name"]:
|
|
75
|
+
raise LocatorResolutionError("stale_snapshot", "Snapshot element identity changed")
|
|
76
|
+
return ResolvedLocator(
|
|
77
|
+
locator.first, {"strategy": "snapshot", "snapshot_id": store.snapshot_id}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
selector_error: PlaywrightError | None = None
|
|
81
|
+
try:
|
|
82
|
+
direct = await _unique(page.locator(target), "selector")
|
|
83
|
+
if direct:
|
|
84
|
+
return direct
|
|
85
|
+
except PlaywrightError as exc:
|
|
86
|
+
selector_error = exc
|
|
87
|
+
if selector_error and target.startswith(("#", ".", "[", "//", "css=", "xpath=")):
|
|
88
|
+
raise LocatorResolutionError(
|
|
89
|
+
"invalid_selector", f"Invalid selector: {target}"
|
|
90
|
+
) from selector_error
|
|
91
|
+
|
|
92
|
+
strategies = [
|
|
93
|
+
(page.get_by_role("button", name=target, exact=True), "button role"),
|
|
94
|
+
(page.get_by_role("link", name=target, exact=True), "link role"),
|
|
95
|
+
(page.get_by_label(target, exact=True), "label"),
|
|
96
|
+
(page.get_by_placeholder(target, exact=True), "placeholder"),
|
|
97
|
+
(page.get_by_text(target, exact=True), "exact text"),
|
|
98
|
+
]
|
|
99
|
+
for locator, label in strategies:
|
|
100
|
+
result = await _unique(locator, label)
|
|
101
|
+
if result:
|
|
102
|
+
return result
|
|
103
|
+
raise LocatorResolutionError("element_not_found", f"No element matched target: {target}")
|
pbb/browser/locks.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""PBB-owned profile lock files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import psutil
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProfileLockedError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class LockInfo:
|
|
19
|
+
pid: int
|
|
20
|
+
start_time: float
|
|
21
|
+
profile: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProfileLock:
|
|
25
|
+
def __init__(self, profile_path: Path, profile: str) -> None:
|
|
26
|
+
self.path = profile_path / "profile.lock"
|
|
27
|
+
self.profile = profile
|
|
28
|
+
self.acquired = False
|
|
29
|
+
|
|
30
|
+
def read(self) -> LockInfo | None:
|
|
31
|
+
try:
|
|
32
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
33
|
+
return LockInfo(**raw)
|
|
34
|
+
except (OSError, ValueError, TypeError):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def is_stale(self) -> bool:
|
|
38
|
+
info = self.read()
|
|
39
|
+
if info is None:
|
|
40
|
+
return self.path.exists()
|
|
41
|
+
if not psutil.pid_exists(info.pid):
|
|
42
|
+
return True
|
|
43
|
+
try:
|
|
44
|
+
return abs(psutil.Process(info.pid).create_time() - info.start_time) > 2
|
|
45
|
+
except (psutil.Error, OSError):
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
def acquire(self) -> None:
|
|
49
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
if self.path.exists():
|
|
51
|
+
info = self.read()
|
|
52
|
+
if not self.is_stale():
|
|
53
|
+
owner = info.pid if info else "unknown"
|
|
54
|
+
raise ProfileLockedError(
|
|
55
|
+
f"Profile '{self.profile}' is already in use by PID {owner}."
|
|
56
|
+
)
|
|
57
|
+
self.path.unlink(missing_ok=True)
|
|
58
|
+
info = LockInfo(os.getpid(), psutil.Process().create_time(), self.profile)
|
|
59
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
60
|
+
try:
|
|
61
|
+
descriptor = os.open(self.path, flags)
|
|
62
|
+
except FileExistsError as exc:
|
|
63
|
+
raise ProfileLockedError(f"Profile '{self.profile}' is already in use.") from exc
|
|
64
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
65
|
+
json.dump(asdict(info), handle)
|
|
66
|
+
self.acquired = True
|
|
67
|
+
|
|
68
|
+
def release(self) -> None:
|
|
69
|
+
if self.acquired:
|
|
70
|
+
self.path.unlink(missing_ok=True)
|
|
71
|
+
self.acquired = False
|
|
72
|
+
|
|
73
|
+
def clear_stale(self) -> bool:
|
|
74
|
+
if self.path.exists() and self.is_stale():
|
|
75
|
+
self.path.unlink()
|
|
76
|
+
return True
|
|
77
|
+
return False
|
pbb/browser/manager.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Own the Playwright persistent context for the daemon lifetime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from playwright.async_api import (
|
|
11
|
+
BrowserContext,
|
|
12
|
+
Error,
|
|
13
|
+
Page,
|
|
14
|
+
Playwright,
|
|
15
|
+
TimeoutError,
|
|
16
|
+
async_playwright,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from pbb.browser.browser_detection import BrowserInstall, select_browser
|
|
20
|
+
from pbb.browser.locator import LocatorResolutionError, resolve_locator
|
|
21
|
+
from pbb.browser.locks import ProfileLock
|
|
22
|
+
from pbb.browser.profile import create_profile
|
|
23
|
+
from pbb.browser.snapshot import SnapshotStore, capture_snapshot
|
|
24
|
+
from pbb.config import Settings
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BrowserManager:
|
|
28
|
+
def __init__(self, settings: Settings, profile: str, browser: str | None = None) -> None:
|
|
29
|
+
self.settings = settings
|
|
30
|
+
self.profile = profile
|
|
31
|
+
self.preference = browser or settings.browser
|
|
32
|
+
self.install: BrowserInstall | None = None
|
|
33
|
+
self.playwright: Playwright | None = None
|
|
34
|
+
self.context: BrowserContext | None = None
|
|
35
|
+
self.active_page: Page | None = None
|
|
36
|
+
self.store = SnapshotStore()
|
|
37
|
+
self.lock = ProfileLock(create_profile(profile), profile)
|
|
38
|
+
self.restarts = 0
|
|
39
|
+
|
|
40
|
+
async def start(self) -> None:
|
|
41
|
+
if self.context:
|
|
42
|
+
return
|
|
43
|
+
self.lock.acquire()
|
|
44
|
+
try:
|
|
45
|
+
self.install = select_browser(self.preference)
|
|
46
|
+
self.playwright = await async_playwright().start()
|
|
47
|
+
options: dict[str, Any] = {
|
|
48
|
+
"user_data_dir": str(create_profile(self.profile)),
|
|
49
|
+
"headless": self.settings.headless,
|
|
50
|
+
"accept_downloads": True,
|
|
51
|
+
"downloads_path": str(self.settings.download_dir),
|
|
52
|
+
"timeout": self.settings.timeout,
|
|
53
|
+
}
|
|
54
|
+
if self.install.channel:
|
|
55
|
+
options["channel"] = self.install.channel
|
|
56
|
+
elif self.install.executable:
|
|
57
|
+
options["executable_path"] = self.install.executable
|
|
58
|
+
self.context = await self.playwright.chromium.launch_persistent_context(**options)
|
|
59
|
+
self.context.set_default_timeout(self.settings.timeout)
|
|
60
|
+
self.active_page = (
|
|
61
|
+
self.context.pages[-1] if self.context.pages else await self.context.new_page()
|
|
62
|
+
)
|
|
63
|
+
except Exception:
|
|
64
|
+
self.lock.release()
|
|
65
|
+
if self.playwright:
|
|
66
|
+
await self.playwright.stop()
|
|
67
|
+
self.playwright = None
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
async def close(self) -> None:
|
|
71
|
+
try:
|
|
72
|
+
if self.context:
|
|
73
|
+
await self.context.close()
|
|
74
|
+
finally:
|
|
75
|
+
self.context = None
|
|
76
|
+
self.active_page = None
|
|
77
|
+
if self.playwright:
|
|
78
|
+
await self.playwright.stop()
|
|
79
|
+
self.playwright = None
|
|
80
|
+
self.lock.release()
|
|
81
|
+
|
|
82
|
+
async def page(self) -> tuple[Page, bool]:
|
|
83
|
+
if not self.context:
|
|
84
|
+
await self.start()
|
|
85
|
+
assert self.active_page is not None
|
|
86
|
+
return self.active_page, True
|
|
87
|
+
assert self.context is not None
|
|
88
|
+
try:
|
|
89
|
+
if not self.active_page or self.active_page.is_closed():
|
|
90
|
+
pages = [page for page in self.context.pages if not page.is_closed()]
|
|
91
|
+
self.active_page = pages[-1] if pages else await self.context.new_page()
|
|
92
|
+
return self.active_page, True
|
|
93
|
+
return self.active_page, False
|
|
94
|
+
except Error:
|
|
95
|
+
await self.close()
|
|
96
|
+
self.restarts += 1
|
|
97
|
+
await self.start()
|
|
98
|
+
assert self.active_page is not None
|
|
99
|
+
return self.active_page, True
|
|
100
|
+
|
|
101
|
+
async def status(self) -> dict[str, Any]:
|
|
102
|
+
page = self.active_page
|
|
103
|
+
running = self.context is not None
|
|
104
|
+
restarted = False
|
|
105
|
+
if running and (page is None or page.is_closed()):
|
|
106
|
+
page, restarted = await self.page()
|
|
107
|
+
return {
|
|
108
|
+
"success": True,
|
|
109
|
+
"browser_running": running,
|
|
110
|
+
"browser": self.install.name if self.install else None,
|
|
111
|
+
"profile": self.profile,
|
|
112
|
+
"url": page.url if page and not page.is_closed() else None,
|
|
113
|
+
"title": await page.title() if page and not page.is_closed() else None,
|
|
114
|
+
"tabs": len(self.context.pages) if self.context else 0,
|
|
115
|
+
"browser_restarted": restarted,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async def navigate(self, url: str) -> dict[str, Any]:
|
|
119
|
+
page, restarted = await self.page()
|
|
120
|
+
started = time.perf_counter()
|
|
121
|
+
try:
|
|
122
|
+
await page.goto(url, wait_until="domcontentloaded", timeout=self.settings.timeout)
|
|
123
|
+
except TimeoutError as exc:
|
|
124
|
+
raise LocatorResolutionError(
|
|
125
|
+
"navigation_timeout", f"Navigation timed out: {url}"
|
|
126
|
+
) from exc
|
|
127
|
+
return {
|
|
128
|
+
"success": True,
|
|
129
|
+
"url": page.url,
|
|
130
|
+
"title": await page.title(),
|
|
131
|
+
"duration_ms": round((time.perf_counter() - started) * 1000),
|
|
132
|
+
"browser_restarted": restarted,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async def snapshot(self) -> dict[str, Any]:
|
|
136
|
+
page, restarted = await self.page()
|
|
137
|
+
result = await capture_snapshot(page, self.store, self.settings.snapshot_max_chars)
|
|
138
|
+
result["browser_restarted"] = restarted
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
async def click(self, target: str) -> dict[str, Any]:
|
|
142
|
+
page, restarted = await self.page()
|
|
143
|
+
resolved = await resolve_locator(page, self.store, target)
|
|
144
|
+
started = time.perf_counter()
|
|
145
|
+
await resolved.locator.click()
|
|
146
|
+
await asyncio.sleep(0.15)
|
|
147
|
+
return {
|
|
148
|
+
"success": True,
|
|
149
|
+
"action": "click",
|
|
150
|
+
"target": target,
|
|
151
|
+
"url": page.url,
|
|
152
|
+
"duration_ms": round((time.perf_counter() - started) * 1000),
|
|
153
|
+
"browser_restarted": restarted,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async def fill(self, target: str, value: str) -> dict[str, Any]:
|
|
157
|
+
page, restarted = await self.page()
|
|
158
|
+
resolved = await resolve_locator(page, self.store, target)
|
|
159
|
+
started = time.perf_counter()
|
|
160
|
+
await resolved.locator.fill(value)
|
|
161
|
+
input_type = await resolved.locator.get_attribute("type")
|
|
162
|
+
return {
|
|
163
|
+
"success": True,
|
|
164
|
+
"action": "fill",
|
|
165
|
+
"target": target,
|
|
166
|
+
"value_redacted": input_type == "password",
|
|
167
|
+
"url": page.url,
|
|
168
|
+
"duration_ms": round((time.perf_counter() - started) * 1000),
|
|
169
|
+
"browser_restarted": restarted,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async def text(self, target: str) -> dict[str, Any]:
|
|
173
|
+
page, restarted = await self.page()
|
|
174
|
+
resolved = await resolve_locator(page, self.store, target)
|
|
175
|
+
return {
|
|
176
|
+
"success": True,
|
|
177
|
+
"text": await resolved.locator.inner_text(),
|
|
178
|
+
"browser_restarted": restarted,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async def download(self, target: str, output: Path | None = None) -> dict[str, Any]:
|
|
182
|
+
page, restarted = await self.page()
|
|
183
|
+
resolved = await resolve_locator(page, self.store, target)
|
|
184
|
+
try:
|
|
185
|
+
async with page.expect_download(timeout=self.settings.timeout) as info:
|
|
186
|
+
await resolved.locator.click()
|
|
187
|
+
download = await info.value
|
|
188
|
+
except TimeoutError as exc:
|
|
189
|
+
raise LocatorResolutionError(
|
|
190
|
+
"download_not_triggered", "The target did not trigger a download before timeout"
|
|
191
|
+
) from exc
|
|
192
|
+
destination = output or self.settings.download_dir
|
|
193
|
+
destination = destination.expanduser().resolve()
|
|
194
|
+
if destination.suffix and not destination.is_dir():
|
|
195
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
saved_path = destination
|
|
197
|
+
else:
|
|
198
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
saved_path = destination / download.suggested_filename
|
|
200
|
+
await download.save_as(saved_path)
|
|
201
|
+
return {
|
|
202
|
+
"success": True,
|
|
203
|
+
"suggested_filename": download.suggested_filename,
|
|
204
|
+
"saved_path": str(saved_path),
|
|
205
|
+
"size": saved_path.stat().st_size,
|
|
206
|
+
"browser_restarted": restarted,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async def screenshot(self, output: Path, full_page: bool) -> dict[str, Any]:
|
|
210
|
+
page, restarted = await self.page()
|
|
211
|
+
path = output.expanduser().resolve()
|
|
212
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
await page.screenshot(path=path, full_page=full_page)
|
|
214
|
+
return {
|
|
215
|
+
"success": True,
|
|
216
|
+
"saved_path": str(path),
|
|
217
|
+
"browser_restarted": restarted,
|
|
218
|
+
}
|
pbb/browser/profile.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Dedicated persistent-profile management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pbb.utils.paths import profiles_dir
|
|
10
|
+
|
|
11
|
+
PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidProfileName(ValueError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_profile_name(name: str) -> str:
|
|
19
|
+
if not PROFILE_RE.fullmatch(name) or name in {".", ".."}:
|
|
20
|
+
raise InvalidProfileName(
|
|
21
|
+
"profile names must be 1-64 characters using letters, numbers, '.', '_' or '-'"
|
|
22
|
+
)
|
|
23
|
+
return name
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def profile_path(name: str) -> Path:
|
|
27
|
+
validate_profile_name(name)
|
|
28
|
+
root = profiles_dir().resolve()
|
|
29
|
+
path = (root / name).resolve()
|
|
30
|
+
if path.parent != root:
|
|
31
|
+
raise InvalidProfileName("profile path escapes the PBB profiles directory")
|
|
32
|
+
return path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_profile(name: str) -> Path:
|
|
36
|
+
path = profile_path(name)
|
|
37
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
marker = path / ".pbb-profile"
|
|
39
|
+
marker.touch(exist_ok=True)
|
|
40
|
+
return path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def list_profiles() -> list[str]:
|
|
44
|
+
root = profiles_dir()
|
|
45
|
+
if not root.exists():
|
|
46
|
+
return []
|
|
47
|
+
return sorted(
|
|
48
|
+
item.name for item in root.iterdir() if item.is_dir() and (item / ".pbb-profile").exists()
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def delete_profile(name: str) -> None:
|
|
53
|
+
path = profile_path(name)
|
|
54
|
+
if not (path / ".pbb-profile").exists():
|
|
55
|
+
raise FileNotFoundError(f"PBB profile '{name}' does not exist")
|
|
56
|
+
shutil.rmtree(path)
|