Source code for scripts.screenshot

#!/usr/bin/env python3
"""Website screenshot: capture one 1600x900 desktop JPEG per configured site.

The site list and the output directory are configured in a `.env` file in the
current working directory:

    SCREENSHOT_SITES="https://example.com,https://example.org"
    SCREENSHOT_OUTPUT_DIR="/home/your-username/Pictures"

`SCREENSHOT_SITES` is a comma-separated list of `http`/`https` URLs and is
required. `SCREENSHOT_OUTPUT_DIR` is optional and defaults to ``~/Pictures``;
the directory is created when absent.

Each site is captured in a fresh Chromium context sized to a 1600x900 viewport
at scale factor 1, and the visible viewport is written as
``<hostname>.jpg``, overwriting the file from the previous run. GitHub Pages
project sites are named after the repository instead of the shared user
hostname, with underscores replaced by hyphens, so
``https://username.github.io/my_project/`` is written as ``my-project.jpg``.
"""

from __future__ import annotations

import argparse
import os
import re
import sys
from pathlib import Path
from urllib.parse import urlparse

from dotenv import load_dotenv
from playwright.sync_api import Browser, Page, sync_playwright

VIEWPORT_WIDTH = 1600
VIEWPORT_HEIGHT = 900
DEVICE_SCALE_FACTOR = 1
NAVIGATION_TIMEOUT_MS = 60000
SETTLE_DELAY_MS = 500
JPEG_QUALITY = 85

GITHUB_PAGES_HOSTNAME_SUFFIX = ".github.io"
# Path segment safe to use as a filename stem: no separators, no `.`/`..`.
SAFE_FILENAME_STEM = re.compile(r"[A-Za-z0-9_-][A-Za-z0-9._-]*")

DISABLE_ANIMATIONS_CSS = """
*,
*::before,
*::after {
    animation: none !important;
    transition: none !important;
    scroll-behavior: auto !important;
}
"""

REMOVE_ELEMENTS_SCRIPT = "elements => elements.forEach(element => element.remove())"

COOKIE_BANNER_SELECTORS: tuple[str, ...] = (
    "#onetrust-consent-sdk",
    "#CybotCookiebotDialog",
    "#usercentrics-root",
    "#cookiescript_injected",
    ".cc-window",
    ".cookie-banner",
    ".cookie-consent",
    ".cookie-notice",
    "[id*='cookie-banner']",
    "[class*='cookie-consent']",
    '[aria-label*="cookie" i]',
)


[docs] def load_environment(env_file: Path) -> None: """Load an environment file using standard python-dotenv semantics. Existing environment variables take precedence over values in the file. """ if not env_file.is_file(): raise FileNotFoundError(f"Environment file not found: {env_file}") load_dotenv(dotenv_path=env_file, override=False)
[docs] def required_setting(name: str) -> str: """Return a required environment setting or raise a clear error.""" value = os.environ.get(name, "").strip() if not value: raise ValueError(f"Missing required .env setting: {name}") return value
[docs] def default_output_directory() -> Path: """Return the screenshot directory used when none is configured.""" return Path.home() / "Pictures"
[docs] def parse_sites(value: str) -> list[str]: """Return the validated website URLs listed in a comma-separated value.""" sites = [entry.strip() for entry in value.split(",")] sites = [entry for entry in sites if entry] if not sites: raise ValueError("No websites listed in .env setting: SCREENSHOT_SITES") for site in sites: parsed = urlparse(site) if parsed.scheme not in ("http", "https") or not parsed.hostname: raise ValueError(f"Invalid website URL in SCREENSHOT_SITES: {site}") return sites
[docs] def load_config() -> tuple[list[str], Path]: """Return the configured sites and the prepared output directory.""" working_directory = Path.cwd() load_environment(working_directory / ".env") sites = parse_sites(required_setting("SCREENSHOT_SITES")) configured_directory = os.environ.get("SCREENSHOT_OUTPUT_DIR", "").strip() if configured_directory: output_directory = Path(configured_directory).expanduser() else: output_directory = default_output_directory() output_directory.mkdir(parents=True, exist_ok=True) return sites, output_directory
[docs] def hostname_to_filename(url: str) -> str: """Return the `.jpg` filename for a website URL. The hostname names the file, except on GitHub Pages project sites, where every project shares one `<username>.github.io` hostname; those are named after the first path segment (the repository) instead, with underscores replaced by hyphens. Args: url: The website URL to derive a filename from. Returns: The filename to write the screenshot to. Raises: ValueError: If the URL carries no hostname. """ parsed = urlparse(url) hostname = parsed.hostname if not hostname: raise ValueError(f"Invalid website URL: {url}") if hostname.endswith(GITHUB_PAGES_HOSTNAME_SUFFIX): segments = [segment for segment in parsed.path.split("/") if segment] if segments and SAFE_FILENAME_STEM.fullmatch(segments[0]): return f"{segments[0].replace('_', '-')}.jpg" return f"{hostname}.jpg"
[docs] def capture_website(browser: Browser, url: str, output_directory: Path) -> None: """Capture one website as a 1600x900 JPEG in the output directory.""" destination = output_directory / hostname_to_filename(url) context = browser.new_context( viewport={"width": VIEWPORT_WIDTH, "height": VIEWPORT_HEIGHT}, device_scale_factor=DEVICE_SCALE_FACTOR, ) try: page = context.new_page() page.emulate_media(reduced_motion="reduce") page.goto(url, wait_until="networkidle", timeout=NAVIGATION_TIMEOUT_MS) page.add_style_tag(content=DISABLE_ANIMATIONS_CSS) hide_cookie_banners(page) page.wait_for_timeout(SETTLE_DELAY_MS) page.screenshot( path=str(destination), type="jpeg", quality=JPEG_QUALITY, full_page=False, animations="disabled", ) finally: context.close()
[docs] def parse_arguments() -> None: """Parse command-line arguments, providing `-h`/`--help` and rejecting others.""" parser = argparse.ArgumentParser( description="Capture one 1600x900 desktop JPEG per website listed in " "SCREENSHOT_SITES, saved to SCREENSHOT_OUTPUT_DIR (default ~/Pictures). " "Both settings are read from the .env file in the current working directory." ) parser.parse_args()
[docs] def main() -> int: """Capture every configured website and report the per-site outcome.""" parse_arguments() try: sites, output_directory = load_config() except (OSError, ValueError) as error: print(f"Error: {error}", file=sys.stderr) return 1 succeeded = 0 failed = 0 try: with sync_playwright() as engine: browser = engine.chromium.launch(headless=True) try: for site in sites: try: capture_website(browser, site, output_directory) except Exception as error: failed += 1 print(f"[FAILED] {site}") print(f"Error: {error}", file=sys.stderr) else: succeeded += 1 print(f"[OK] {site}") finally: browser.close() except Exception as error: print(f"Error: {error}", file=sys.stderr) return 1 print(f"Finished: {succeeded} succeeded, {failed} failed.") return 1 if failed else 0
if __name__ == "__main__": raise SystemExit(main())