Files

356 lines
14 KiB
Python

"""@file
@brief Capture one screenshot per lecture step with headless Chromium.
Usage: `python tests/e2e/capture_screenshots.py <output_dir>`. Runs only inside the Docker
container (see tests/e2e/run_e2e.sh), never on the host desktop.
"""
import html
import re
import sys
from collections.abc import Sequence
from pathlib import Path
from tempfile import TemporaryDirectory
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support.ui import WebDriverWait
from scraping_wiki.constants import (
ARTICLE_COUNT_SELECTOR,
LECTURE_SELECTOR,
WIKIPEDIA_MAIN_PAGE_URL,
)
from scraping_wiki.interaction import create_driver, open_main_page, read_article_count
## @brief Folder with the lecture step scripts, relative to the repository root.
LECTURE_STEPS_FOLDER: Path = Path("docs") / "lecture-steps"
## @brief The repository root, two folders above this file (tests/e2e/).
REPOSITORY_ROOT: Path = Path(__file__).resolve().parents[2]
## @brief Step number, file name prefix of the lecture script, screenshot name and title.
STEPS: dict[int, tuple[str, str, str]] = {
1: ("step_1_", "step-1-create-the-file.png", "Create the file"),
2: ("step_2_", "step-2-import-selenium.png", "Import Selenium"),
3: ("step_3_", "step-3-initialise-chrome-driver.png", "Initialise the Chrome driver"),
4: ("step_4_", "step-4-navigate-with-get.png", "Navigate with get()"),
5: ("step_5_", "step-5-identify-the-element.png", "Identify the element"),
6: ("step_6_", "step-6-find-element-and-print.png", "Find the element and print its text"),
7: ("step_7_", "step-7-future-interactions.png", "Future interactions"),
}
## @brief Title of the editor window in the rendered source pages.
EDITOR_FILE_NAME: str = "interaction.py"
## @brief The command shown in the terminal panel of step 6.
TERMINAL_COMMAND: str = "$ python interaction.py"
## @brief Pattern the article count text must match.
ARTICLE_COUNT_PATTERN: str = r"^[\d,]+$"
## @brief CSS selector of the Wikipedia search field, and its fallback.
SEARCH_SELECTOR: str = 'input[name="search"]'
SEARCH_FALLBACK_SELECTOR: str = 'input[type="search"]'
## @brief Banner texts of the steps that annotate the real Wikipedia page.
STEP_4_BANNER: str = f'Step 4: driver.get("{WIKIPEDIA_MAIN_PAGE_URL}")'
STEP_5_BANNER: str = (
f"Step 5: {LECTURE_SELECTOR} (red). "
f"Today the articles link is the 2nd: {ARTICLE_COUNT_SELECTOR} (green)"
)
STEP_7_BANNER: str = "Step 7: next lectures: click links and type into search fields"
## @brief Style of the highlighted elements: the lecture selector red, the article link green.
LECTURE_HIGHLIGHT_COLOR: str = "red"
ARTICLE_HIGHLIGHT_COLOR: str = "green"
HIGHLIGHT_WIDTH: str = "4px solid "
HIGHLIGHT_BACKGROUND: str = "lightyellow"
## @brief Second line of the terminal panel in step 6, before the real article count.
TODAYS_COUNT_PREFIX: str = "# today's article count: "
## @brief Style of the banner at the bottom of the Wikipedia page; long text wraps.
BANNER_STYLE: str = (
"position:fixed;bottom:0;left:0;right:0;white-space:normal;word-wrap:break-word;"
"z-index:2147483647;padding:12px 20px;"
"background:#1e1e1e;color:#fff;font:bold 20px 'DejaVu Sans Mono',monospace;"
)
## @brief Style sheet shared by the rendered source pages.
PAGE_STYLE: str = """
body { margin: 0; background: #1e1e1e; color: #d4d4d4;
font: 20px/1.5 'DejaVu Sans Mono', monospace; }
.title-bar { background: #333; color: #fff; padding: 8px 16px; font-size: 18px; }
h1 { margin: 16px; font-size: 28px; color: #4ec9b0; }
.layout { display: flex; }
.editor { flex: 1; padding: 0 16px; }
.terminal { flex: 1; margin: 0 16px; padding: 16px; background: #000; color: #0f0;
min-height: 200px; }
.line { white-space: pre; }
.number { display: inline-block; width: 3em; color: #858585; text-align: right;
margin-right: 1.5em; }
"""
## @brief Seconds to wait until the images in the first view are loaded.
IMAGE_WAIT_SECONDS: int = 15
## @brief Where a scrolled element lands in the window.
SCROLL_ELEMENT_BLOCK: str = "center"
def find_step_source(step_number: int) -> str:
"""@brief Read the lecture script of one step.
@param step_number The step, 1 to 7.
@return The source text of docs/lecture-steps/step_N_*.py.
@raises FileNotFoundError When the script of the step does not exist.
"""
prefix = STEPS[step_number][0]
folder = REPOSITORY_ROOT / LECTURE_STEPS_FOLDER
matches = sorted(folder.glob(f"{prefix}*.py"))
if not matches:
raise FileNotFoundError(f"No lecture script {prefix}*.py in {folder}")
return matches[0].read_text(encoding="utf-8")
def render_source_lines(source: str) -> str:
"""@brief Render source text as numbered, HTML-escaped lines.
@param source The source text.
@return HTML of the lines, each with its line number.
"""
lines = source.rstrip("\n").split("\n")
return "".join(
f'<div class="line"><span class="number">{number}</span>{html.escape(line)}</div>'
for number, line in enumerate(lines, start=1)
)
def render_page(step_number: int, source: str, terminal_text: str | None = None) -> str:
"""@brief Render the source of a step as an editor-style HTML page.
@param step_number The step, 1 to 7.
@param source The source text of the step.
@param terminal_text When given, a terminal panel with this text is shown on the right.
@return The HTML page.
"""
title = STEPS[step_number][2]
terminal = ""
if terminal_text is not None:
terminal_lines = "<br>".join(html.escape(line) for line in terminal_text.split("\n"))
terminal = f'<div class="terminal">{terminal_lines}</div>'
return (
f"<!DOCTYPE html><html><head><meta charset='utf-8'><style>{PAGE_STYLE}</style></head>"
f"<body><div class='title-bar'>{html.escape(EDITOR_FILE_NAME)}</div>"
f"<h1>Step {step_number}: {html.escape(title)}</h1>"
f"<div class='layout'><div class='editor'>{render_source_lines(source)}</div>"
f"{terminal}</div></body></html>"
)
def screenshot_html(driver: WebDriver, page: str, target: Path) -> None:
"""@brief Load an HTML page from a temporary file and save a screenshot of it.
@param driver The driver that controls the browser.
@param page The HTML page.
@param target The PNG file to write.
"""
with TemporaryDirectory() as folder:
page_file = Path(folder) / "page.html"
page_file.write_text(page, encoding="utf-8", newline="\n")
driver.get(page_file.as_uri())
save_screenshot(driver, target)
def save_screenshot(driver: WebDriver, target: Path) -> None:
"""@brief Save the current view of the browser as a PNG and report it.
@param driver The driver that controls the browser.
@param target The PNG file to write.
@raises RuntimeError When the browser cannot save the file.
"""
if not driver.save_screenshot(str(target)):
raise RuntimeError(f"Could not save screenshot {target}")
print(f"saved {target}")
def add_banner(driver: WebDriver, text: str) -> None:
"""@brief Show a fixed banner at the bottom of the current page.
@param driver The driver that controls the browser.
@param text The banner text.
"""
driver.execute_script(
"const b = document.createElement('div'); b.textContent = arguments[0];"
"b.style.cssText = arguments[1]; document.body.appendChild(b);",
text,
BANNER_STYLE,
)
def highlight(driver: WebDriver, element: WebElement, color: str) -> None:
"""@brief Outline an element in a colour on a light yellow background.
@param driver The driver that controls the browser.
@param element The element to highlight.
@param color The outline colour.
"""
driver.execute_script(
"arguments[0].style.outline = arguments[1]; arguments[0].style.background = arguments[2];",
element,
HIGHLIGHT_WIDTH + color,
HIGHLIGHT_BACKGROUND,
)
def scroll_to_center(driver: WebDriver, element: WebElement) -> None:
"""@brief Scroll an element to the middle of the window, clear of the banner.
@param driver The driver that controls the browser.
@param element The element to show.
"""
driver.execute_script(
"arguments[0].scrollIntoView({block: arguments[1]});", element, SCROLL_ELEMENT_BLOCK
)
def find_required(driver: WebDriver, selector: str) -> WebElement:
"""@brief Find an element by CSS selector.
@param driver The driver that controls the browser.
@param selector The CSS selector.
@return The element.
@raises LookupError When the selector finds nothing.
"""
elements = driver.find_elements(By.CSS_SELECTOR, selector)
if not elements:
raise LookupError(f"Selector {selector!r} found nothing on {driver.current_url}")
return elements[0]
def find_search_field(driver: WebDriver) -> WebElement:
"""@brief Find the Wikipedia search input, with a fallback to any search input.
@param driver The driver that controls the browser.
@return The search input.
@raises LookupError When neither selector finds an element.
"""
for selector in (SEARCH_SELECTOR, SEARCH_FALLBACK_SELECTOR):
elements = driver.find_elements(By.CSS_SELECTOR, selector)
if elements:
return elements[0]
raise LookupError(f"Neither {SEARCH_SELECTOR!r} nor {SEARCH_FALLBACK_SELECTOR!r} found")
def read_checked_article_count(driver: WebDriver) -> str:
"""@brief Read the article count and check that it looks like a number.
@param driver A driver that has the Wikipedia main page open.
@return The article count text.
@raises LookupError When the text does not match the number pattern.
"""
find_required(driver, ARTICLE_COUNT_SELECTOR)
count = read_article_count(driver)
if not re.fullmatch(ARTICLE_COUNT_PATTERN, count):
raise LookupError(f"Article count {count!r} does not match {ARTICLE_COUNT_PATTERN!r}")
return count
def open_loaded_main_page(driver: WebDriver) -> None:
"""@brief Open the main page and wait until the images in the first view are loaded.
@param driver The driver that controls the browser.
@raises selenium.common.exceptions.TimeoutException When the images do not load in time.
"""
open_main_page(driver)
WebDriverWait(driver, IMAGE_WAIT_SECONDS).until(
lambda d: d.execute_script(
"return Array.from(document.images)"
".filter(i => i.getBoundingClientRect().top < window.innerHeight)"
".every(i => i.complete);"
)
)
def capture_source_steps(driver: WebDriver, output: Path) -> None:
"""@brief Capture steps 1 to 3: the source of the step in an editor-style page.
@param driver The driver that controls the browser.
@param output The folder for the PNG files.
"""
for step_number in (1, 2, 3):
page = render_page(step_number, find_step_source(step_number))
screenshot_html(driver, page, output / STEPS[step_number][1])
def capture_page_steps(driver: WebDriver, output: Path) -> None:
"""@brief Capture steps 4, 5 and 7: the real Wikipedia page with a banner.
@param driver The driver that controls the browser.
@param output The folder for the PNG files.
"""
open_loaded_main_page(driver)
add_banner(driver, STEP_4_BANNER)
save_screenshot(driver, output / STEPS[4][1])
open_loaded_main_page(driver)
lecture_match = find_required(driver, LECTURE_SELECTOR)
article_count = find_required(driver, ARTICLE_COUNT_SELECTOR)
scroll_to_center(driver, article_count)
highlight(driver, lecture_match, LECTURE_HIGHLIGHT_COLOR)
highlight(driver, article_count, ARTICLE_HIGHLIGHT_COLOR)
add_banner(driver, STEP_5_BANNER)
save_screenshot(driver, output / STEPS[5][1])
open_loaded_main_page(driver)
article_count = find_required(driver, ARTICLE_COUNT_SELECTOR)
search = find_search_field(driver)
# The input's own outline is clipped by its wrapper, so outline the wrapper.
highlight(driver, search.find_element(By.XPATH, ".."), LECTURE_HIGHLIGHT_COLOR)
highlight(driver, article_count, ARTICLE_HIGHLIGHT_COLOR)
driver.execute_script("window.scrollTo(0, 0);")
add_banner(driver, STEP_7_BANNER)
save_screenshot(driver, output / STEPS[7][1])
def capture_print_step(driver: WebDriver, output: Path) -> None:
"""@brief Capture step 6: the source and a terminal with the real article count.
@param driver The driver that controls the browser.
@param output The folder for the PNG files.
"""
open_main_page(driver)
count = read_checked_article_count(driver)
lecture_text = find_required(driver, LECTURE_SELECTOR).text
terminal_text = f"{TERMINAL_COMMAND}\n{lecture_text}\n{TODAYS_COUNT_PREFIX}{count}"
page = render_page(6, find_step_source(6), terminal_text)
screenshot_html(driver, page, output / STEPS[6][1])
def main(argv: Sequence[str] | None = None) -> int:
"""@brief Capture the seven screenshots into a folder.
@param argv Command line arguments: the output folder.
@return The exit code, 0 when all seven files were saved, 2 for a usage error.
"""
arguments = list(sys.argv[1:] if argv is None else argv)
if len(arguments) != 1:
print("usage: capture_screenshots.py <output_dir>", file=sys.stderr)
return 2
output = Path(arguments[0])
output.mkdir(parents=True, exist_ok=True)
driver = create_driver(headless=True)
try:
capture_source_steps(driver, output)
capture_page_steps(driver, output)
capture_print_step(driver, output)
finally:
driver.quit()
return 0
if __name__ == "__main__":
raise SystemExit(main())