chromeappcap 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chromeappcap-0.1.0/PKG-INFO +106 -0
- chromeappcap-0.1.0/README.md +83 -0
- chromeappcap-0.1.0/pyproject.toml +46 -0
- chromeappcap-0.1.0/setup.cfg +4 -0
- chromeappcap-0.1.0/src/chromeappcap/__init__.py +4 -0
- chromeappcap-0.1.0/src/chromeappcap/cli.py +730 -0
- chromeappcap-0.1.0/src/chromeappcap.egg-info/PKG-INFO +106 -0
- chromeappcap-0.1.0/src/chromeappcap.egg-info/SOURCES.txt +12 -0
- chromeappcap-0.1.0/src/chromeappcap.egg-info/dependency_links.txt +1 -0
- chromeappcap-0.1.0/src/chromeappcap.egg-info/entry_points.txt +2 -0
- chromeappcap-0.1.0/src/chromeappcap.egg-info/requires.txt +9 -0
- chromeappcap-0.1.0/src/chromeappcap.egg-info/top_level.txt +1 -0
- chromeappcap-0.1.0/tests/test_cli_utils.py +40 -0
- chromeappcap-0.1.0/tests/test_output_paths.py +21 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chromeappcap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for polished website screenshots
|
|
5
|
+
Author: William Blackie
|
|
6
|
+
Keywords: cli,screenshot,chrome,playwright,automation
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Topic :: Multimedia :: Graphics :: Capture :: Screen Capture
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: typer<1.0,>=0.12
|
|
18
|
+
Requires-Dist: playwright<2.0,>=1.44
|
|
19
|
+
Requires-Dist: Pillow<12.0,>=10.0
|
|
20
|
+
Requires-Dist: pyobjc-framework-Quartz<12.0,>=11.0; sys_platform == "darwin"
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
|
23
|
+
|
|
24
|
+
# chromeappcap
|
|
25
|
+
|
|
26
|
+
Screenshots that look like you meant to share them.
|
|
27
|
+
|
|
28
|
+
`chromeappcap` is a CLI for polished website/app screenshots with native Chrome app-window capture on macOS and Playwright fallback everywhere else.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
### `pipx` (recommended)
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pipx install chromeappcap
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### `uvx` (zero install)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uvx --from git+https://github.com/William-Blackie/chromeappcap.git chromeappcap https://example.com
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### local dev
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git clone https://github.com/William-Blackie/chromeappcap.git
|
|
48
|
+
cd chromeappcap
|
|
49
|
+
python3 -m venv .venv
|
|
50
|
+
source .venv/bin/activate
|
|
51
|
+
pip install -e .[dev]
|
|
52
|
+
playwright install chromium
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
make dev-install
|
|
59
|
+
make playwright-install
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick Start
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
chromeappcap https://example.com
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
By default, output is saved in your current shell directory.
|
|
69
|
+
|
|
70
|
+
## Common Commands
|
|
71
|
+
|
|
72
|
+
Native app-window capture (macOS):
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
chromeappcap https://example.com --capture-mode app -o native.png
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
High-quality compressed WebP:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
chromeappcap https://example.com --capture-mode app --device-scale 2.5 --format webp --compress --quality 85 -o shot.webp
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Cross-platform page fallback with no synthetic frame:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
chromeappcap https://example.com --capture-mode page --no-frame -o raw.png
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Notes
|
|
91
|
+
|
|
92
|
+
- `--capture-mode auto` prefers app mode on macOS and falls back to page mode.
|
|
93
|
+
- `--capture-mode page` requires Playwright Chromium:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
playwright install chromium
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- macOS app mode requires Screen Recording permission:
|
|
100
|
+
- `System Settings > Privacy & Security > Screen Recording`
|
|
101
|
+
|
|
102
|
+
## Help
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
chromeappcap --help
|
|
106
|
+
```
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# chromeappcap
|
|
2
|
+
|
|
3
|
+
Screenshots that look like you meant to share them.
|
|
4
|
+
|
|
5
|
+
`chromeappcap` is a CLI for polished website/app screenshots with native Chrome app-window capture on macOS and Playwright fallback everywhere else.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
### `pipx` (recommended)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pipx install chromeappcap
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### `uvx` (zero install)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uvx --from git+https://github.com/William-Blackie/chromeappcap.git chromeappcap https://example.com
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### local dev
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
git clone https://github.com/William-Blackie/chromeappcap.git
|
|
25
|
+
cd chromeappcap
|
|
26
|
+
python3 -m venv .venv
|
|
27
|
+
source .venv/bin/activate
|
|
28
|
+
pip install -e .[dev]
|
|
29
|
+
playwright install chromium
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
make dev-install
|
|
36
|
+
make playwright-install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
chromeappcap https://example.com
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
By default, output is saved in your current shell directory.
|
|
46
|
+
|
|
47
|
+
## Common Commands
|
|
48
|
+
|
|
49
|
+
Native app-window capture (macOS):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
chromeappcap https://example.com --capture-mode app -o native.png
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
High-quality compressed WebP:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
chromeappcap https://example.com --capture-mode app --device-scale 2.5 --format webp --compress --quality 85 -o shot.webp
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Cross-platform page fallback with no synthetic frame:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
chromeappcap https://example.com --capture-mode page --no-frame -o raw.png
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Notes
|
|
68
|
+
|
|
69
|
+
- `--capture-mode auto` prefers app mode on macOS and falls back to page mode.
|
|
70
|
+
- `--capture-mode page` requires Playwright Chromium:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
playwright install chromium
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- macOS app mode requires Screen Recording permission:
|
|
77
|
+
- `System Settings > Privacy & Security > Screen Recording`
|
|
78
|
+
|
|
79
|
+
## Help
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
chromeappcap --help
|
|
83
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chromeappcap"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI for polished website screenshots"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "William Blackie" }]
|
|
12
|
+
keywords = ["cli", "screenshot", "chrome", "playwright", "automation"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
"Topic :: Multimedia :: Graphics :: Capture :: Screen Capture",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"typer>=0.12,<1.0",
|
|
25
|
+
"playwright>=1.44,<2.0",
|
|
26
|
+
"Pillow>=10.0,<12.0",
|
|
27
|
+
"pyobjc-framework-Quartz>=11.0,<12.0; sys_platform == 'darwin'",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=8.0,<9.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
chromeappcap = "chromeappcap.cli:run"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools]
|
|
39
|
+
package-dir = {"" = "src"}
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
addopts = "-q"
|
|
46
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
from PIL import Image, ImageDraw, ImageFilter
|
|
19
|
+
from playwright.async_api import Error as PlaywrightError
|
|
20
|
+
from playwright.async_api import async_playwright
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
|
|
24
|
+
APP_NAME = "chromeappcap"
|
|
25
|
+
SUPPORTED_FORMATS = {"png", "jpeg", "jpg", "webp"}
|
|
26
|
+
SUPPORTED_CAPTURE_MODES = {"auto", "app", "page"}
|
|
27
|
+
HTTP_SCHEMES = {"http", "https"}
|
|
28
|
+
DEFAULT_CHROME_BINARY_MAC = Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def warn(message: str) -> None:
|
|
32
|
+
typer.echo(message, err=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_url(raw_url: str) -> str:
|
|
36
|
+
value = raw_url.strip()
|
|
37
|
+
parsed = urlparse(value)
|
|
38
|
+
|
|
39
|
+
if not parsed.scheme:
|
|
40
|
+
value = f"https://{value}"
|
|
41
|
+
parsed = urlparse(value)
|
|
42
|
+
|
|
43
|
+
if parsed.scheme not in HTTP_SCHEMES:
|
|
44
|
+
raise typer.BadParameter("URL must start with http:// or https://")
|
|
45
|
+
if not parsed.netloc:
|
|
46
|
+
raise typer.BadParameter("URL is missing a hostname")
|
|
47
|
+
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def normalize_output_format(input_format: Optional[str], output_path: Path) -> str:
|
|
52
|
+
if input_format:
|
|
53
|
+
output_format = input_format.strip().lower()
|
|
54
|
+
if output_format not in SUPPORTED_FORMATS:
|
|
55
|
+
allowed = ", ".join(sorted(SUPPORTED_FORMATS))
|
|
56
|
+
raise typer.BadParameter(f"Invalid --format. Use one of: {allowed}")
|
|
57
|
+
return "jpeg" if output_format == "jpg" else output_format
|
|
58
|
+
|
|
59
|
+
suffix = output_path.suffix.lower().lstrip(".")
|
|
60
|
+
if suffix in SUPPORTED_FORMATS:
|
|
61
|
+
return "jpeg" if suffix == "jpg" else suffix
|
|
62
|
+
return "png"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def normalize_capture_mode(mode: str) -> str:
|
|
66
|
+
value = mode.strip().lower()
|
|
67
|
+
if value not in SUPPORTED_CAPTURE_MODES:
|
|
68
|
+
allowed = ", ".join(sorted(SUPPORTED_CAPTURE_MODES))
|
|
69
|
+
raise typer.BadParameter(f"Invalid --capture-mode. Use one of: {allowed}")
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ensure_output_extension(path: Path, output_format: str) -> Path:
|
|
74
|
+
wanted_suffix = ".jpg" if output_format == "jpeg" else f".{output_format}"
|
|
75
|
+
|
|
76
|
+
if path.suffix.lower() == wanted_suffix:
|
|
77
|
+
return path
|
|
78
|
+
if path.suffix:
|
|
79
|
+
return path.with_suffix(wanted_suffix)
|
|
80
|
+
return path.parent / f"{path.name}{wanted_suffix}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def default_output_path(url: str) -> Path:
|
|
84
|
+
parsed = urlparse(url)
|
|
85
|
+
hostname = parsed.netloc or "capture"
|
|
86
|
+
safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "-", hostname).strip("-") or "capture"
|
|
87
|
+
return Path.cwd() / f"{safe_name}.png"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_output_path(path: Path) -> Path:
|
|
91
|
+
expanded = path.expanduser()
|
|
92
|
+
if expanded.is_absolute():
|
|
93
|
+
return expanded
|
|
94
|
+
return Path.cwd() / expanded
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def resolve_capture_order(mode: str) -> list[str]:
|
|
98
|
+
if mode == "auto":
|
|
99
|
+
return ["app", "page"] if sys.platform == "darwin" else ["page"]
|
|
100
|
+
return [mode]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def resolve_chrome_binary(custom_path: Optional[Path]) -> Path:
|
|
104
|
+
if custom_path is not None:
|
|
105
|
+
candidate = custom_path.expanduser().resolve()
|
|
106
|
+
if not candidate.exists():
|
|
107
|
+
raise RuntimeError(f"Chrome binary not found: {candidate}")
|
|
108
|
+
return candidate
|
|
109
|
+
|
|
110
|
+
if DEFAULT_CHROME_BINARY_MAC.exists():
|
|
111
|
+
return DEFAULT_CHROME_BINARY_MAC
|
|
112
|
+
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
"Chrome binary not found. Install Google Chrome or pass --chrome-binary /path/to/Google Chrome"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def capture_page_png(
|
|
119
|
+
url: str,
|
|
120
|
+
width: int,
|
|
121
|
+
height: int,
|
|
122
|
+
timeout_ms: int,
|
|
123
|
+
settle_ms: int,
|
|
124
|
+
full_page: bool,
|
|
125
|
+
browser_channel: str,
|
|
126
|
+
headed: bool,
|
|
127
|
+
device_scale: float,
|
|
128
|
+
) -> bytes:
|
|
129
|
+
async with async_playwright() as playwright:
|
|
130
|
+
browser = None
|
|
131
|
+
try:
|
|
132
|
+
browser = await playwright.chromium.launch(
|
|
133
|
+
channel=browser_channel,
|
|
134
|
+
headless=not headed,
|
|
135
|
+
)
|
|
136
|
+
except PlaywrightError:
|
|
137
|
+
if browser_channel != "chrome":
|
|
138
|
+
raise
|
|
139
|
+
warn("Warning: channel='chrome' unavailable, falling back to bundled Chromium.")
|
|
140
|
+
browser = await playwright.chromium.launch(headless=not headed)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
context = await browser.new_context(
|
|
144
|
+
viewport={"width": width, "height": height},
|
|
145
|
+
device_scale_factor=device_scale,
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
page = await context.new_page()
|
|
149
|
+
await page.goto(url, wait_until="networkidle", timeout=timeout_ms)
|
|
150
|
+
if settle_ms > 0:
|
|
151
|
+
await page.wait_for_timeout(settle_ms)
|
|
152
|
+
return await page.screenshot(type="png", full_page=full_page)
|
|
153
|
+
finally:
|
|
154
|
+
await context.close()
|
|
155
|
+
finally:
|
|
156
|
+
await browser.close()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def macos_visible_chrome_windows(quartz_module: object) -> list[dict[str, int]]:
|
|
160
|
+
windows = quartz_module.CGWindowListCopyWindowInfo(
|
|
161
|
+
quartz_module.kCGWindowListOptionOnScreenOnly,
|
|
162
|
+
quartz_module.kCGNullWindowID,
|
|
163
|
+
)
|
|
164
|
+
if windows is None:
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
owner_names = {"Google Chrome", "Chromium"}
|
|
168
|
+
visible_windows: list[dict[str, int]] = []
|
|
169
|
+
|
|
170
|
+
for window in windows:
|
|
171
|
+
owner_name = str(window.get(quartz_module.kCGWindowOwnerName, ""))
|
|
172
|
+
if owner_name not in owner_names:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
layer = int(window.get(quartz_module.kCGWindowLayer, 1))
|
|
176
|
+
if layer != 0:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
bounds = window.get(quartz_module.kCGWindowBounds, {}) or {}
|
|
180
|
+
width = int(bounds.get("Width", 0))
|
|
181
|
+
height = int(bounds.get("Height", 0))
|
|
182
|
+
window_id = int(window.get(quartz_module.kCGWindowNumber, 0))
|
|
183
|
+
owner_pid = int(window.get(quartz_module.kCGWindowOwnerPID, 0))
|
|
184
|
+
|
|
185
|
+
if window_id <= 0 or width < 160 or height < 140:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
visible_windows.append(
|
|
189
|
+
{
|
|
190
|
+
"id": window_id,
|
|
191
|
+
"width": width,
|
|
192
|
+
"height": height,
|
|
193
|
+
"owner_pid": owner_pid,
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return visible_windows
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def rank_app_window_ids(
|
|
201
|
+
windows: list[dict[str, int]],
|
|
202
|
+
baseline_ids: set[int],
|
|
203
|
+
target_width: int,
|
|
204
|
+
target_height: int,
|
|
205
|
+
owner_pid: Optional[int] = None,
|
|
206
|
+
) -> list[int]:
|
|
207
|
+
if not windows:
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
def score(window: dict[str, int]) -> int:
|
|
211
|
+
return abs(window["width"] - target_width) + abs(window["height"] - target_height)
|
|
212
|
+
|
|
213
|
+
if owner_pid is not None:
|
|
214
|
+
owned = [window for window in windows if window.get("owner_pid") == owner_pid]
|
|
215
|
+
if owned:
|
|
216
|
+
return [window["id"] for window in sorted(owned, key=score)]
|
|
217
|
+
|
|
218
|
+
new_windows = [window for window in windows if window["id"] not in baseline_ids]
|
|
219
|
+
if new_windows:
|
|
220
|
+
return [window["id"] for window in sorted(new_windows, key=score)]
|
|
221
|
+
|
|
222
|
+
if baseline_ids:
|
|
223
|
+
return []
|
|
224
|
+
|
|
225
|
+
return [window["id"] for window in sorted(windows, key=score)]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def ensure_macos_screen_recording_access(quartz_module: object) -> None:
|
|
229
|
+
checker = getattr(quartz_module, "CGPreflightScreenCaptureAccess", None)
|
|
230
|
+
if checker is None:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
granted = bool(checker())
|
|
235
|
+
except Exception:
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
if not granted:
|
|
239
|
+
raise RuntimeError(
|
|
240
|
+
"Screen Recording permission is not granted for this app/terminal. "
|
|
241
|
+
"Enable it in System Settings > Privacy & Security > Screen Recording, then retry."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def try_screencapture_window(
|
|
246
|
+
window_id: int,
|
|
247
|
+
destination: Path,
|
|
248
|
+
command_timeout_seconds: float = 4.0,
|
|
249
|
+
) -> Optional[str]:
|
|
250
|
+
try:
|
|
251
|
+
result = subprocess.run(
|
|
252
|
+
["screencapture", "-x", "-o", "-l", str(window_id), str(destination)],
|
|
253
|
+
check=False,
|
|
254
|
+
capture_output=True,
|
|
255
|
+
text=True,
|
|
256
|
+
timeout=command_timeout_seconds,
|
|
257
|
+
)
|
|
258
|
+
except subprocess.TimeoutExpired:
|
|
259
|
+
return "timed out waiting for screencapture"
|
|
260
|
+
|
|
261
|
+
if result.returncode != 0:
|
|
262
|
+
return (result.stderr or result.stdout or "unknown error").strip()
|
|
263
|
+
if not destination.exists() or destination.stat().st_size == 0:
|
|
264
|
+
return "empty screenshot"
|
|
265
|
+
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def capture_app_window_png_mac(
|
|
270
|
+
url: str,
|
|
271
|
+
width: int,
|
|
272
|
+
height: int,
|
|
273
|
+
timeout_seconds: float,
|
|
274
|
+
settle_seconds: float,
|
|
275
|
+
chrome_binary: Optional[Path],
|
|
276
|
+
device_scale: float,
|
|
277
|
+
) -> bytes:
|
|
278
|
+
if sys.platform != "darwin":
|
|
279
|
+
raise RuntimeError("--capture-mode app is currently supported on macOS only")
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
import Quartz # type: ignore
|
|
283
|
+
except ImportError as exc:
|
|
284
|
+
raise RuntimeError("Missing dependency for app mode: install `pyobjc-framework-Quartz`") from exc
|
|
285
|
+
|
|
286
|
+
ensure_macos_screen_recording_access(Quartz)
|
|
287
|
+
|
|
288
|
+
binary = resolve_chrome_binary(chrome_binary)
|
|
289
|
+
baseline_windows = macos_visible_chrome_windows(Quartz)
|
|
290
|
+
baseline_ids = {window["id"] for window in baseline_windows}
|
|
291
|
+
|
|
292
|
+
process: Optional[subprocess.Popen[bytes]] = None
|
|
293
|
+
with tempfile.NamedTemporaryFile(prefix=f"{APP_NAME}-", suffix=".png", delete=False) as handle:
|
|
294
|
+
temp_png = Path(handle.name)
|
|
295
|
+
|
|
296
|
+
with tempfile.TemporaryDirectory(prefix=f"{APP_NAME}-profile-") as profile_dir:
|
|
297
|
+
command = [
|
|
298
|
+
str(binary),
|
|
299
|
+
f"--app={url}",
|
|
300
|
+
"--new-window",
|
|
301
|
+
"--no-first-run",
|
|
302
|
+
"--no-default-browser-check",
|
|
303
|
+
"--disable-session-crashed-bubble",
|
|
304
|
+
"--disable-features=Translate",
|
|
305
|
+
"--user-data-dir=" + profile_dir,
|
|
306
|
+
f"--window-size={width},{height}",
|
|
307
|
+
"--window-position=80,80",
|
|
308
|
+
f"--force-device-scale-factor={device_scale}",
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
warn("Launching Chrome app window...")
|
|
313
|
+
process = subprocess.Popen(
|
|
314
|
+
command,
|
|
315
|
+
stdout=subprocess.DEVNULL,
|
|
316
|
+
stderr=subprocess.DEVNULL,
|
|
317
|
+
start_new_session=True,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
deadline = time.time() + timeout_seconds
|
|
321
|
+
last_error: Optional[str] = None
|
|
322
|
+
settle_applied = False
|
|
323
|
+
|
|
324
|
+
while time.time() < deadline:
|
|
325
|
+
windows = macos_visible_chrome_windows(Quartz)
|
|
326
|
+
ranked_ids = rank_app_window_ids(
|
|
327
|
+
windows,
|
|
328
|
+
baseline_ids=baseline_ids,
|
|
329
|
+
target_width=width,
|
|
330
|
+
target_height=height,
|
|
331
|
+
owner_pid=process.pid if process else None,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if not ranked_ids:
|
|
335
|
+
time.sleep(0.1)
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
if settle_seconds > 0 and not settle_applied:
|
|
339
|
+
time.sleep(settle_seconds)
|
|
340
|
+
settle_applied = True
|
|
341
|
+
|
|
342
|
+
for window_id in ranked_ids:
|
|
343
|
+
last_error = try_screencapture_window(window_id, temp_png)
|
|
344
|
+
if last_error is None:
|
|
345
|
+
return temp_png.read_bytes()
|
|
346
|
+
|
|
347
|
+
lower_error = last_error.lower()
|
|
348
|
+
if "timed out" in lower_error or "not permitted" in lower_error:
|
|
349
|
+
raise RuntimeError(
|
|
350
|
+
"Window capture is blocked or stalled. "
|
|
351
|
+
f"screencapture error: {last_error}. "
|
|
352
|
+
"Check Screen Recording permission and retry."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
time.sleep(0.15)
|
|
356
|
+
|
|
357
|
+
if last_error is None:
|
|
358
|
+
raise RuntimeError("Could not find the Chrome app window in time")
|
|
359
|
+
|
|
360
|
+
raise RuntimeError(
|
|
361
|
+
"Could not capture the Chrome app window. "
|
|
362
|
+
f"Last screencapture error: {last_error}. "
|
|
363
|
+
"Make sure Screen Recording permission is enabled for your terminal/Codex app."
|
|
364
|
+
)
|
|
365
|
+
finally:
|
|
366
|
+
with contextlib.suppress(Exception):
|
|
367
|
+
if temp_png.exists():
|
|
368
|
+
temp_png.unlink()
|
|
369
|
+
|
|
370
|
+
if process is not None and process.poll() is None:
|
|
371
|
+
with contextlib.suppress(Exception):
|
|
372
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
373
|
+
process.wait(timeout=3)
|
|
374
|
+
|
|
375
|
+
if process.poll() is None:
|
|
376
|
+
with contextlib.suppress(Exception):
|
|
377
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def render_synthetic_chrome_frame(image: Image.Image, url: str, radius: int) -> Image.Image:
|
|
381
|
+
page_rgba = image.convert("RGBA")
|
|
382
|
+
page_width, page_height = page_rgba.size
|
|
383
|
+
|
|
384
|
+
top_bar_height = 46
|
|
385
|
+
window_height = top_bar_height + page_height
|
|
386
|
+
|
|
387
|
+
window = Image.new("RGBA", (page_width, window_height), (255, 255, 255, 255))
|
|
388
|
+
draw = ImageDraw.Draw(window)
|
|
389
|
+
|
|
390
|
+
draw.rectangle((0, 0, page_width, top_bar_height), fill=(244, 245, 247, 255))
|
|
391
|
+
draw.line(
|
|
392
|
+
(0, top_bar_height - 1, page_width, top_bar_height - 1),
|
|
393
|
+
fill=(216, 218, 221, 255),
|
|
394
|
+
width=1,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
dot_y = top_bar_height // 2
|
|
398
|
+
dot_radius = 6
|
|
399
|
+
dot_x = 18
|
|
400
|
+
for color in ((255, 95, 86, 255), (255, 189, 46, 255), (39, 201, 63, 255)):
|
|
401
|
+
draw.ellipse(
|
|
402
|
+
(
|
|
403
|
+
dot_x - dot_radius,
|
|
404
|
+
dot_y - dot_radius,
|
|
405
|
+
dot_x + dot_radius,
|
|
406
|
+
dot_y + dot_radius,
|
|
407
|
+
),
|
|
408
|
+
fill=color,
|
|
409
|
+
)
|
|
410
|
+
dot_x += 18
|
|
411
|
+
|
|
412
|
+
address_left = 110
|
|
413
|
+
address_right = page_width - 18
|
|
414
|
+
if address_right > address_left + 40:
|
|
415
|
+
bar_height = 26
|
|
416
|
+
bar_top = (top_bar_height - bar_height) // 2
|
|
417
|
+
draw.rounded_rectangle(
|
|
418
|
+
(address_left, bar_top, address_right, bar_top + bar_height),
|
|
419
|
+
radius=bar_height // 2,
|
|
420
|
+
fill=(231, 234, 238, 255),
|
|
421
|
+
)
|
|
422
|
+
address_text = f"{url[:90]}..." if len(url) > 90 else url
|
|
423
|
+
draw.text((address_left + 12, bar_top + 7), address_text, fill=(84, 88, 92, 255))
|
|
424
|
+
|
|
425
|
+
window.paste(page_rgba, (0, top_bar_height))
|
|
426
|
+
|
|
427
|
+
safe_radius = max(radius, 0)
|
|
428
|
+
mask = Image.new("L", window.size, 0)
|
|
429
|
+
mask_draw = ImageDraw.Draw(mask)
|
|
430
|
+
mask_draw.rounded_rectangle(
|
|
431
|
+
(0, 0, page_width - 1, window_height - 1),
|
|
432
|
+
radius=safe_radius,
|
|
433
|
+
fill=255,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
rounded = Image.new("RGBA", window.size, (0, 0, 0, 0))
|
|
437
|
+
rounded.paste(window, (0, 0), mask)
|
|
438
|
+
|
|
439
|
+
border = Image.new("RGBA", window.size, (0, 0, 0, 0))
|
|
440
|
+
border_draw = ImageDraw.Draw(border)
|
|
441
|
+
border_draw.rounded_rectangle(
|
|
442
|
+
(0, 0, page_width - 1, window_height - 1),
|
|
443
|
+
radius=safe_radius,
|
|
444
|
+
outline=(45, 48, 52, 70),
|
|
445
|
+
width=1,
|
|
446
|
+
)
|
|
447
|
+
rounded = Image.alpha_composite(rounded, border)
|
|
448
|
+
|
|
449
|
+
shadow = Image.new("RGBA", window.size, (0, 0, 0, 0))
|
|
450
|
+
shadow_draw = ImageDraw.Draw(shadow)
|
|
451
|
+
shadow_draw.rounded_rectangle(
|
|
452
|
+
(0, 0, page_width - 1, window_height - 1),
|
|
453
|
+
radius=safe_radius,
|
|
454
|
+
fill=(0, 0, 0, 95),
|
|
455
|
+
)
|
|
456
|
+
shadow = shadow.filter(ImageFilter.GaussianBlur(14))
|
|
457
|
+
|
|
458
|
+
padding = 28
|
|
459
|
+
framed = Image.new(
|
|
460
|
+
"RGBA",
|
|
461
|
+
(page_width + padding * 2, window_height + padding * 2),
|
|
462
|
+
(0, 0, 0, 0),
|
|
463
|
+
)
|
|
464
|
+
framed.paste(shadow, (padding, padding + 6), shadow)
|
|
465
|
+
framed.paste(rounded, (padding, padding), rounded)
|
|
466
|
+
return framed
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def maybe_scale_image(image: Image.Image, scale: float) -> Image.Image:
|
|
470
|
+
if scale == 1.0:
|
|
471
|
+
return image
|
|
472
|
+
|
|
473
|
+
target_width = max(1, int(image.width * scale))
|
|
474
|
+
target_height = max(1, int(image.height * scale))
|
|
475
|
+
return image.resize((target_width, target_height), Image.Resampling.LANCZOS)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def save_image(
|
|
479
|
+
image: Image.Image,
|
|
480
|
+
output_path: Path,
|
|
481
|
+
output_format: str,
|
|
482
|
+
compress: bool,
|
|
483
|
+
quality: Optional[int],
|
|
484
|
+
) -> None:
|
|
485
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
486
|
+
|
|
487
|
+
if output_format == "png":
|
|
488
|
+
image.save(
|
|
489
|
+
output_path,
|
|
490
|
+
format="PNG",
|
|
491
|
+
optimize=compress,
|
|
492
|
+
compress_level=9 if compress else 6,
|
|
493
|
+
)
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
if output_format == "jpeg":
|
|
497
|
+
rgb = Image.new("RGB", image.size, (255, 255, 255))
|
|
498
|
+
alpha = image.getchannel("A") if "A" in image.getbands() else None
|
|
499
|
+
rgb.paste(image.convert("RGB"), mask=alpha)
|
|
500
|
+
rgb.save(
|
|
501
|
+
output_path,
|
|
502
|
+
format="JPEG",
|
|
503
|
+
quality=quality if quality is not None else (82 if compress else 92),
|
|
504
|
+
optimize=compress,
|
|
505
|
+
progressive=compress,
|
|
506
|
+
)
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
image.save(
|
|
510
|
+
output_path,
|
|
511
|
+
format="WEBP",
|
|
512
|
+
quality=quality if quality is not None else (80 if compress else 92),
|
|
513
|
+
method=6 if compress else 4,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def run_capture(
|
|
518
|
+
url: str,
|
|
519
|
+
output: Optional[Path],
|
|
520
|
+
output_format: Optional[str],
|
|
521
|
+
width: int,
|
|
522
|
+
height: int,
|
|
523
|
+
full_page: bool,
|
|
524
|
+
timeout: float,
|
|
525
|
+
settle: float,
|
|
526
|
+
browser_channel: str,
|
|
527
|
+
headed: bool,
|
|
528
|
+
frame: bool,
|
|
529
|
+
corner_radius: int,
|
|
530
|
+
compress: bool,
|
|
531
|
+
quality: Optional[int],
|
|
532
|
+
scale: float,
|
|
533
|
+
capture_mode: str,
|
|
534
|
+
chrome_binary: Optional[Path],
|
|
535
|
+
device_scale: float,
|
|
536
|
+
) -> None:
|
|
537
|
+
normalized_url = normalize_url(url)
|
|
538
|
+
|
|
539
|
+
output_path = output if output is not None else default_output_path(normalized_url)
|
|
540
|
+
output_path = resolve_output_path(output_path)
|
|
541
|
+
normalized_format = normalize_output_format(output_format, output_path)
|
|
542
|
+
destination = ensure_output_extension(output_path, normalized_format)
|
|
543
|
+
|
|
544
|
+
if normalized_format == "png" and quality is not None:
|
|
545
|
+
raise typer.BadParameter("--quality only applies to jpeg/webp output")
|
|
546
|
+
|
|
547
|
+
normalized_mode = normalize_capture_mode(capture_mode)
|
|
548
|
+
capture_order = resolve_capture_order(normalized_mode)
|
|
549
|
+
|
|
550
|
+
screenshot_bytes: Optional[bytes] = None
|
|
551
|
+
used_mode: Optional[str] = None
|
|
552
|
+
last_error: Optional[Exception] = None
|
|
553
|
+
|
|
554
|
+
for mode in capture_order:
|
|
555
|
+
try:
|
|
556
|
+
if mode == "app":
|
|
557
|
+
warn("Capture mode: native Chrome app window")
|
|
558
|
+
if full_page:
|
|
559
|
+
warn("Warning: --full-page is ignored in app mode (window capture is viewport-only).")
|
|
560
|
+
|
|
561
|
+
screenshot_bytes = capture_app_window_png_mac(
|
|
562
|
+
normalized_url,
|
|
563
|
+
width=width,
|
|
564
|
+
height=height,
|
|
565
|
+
timeout_seconds=timeout,
|
|
566
|
+
settle_seconds=settle,
|
|
567
|
+
chrome_binary=chrome_binary,
|
|
568
|
+
device_scale=device_scale,
|
|
569
|
+
)
|
|
570
|
+
used_mode = "app"
|
|
571
|
+
else:
|
|
572
|
+
warn("Capture mode: Playwright page screenshot")
|
|
573
|
+
screenshot_bytes = asyncio.run(
|
|
574
|
+
capture_page_png(
|
|
575
|
+
normalized_url,
|
|
576
|
+
width=width,
|
|
577
|
+
height=height,
|
|
578
|
+
timeout_ms=int(timeout * 1000),
|
|
579
|
+
settle_ms=int(settle * 1000),
|
|
580
|
+
full_page=full_page,
|
|
581
|
+
browser_channel=browser_channel,
|
|
582
|
+
headed=headed,
|
|
583
|
+
device_scale=device_scale,
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
used_mode = "page"
|
|
587
|
+
|
|
588
|
+
break
|
|
589
|
+
except (RuntimeError, PlaywrightError, subprocess.SubprocessError, FileNotFoundError) as exc:
|
|
590
|
+
last_error = exc
|
|
591
|
+
if normalized_mode == "auto" and mode == "app":
|
|
592
|
+
warn(f"Warning: app capture failed ({exc}). Falling back to page mode.")
|
|
593
|
+
continue
|
|
594
|
+
|
|
595
|
+
warn(f"Capture failed in {mode} mode: {exc}")
|
|
596
|
+
raise typer.Exit(code=1) from exc
|
|
597
|
+
|
|
598
|
+
if screenshot_bytes is None or used_mode is None:
|
|
599
|
+
warn(f"Capture failed: {last_error}")
|
|
600
|
+
raise typer.Exit(code=1)
|
|
601
|
+
|
|
602
|
+
with Image.open(io.BytesIO(screenshot_bytes)) as opened:
|
|
603
|
+
screenshot = opened.convert("RGBA")
|
|
604
|
+
|
|
605
|
+
apply_synthetic_frame = frame and used_mode != "app"
|
|
606
|
+
if frame and used_mode == "app":
|
|
607
|
+
warn("Note: --frame is ignored in app mode because the native Chrome window is already captured.")
|
|
608
|
+
|
|
609
|
+
final_image = screenshot
|
|
610
|
+
if apply_synthetic_frame:
|
|
611
|
+
final_image = render_synthetic_chrome_frame(final_image, normalized_url, corner_radius)
|
|
612
|
+
|
|
613
|
+
final_image = maybe_scale_image(final_image, scale)
|
|
614
|
+
save_image(
|
|
615
|
+
final_image,
|
|
616
|
+
output_path=destination,
|
|
617
|
+
output_format=normalized_format,
|
|
618
|
+
compress=compress,
|
|
619
|
+
quality=quality,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
size_kb = destination.stat().st_size / 1024
|
|
623
|
+
typer.echo(f"Saved {destination} ({size_kb:.1f} KB) [mode={used_mode}]")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def main(
|
|
627
|
+
ctx: typer.Context,
|
|
628
|
+
url: Optional[str] = typer.Argument(
|
|
629
|
+
None,
|
|
630
|
+
help="URL to capture. Example: https://example.com",
|
|
631
|
+
),
|
|
632
|
+
output: Optional[Path] = typer.Option(
|
|
633
|
+
None,
|
|
634
|
+
"--output",
|
|
635
|
+
"-o",
|
|
636
|
+
help="Output file path (default: <cwd>/hostname.png)",
|
|
637
|
+
),
|
|
638
|
+
output_format: Optional[str] = typer.Option(
|
|
639
|
+
None,
|
|
640
|
+
"--format",
|
|
641
|
+
"-f",
|
|
642
|
+
help="Output format: png, jpeg/jpg, webp",
|
|
643
|
+
),
|
|
644
|
+
width: int = typer.Option(1440, min=320, max=6000, help="Capture window/page width"),
|
|
645
|
+
height: int = typer.Option(900, min=240, max=6000, help="Capture window/page height"),
|
|
646
|
+
full_page: bool = typer.Option(False, help="Capture full scroll height (page mode only)"),
|
|
647
|
+
timeout: float = typer.Option(30.0, min=1.0, help="Capture timeout in seconds"),
|
|
648
|
+
settle: float = typer.Option(0.8, min=0.0, help="Extra settle wait in seconds"),
|
|
649
|
+
capture_mode: str = typer.Option(
|
|
650
|
+
"auto",
|
|
651
|
+
help="Capture mode: auto (prefer app on macOS), app (native Chrome app window), or page",
|
|
652
|
+
),
|
|
653
|
+
chrome_binary: Optional[Path] = typer.Option(None, help="Path to Chrome binary for app mode"),
|
|
654
|
+
browser_channel: str = typer.Option(
|
|
655
|
+
"chrome",
|
|
656
|
+
help="Playwright browser channel (used in page mode)",
|
|
657
|
+
),
|
|
658
|
+
headed: bool = typer.Option(False, help="Open visible browser window in page mode"),
|
|
659
|
+
device_scale: float = typer.Option(
|
|
660
|
+
2.0,
|
|
661
|
+
min=1.0,
|
|
662
|
+
max=4.0,
|
|
663
|
+
help="Device scale factor for higher pixel density",
|
|
664
|
+
),
|
|
665
|
+
frame: bool = typer.Option(
|
|
666
|
+
True,
|
|
667
|
+
"--frame/--no-frame",
|
|
668
|
+
help="Enable/disable synthetic Chrome frame (page mode only)",
|
|
669
|
+
),
|
|
670
|
+
corner_radius: int = typer.Option(
|
|
671
|
+
20,
|
|
672
|
+
min=0,
|
|
673
|
+
max=200,
|
|
674
|
+
help="Rounded corner radius for synthetic frame",
|
|
675
|
+
),
|
|
676
|
+
compress: bool = typer.Option(
|
|
677
|
+
False,
|
|
678
|
+
"--compress/--no-compress",
|
|
679
|
+
help="Enable stronger size compression",
|
|
680
|
+
),
|
|
681
|
+
quality: Optional[int] = typer.Option(
|
|
682
|
+
None,
|
|
683
|
+
min=1,
|
|
684
|
+
max=100,
|
|
685
|
+
help="Quality for jpeg/webp output",
|
|
686
|
+
),
|
|
687
|
+
scale: float = typer.Option(
|
|
688
|
+
1.0,
|
|
689
|
+
min=0.1,
|
|
690
|
+
max=1.0,
|
|
691
|
+
help="Downscale output for smaller files (0.1 - 1.0)",
|
|
692
|
+
),
|
|
693
|
+
version: bool = typer.Option(False, "--version", help="Show version and exit"),
|
|
694
|
+
) -> None:
|
|
695
|
+
if version:
|
|
696
|
+
typer.echo(__version__)
|
|
697
|
+
raise typer.Exit(code=0)
|
|
698
|
+
|
|
699
|
+
if url is None:
|
|
700
|
+
typer.echo(ctx.get_help())
|
|
701
|
+
raise typer.Exit(code=0)
|
|
702
|
+
|
|
703
|
+
run_capture(
|
|
704
|
+
url=url,
|
|
705
|
+
output=output,
|
|
706
|
+
output_format=output_format,
|
|
707
|
+
width=width,
|
|
708
|
+
height=height,
|
|
709
|
+
full_page=full_page,
|
|
710
|
+
timeout=timeout,
|
|
711
|
+
settle=settle,
|
|
712
|
+
browser_channel=browser_channel,
|
|
713
|
+
headed=headed,
|
|
714
|
+
frame=frame,
|
|
715
|
+
corner_radius=corner_radius,
|
|
716
|
+
compress=compress,
|
|
717
|
+
quality=quality,
|
|
718
|
+
scale=scale,
|
|
719
|
+
capture_mode=capture_mode,
|
|
720
|
+
chrome_binary=chrome_binary,
|
|
721
|
+
device_scale=device_scale,
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def run() -> None:
|
|
726
|
+
typer.run(main)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
if __name__ == "__main__":
|
|
730
|
+
run()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chromeappcap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for polished website screenshots
|
|
5
|
+
Author: William Blackie
|
|
6
|
+
Keywords: cli,screenshot,chrome,playwright,automation
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Topic :: Multimedia :: Graphics :: Capture :: Screen Capture
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: typer<1.0,>=0.12
|
|
18
|
+
Requires-Dist: playwright<2.0,>=1.44
|
|
19
|
+
Requires-Dist: Pillow<12.0,>=10.0
|
|
20
|
+
Requires-Dist: pyobjc-framework-Quartz<12.0,>=11.0; sys_platform == "darwin"
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
|
23
|
+
|
|
24
|
+
# chromeappcap
|
|
25
|
+
|
|
26
|
+
Screenshots that look like you meant to share them.
|
|
27
|
+
|
|
28
|
+
`chromeappcap` is a CLI for polished website/app screenshots with native Chrome app-window capture on macOS and Playwright fallback everywhere else.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
### `pipx` (recommended)
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pipx install chromeappcap
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### `uvx` (zero install)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uvx --from git+https://github.com/William-Blackie/chromeappcap.git chromeappcap https://example.com
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### local dev
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git clone https://github.com/William-Blackie/chromeappcap.git
|
|
48
|
+
cd chromeappcap
|
|
49
|
+
python3 -m venv .venv
|
|
50
|
+
source .venv/bin/activate
|
|
51
|
+
pip install -e .[dev]
|
|
52
|
+
playwright install chromium
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
make dev-install
|
|
59
|
+
make playwright-install
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick Start
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
chromeappcap https://example.com
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
By default, output is saved in your current shell directory.
|
|
69
|
+
|
|
70
|
+
## Common Commands
|
|
71
|
+
|
|
72
|
+
Native app-window capture (macOS):
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
chromeappcap https://example.com --capture-mode app -o native.png
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
High-quality compressed WebP:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
chromeappcap https://example.com --capture-mode app --device-scale 2.5 --format webp --compress --quality 85 -o shot.webp
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Cross-platform page fallback with no synthetic frame:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
chromeappcap https://example.com --capture-mode page --no-frame -o raw.png
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Notes
|
|
91
|
+
|
|
92
|
+
- `--capture-mode auto` prefers app mode on macOS and falls back to page mode.
|
|
93
|
+
- `--capture-mode page` requires Playwright Chromium:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
playwright install chromium
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- macOS app mode requires Screen Recording permission:
|
|
100
|
+
- `System Settings > Privacy & Security > Screen Recording`
|
|
101
|
+
|
|
102
|
+
## Help
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
chromeappcap --help
|
|
106
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/chromeappcap/__init__.py
|
|
4
|
+
src/chromeappcap/cli.py
|
|
5
|
+
src/chromeappcap.egg-info/PKG-INFO
|
|
6
|
+
src/chromeappcap.egg-info/SOURCES.txt
|
|
7
|
+
src/chromeappcap.egg-info/dependency_links.txt
|
|
8
|
+
src/chromeappcap.egg-info/entry_points.txt
|
|
9
|
+
src/chromeappcap.egg-info/requires.txt
|
|
10
|
+
src/chromeappcap.egg-info/top_level.txt
|
|
11
|
+
tests/test_cli_utils.py
|
|
12
|
+
tests/test_output_paths.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chromeappcap
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from chromeappcap.cli import (
|
|
7
|
+
normalize_capture_mode,
|
|
8
|
+
normalize_output_format,
|
|
9
|
+
normalize_url,
|
|
10
|
+
resolve_capture_order,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_normalize_url_adds_https_when_missing_scheme() -> None:
|
|
15
|
+
assert normalize_url("example.com") == "https://example.com"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_normalize_url_rejects_invalid_scheme() -> None:
|
|
19
|
+
with pytest.raises(typer.BadParameter):
|
|
20
|
+
normalize_url("ftp://example.com")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_normalize_output_format_from_flag_and_suffix() -> None:
|
|
24
|
+
assert normalize_output_format("jpg", Path("shot.any")) == "jpeg"
|
|
25
|
+
assert normalize_output_format(None, Path("shot.webp")) == "webp"
|
|
26
|
+
assert normalize_output_format(None, Path("shot")) == "png"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_normalize_capture_mode_validation() -> None:
|
|
30
|
+
assert normalize_capture_mode("AUTO") == "auto"
|
|
31
|
+
with pytest.raises(typer.BadParameter):
|
|
32
|
+
normalize_capture_mode("banana")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_resolve_capture_order_page_mode() -> None:
|
|
36
|
+
assert resolve_capture_order("page") == ["page"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_resolve_capture_order_app_mode() -> None:
|
|
40
|
+
assert resolve_capture_order("app") == ["app"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from chromeappcap.cli import default_output_path, resolve_output_path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_default_output_path_uses_invocation_directory(tmp_path, monkeypatch):
|
|
7
|
+
monkeypatch.chdir(tmp_path)
|
|
8
|
+
output = default_output_path("https://example.com")
|
|
9
|
+
assert output == tmp_path / "example.com.png"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_resolve_output_path_makes_relative_paths_cwd_absolute(tmp_path, monkeypatch):
|
|
13
|
+
monkeypatch.chdir(tmp_path)
|
|
14
|
+
output = resolve_output_path(Path("shots/homepage"))
|
|
15
|
+
assert output == tmp_path / "shots/homepage"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_resolve_output_path_keeps_absolute_path(tmp_path):
|
|
19
|
+
absolute = tmp_path / "x.png"
|
|
20
|
+
output = resolve_output_path(absolute)
|
|
21
|
+
assert output == absolute
|