MIL-002: parsing HTML and making soup #36
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
@@ -21,6 +21,7 @@ dev = [
|
||||
"ruff>=0.6",
|
||||
"mypy>=1.11",
|
||||
"types-requests>=2.32",
|
||||
"pillow>=10.4",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@@ -46,3 +47,7 @@ ignore = ["D203", "D213"]
|
||||
strict = true
|
||||
python_version = "3.13"
|
||||
mypy_path = "src"
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["bs4.*", "requests.*", "PIL.*", "pytest.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""@brief Allow `python -m scrape_movies`."""
|
||||
|
||||
import sys
|
||||
|
||||
from scrape_movies.presentation.cli import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,36 @@
|
||||
"""@brief Command-line entry point that runs the lecture steps."""
|
||||
|
||||
import argparse
|
||||
from collections.abc import Sequence
|
||||
|
||||
from scrape_movies.presentation import soup_steps
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""@brief Build the argument parser.
|
||||
|
||||
@return The parser with one sub-command per lecture.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m scrape_movies",
|
||||
description="Day 45 of 100 Days of Code: web scraping exercises.",
|
||||
)
|
||||
commands = parser.add_subparsers(dest="command")
|
||||
soup = commands.add_parser("soup", help="parse the sample page website.html")
|
||||
soup.add_argument("step", choices=list(soup_steps.STEPS))
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""@brief Run the command given on the command line.
|
||||
|
||||
@param argv The arguments, or None to read `sys.argv`.
|
||||
@return The process exit code.
|
||||
"""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "soup":
|
||||
print(soup_steps.render_step(args.step))
|
||||
return 0
|
||||
parser.print_help()
|
||||
return 0
|
||||
@@ -0,0 +1,55 @@
|
||||
"""@brief Text output of each "Parsing HTML and Making Soup" lecture step."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from scrape_movies.constants import WEBSITE_HTML_PATH
|
||||
from scrape_movies.infrastructure import soup_basics
|
||||
|
||||
|
||||
def _title(soup: BeautifulSoup) -> str:
|
||||
return f"soup.title.string -> {soup_basics.get_title(soup)}"
|
||||
|
||||
|
||||
def _prettify(soup: BeautifulSoup) -> str:
|
||||
return soup_basics.prettify_html(soup).rstrip()
|
||||
|
||||
|
||||
def _find_all(soup: BeautifulSoup) -> str:
|
||||
return "\n".join(
|
||||
f"{link.text} -> {link.url}" for link in soup_basics.find_all_anchors(soup)
|
||||
)
|
||||
|
||||
|
||||
def _find(soup: BeautifulSoup) -> str:
|
||||
by_id = soup_basics.find_text_by_id(soup, "name")
|
||||
by_class = soup_basics.find_all_texts_by_class(soup, "h3", "heading")
|
||||
return f'find(id="name") -> {by_id}\nfind_all("h3", class_="heading") -> {by_class}'
|
||||
|
||||
|
||||
def _select(soup: BeautifulSoup) -> str:
|
||||
first = soup_basics.select_one_text(soup, "p a")
|
||||
every = soup_basics.select_texts(soup, ".heading")
|
||||
return f'select_one("p a") -> {first}\nselect(".heading") -> {every}'
|
||||
|
||||
|
||||
## The lecture steps by command-line name, in lecture order.
|
||||
STEPS: dict[str, Callable[[BeautifulSoup], str]] = {
|
||||
"title": _title,
|
||||
"prettify": _prettify,
|
||||
"find-all": _find_all,
|
||||
"find": _find,
|
||||
"select": _select,
|
||||
}
|
||||
|
||||
|
||||
def render_step(step: str) -> str:
|
||||
"""@brief Run one lecture step against the sample page.
|
||||
|
||||
@param step A key of STEPS.
|
||||
@return The text the step prints.
|
||||
@throws KeyError When the step name is unknown.
|
||||
"""
|
||||
soup = soup_basics.make_soup(soup_basics.load_html(WEBSITE_HTML_PATH))
|
||||
return STEPS[step](soup)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""@brief Run the lecture steps and save one terminal screenshot per step.
|
||||
|
||||
Usage: python tools/make_screenshots.py [name-prefix]
|
||||
The images are written to docs/images/ and used by the README and the wiki.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from render_terminal_png import render
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
IMAGES = ROOT / "docs" / "images"
|
||||
|
||||
## File name (without .png) and the arguments given to `python -m scrape_movies`.
|
||||
STEPS: dict[str, list[str]] = {
|
||||
"soup-1-title": ["soup", "title"],
|
||||
"soup-2-prettify": ["soup", "prettify"],
|
||||
"soup-3-find-all": ["soup", "find-all"],
|
||||
"soup-4-find": ["soup", "find"],
|
||||
"soup-5-select": ["soup", "select"],
|
||||
}
|
||||
|
||||
|
||||
def capture(name: str, args: list[str]) -> Path:
|
||||
"""@brief Run one step and write its screenshot.
|
||||
|
||||
@param name The image name without extension.
|
||||
@param args The arguments for `python -m scrape_movies`.
|
||||
@return The path of the PNG.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "scrape_movies", *args],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
target = IMAGES / f"{name}.png"
|
||||
render("python -m scrape_movies " + " ".join(args), result.stdout, target)
|
||||
return target
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""@brief Capture every step whose name starts with the given prefix."""
|
||||
prefix = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
for name, args in STEPS.items():
|
||||
if name.startswith(prefix):
|
||||
print(capture(name, args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""@brief Render a command and its output as a terminal-style PNG screenshot.
|
||||
|
||||
The screenshots of the README and the wiki are made with this script, so no
|
||||
desktop is captured. Needs Pillow (`pip install -e ".[dev]"`).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
BACKGROUND = (30, 30, 30)
|
||||
TITLE_BAR = (50, 50, 50)
|
||||
TEXT = (212, 212, 212)
|
||||
PROMPT = (96, 200, 96)
|
||||
FONT_SIZE = 16
|
||||
PADDING = 16
|
||||
BAR_HEIGHT = 30
|
||||
MAX_COLUMNS = 110
|
||||
MAX_LINES = 40
|
||||
FONT_FILES = (
|
||||
"consola.ttf",
|
||||
"DejaVuSansMono.ttf",
|
||||
"LiberationMono-Regular.ttf",
|
||||
"Menlo.ttc",
|
||||
)
|
||||
|
||||
|
||||
def _load_font() -> ImageFont.ImageFont | ImageFont.FreeTypeFont:
|
||||
for name in FONT_FILES:
|
||||
try:
|
||||
return ImageFont.truetype(name, FONT_SIZE)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _fit(lines: list[str]) -> list[str]:
|
||||
fitted = [
|
||||
line if len(line) <= MAX_COLUMNS else line[: MAX_COLUMNS - 3] + "..."
|
||||
for line in lines
|
||||
]
|
||||
if len(fitted) > MAX_LINES:
|
||||
fitted = fitted[:MAX_LINES] + ["..."]
|
||||
return fitted
|
||||
|
||||
|
||||
def render(command: str, output: str, target: Path) -> None:
|
||||
"""@brief Draw the command and its output and save the PNG.
|
||||
|
||||
@param command The command line shown after the prompt.
|
||||
@param output The text the command printed.
|
||||
@param target The PNG file to write.
|
||||
"""
|
||||
font = _load_font()
|
||||
lines = _fit(output.rstrip().splitlines())
|
||||
prompt = "$ "
|
||||
columns = max([len(prompt + command), *(len(line) for line in lines)])
|
||||
char_width = font.getlength("M")
|
||||
line_height = FONT_SIZE + 6
|
||||
width = int(columns * char_width) + 2 * PADDING
|
||||
height = BAR_HEIGHT + 2 * PADDING + line_height * (len(lines) + 1)
|
||||
image = Image.new("RGB", (max(width, 480), height), BACKGROUND)
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((0, 0, image.width, BAR_HEIGHT), fill=TITLE_BAR)
|
||||
for index, color in enumerate(((255, 95, 86), (255, 189, 46), (39, 201, 63))):
|
||||
x = PADDING + index * 22
|
||||
draw.ellipse((x, 9, x + 12, 21), fill=color)
|
||||
y = BAR_HEIGHT + PADDING
|
||||
draw.text((PADDING, y), prompt, font=font, fill=PROMPT)
|
||||
draw.text((PADDING + font.getlength(prompt), y), command, font=font, fill=TEXT)
|
||||
for line in lines:
|
||||
y += line_height
|
||||
draw.text((PADDING, y), line, font=font, fill=TEXT)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(target)
|
||||
Reference in New Issue
Block a user