playwrightauthor 1.0.2__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.
- playwrightauthor/__init__.py +11 -0
- playwrightauthor/__main__.py +12 -0
- playwrightauthor/author.py +85 -0
- playwrightauthor/browser/__init__.py +1 -0
- playwrightauthor/browser/finder.py +45 -0
- playwrightauthor/browser/installer.py +64 -0
- playwrightauthor/browser/launcher.py +23 -0
- playwrightauthor/browser/process.py +19 -0
- playwrightauthor/browser_manager.py +88 -0
- playwrightauthor/cli.py +52 -0
- playwrightauthor/exceptions.py +11 -0
- playwrightauthor/onboarding.py +25 -0
- playwrightauthor/templates/onboarding.html +34 -0
- playwrightauthor/typing.py +3 -0
- playwrightauthor/utils/logger.py +16 -0
- playwrightauthor/utils/paths.py +14 -0
- playwrightauthor-1.0.2.dist-info/METADATA +498 -0
- playwrightauthor-1.0.2.dist-info/RECORD +21 -0
- playwrightauthor-1.0.2.dist-info/WHEEL +4 -0
- playwrightauthor-1.0.2.dist-info/entry_points.txt +2 -0
- playwrightauthor-1.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run -s
|
|
2
|
+
# /// script
|
|
3
|
+
# dependencies = ["playwright"]
|
|
4
|
+
# ///
|
|
5
|
+
# this_file: playwrightauthor/author.py
|
|
6
|
+
|
|
7
|
+
"""The core Browser and AsyncBrowser classes."""
|
|
8
|
+
|
|
9
|
+
from playwright.sync_api import (
|
|
10
|
+
sync_playwright,
|
|
11
|
+
Playwright,
|
|
12
|
+
Browser as PlaywrightBrowser,
|
|
13
|
+
)
|
|
14
|
+
from playwright.async_api import (
|
|
15
|
+
async_playwright,
|
|
16
|
+
Playwright as AsyncPlaywright,
|
|
17
|
+
Browser as AsyncPlaywrightBrowser,
|
|
18
|
+
)
|
|
19
|
+
from .browser_manager import ensure_browser, _DEBUGGING_PORT
|
|
20
|
+
from .utils.logger import configure as configure_logger
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Browser:
|
|
24
|
+
"""
|
|
25
|
+
A sync context manager for an authenticated Playwright Browser.
|
|
26
|
+
|
|
27
|
+
This class handles the setup and teardown of a Playwright browser instance that is connected
|
|
28
|
+
to a persistent browser session. This allows you to maintain your login sessions and other
|
|
29
|
+
browser data between runs.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
verbose (bool, optional): Whether to enable verbose logging. Defaults to False.
|
|
33
|
+
"""
|
|
34
|
+
def __init__(self, verbose: bool = False):
|
|
35
|
+
self.verbose = verbose
|
|
36
|
+
self.logger = configure_logger(verbose)
|
|
37
|
+
self.playwright: Playwright | None = None
|
|
38
|
+
self.browser: PlaywrightBrowser | None = None
|
|
39
|
+
|
|
40
|
+
def __enter__(self) -> PlaywrightBrowser:
|
|
41
|
+
self.logger.info("Starting sync browser session...")
|
|
42
|
+
ensure_browser(self.verbose)
|
|
43
|
+
self.playwright = sync_playwright().start()
|
|
44
|
+
self.browser = self.playwright.chromium.connect_over_cdp(f"http://localhost:{_DEBUGGING_PORT}")
|
|
45
|
+
self.logger.info("Sync browser session started.")
|
|
46
|
+
return self.browser
|
|
47
|
+
|
|
48
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
49
|
+
if self.browser:
|
|
50
|
+
self.browser.close()
|
|
51
|
+
if self.playwright:
|
|
52
|
+
self.playwright.stop()
|
|
53
|
+
self.logger.info("Sync browser session closed.")
|
|
54
|
+
|
|
55
|
+
class AsyncBrowser:
|
|
56
|
+
"""
|
|
57
|
+
An async context manager for an authenticated Playwright Browser.
|
|
58
|
+
|
|
59
|
+
This class is the asynchronous version of the Browser class. It handles the setup and teardown
|
|
60
|
+
of a Playwright browser instance that is connected to a persistent browser session. This allows
|
|
61
|
+
you to maintain your login sessions and other browser data between runs.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
verbose (bool, optional): Whether to enable verbose logging. Defaults to False.
|
|
65
|
+
"""
|
|
66
|
+
def __init__(self, verbose: bool = False):
|
|
67
|
+
self.verbose = verbose
|
|
68
|
+
self.logger = configure_logger(verbose)
|
|
69
|
+
self.playwright: AsyncPlaywright | None = None
|
|
70
|
+
self.browser: AsyncPlaywrightBrowser | None = None
|
|
71
|
+
|
|
72
|
+
async def __aenter__(self) -> AsyncPlaywrightBrowser:
|
|
73
|
+
self.logger.info("Starting async browser session...")
|
|
74
|
+
ensure_browser(self.verbose)
|
|
75
|
+
self.playwright = await async_playwright().start()
|
|
76
|
+
self.browser = await self.playwright.chromium.connect_over_cdp(f"http://localhost:{_DEBUGGING_PORT}")
|
|
77
|
+
self.logger.info("Async browser session started.")
|
|
78
|
+
return self.browser
|
|
79
|
+
|
|
80
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
81
|
+
if self.browser:
|
|
82
|
+
await self.browser.close()
|
|
83
|
+
if self.playwright:
|
|
84
|
+
await self.playwright.stop()
|
|
85
|
+
self.logger.info("Async browser session closed.")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# this_file: playwrightauthor/browser/__init__.py
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# this_file: playwrightauthor/browser/finder.py
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from ..utils.paths import install_dir
|
|
7
|
+
|
|
8
|
+
def find_chrome_executable() -> Path | None:
|
|
9
|
+
"""Find the Chrome for Testing executable in the install directory or common system locations."""
|
|
10
|
+
install_path = install_dir()
|
|
11
|
+
if sys.platform == "darwin":
|
|
12
|
+
exec_paths = [
|
|
13
|
+
install_path
|
|
14
|
+
/ "chrome-mac-arm64"
|
|
15
|
+
/ "Google Chrome for Testing.app"
|
|
16
|
+
/ "Contents"
|
|
17
|
+
/ "MacOS"
|
|
18
|
+
/ "Google Chrome for Testing",
|
|
19
|
+
install_path
|
|
20
|
+
/ "chrome-mac-x64"
|
|
21
|
+
/ "Google Chrome for Testing.app"
|
|
22
|
+
/ "Contents"
|
|
23
|
+
/ "MacOS"
|
|
24
|
+
/ "Google Chrome for Testing",
|
|
25
|
+
Path("/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
|
|
26
|
+
Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
|
27
|
+
]
|
|
28
|
+
elif sys.platform == "win32":
|
|
29
|
+
exec_paths = [
|
|
30
|
+
install_path / "chrome-win64" / "chrome.exe",
|
|
31
|
+
Path(os.environ.get("ProgramFiles(x86)", "")) / "Google" / "Chrome" / "Application" / "chrome.exe",
|
|
32
|
+
Path(os.environ.get("ProgramFiles", "")) / "Google" / "Chrome" / "Application" / "chrome.exe",
|
|
33
|
+
]
|
|
34
|
+
elif sys.platform.startswith("linux"):
|
|
35
|
+
exec_paths = [
|
|
36
|
+
install_path / "chrome-linux64" / "chrome",
|
|
37
|
+
Path("/usr/bin/google-chrome"),
|
|
38
|
+
]
|
|
39
|
+
else:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
for path in exec_paths:
|
|
43
|
+
if path.exists():
|
|
44
|
+
return path
|
|
45
|
+
return None
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# this_file: playwrightauthor/browser/installer.py
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import time
|
|
7
|
+
import requests
|
|
8
|
+
from ..utils.paths import install_dir
|
|
9
|
+
from ..exceptions import BrowserManagerError
|
|
10
|
+
|
|
11
|
+
_LKGV_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json"
|
|
12
|
+
|
|
13
|
+
def install_from_lkgv(logger, retries=3, delay=5):
|
|
14
|
+
"""Download and extract Chrome for Testing from the LKGV JSON."""
|
|
15
|
+
for attempt in range(retries):
|
|
16
|
+
try:
|
|
17
|
+
logger.info(f"Fetching latest versions from {_LKGV_URL} (attempt {attempt + 1}/{retries})...")
|
|
18
|
+
response = requests.get(_LKGV_URL, timeout=30)
|
|
19
|
+
response.raise_for_status()
|
|
20
|
+
data = response.json()
|
|
21
|
+
|
|
22
|
+
if platform.system() == "Darwin":
|
|
23
|
+
arch = platform.machine()
|
|
24
|
+
platform_key = "mac-arm64" if arch == "arm64" else "mac-x64"
|
|
25
|
+
elif platform.system() == "Windows":
|
|
26
|
+
platform_key = "win64"
|
|
27
|
+
elif platform.system() == "Linux":
|
|
28
|
+
platform_key = "linux64"
|
|
29
|
+
else:
|
|
30
|
+
raise BrowserManagerError("Unsupported operating system")
|
|
31
|
+
|
|
32
|
+
logger.info(f"Detected platform: {platform_key}")
|
|
33
|
+
downloads = data.get("channels", {}).get("Stable", {}).get("downloads", {}).get("chrome")
|
|
34
|
+
if not downloads:
|
|
35
|
+
raise BrowserManagerError("Could not find 'chrome' downloads in LKGV JSON.")
|
|
36
|
+
|
|
37
|
+
url = next((item["url"] for item in downloads if item["platform"] == platform_key), None)
|
|
38
|
+
if not url:
|
|
39
|
+
raise BrowserManagerError(f"Could not find a download URL for platform {platform_key}")
|
|
40
|
+
|
|
41
|
+
logger.info(f"Downloading from: {url}")
|
|
42
|
+
install_path = install_dir()
|
|
43
|
+
install_path.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
zip_path = install_path / "chrome.zip"
|
|
45
|
+
|
|
46
|
+
with requests.get(url, stream=True, timeout=300) as r:
|
|
47
|
+
r.raise_for_status()
|
|
48
|
+
with open(zip_path, "wb") as f:
|
|
49
|
+
for chunk in r.iter_content(chunk_size=8192):
|
|
50
|
+
f.write(chunk)
|
|
51
|
+
|
|
52
|
+
logger.info("Download complete. Extracting...")
|
|
53
|
+
shutil.unpack_archive(zip_path, install_path)
|
|
54
|
+
logger.info("Extraction complete.")
|
|
55
|
+
os.remove(zip_path)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
except requests.RequestException as e:
|
|
59
|
+
logger.warning(f"Download failed: {e}. Retrying in {delay} seconds...")
|
|
60
|
+
time.sleep(delay)
|
|
61
|
+
except (OSError, ValueError, shutil.ReadError, BrowserManagerError) as e:
|
|
62
|
+
raise BrowserManagerError(f"Failed to install Chrome: {e}") from e
|
|
63
|
+
|
|
64
|
+
raise BrowserManagerError(f"Failed to download Chrome after {retries} attempts.")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# this_file: playwrightauthor/browser/launcher.py
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from ..exceptions import BrowserManagerError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def launch_chrome(browser_path: Path, data_dir: Path, port: int, logger):
|
|
9
|
+
"""Launch the Chrome executable as a detached process."""
|
|
10
|
+
logger.info(f"Launching Chrome from: {browser_path}")
|
|
11
|
+
command = [
|
|
12
|
+
str(browser_path),
|
|
13
|
+
f"--remote-debugging-port={port}",
|
|
14
|
+
f"--user-data-dir={data_dir}",
|
|
15
|
+
]
|
|
16
|
+
try:
|
|
17
|
+
process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
18
|
+
try:
|
|
19
|
+
process.wait(timeout=30)
|
|
20
|
+
except subprocess.TimeoutExpired:
|
|
21
|
+
logger.info("Chrome launched successfully as a detached process.")
|
|
22
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
23
|
+
raise BrowserManagerError(f"Failed to launch Chrome: {e}") from e
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# this_file: playwrightauthor/browser/process.py
|
|
2
|
+
|
|
3
|
+
import psutil
|
|
4
|
+
|
|
5
|
+
def get_chrome_process(port: int | None = None) -> psutil.Process | None:
|
|
6
|
+
"""Find a running Chrome process."""
|
|
7
|
+
for proc in psutil.process_iter(["name", "cmdline"]):
|
|
8
|
+
try:
|
|
9
|
+
if "chrome" in proc.info["name"].lower():
|
|
10
|
+
if port is None:
|
|
11
|
+
return proc
|
|
12
|
+
if any(
|
|
13
|
+
f"--remote-debugging-port={port}" in arg
|
|
14
|
+
for arg in proc.info["cmdline"]
|
|
15
|
+
):
|
|
16
|
+
return proc
|
|
17
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
18
|
+
pass
|
|
19
|
+
return None
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run -s
|
|
2
|
+
# /// script
|
|
3
|
+
# dependencies = ["requests", "platformdirs", "rich", "psutil"]
|
|
4
|
+
# ///
|
|
5
|
+
# this_file: playwrightauthor/browser_manager.py
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
Ensure a Chrome for Testing build is present & running in debug mode.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import psutil
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from .utils.paths import install_dir
|
|
16
|
+
from .utils.logger import configure as configure_logger
|
|
17
|
+
from .exceptions import BrowserManagerError
|
|
18
|
+
from .browser.finder import find_chrome_executable
|
|
19
|
+
from .browser.installer import install_from_lkgv
|
|
20
|
+
from .browser.launcher import launch_chrome
|
|
21
|
+
from .browser.process import get_chrome_process
|
|
22
|
+
|
|
23
|
+
_DEBUGGING_PORT = 9222
|
|
24
|
+
|
|
25
|
+
def ensure_browser(verbose: bool = False) -> tuple[str, str]:
|
|
26
|
+
"""
|
|
27
|
+
Ensures a Chrome for Testing instance is running with remote debugging.
|
|
28
|
+
Returns the path to the browser executable and the user data directory.
|
|
29
|
+
"""
|
|
30
|
+
console = Console()
|
|
31
|
+
logger = configure_logger(verbose)
|
|
32
|
+
start_time = time.time()
|
|
33
|
+
|
|
34
|
+
if get_chrome_process(_DEBUGGING_PORT):
|
|
35
|
+
logger.info(f"Chrome is already running in debug mode on port {_DEBUGGING_PORT}.")
|
|
36
|
+
browser_path = find_chrome_executable()
|
|
37
|
+
data_dir = install_dir()
|
|
38
|
+
if not browser_path:
|
|
39
|
+
raise BrowserManagerError("Could not find Chrome executable even though process is running.")
|
|
40
|
+
logger.info(f"ensure_browser took {time.time() - start_time:.2f}s")
|
|
41
|
+
return str(browser_path), str(data_dir)
|
|
42
|
+
|
|
43
|
+
proc = get_chrome_process()
|
|
44
|
+
if proc:
|
|
45
|
+
logger.warning("Found a running Chrome instance without the debugging port. Killing it...")
|
|
46
|
+
kill_start_time = time.time()
|
|
47
|
+
try:
|
|
48
|
+
proc.kill()
|
|
49
|
+
for _ in range(5):
|
|
50
|
+
if not proc.is_running():
|
|
51
|
+
break
|
|
52
|
+
time.sleep(1)
|
|
53
|
+
else:
|
|
54
|
+
raise BrowserManagerError("Failed to kill existing Chrome process.")
|
|
55
|
+
logger.info(f"Chrome process killed in {time.time() - kill_start_time:.2f}s")
|
|
56
|
+
except psutil.NoSuchProcess:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
find_start_time = time.time()
|
|
60
|
+
browser_path = find_chrome_executable()
|
|
61
|
+
logger.info(f"Finding executable took {time.time() - find_start_time:.2f}s")
|
|
62
|
+
if not browser_path:
|
|
63
|
+
logger.warning("Chrome for Testing not found. Attempting to install...")
|
|
64
|
+
install_start_time = time.time()
|
|
65
|
+
try:
|
|
66
|
+
install_from_lkgv(logger)
|
|
67
|
+
except BrowserManagerError as e:
|
|
68
|
+
console.print(f"[red]{e}[/red]")
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
logger.info(f"Installation took {time.time() - install_start_time:.2f}s")
|
|
71
|
+
|
|
72
|
+
browser_path = find_chrome_executable()
|
|
73
|
+
if not browser_path:
|
|
74
|
+
raise BrowserManagerError("Failed to find Chrome for Testing after installation.")
|
|
75
|
+
logger.info(f"Chrome for Testing installed at: {browser_path}")
|
|
76
|
+
|
|
77
|
+
data_dir = install_dir()
|
|
78
|
+
launch_start_time = time.time()
|
|
79
|
+
try:
|
|
80
|
+
launch_chrome(browser_path, data_dir, _DEBUGGING_PORT, logger)
|
|
81
|
+
except BrowserManagerError as e:
|
|
82
|
+
console.print(f"[red]{e}[/red]")
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
logger.info(f"Chrome launch took {time.time() - launch_start_time:.2f}s")
|
|
85
|
+
|
|
86
|
+
logger.info("Chrome for Testing launched successfully.")
|
|
87
|
+
logger.info(f"ensure_browser took {time.time() - start_time:.2f}s")
|
|
88
|
+
return str(browser_path), str(data_dir)
|
playwrightauthor/cli.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run -s
|
|
2
|
+
# /// script
|
|
3
|
+
# dependencies = ["fire", "rich"]
|
|
4
|
+
# ///
|
|
5
|
+
# this_file: playwrightauthor/cli.py
|
|
6
|
+
|
|
7
|
+
"""Fire-powered command-line interface for utility tasks."""
|
|
8
|
+
|
|
9
|
+
import fire
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
import shutil
|
|
12
|
+
from .browser_manager import ensure_browser
|
|
13
|
+
from .exceptions import BrowserManagerError
|
|
14
|
+
from .utils.logger import configure as configure_logger
|
|
15
|
+
from .utils.paths import install_dir
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Cli:
|
|
19
|
+
"""CLI for PlaywrightAuthor."""
|
|
20
|
+
|
|
21
|
+
def status(self, verbose: bool = False):
|
|
22
|
+
"""Checks browser status and launches it if not running."""
|
|
23
|
+
console = Console()
|
|
24
|
+
logger = configure_logger(verbose)
|
|
25
|
+
logger.info("Checking browser status...")
|
|
26
|
+
try:
|
|
27
|
+
browser_path, data_dir = ensure_browser(verbose=verbose)
|
|
28
|
+
console.print("[green]Browser is ready.[/green]")
|
|
29
|
+
console.print(f" - Path: {browser_path}")
|
|
30
|
+
console.print(f" - User Data: {data_dir}")
|
|
31
|
+
except BrowserManagerError as e:
|
|
32
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
33
|
+
except SystemExit as e:
|
|
34
|
+
if e.code != 0:
|
|
35
|
+
console.print(f"[red]CLI command failed with exit code {e.code}.[/red]")
|
|
36
|
+
|
|
37
|
+
def clear_cache(self):
|
|
38
|
+
"""
|
|
39
|
+
Removes the browser installation directory, including the browser itself and user data.
|
|
40
|
+
"""
|
|
41
|
+
console = Console()
|
|
42
|
+
install_path = install_dir()
|
|
43
|
+
if install_path.exists():
|
|
44
|
+
console.print(f"Removing {install_path}...")
|
|
45
|
+
shutil.rmtree(install_path)
|
|
46
|
+
console.print("[green]Cache cleared.[/green]")
|
|
47
|
+
else:
|
|
48
|
+
console.print("[yellow]Cache directory not found.[/yellow]")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> None:
|
|
52
|
+
fire.Fire(Cli)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# this_file: playwrightauthor/exceptions.py
|
|
2
|
+
|
|
3
|
+
"""Custom exceptions for PlaywrightAuthor."""
|
|
4
|
+
|
|
5
|
+
class PlaywrightAuthorError(Exception):
|
|
6
|
+
"""Base exception for all PlaywrightAuthor errors."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class BrowserManagerError(PlaywrightAuthorError):
|
|
10
|
+
"""Raised for errors related to browser management."""
|
|
11
|
+
pass
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run -s
|
|
2
|
+
# /// script
|
|
3
|
+
# dependencies = ["playwright"]
|
|
4
|
+
# ///
|
|
5
|
+
# this_file: playwrightauthor/onboarding.py
|
|
6
|
+
|
|
7
|
+
"""Serve a local HTML page instructing the user to log in."""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from playwright.async_api import Browser as AsyncBrowser
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def show(browser: AsyncBrowser, logger) -> None:
|
|
14
|
+
"""Shows the onboarding page in a new tab and waits for the user to navigate away."""
|
|
15
|
+
html_path = Path(__file__).parent / "templates" / "onboarding.html"
|
|
16
|
+
page = await browser.new_page()
|
|
17
|
+
await page.set_content(html_path.read_text("utf-8"), wait_until="domcontentloaded")
|
|
18
|
+
logger.info("Onboarding page shown. Waiting for user to navigate to a different page...")
|
|
19
|
+
try:
|
|
20
|
+
await page.wait_for_navigation(timeout=300_000) # 5 minutes
|
|
21
|
+
logger.info("User navigated away from the onboarding page.")
|
|
22
|
+
except Exception:
|
|
23
|
+
logger.warning("Timed out waiting for user to navigate away from the onboarding page.")
|
|
24
|
+
finally:
|
|
25
|
+
await page.close()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!-- this_file: playwrightauthor/templates/onboarding.html -->
|
|
2
|
+
<!DOCTYPE html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<title>PlaywrightAuthor Onboarding</title>
|
|
8
|
+
<style>
|
|
9
|
+
body {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
11
|
+
line-height: 1.6;
|
|
12
|
+
color: #333;
|
|
13
|
+
max-width: 600px;
|
|
14
|
+
margin: 40px auto;
|
|
15
|
+
padding: 20px;
|
|
16
|
+
border: 1px solid #ddd;
|
|
17
|
+
border-radius: 8px;
|
|
18
|
+
}
|
|
19
|
+
h1 {
|
|
20
|
+
font-size: 24px;
|
|
21
|
+
color: #111;
|
|
22
|
+
}
|
|
23
|
+
strong {
|
|
24
|
+
color: #007aff;
|
|
25
|
+
}
|
|
26
|
+
</style>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<h1>One small step…</h1>
|
|
30
|
+
<p>Welcome to PlaywrightAuthor! To get started, please <strong>log into any websites you need</strong> in this browser window.</p>
|
|
31
|
+
<p>This browser session will be saved, so you won't have to log in again next time.</p>
|
|
32
|
+
<p>When you're done, simply <strong>navigate to any other website</strong> (e.g., by typing a URL in the address bar) or close this tab to continue.</p>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run -s
|
|
2
|
+
# /// script
|
|
3
|
+
# dependencies = ["loguru"]
|
|
4
|
+
# ///
|
|
5
|
+
# this_file: playwrightauthor/utils/logger.py
|
|
6
|
+
|
|
7
|
+
"""Project-wide Loguru configuration."""
|
|
8
|
+
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def configure(verbose: bool = False):
|
|
13
|
+
logger.remove()
|
|
14
|
+
level = "DEBUG" if verbose else "INFO"
|
|
15
|
+
logger.add(lambda m: print(m, end=""), level=level)
|
|
16
|
+
return logger
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run -s
|
|
2
|
+
# /// script
|
|
3
|
+
# dependencies = ["platformdirs"]
|
|
4
|
+
# ///
|
|
5
|
+
# this_file: playwrightauthor/utils/paths.py
|
|
6
|
+
|
|
7
|
+
"""Cross-platform install locations."""
|
|
8
|
+
|
|
9
|
+
from platformdirs import user_cache_dir
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def install_dir() -> Path:
|
|
14
|
+
return Path(user_cache_dir("playwrightauthor")) / "browser"
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: playwrightauthor
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: Your personal, authenticated browser for Playwright, ready in one line of code.
|
|
5
|
+
Project-URL: Homepage, https://github.com/twardoch/playwrightauthor
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/twardoch/playwrightauthor/issues
|
|
7
|
+
Author-email: Adam Twardoch <adam+github@twardoch.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Requires-Dist: fire
|
|
14
|
+
Requires-Dist: loguru
|
|
15
|
+
Requires-Dist: platformdirs
|
|
16
|
+
Requires-Dist: playwright
|
|
17
|
+
Requires-Dist: psutil
|
|
18
|
+
Requires-Dist: requests
|
|
19
|
+
Requires-Dist: rich
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# PlaywrightAuthor
|
|
23
|
+
|
|
24
|
+
*Your personal, authenticated browser for Playwright, ready in one line of code.*
|
|
25
|
+
|
|
26
|
+
PlaywrightAuthor is a convenience package for **Microsoft Playwright**. It handles the tedious parts of browser automation: finding and launching a **Chrome for Testing** instance, keeping it authenticated with your user profile, and connecting Playwright to it. All you need to do is instantiate a class, and you get a ready-to-use `Browser` object. This lets you focus on writing your automation script, not on the boilerplate.
|
|
27
|
+
|
|
28
|
+
The core idea is to let you do this:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from playwrightauthor import Browser
|
|
32
|
+
|
|
33
|
+
with Browser() as browser:
|
|
34
|
+
# you get a standard Playwright browser object
|
|
35
|
+
# that is already connected to a logged-in browser
|
|
36
|
+
page = browser.new_page()
|
|
37
|
+
page.goto("https://github.com/me")
|
|
38
|
+
print(f"Welcome, {page.locator('.user-profile-name').inner_text()}!")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Contents
|
|
44
|
+
|
|
45
|
+
* [Features](#features)
|
|
46
|
+
* [Quick‑start](#quick-start)
|
|
47
|
+
* [Developer workflow](#developer-workflow)
|
|
48
|
+
* [Package layout](#package-layout) – **file tree, code snippet, explanation & rationale for every file**
|
|
49
|
+
* [Contributing](#contributing)
|
|
50
|
+
* [License](#license)
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Features
|
|
55
|
+
|
|
56
|
+
| ✔︎ | Capability |
|
|
57
|
+
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
58
|
+
| Automatically discovers or installs a suitable **Chrome for Testing** build via the *last‑known‑good‑versions* JSON, falling back to `npx puppeteer` when possible. | |
|
|
59
|
+
| Launches Chrome with `--remote-debugging-port` (killing any non‑debug instance first) and shows a friendly onboarding HTML if the user still needs to log in. | |
|
|
60
|
+
| Provides simple `Browser()` and `AsyncBrowser()` classes that return a ready-to-use, authenticated Playwright browser object. | |
|
|
61
|
+
| Fire‑powered CLI (`python -m playwrightauthor ...`) with rich‐colour output and `--verbose` flag wiring straight into **loguru**. | |
|
|
62
|
+
| 100 % type‑hinted, PEP 8‑compliant, flat, self‑documenting codebase; every source has a `this_file:` tracker line. | |
|
|
63
|
+
| Batteries included: pytest suite, Ruff/pyupgrade/autoflake hooks, uv‑based reproducible environments. | |
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Quick start
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# ➀ create & sync env
|
|
71
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
72
|
+
uv venv --python 3.12
|
|
73
|
+
uv init
|
|
74
|
+
# Add playwrightauthor and its dependencies
|
|
75
|
+
uv add playwright rich fire loguru platformdirs requests psutil
|
|
76
|
+
|
|
77
|
+
# ➁ run your script
|
|
78
|
+
# (create a file like 'myscript.py' with the code below)
|
|
79
|
+
python myscript.py
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Example `myscript.py`:
|
|
83
|
+
```python
|
|
84
|
+
from playwrightauthor import Browser, AsyncBrowser
|
|
85
|
+
import asyncio
|
|
86
|
+
|
|
87
|
+
# Synchronous API
|
|
88
|
+
print("--- Running Sync Example ---")
|
|
89
|
+
with Browser(verbose=True) as browser:
|
|
90
|
+
page = browser.new_page()
|
|
91
|
+
page.goto("https://github.com")
|
|
92
|
+
print(f"Page title: {page.title()}")
|
|
93
|
+
|
|
94
|
+
# Asynchronous API
|
|
95
|
+
async def main():
|
|
96
|
+
print("\n--- Running Async Example ---")
|
|
97
|
+
async with AsyncBrowser(verbose=True) as browser:
|
|
98
|
+
page = await browser.new_page()
|
|
99
|
+
await page.goto("https://duckduckgo.com")
|
|
100
|
+
print(f"Page title: {await page.title()}")
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
asyncio.run(main())
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Command-Line Interface
|
|
109
|
+
|
|
110
|
+
PlaywrightAuthor comes with a command-line interface for managing the browser installation.
|
|
111
|
+
|
|
112
|
+
### `status`
|
|
113
|
+
|
|
114
|
+
Checks the status of the browser and launches it if it's not running.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
python -m playwrightauthor status
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### `clear-cache`
|
|
121
|
+
|
|
122
|
+
Removes the browser installation directory, including the browser itself and user data.
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python -m playwrightauthor clear-cache
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Developer workflow
|
|
131
|
+
|
|
132
|
+
1. **Read** `WORK.md` & `PLAN.md` before touching code.
|
|
133
|
+
|
|
134
|
+
2. **Iterate** in minimal, self‑contained commits.
|
|
135
|
+
|
|
136
|
+
3. After Python changes run:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
fd -e py -x uvx autoflake -i {}; \
|
|
140
|
+
fd -e py -x uvx pyupgrade --py312-plus {}; \
|
|
141
|
+
fd -e py -x uvx ruff check --output-format=github --fix --unsafe-fixes {}; \
|
|
142
|
+
fd -e py -x uvx ruff format --respect-gitignore --target-version py312 {}; \
|
|
143
|
+
python -m pytest
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
4. Update `CHANGELOG.md`, tick items in `TODO.md`, push.
|
|
147
|
+
|
|
148
|
+
5. Always finish a work session with **“Wait, but”** → reflect → refine → push again.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Package layout
|
|
153
|
+
|
|
154
|
+
> Below is the *envisioned* file tree.
|
|
155
|
+
> Each entry shows (a) **code snippet** – only the essential lines,
|
|
156
|
+
> (b) **explanation** – what it does,
|
|
157
|
+
> (c) **rationale** – why it belongs.
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
.
|
|
161
|
+
├── playwrightauthor/
|
|
162
|
+
│ ├── __init__.py
|
|
163
|
+
│ ├── __main__.py
|
|
164
|
+
│ ├── cli.py
|
|
165
|
+
│ ├── author.py
|
|
166
|
+
│ ├── browser_manager.py
|
|
167
|
+
│ ├── onboarding.py
|
|
168
|
+
│ ├── templates/
|
|
169
|
+
│ │ └── onboarding.html
|
|
170
|
+
│ ├── utils/
|
|
171
|
+
│ │ ├── logger.py
|
|
172
|
+
│ │ └── paths.py
|
|
173
|
+
│ └── typing.py
|
|
174
|
+
├── tests/
|
|
175
|
+
│ └── test_author.py
|
|
176
|
+
├── README.md ← *← this file*
|
|
177
|
+
├── PLAN.md
|
|
178
|
+
├── TODO.md
|
|
179
|
+
├── WORK.md
|
|
180
|
+
└── CHANGELOG.md
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### `playwrightauthor/__init__.py`
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
#!/usr/bin/env -S uv run -s
|
|
187
|
+
# /// script
|
|
188
|
+
# dependencies = []
|
|
189
|
+
# ///
|
|
190
|
+
# this_file: playwrightauthor/__init__.py
|
|
191
|
+
|
|
192
|
+
"""Public re‑exports for library consumers."""
|
|
193
|
+
from .author import Browser, AsyncBrowser
|
|
194
|
+
|
|
195
|
+
__all__ = ["Browser", "AsyncBrowser"]
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
*Explanation* – Presents a tiny, stable API surface.
|
|
199
|
+
*Rationale* – Hides internal churn; semver‑compatible.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
### `playwrightauthor/__main__.py`
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
#!/usr/bin/env -S uv run -s
|
|
208
|
+
# /// script
|
|
209
|
+
# dependencies = ["fire"]
|
|
210
|
+
# ///
|
|
211
|
+
# this_file: playwrightauthor/__main__.py
|
|
212
|
+
|
|
213
|
+
""`python -m playwrightauthor` entry‑point."""
|
|
214
|
+
from .cli import main
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
main()
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
*Explanation* – Delegates to the Fire CLI.
|
|
221
|
+
*Rationale* – Keeps `__init__` import‑only; avoids side‑effects.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
### `playwrightauthor/cli.py`
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
#!/usr/bin/env -S uv run -s
|
|
230
|
+
# /// script
|
|
231
|
+
# dependencies = ["fire", "rich"]
|
|
232
|
+
# ///
|
|
233
|
+
# this_file: playwrightauthor/cli.py
|
|
234
|
+
|
|
235
|
+
"""Fire‑powered command‑line interface for utility tasks."""
|
|
236
|
+
from rich.console import Console
|
|
237
|
+
from .browser_manager import ensure_browser
|
|
238
|
+
|
|
239
|
+
def status(verbose: bool = False) -> None:
|
|
240
|
+
"""Checks browser status and launches it if not running."""
|
|
241
|
+
console = Console()
|
|
242
|
+
console.print("Checking browser status...")
|
|
243
|
+
browser_path, data_dir = ensure_browser(verbose=verbose)
|
|
244
|
+
console.print(f"[green]Browser is ready.[/green]")
|
|
245
|
+
console.print(f" - Path: {browser_path}")
|
|
246
|
+
console.print(f" - User Data: {data_dir}")
|
|
247
|
+
|
|
248
|
+
def main() -> None:
|
|
249
|
+
import fire
|
|
250
|
+
fire.Fire({"status": status})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
*Explanation* – Offers utility commands like `status`.
|
|
254
|
+
*Rationale* – Provides a simple way to manage the browser without writing a script.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
### `playwrightauthor/author.py`
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
#!/usr/bin/env -S uv run -s
|
|
263
|
+
# /// script
|
|
264
|
+
# dependencies = ["playwright"]
|
|
265
|
+
# ///
|
|
266
|
+
# this_file: playwrightauthor/author.py
|
|
267
|
+
|
|
268
|
+
"""The core Browser and AsyncBrowser classes."""
|
|
269
|
+
from playwright.sync_api import sync_playwright, Playwright, Browser as PlaywrightBrowser
|
|
270
|
+
from playwright.async_api import async_playwright, AsyncPlaywright, Browser as AsyncPlaywrightBrowser
|
|
271
|
+
from .browser_manager import ensure_browser
|
|
272
|
+
|
|
273
|
+
class Browser:
|
|
274
|
+
"""A sync context manager for an authenticated Playwright Browser."""
|
|
275
|
+
def __init__(self, verbose: bool = False):
|
|
276
|
+
self.verbose = verbose
|
|
277
|
+
|
|
278
|
+
def __enter__(self) -> PlaywrightBrowser:
|
|
279
|
+
# 1. Ensure browser is running and get debug port
|
|
280
|
+
# 2. playwright.chromium.connect_over_cdp()
|
|
281
|
+
# 3. Return browser object
|
|
282
|
+
...
|
|
283
|
+
|
|
284
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
285
|
+
# Disconnect
|
|
286
|
+
...
|
|
287
|
+
|
|
288
|
+
class AsyncBrowser:
|
|
289
|
+
"""An async context manager for an authenticated Playwright Browser."""
|
|
290
|
+
def __init__(self, verbose: bool = False):
|
|
291
|
+
self.verbose = verbose
|
|
292
|
+
|
|
293
|
+
async def __aenter__(self) -> AsyncPlaywrightBrowser:
|
|
294
|
+
...
|
|
295
|
+
|
|
296
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
297
|
+
...
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
*Explanation* – The main entry point for the library.
|
|
301
|
+
*Rationale* – Provides a simple, Pythonic `with` statement syntax for browser management.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
### `playwrightauthor/browser_manager.py`
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
#!/usr/bin/env -S uv run -s
|
|
310
|
+
# /// script
|
|
311
|
+
# dependencies = ["requests", "platformdirs", "rich", "psutil"]
|
|
312
|
+
# ///
|
|
313
|
+
# this_file: playwrightauthor/browser_manager.py
|
|
314
|
+
|
|
315
|
+
"""
|
|
316
|
+
Ensure a Chrome for Testing build is present & running in debug mode.
|
|
317
|
+
|
|
318
|
+
Algorithm:
|
|
319
|
+
1. Try to connect to localhost:9222.
|
|
320
|
+
2. If Chrome is running *without* --remote-debugging-port ⇒ kill & restart.
|
|
321
|
+
3. If Chrome isn't installed:
|
|
322
|
+
3a. Prefer `npx puppeteer browsers install`.
|
|
323
|
+
3b. Else download the matching archive from LKGV JSON.
|
|
324
|
+
4. Launch Chrome with a persistent user‑data‑dir.
|
|
325
|
+
"""
|
|
326
|
+
import os, subprocess, json, sys, platform, shutil, tempfile
|
|
327
|
+
from pathlib import Path
|
|
328
|
+
from rich.console import Console
|
|
329
|
+
from .utils.paths import install_dir
|
|
330
|
+
|
|
331
|
+
_LKGV_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json"
|
|
332
|
+
|
|
333
|
+
def ensure_browser(verbose: bool = False):
|
|
334
|
+
console = Console()
|
|
335
|
+
...
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
*Explanation* – Central authority for “is Chrome available?”.
|
|
339
|
+
*Rationale* – Encapsulates the complex, platform-specific logic of managing the browser binary and its process.
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
### `playwrightauthor/onboarding.py`
|
|
345
|
+
|
|
346
|
+
```python
|
|
347
|
+
#!/usr/bin/env -S uv run -s
|
|
348
|
+
# /// script
|
|
349
|
+
# dependencies = ["playwright"]
|
|
350
|
+
# ///
|
|
351
|
+
# this_file: playwrightauthor/onboarding.py
|
|
352
|
+
|
|
353
|
+
"""Serve a local HTML page instructing the user to log in."""
|
|
354
|
+
from pathlib import Path
|
|
355
|
+
from playwright.async_api import Browser
|
|
356
|
+
|
|
357
|
+
def show(browser: Browser) -> None:
|
|
358
|
+
html = Path(__file__).parent.parent / "templates" / "onboarding.html"
|
|
359
|
+
page = browser.new_page()
|
|
360
|
+
page.set_content(html.read_text("utf-8"), wait_until="domcontentloaded")
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
*Explanation* – Visual cue when manual steps are needed.
|
|
364
|
+
*Rationale* – Human‑friendly recovery path builds trust.
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
### `playwrightauthor/templates/onboarding.html`
|
|
370
|
+
|
|
371
|
+
```html
|
|
372
|
+
<!-- this_file: playwrightauthor/templates/onboarding.html -->
|
|
373
|
+
<!DOCTYPE html>
|
|
374
|
+
<html>
|
|
375
|
+
<body>
|
|
376
|
+
<h1>One small step…</h1>
|
|
377
|
+
<p>Please <strong>log into any websites you need</strong> in this browser window.<br>
|
|
378
|
+
This session will be saved for future runs.<br><br>
|
|
379
|
+
You can close this tab and return to the terminal when you’re done!</p>
|
|
380
|
+
</body>
|
|
381
|
+
</html>
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
*Explanation* – Tiny static asset for user guidance.
|
|
385
|
+
*Rationale* – Kept under `templates/` to avoid clutter.
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
### `playwrightauthor/utils/logger.py`
|
|
391
|
+
|
|
392
|
+
```python
|
|
393
|
+
#!/usr/bin/env -S uv run -s
|
|
394
|
+
# /// script
|
|
395
|
+
# dependencies = ["loguru"]
|
|
396
|
+
# ///
|
|
397
|
+
# this_file: playwrightauthor/utils/logger.py
|
|
398
|
+
|
|
399
|
+
"""Project‑wide Loguru configuration."""
|
|
400
|
+
from loguru import logger
|
|
401
|
+
|
|
402
|
+
def configure(verbose: bool = False):
|
|
403
|
+
logger.remove()
|
|
404
|
+
level = "DEBUG" if verbose else "INFO"
|
|
405
|
+
logger.add(lambda m: print(m, end=""), level=level)
|
|
406
|
+
return logger
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
*Explanation* – Single point of logging policy.
|
|
410
|
+
*Rationale* – Avoids duplicating “verbose” handling everywhere.
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
### `playwrightauthor/utils/paths.py`
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
#!/usr/bin/env -S uv run -s
|
|
419
|
+
# /// script
|
|
420
|
+
# dependencies = ["platformdirs"]
|
|
421
|
+
# ///
|
|
422
|
+
# this_file: playwrightauthor/utils/paths.py
|
|
423
|
+
|
|
424
|
+
"""Cross‑platform install locations."""
|
|
425
|
+
from platformdirs import user_cache_dir
|
|
426
|
+
from pathlib import Path
|
|
427
|
+
|
|
428
|
+
def install_dir() -> Path:
|
|
429
|
+
return Path(user_cache_dir("playwrightauthor", roaming=True)) / "browser"
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
*Explanation* – Abstracts OS differences.
|
|
433
|
+
*Rationale* – Ensures browser data is stored in a conventional, user-specific location.
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
### `tests/test_author.py`
|
|
439
|
+
|
|
440
|
+
```python
|
|
441
|
+
# this_file: tests/test_author.py
|
|
442
|
+
import pytest
|
|
443
|
+
from playwrightauthor import Browser
|
|
444
|
+
|
|
445
|
+
@pytest.mark.skip("requires live Chrome and user interaction")
|
|
446
|
+
def test_browser_smoke():
|
|
447
|
+
"""A basic smoke test to ensure the Browser class can be instantiated."""
|
|
448
|
+
try:
|
|
449
|
+
with Browser() as browser:
|
|
450
|
+
assert browser is not None
|
|
451
|
+
assert len(browser.contexts) > 0
|
|
452
|
+
except Exception as e:
|
|
453
|
+
# This test is expected to fail in a headless CI environment
|
|
454
|
+
# without a display server or a running Chrome instance.
|
|
455
|
+
# We just check that it doesn't raise an unexpected error.
|
|
456
|
+
pass
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
*Explanation* – Smoke test for the core `Browser` class.
|
|
460
|
+
*Rationale* – Keeps CI fast but ensures the main library entry point is importable and structurally sound.
|
|
461
|
+
|
|
462
|
+
---
|
|
463
|
+
|
|
464
|
+
## Troubleshooting
|
|
465
|
+
|
|
466
|
+
### `BrowserManagerError: Could not find Chrome executable...`
|
|
467
|
+
|
|
468
|
+
This error means that `playwrightauthor` could not find a Chrome executable on your system. You can either install Chrome for Testing using the `npx puppeteer browsers install chrome` command, or install Google Chrome and ensure it is in a common system location.
|
|
469
|
+
|
|
470
|
+
### `playwright._impl._api_types.Error: Target page, context or browser has been closed`
|
|
471
|
+
|
|
472
|
+
This error usually means that the browser was closed while your script was running. This can happen if you manually close the browser window, or if the browser crashes. If you are running into this issue, you can try running your script with the `--verbose` flag to get more information.
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
476
|
+
## Contributing
|
|
477
|
+
|
|
478
|
+
Pull‑requests are welcome! Please follow the **General Coding Principles** in the main `README.md`, keep every file’s `this_file` header accurate, and end each session with a short *“Wait, but”* reflection in your PR description.
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## License
|
|
483
|
+
|
|
484
|
+
MIT – *see* `LICENSE`.
|
|
485
|
+
|
|
486
|
+
---
|
|
487
|
+
|
|
488
|
+
Wait, but…
|
|
489
|
+
|
|
490
|
+
**Reflection & tiny refinements**
|
|
491
|
+
|
|
492
|
+
* Refocused the entire project from a specific scraper to a general-purpose Playwright convenience library.
|
|
493
|
+
* The core API is now class-based (`Browser`, `AsyncBrowser`) for a more Pythonic feel.
|
|
494
|
+
* Updated the file layout (`author.py`) and CLI (`status` command) to match the new scope.
|
|
495
|
+
* Generalized the onboarding HTML to be site-agnostic.
|
|
496
|
+
* Ensured all snippets and explanations align with the new vision of providing a zero-setup, authenticated browser.
|
|
497
|
+
|
|
498
|
+
(End of iteration – ready for review.)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
playwrightauthor/__init__.py,sha256=TbNVREWLCEupn7b6JnzjXlyd6f8ncxFwU6L0QaHqUA0,239
|
|
2
|
+
playwrightauthor/__main__.py,sha256=S0yG-x3IVqq2qzT-lkHV3d54qHPEzr7irESbyPwO7nk,226
|
|
3
|
+
playwrightauthor/author.py,sha256=9qtN3YBtM1dwEkeWRo9xYAjg1kh2LRVKT9UrKfqauvc,3185
|
|
4
|
+
playwrightauthor/browser_manager.py,sha256=tYQsaz_vAl5KzZxbXo1xdQK4rFohNmkwapZ10r-QXQQ,3354
|
|
5
|
+
playwrightauthor/cli.py,sha256=EQun2eQhmkejn4Ezo2NEiKDVg492W1BjVPdH_o2ztgg,1733
|
|
6
|
+
playwrightauthor/exceptions.py,sha256=c9h1awC5y273V--pT28CmQlpJSZmhExWDgBo5v2Kl9U,318
|
|
7
|
+
playwrightauthor/onboarding.py,sha256=bpYQNnhwny04ZNMPVBFJnRspVIBCEEVtH9lBX_L7eag,1011
|
|
8
|
+
playwrightauthor/typing.py,sha256=5WRtQY2BtnXeYTTV3kDoFwXFJ05Kiys_lf_PzRmvh3o,82
|
|
9
|
+
playwrightauthor/browser/__init__.py,sha256=VeEX7QUTODa6Q6jQYZxaCgdxbhmrD1mmxTGy6Bh4xhI,50
|
|
10
|
+
playwrightauthor/browser/finder.py,sha256=_mgPhgl7M261dcDAAJeNgdoZOfko6RoKnH-L_QmSTmM,1599
|
|
11
|
+
playwrightauthor/browser/installer.py,sha256=Rde1xnjawSKnQ5pSbBwLEurF-FtGkh5LKLGVJF6D-ao,2772
|
|
12
|
+
playwrightauthor/browser/launcher.py,sha256=gpCymvVc15SZ-T-dOn4FAPHDn-0bARRln4Dku2S7A6Q,873
|
|
13
|
+
playwrightauthor/browser/process.py,sha256=t7sjeSU7jqZlm2QKfHC0JVpufQ8CqBXPz7hzdSDcoUQ,673
|
|
14
|
+
playwrightauthor/templates/onboarding.html,sha256=KsxWCMJHaNP2SpgTkmBuFCtEqoKgWd0Kelh_C0pJii8,1200
|
|
15
|
+
playwrightauthor/utils/logger.py,sha256=gTLfcWs1Rxsxa8v9-tQU7vcPQS525JEqmnw2gXjSWR0,367
|
|
16
|
+
playwrightauthor/utils/paths.py,sha256=9c47252Z6EXQahptRW3G13HeAJdzwIvMfYGJC6fg3VI,326
|
|
17
|
+
playwrightauthor-1.0.2.dist-info/METADATA,sha256=2e-QEMC-k7Bz1R1yPdmDF7dQzLrZKQOgG343h_6-bpI,15194
|
|
18
|
+
playwrightauthor-1.0.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
19
|
+
playwrightauthor-1.0.2.dist-info/entry_points.txt,sha256=4tFt94NbqiN86XWVWg4ph12PVTNfCKERvrbvc-uNeMA,63
|
|
20
|
+
playwrightauthor-1.0.2.dist-info/licenses/LICENSE,sha256=ZMAJpSuarzr23IJGvncVH1ouB-SWSySQTQtSc3Pmrsg,1070
|
|
21
|
+
playwrightauthor-1.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Adam Twardoch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|