diff --git a/README.md b/README.md
index 3b87547..65268fe 100644
--- a/README.md
+++ b/README.md
@@ -161,6 +161,52 @@ A short summary in our own words; it is not legal advice.
- **Commercial use** of scraped data invites expensive disputes even where the scraping itself was allowed.
- **Be a good guest:** prefer an official API when there is one, read the site's `robots.txt` to see what it allows, and send at most one request a minute. This project fetches one page per run and identifies itself with a `User-Agent`.
+### Project: 100 movies to watch
+
+The assignment: scrape Empire's "100 Greatest Movies" and write `movies.txt` with the titles numbered from `1)` upwards. The code is `infrastructure/empire_movies.py` (parsing), `domain/movie.py` (reverse and number) and `infrastructure/movie_file.py` (writing). `--source wayback` (default) reads the Internet Archive snapshot of May 2020 that the assignment pins, `--source live` reads the current Empire page and `--source fixture` reads the saved headings in `tests/fixtures/`.
+
+**1. Choose the page** (the saved headings of the page)
+
+
+
+**2. Inspect the markup** (the 2020 page has `
`; the current page has `` headings that start with a rank)
+
+```bash
+python -m scrape_movies movies markup
+```
+
+
+
+**3. Titles in page order** (`select("h3.title")` and a list comprehension over `get_text()`; the page counts down from 100)
+
+```bash
+python -m scrape_movies movies titles
+```
+
+
+
+**4. Reverse the list** (`titles[::-1]`, so the list counts up from 1)
+
+```bash
+python -m scrape_movies movies reversed
+```
+
+
+
+**5. Write `movies.txt`** (a `with open(...)` block, one title per line)
+
+```bash
+python -m scrape_movies movies write
+```
+
+
+
+**6. The result**
+
+
+
+The committed [`movies.txt`](./movies.txt) was produced with `--source fixture` on 2026-10-28 from the current Empire list, so its ranking differs from the 2020 list in the lecture video. The Internet Archive answered `429 Too Many Requests` while this project was built, so the snapshot is read live only; its `h3.title` layout is covered by a hand-written sample in the tests.
+
## 🧪 Tests
```bash
diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md
index c7a9d18..54c0f3e 100644
--- a/docs/artifact-registry.md
+++ b/docs/artifact-registry.md
@@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
| PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 006 |
-| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 014 |
+| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 015 |
## Languages
diff --git a/docs/images/movies-1-page.png b/docs/images/movies-1-page.png
new file mode 100644
index 0000000..91ff187
Binary files /dev/null and b/docs/images/movies-1-page.png differ
diff --git a/docs/images/movies-2-markup.png b/docs/images/movies-2-markup.png
new file mode 100644
index 0000000..053ddb1
Binary files /dev/null and b/docs/images/movies-2-markup.png differ
diff --git a/docs/images/movies-3-titles.png b/docs/images/movies-3-titles.png
new file mode 100644
index 0000000..3244fa3
Binary files /dev/null and b/docs/images/movies-3-titles.png differ
diff --git a/docs/images/movies-4-reversed.png b/docs/images/movies-4-reversed.png
new file mode 100644
index 0000000..8dac658
Binary files /dev/null and b/docs/images/movies-4-reversed.png differ
diff --git a/docs/images/movies-5-write.png b/docs/images/movies-5-write.png
new file mode 100644
index 0000000..d6258e1
Binary files /dev/null and b/docs/images/movies-5-write.png differ
diff --git a/docs/images/movies-6-file.png b/docs/images/movies-6-file.png
new file mode 100644
index 0000000..4cc63ea
Binary files /dev/null and b/docs/images/movies-6-file.png differ
diff --git a/docs/sqa/reviews/rc-014-mil-004-python.md b/docs/sqa/reviews/rc-014-mil-004-python.md
new file mode 100644
index 0000000..2a3c4c3
--- /dev/null
+++ b/docs/sqa/reviews/rc-014-mil-004-python.md
@@ -0,0 +1,63 @@
+# Review of MIL-004 Python code
+
+## Metadata
+| Key | Value |
+| --- | --- |
+| ID | RC-014 |
+| CrossReference | [MIL-004], [QC-PY-001] |
+
+## Version History
+| Date | Status | Author | Reviewer | Change | Commit |
+| --- | --- | --- | --- | --- | --- |
+| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | S01 (AI as stand-in, autonomous mode) | Initial version | [02c339c] |
+
+---
+
+## Artifact Under Review
+
+- Instance reviewed: [MIL-004]
+- Checklist used: [QC-PY-001]
+- Scope: full review. Code reviewed: movie, empire_movies, movie_file, use_cases, movies_steps, cli, tools/, tests/unit and tests/integration.
+- Language and domain: n/a (technical type)
+- Language reviewer: none
+- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
+
+## Checklist Results
+
+| # | Criterion | Status | Evidence/Notes |
+| --- | --- | --- | --- |
+| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | modules and functions are snake_case, classes PascalCase, constants UPPER_SNAKE. |
+| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | names state purpose; the lecture names (`find_all`, `select_one`) are kept in the helper names. |
+| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` are clean with no `noqa`. |
+| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | every signature is annotated; `mypy --strict` passes, also in a venv without the third-party packages. |
+| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | fetch errors are raised as `FetchError` from the cause (MIL-003); no exception is swallowed. |
+| 6 | No mutable default arguments and no shadowed builtins | Pass | no mutable defaults, no shadowed builtins. |
+| 7 | Files, locks and connections are managed with context managers | Pass | `write_movies` writes inside a `with path.open(...)` block. |
+| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | every public module, class and function has a Doxygen docstring saying what it does. |
+| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | N-A | no logging; the command-line output is the product, written with `print` in the presentation layer. |
+| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | no Design Class Diagram; all tasks are plain technical tasks (Needs Use Case: No). |
+| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | tests are named for behaviour, offline and independent of order. |
+| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` runs over `src`, `tests` and `tools`. |
+| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Fail | dependencies are declared with lower bounds, not pinned (see RC-009 action item); none is unused. |
+| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | each constant and format is defined once; the step texts are built in one place. |
+| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` imports nothing from other layers; `infrastructure` imports `domain` and `constants`; `presentation` composes them. |
+| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | use cases take callables (ports); a new page layout needs a new parser, not an edit of the use case. |
+| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | code is in `domain`, `application`, `infrastructure`, `presentation`; tests in `tests/unit` and `tests/integration` (the CLI-to-file flow); `e2e` stays empty because the CLI is covered by the integration test. |
+| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | all names are English lowercase with `_`, no space. |
+
+## Overall Verdict
+
+Go — no Mandatory criterion fails and none is Pending. Stand-in review in autonomous mode, not independent; S01 challenges it in the run summary.
+
+## Action Items
+
+| Action | Owner | Due |
+| --- | --- | --- |
+| Pin dependencies or accept lower bounds (open from RC-009, optional criterion 13) | S01 | 2026-11-14 |
+| Refresh the Wayback fixture when archive.org stops answering 429 (the snapshot layout is covered by a hand-written sample) | S01 | 2026-11-14 |
+
+---
+
+[MIL-004]: ../../milestones/mil-004-top-100-movies.md
+[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
+[02c339c]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/045-scrape-movies/commit/02c339c2dbe95b0e2eff5e4a3738f6e946014d26
diff --git a/movies.txt b/movies.txt
new file mode 100644
index 0000000..003a005
--- /dev/null
+++ b/movies.txt
@@ -0,0 +1,100 @@
+1) Jaws (1975)
+2) The Godfather (1972)
+3) The Shawshank Redemption (1994)
+4) Star Wars: The Empire Strikes Back (1980)
+5) Aliens (1986)
+6) Inception (2010)
+7) Goodfellas (1990)
+8) Star Wars: Episode IV – A New Hope (1977)
+9) The Lord Of The Rings: The Fellowship Of The Ring (2001)
+10) The Dark Knight (2008)
+11) Pulp Fiction (1994)
+12) Back To The Future (1985)
+13) Jurassic Park (1993)
+14) Raiders Of The Lost Ark (1981)
+15) Blade Runner (1982)
+16) The Godfather Part II (1974)
+17) Alien (1979)
+18) Terminator 2: Judgment Day (1991)
+19) Interstellar (2014)
+20) Heat (1995)
+21) The Lord Of The Rings: The Return Of The King (2003)
+22) The Matrix (1999)
+23) Die Hard (1988)
+24) There Will Be Blood (2007)
+25) 2001: A Space Odyssey (1968)
+26) Gladiator (2000)
+27) Parasite (2019)
+28) Apocalypse Now (1979)
+29) 12 Angry Men (1957)
+30) Casablanca (1942)
+31) The Thing (1982)
+32) Seven (1995)
+33) The Lord Of The Rings: The Two Towers (2002)
+34) The Shining (1980)
+35) Mad Max: Fury Road (2015)
+36) Point Break (1991)
+37) Fight Club (1999)
+38) The Good, The Bad And The Ugly (1966)
+39) Lawrence Of Arabia (1962)
+40) Mulholland Drive (2001)
+41) Schindler's List (1993)
+42) Saving Private Ryan (1998)
+43) La La Land (2016)
+44) Taxi Driver (1976)
+45) Past Lives (2023)
+46) Whiplash (2014)
+47) It's A Wonderful Life (1946)
+48) Avengers: Infinity War (2018)
+49) Psycho (1960)
+50) No Country For Old Men (2007)
+51) Rear Window (1954)
+52) Citizen Kane (1941)
+53) Vertigo (1958)
+54) Get Out (2017)
+55) Hot Fuzz (2007)
+56) Spirited Away (2001)
+57) Seven Samurai (1954)
+58) Titanic (1997)
+59) The Exorcist (1973)
+60) Fargo (1996)
+61) The Social Network (2010)
+62) Eternal Sunshine Of The Spotless Mind (2004)
+63) The Big Lebowski (1998)
+64) Oppenheimer (2023)
+65) Predator (1987)
+66) Indiana Jones And The Last Crusade (1989)
+67) Do The Right Thing (1989)
+68) Avengers: Endgame (2019)
+69) The Babadook (2014)
+70) Children Of Men (2006)
+71) Arrival (2016)
+72) Magnolia (1999)
+73) The Lion King (1994)
+74) Trainspotting (1996)
+75) The Silence Of The Lambs (1991)
+76) Once Upon A Time In Hollywood (2019)
+77) In The Mood For Love (2000)
+78) Ghostbusters (1984)
+79) Paddington 2 (2017)
+80) Spider-Man: Into The Spider-Verse (2018)
+81) Everything Everywhere All At Once (2022)
+82) Brokeback Mountain (2005)
+83) Inglourious Basterds (2009)
+84) Pan's Labyrinth (2006)
+85) Rocky (1976)
+86) Lady Bird (2017)
+87) Raging Bull (1980)
+88) The Piano (1993)
+89) American Psycho (2000)
+90) Groundhog Day (1993)
+91) The Grand Budapest Hotel (2014)
+92) One Flew Over The Cuckoo's Nest (1975)
+93) Lost In Translation (2003)
+94) Moonlight (2016)
+95) Memento (2000)
+96) E.T. The Extra Terrestrial (1982)
+97) Barbie (2023)
+98) When Harry Met Sally... (1989)
+99) Portrait Of A Lady On Fire (2020)
+100) Avatar (2009)
diff --git a/src/scrape_movies/application/ports.py b/src/scrape_movies/application/ports.py
index 83f921b..b59c9bd 100644
--- a/src/scrape_movies/application/ports.py
+++ b/src/scrape_movies/application/ports.py
@@ -1,6 +1,7 @@
"""@brief The ports (interfaces) the use cases need from the outside world."""
-from collections.abc import Callable
+from collections.abc import Callable, Sequence
+from pathlib import Path
from typing import TypeAlias
from scrape_movies.domain.story import Story
@@ -9,3 +10,7 @@ from scrape_movies.domain.story import Story
HtmlFetcher: TypeAlias = Callable[[str], str]
## Turns Hacker News HTML into stories.
StoryParser: TypeAlias = Callable[[str], list[Story]]
+## Turns the Empire page HTML into the movie lines found on it, in page order.
+MovieParser: TypeAlias = Callable[[str], list[str]]
+## Writes lines to a file, one per line.
+MovieWriter: TypeAlias = Callable[[Sequence[str], Path], None]
diff --git a/src/scrape_movies/application/use_cases.py b/src/scrape_movies/application/use_cases.py
index e8b1146..493aa93 100644
--- a/src/scrape_movies/application/use_cases.py
+++ b/src/scrape_movies/application/use_cases.py
@@ -1,26 +1,67 @@
-"""@brief Use cases of the scraping exercises."""
-
-from scrape_movies.application.ports import HtmlFetcher, StoryParser
-from scrape_movies.domain.story import Story, top_story
-
-
-def list_stories(fetch: HtmlFetcher, parse: StoryParser, url: str) -> list[Story]:
- """@brief Fetch a Hacker News page and read its stories.
-
- @param fetch Fetches the HTML of the page.
- @param parse Reads the stories from the HTML.
- @param url The page address.
- @return The stories in page order.
- """
- return parse(fetch(url))
-
-
-def find_top_story(fetch: HtmlFetcher, parse: StoryParser, url: str) -> Story | None:
- """@brief Find the story with the most upvotes on a Hacker News page.
-
- @param fetch Fetches the HTML of the page.
- @param parse Reads the stories from the HTML.
- @param url The page address.
- @return The top story, or None when the page has no stories.
- """
- return top_story(list_stories(fetch, parse, url))
+"""@brief Use cases of the scraping exercises."""
+
+from collections.abc import Sequence
+from pathlib import Path
+
+from scrape_movies.application.ports import (
+ HtmlFetcher,
+ MovieParser,
+ MovieWriter,
+ StoryParser,
+)
+from scrape_movies.domain.movie import format_titles
+from scrape_movies.domain.story import Story, top_story
+
+
+def list_stories(fetch: HtmlFetcher, parse: StoryParser, url: str) -> list[Story]:
+ """@brief Fetch a Hacker News page and read its stories.
+
+ @param fetch Fetches the HTML of the page.
+ @param parse Reads the stories from the HTML.
+ @param url The page address.
+ @return The stories in page order.
+ """
+ return parse(fetch(url))
+
+
+def find_top_story(fetch: HtmlFetcher, parse: StoryParser, url: str) -> Story | None:
+ """@brief Find the story with the most upvotes on a Hacker News page.
+
+ @param fetch Fetches the HTML of the page.
+ @param parse Reads the stories from the HTML.
+ @param url The page address.
+ @return The top story, or None when the page has no stories.
+ """
+ return top_story(list_stories(fetch, parse, url))
+
+
+def scrape_movie_titles(fetch: HtmlFetcher, parse: MovieParser, url: str) -> list[str]:
+ """@brief Fetch the Empire page and read the titles in page order.
+
+ @param fetch Fetches the HTML of the page.
+ @param parse Reads the titles from the HTML.
+ @param url The page address.
+ @return The titles in page order (100 down to 1).
+ """
+ return parse(fetch(url))
+
+
+def save_movie_list(
+ fetch: HtmlFetcher,
+ parse: MovieParser,
+ write: MovieWriter,
+ url: str,
+ path: Path,
+) -> Sequence[str]:
+ """@brief Scrape the list and write it, ascending and numbered, to a file.
+
+ @param fetch Fetches the HTML of the page.
+ @param parse Reads the titles from the HTML.
+ @param write Writes the lines to the file.
+ @param url The page address.
+ @param path The file to write, for example `movies.txt`.
+ @return The lines that were written.
+ """
+ lines = format_titles(scrape_movie_titles(fetch, parse, url))
+ write(lines, path)
+ return lines
diff --git a/src/scrape_movies/constants.py b/src/scrape_movies/constants.py
index 61c1756..83677fd 100644
--- a/src/scrape_movies/constants.py
+++ b/src/scrape_movies/constants.py
@@ -35,3 +35,6 @@ MOVIES_OUTPUT_FILE: str = "movies.txt"
## The saved copy of the course's Hacker News page, used offline.
HACKER_NEWS_FIXTURE_PATH: Path = FIXTURES_DIR / "hacker_news.html"
+
+## The saved headings of the live Empire page, used offline.
+EMPIRE_FIXTURE_PATH: Path = FIXTURES_DIR / "empire_live_excerpt.html"
diff --git a/src/scrape_movies/domain/movie.py b/src/scrape_movies/domain/movie.py
new file mode 100644
index 0000000..1372c22
--- /dev/null
+++ b/src/scrape_movies/domain/movie.py
@@ -0,0 +1,22 @@
+"""@brief The movie list rule: ascending order, every line starting with its rank."""
+
+import re
+from collections.abc import Sequence
+
+_RANK_PREFIX = re.compile(r"^\d+\) ")
+
+
+def format_titles(titles: Sequence[str]) -> list[str]:
+ """@brief Turn a descending page list into an ascending, numbered list.
+
+ Empire lists the movies from 100 down to 1, so the page order is reversed.
+ A title that already starts with its rank, such as `3) Jaws`, is kept as
+ it is; any other title gets `) ` in front of it.
+
+ @param titles The titles in page order.
+ @return The lines `1) ...`, `2) ...` in ascending order.
+ """
+ lines: list[str] = []
+ for rank, title in enumerate(reversed(titles), 1):
+ lines.append(title if _RANK_PREFIX.match(title) else f"{rank}) {title}")
+ return lines
diff --git a/src/scrape_movies/infrastructure/empire_movies.py b/src/scrape_movies/infrastructure/empire_movies.py
new file mode 100644
index 0000000..7ce3217
--- /dev/null
+++ b/src/scrape_movies/infrastructure/empire_movies.py
@@ -0,0 +1,55 @@
+"""@brief Reading the movie titles from the Empire "100 Greatest Movies" page."""
+
+import re
+
+from bs4 import Tag
+
+from scrape_movies.infrastructure.soup_basics import make_soup
+
+## The 2020 page (the assignment's snapshot): `100) Title
`.
+SNAPSHOT_TITLE = "h3.title"
+## The current page: `100) Title (year)
`.
+_RANKED_HEADING = re.compile(r"^\s*\d{1,3}\)")
+
+
+def parse_titles(html: str) -> list[str]:
+ """@brief Read the movie titles in page order.
+
+ The assignment's snapshot is read through `h3.title`. When the page has no
+ such tag, as on the current live page, the `h2` headings that start with a
+ rank such as `100)` are used.
+
+ @param html The HTML of the page.
+ @return The titles in page order, each as the page shows it.
+ """
+ soup = make_soup(html)
+ titles = [tag.get_text().strip() for tag in soup.select(SNAPSHOT_TITLE)]
+ if titles:
+ return titles
+ headings = [tag for tag in soup.find_all("h2") if isinstance(tag, Tag)]
+ return [
+ text
+ for text in (heading.get_text().strip() for heading in headings)
+ if _RANKED_HEADING.match(text)
+ ]
+
+
+def first_title_markup(html: str) -> str:
+ """@brief Show the markup of the first title, as seen with "Inspect".
+
+ @param html The HTML of the page.
+ @return The indented HTML of the first title tag, or "" when there is none.
+ """
+ soup = make_soup(html)
+ tag = soup.select_one(SNAPSHOT_TITLE)
+ if tag is None:
+ tag = next(
+ (
+ heading
+ for heading in soup.find_all("h2")
+ if isinstance(heading, Tag)
+ and _RANKED_HEADING.match(heading.get_text())
+ ),
+ None,
+ )
+ return str(tag.prettify()).rstrip() if tag is not None else ""
diff --git a/src/scrape_movies/infrastructure/movie_file.py b/src/scrape_movies/infrastructure/movie_file.py
new file mode 100644
index 0000000..2ef8b18
--- /dev/null
+++ b/src/scrape_movies/infrastructure/movie_file.py
@@ -0,0 +1,15 @@
+"""@brief Writing the movie list to a text file."""
+
+from collections.abc import Sequence
+from pathlib import Path
+
+
+def write_movies(lines: Sequence[str], path: Path) -> None:
+ """@brief Write one line per movie, UTF-8, with a final newline.
+
+ @param lines The lines to write.
+ @param path The file to create or overwrite.
+ """
+ with path.open("w", encoding="utf-8", newline="\n") as file:
+ for line in lines:
+ file.write(f"{line}\n")
diff --git a/src/scrape_movies/presentation/cli.py b/src/scrape_movies/presentation/cli.py
index 0b17b87..c35d3fd 100644
--- a/src/scrape_movies/presentation/cli.py
+++ b/src/scrape_movies/presentation/cli.py
@@ -2,8 +2,9 @@
import argparse
from collections.abc import Sequence
+from pathlib import Path
-from scrape_movies.presentation import hacker_news_steps, soup_steps
+from scrape_movies.presentation import hacker_news_steps, movies_steps, soup_steps
def build_parser() -> argparse.ArgumentParser:
@@ -26,6 +27,17 @@ def build_parser() -> argparse.ArgumentParser:
default="mirror",
help="the course mirror (default), the live site or the saved copy",
)
+ movies = commands.add_parser("movies", help="scrape Empire's 100 greatest movies")
+ movies.add_argument("step", choices=movies_steps.STEPS)
+ movies.add_argument(
+ "--source",
+ choices=movies_steps.SOURCES,
+ default="wayback",
+ help="the Wayback snapshot (default), the live site or the saved copy",
+ )
+ movies.add_argument(
+ "--output", type=Path, help="file written by the write step (movies.txt)"
+ )
return parser
@@ -43,5 +55,8 @@ def main(argv: Sequence[str] | None = None) -> int:
if args.command == "hacker-news":
print(hacker_news_steps.render_step(args.step, args.source))
return 0
+ if args.command == "movies":
+ print(movies_steps.render_step(args.step, args.source, args.output))
+ return 0
parser.print_help()
return 0
diff --git a/src/scrape_movies/presentation/movies_steps.py b/src/scrape_movies/presentation/movies_steps.py
new file mode 100644
index 0000000..dc9b664
--- /dev/null
+++ b/src/scrape_movies/presentation/movies_steps.py
@@ -0,0 +1,63 @@
+"""@brief Text output of each "100 Movies to Watch" lecture step."""
+
+from pathlib import Path
+
+from scrape_movies.application import use_cases
+from scrape_movies.application.ports import HtmlFetcher
+from scrape_movies.constants import (
+ EMPIRE_FIXTURE_PATH,
+ EMPIRE_LIVE_URL,
+ EMPIRE_WAYBACK_URL,
+ MOVIES_OUTPUT_FILE,
+)
+from scrape_movies.infrastructure import empire_movies, http_client, soup_basics
+from scrape_movies.infrastructure.movie_file import write_movies
+
+## Where a step reads the page: the Wayback snapshot, the live site or the saved copy.
+SOURCES: tuple[str, ...] = ("wayback", "live", "fixture")
+_URLS: dict[str, str] = {
+ "wayback": EMPIRE_WAYBACK_URL,
+ "live": EMPIRE_LIVE_URL,
+ "fixture": str(EMPIRE_FIXTURE_PATH),
+}
+## The lecture steps by command-line name, in lecture order.
+STEPS: tuple[str, ...] = ("markup", "titles", "reversed", "write")
+_SHOWN_LINES = 12
+
+
+def _fetcher(source: str) -> HtmlFetcher:
+ if source == "fixture":
+ return lambda path: soup_basics.load_html(EMPIRE_FIXTURE_PATH)
+ return http_client.fetch_html
+
+
+def _head(lines: list[str]) -> str:
+ shown = lines[:_SHOWN_LINES]
+ more = len(lines) - len(shown)
+ return "\n".join(shown + ([f"... and {more} more"] if more > 0 else []))
+
+
+def render_step(step: str, source: str, output: Path | None = None) -> str:
+ """@brief Run one lecture step.
+
+ @param step One of STEPS.
+ @param source One of SOURCES.
+ @param output The file the `write` step creates; `movies.txt` when None.
+ @return The text the step prints.
+ @throws KeyError When the step or the source is unknown.
+ """
+ if step not in STEPS:
+ raise KeyError(step)
+ fetch, url = _fetcher(source), _URLS[source]
+ if step == "markup":
+ return empire_movies.first_title_markup(fetch(url))
+ if step == "titles":
+ titles = use_cases.scrape_movie_titles(fetch, empire_movies.parse_titles, url)
+ return _head(titles)
+ target = output or Path(MOVIES_OUTPUT_FILE)
+ lines = use_cases.save_movie_list(
+ fetch, empire_movies.parse_titles, write_movies, url, target
+ )
+ if step == "reversed":
+ return _head(list(lines))
+ return f"{_head(list(lines))}\nWrote {len(lines)} lines to {target}"
diff --git a/tests/fixtures/empire_live_excerpt.html b/tests/fixtures/empire_live_excerpt.html
new file mode 100644
index 0000000..2d32137
--- /dev/null
+++ b/tests/fixtures/empire_live_excerpt.html
@@ -0,0 +1,105 @@
+
+Empire best movies (headings excerpt)
+
+
+100) Avatar (2009)
+99) Portrait Of A Lady On Fire (2020)
+98) When Harry Met Sally... (1989)
+97) Barbie (2023)
+96) E.T. The Extra Terrestrial (1982)
+95) Memento (2000)
+94) Moonlight (2016)
+93) Lost In Translation (2003)
+92) One Flew Over The Cuckoo's Nest (1975)
+91) The Grand Budapest Hotel (2014)
+90) Groundhog Day (1993)
+89) American Psycho (2000)
+88) The Piano (1993)
+87) Raging Bull (1980)
+86) Lady Bird (2017)
+85) Rocky (1976)
+84) Pan's Labyrinth (2006)
+83) Inglourious Basterds (2009)
+82) Brokeback Mountain (2005)
+81) Everything Everywhere All At Once (2022)
+80) Spider-Man: Into The Spider-Verse (2018)
+79) Paddington 2 (2017)
+78) Ghostbusters (1984)
+77) In The Mood For Love (2000)
+76) Once Upon A Time In Hollywood (2019)
+75) The Silence Of The Lambs (1991)
+74) Trainspotting (1996)
+73) The Lion King (1994)
+72) Magnolia (1999)
+71) Arrival (2016)
+70) Children Of Men (2006)
+69) The Babadook (2014)
+68) Avengers: Endgame (2019)
+67) Do The Right Thing (1989)
+66) Indiana Jones And The Last Crusade (1989)
+65) Predator (1987)
+64) Oppenheimer (2023)
+63) The Big Lebowski (1998)
+62) Eternal Sunshine Of The Spotless Mind (2004)
+61) The Social Network (2010)
+60) Fargo (1996)
+59) The Exorcist (1973)
+58) Titanic (1997)
+57) Seven Samurai (1954)
+56) Spirited Away (2001)
+55) Hot Fuzz (2007)
+54) Get Out (2017)
+53) Vertigo (1958)
+52) Citizen Kane (1941)
+51) Rear Window (1954)
+50) No Country For Old Men (2007)
+49) Psycho (1960)
+48) Avengers: Infinity War (2018)
+47) It's A Wonderful Life (1946)
+46) Whiplash (2014)
+45) Past Lives (2023)
+44) Taxi Driver (1976)
+43) La La Land (2016)
+42) Saving Private Ryan (1998)
+41) Schindler's List (1993)
+40) Mulholland Drive (2001)
+39) Lawrence Of Arabia (1962)
+38) The Good, The Bad And The Ugly (1966)
+37) Fight Club (1999)
+36) Point Break (1991)
+35) Mad Max: Fury Road (2015)
+34) The Shining (1980)
+33) The Lord Of The Rings: The Two Towers (2002)
+32) Seven (1995)
+31) The Thing (1982)
+30) Casablanca (1942)
+29) 12 Angry Men (1957)
+28) Apocalypse Now (1979)
+27) Parasite (2019)
+26) Gladiator (2000)
+25) 2001: A Space Odyssey (1968)
+24) There Will Be Blood (2007)
+23) Die Hard (1988)
+22) The Matrix (1999)
+21) The Lord Of The Rings: The Return Of The King (2003)
+20) Heat (1995)
+19) Interstellar (2014)
+18) Terminator 2: Judgment Day (1991)
+17) Alien (1979)
+16) The Godfather Part II (1974)
+15) Blade Runner (1982)
+14) Raiders Of The Lost Ark (1981)
+13) Jurassic Park (1993)
+12) Back To The Future (1985)
+11) Pulp Fiction (1994)
+10) The Dark Knight (2008)
+9) The Lord Of The Rings: The Fellowship Of The Ring (2001)
+8) Star Wars: Episode IV – A New Hope (1977)
+7) Goodfellas (1990)
+6) Inception (2010)
+5) Aliens (1986)
+4) Star Wars: The Empire Strikes Back (1980)
+3) The Shawshank Redemption (1994)
+2) The Godfather (1972)
+1) Jaws (1975)
+
diff --git a/tests/integration/test_movies_flow.py b/tests/integration/test_movies_flow.py
new file mode 100644
index 0000000..2087320
--- /dev/null
+++ b/tests/integration/test_movies_flow.py
@@ -0,0 +1,27 @@
+from pathlib import Path
+
+import pytest
+
+from scrape_movies.presentation import cli
+
+
+def _run(target: Path, capsys: pytest.CaptureFixture[str]) -> str:
+ argv = ["movies", "write", "--source", "fixture", "--output", str(target)]
+ assert cli.main(argv) == 0
+ capsys.readouterr()
+ return target.read_text(encoding="utf-8")
+
+
+def test_write_when_run_from_fixture_creates_100_ascending_lines(
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
+) -> None:
+ lines = _run(tmp_path / "movies.txt", capsys).splitlines()
+ assert len(lines) == 100
+ assert [int(line.split(")")[0]) for line in lines] == list(range(1, 101))
+
+
+def test_write_when_run_twice_produces_identical_files(
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
+) -> None:
+ first = _run(tmp_path / "a.txt", capsys)
+ assert _run(tmp_path / "b.txt", capsys) == first
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index b288338..13246a8 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -39,3 +39,10 @@ def test_main_when_every_hacker_news_step_runs_from_fixture_prints_output(
for step in hacker_news_steps.STEPS:
assert cli.main(["hacker-news", step, "--source", "fixture"]) == 0
assert capsys.readouterr().out.strip(), step
+
+
+def test_main_when_movies_markup_from_fixture_prints_heading(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ assert cli.main(["movies", "markup", "--source", "fixture"]) == 0
+ assert "" in capsys.readouterr().out
diff --git a/tests/unit/test_empire_movies.py b/tests/unit/test_empire_movies.py
new file mode 100644
index 0000000..8ead7dc
--- /dev/null
+++ b/tests/unit/test_empire_movies.py
@@ -0,0 +1,48 @@
+from scrape_movies.constants import EMPIRE_FIXTURE_PATH
+from scrape_movies.infrastructure import empire_movies
+from scrape_movies.infrastructure.soup_basics import load_html
+
+SNAPSHOT_LAYOUT = """
+4) The Shawshank Redemption
+3) The Dark Knight
+not a title
+2) The Empire Strikes Back
+"""
+
+
+def test_parse_titles_when_snapshot_layout_reads_h3_title_in_page_order() -> None:
+ assert empire_movies.parse_titles(SNAPSHOT_LAYOUT) == [
+ "4) The Shawshank Redemption",
+ "3) The Dark Knight",
+ "2) The Empire Strikes Back",
+ ]
+
+
+def test_parse_titles_when_live_layout_reads_ranked_h2_headings() -> None:
+ titles = empire_movies.parse_titles(load_html(EMPIRE_FIXTURE_PATH))
+ assert len(titles) == 100
+ assert titles[0].startswith("100) ")
+ assert titles[-1].startswith("1) ")
+
+
+def test_parse_titles_when_h2_has_no_rank_ignores_it() -> None:
+ assert empire_movies.parse_titles("Jump to
2) Two
") == ["2) Two"]
+
+
+def test_parse_titles_when_page_has_no_titles_returns_empty() -> None:
+ assert empire_movies.parse_titles("
nothing
") == []
+
+
+def test_first_title_markup_when_snapshot_layout_shows_h3() -> None:
+ assert empire_movies.first_title_markup(SNAPSHOT_LAYOUT).startswith(
+ ''
+ )
+
+
+def test_first_title_markup_when_live_layout_shows_h2() -> None:
+ markup = empire_movies.first_title_markup(load_html(EMPIRE_FIXTURE_PATH))
+ assert markup.startswith("")
+
+
+def test_first_title_markup_when_nothing_found_returns_empty() -> None:
+ assert empire_movies.first_title_markup("
x
") == ""
diff --git a/tests/unit/test_movie.py b/tests/unit/test_movie.py
new file mode 100644
index 0000000..a7d063b
--- /dev/null
+++ b/tests/unit/test_movie.py
@@ -0,0 +1,18 @@
+from scrape_movies.domain.movie import format_titles
+
+
+def test_format_titles_when_titles_are_ranked_reverses_to_ascending() -> None:
+ page = ["3) Three", "2) Two", "1) One"]
+ assert format_titles(page) == ["1) One", "2) Two", "3) Three"]
+
+
+def test_format_titles_when_titles_have_no_rank_numbers_them() -> None:
+ assert format_titles(["Third", "Second", "First"]) == [
+ "1) First",
+ "2) Second",
+ "3) Third",
+ ]
+
+
+def test_format_titles_when_list_is_empty_returns_empty() -> None:
+ assert format_titles([]) == []
diff --git a/tests/unit/test_movie_file.py b/tests/unit/test_movie_file.py
new file mode 100644
index 0000000..1e0660a
--- /dev/null
+++ b/tests/unit/test_movie_file.py
@@ -0,0 +1,16 @@
+from pathlib import Path
+
+from scrape_movies.infrastructure.movie_file import write_movies
+
+
+def test_write_movies_when_lines_given_writes_one_per_line(tmp_path: Path) -> None:
+ target = tmp_path / "movies.txt"
+ write_movies(["1) One", "2) Zoë"], target)
+ assert target.read_bytes() == "1) One\n2) Zoë\n".encode()
+
+
+def test_write_movies_when_file_exists_replaces_it(tmp_path: Path) -> None:
+ target = tmp_path / "movies.txt"
+ target.write_text("old\n", encoding="utf-8")
+ write_movies(["1) New"], target)
+ assert target.read_text(encoding="utf-8") == "1) New\n"
diff --git a/tests/unit/test_use_cases.py b/tests/unit/test_use_cases.py
index b2d17f2..427a1f1 100644
--- a/tests/unit/test_use_cases.py
+++ b/tests/unit/test_use_cases.py
@@ -1,3 +1,6 @@
+from collections.abc import Sequence
+from pathlib import Path
+
from scrape_movies.application import use_cases
from scrape_movies.domain.story import Story
@@ -22,3 +25,21 @@ def test_find_top_story_when_stories_exist_returns_highest() -> None:
def test_find_top_story_when_page_is_empty_returns_none() -> None:
assert use_cases.find_top_story(_fetch, lambda html: [], "http://x") is None
+
+
+def test_save_movie_list_when_titles_are_descending_writes_ascending() -> None:
+ written: list[tuple[list[str], Path]] = []
+
+ def write(lines: Sequence[str], path: Path) -> None:
+ written.append((list(lines), path))
+
+ lines = use_cases.save_movie_list(
+ _fetch, lambda html: ["2) Two", "1) One"], write, "http://x", Path("m.txt")
+ )
+ assert list(lines) == ["1) One", "2) Two"]
+ assert written == [(["1) One", "2) Two"], Path("m.txt"))]
+
+
+def test_scrape_movie_titles_when_called_keeps_page_order() -> None:
+ titles = use_cases.scrape_movie_titles(_fetch, lambda html: ["b", "a"], "http://x")
+ assert titles == ["b", "a"]
diff --git a/tools/capture_page.py b/tools/capture_page.py
index 0dd4c82..fd35bc4 100644
--- a/tools/capture_page.py
+++ b/tools/capture_page.py
@@ -1,56 +1,59 @@
-"""@brief Save a screenshot of a web page with a headless Chromium browser.
-
-A headless browser draws into memory, so no desktop window is opened and no
-mouse or keyboard is used. It needs Edge, Chrome or Chromium on the machine.
-"""
-
-import shutil
-import subprocess
-from pathlib import Path
-
-CANDIDATES = (
- r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
- r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
- r"C:\Program Files\Google\Chrome\Application\chrome.exe",
- "google-chrome",
- "chromium",
- "chromium-browser",
-)
-WINDOW = "1280,1000"
-
-
-def find_browser() -> str:
- """@brief Locate a Chromium-based browser.
-
- @return The path or command name of the browser.
- @throws FileNotFoundError When none is installed.
- """
- for candidate in CANDIDATES:
- if Path(candidate).exists() or shutil.which(candidate):
- return candidate
- raise FileNotFoundError("Install Edge, Chrome or Chromium to capture pages.")
-
-
-def capture_page(url: str, target: Path) -> Path:
- """@brief Render a page and save it as a PNG.
-
- @param url The page address.
- @param target The PNG file to write.
- @return The path of the PNG.
- """
- target.parent.mkdir(parents=True, exist_ok=True)
- subprocess.run(
- [
- find_browser(),
- "--headless=new",
- "--disable-gpu",
- "--hide-scrollbars",
- f"--window-size={WINDOW}",
- f"--screenshot={target}",
- url,
- ],
- check=True,
- capture_output=True,
- timeout=120,
- )
- return target
+"""@brief Save a screenshot of a web page with a headless Chromium browser.
+
+A headless browser draws into memory, so no desktop window is opened and no
+mouse or keyboard is used. It needs Edge, Chrome or Chromium on the machine.
+"""
+
+import shutil
+import subprocess
+from pathlib import Path
+
+CANDIDATES = (
+ r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
+ r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
+ "google-chrome",
+ "chromium",
+ "chromium-browser",
+)
+WINDOW = "1280,1000"
+
+
+def find_browser() -> str:
+ """@brief Locate a Chromium-based browser.
+
+ @return The path or command name of the browser.
+ @throws FileNotFoundError When none is installed.
+ """
+ for candidate in CANDIDATES:
+ if Path(candidate).exists() or shutil.which(candidate):
+ return candidate
+ raise FileNotFoundError("Install Edge, Chrome or Chromium to capture pages.")
+
+
+def capture_page(url: str, target: Path) -> Path:
+ """@brief Render a page and save it as a PNG.
+
+ @param url The page address.
+ @param target The PNG file to write.
+ @return The path of the PNG.
+ """
+ target.parent.mkdir(parents=True, exist_ok=True)
+ browser = find_browser()
+ for mode in ("new", "old"):
+ result = subprocess.run(
+ [
+ browser,
+ f"--headless={mode}",
+ "--disable-gpu",
+ "--hide-scrollbars",
+ f"--window-size={WINDOW}",
+ f"--screenshot={target}",
+ url,
+ ],
+ capture_output=True,
+ timeout=120,
+ )
+ if result.returncode == 0 and target.exists():
+ return target
+ raise RuntimeError(f"The browser could not capture {url}")
diff --git a/tools/make_screenshots.py b/tools/make_screenshots.py
index dd527a1..12b1ca6 100644
--- a/tools/make_screenshots.py
+++ b/tools/make_screenshots.py
@@ -29,10 +29,20 @@ STEPS: dict[str, list[str]] = {
"hn-4-links": ["hacker-news", "links"],
"hn-5-upvotes": ["hacker-news", "upvotes"],
"hn-6-top": ["hacker-news", "top"],
+ "movies-2-markup": ["movies", "markup", "--source", "fixture"],
+ "movies-3-titles": ["movies", "titles", "--source", "fixture"],
+ "movies-4-reversed": ["movies", "reversed", "--source", "fixture"],
+ "movies-5-write": ["movies", "write", "--source", "fixture"],
}
+## Screenshots of a file's first lines: image name, file and number of lines.
+FILES: dict[str, tuple[str, int]] = {"movies-6-file": ("movies.txt", 15)}
## Screenshots of real web pages: image name and the address of the page.
PAGES: dict[str, str] = {
"hn-1-page": "https://appbrewery.github.io/news.ycombinator.com/",
+ # The live page opens a consent wall, so the saved headings are drawn instead.
+ "movies-1-page": (
+ ROOT / "tests" / "fixtures" / "empire_live_excerpt.html"
+ ).as_uri(),
}
@@ -63,6 +73,13 @@ def main() -> None:
for name, args in STEPS.items():
if name.startswith(prefix):
print(capture(name, args))
+ for name, (file_name, count) in FILES.items():
+ if name.startswith(prefix):
+ text = (ROOT / file_name).read_text(encoding="utf-8")
+ target = IMAGES / f"{name}.png"
+ shown = os.linesep.join(text.splitlines()[:count])
+ render(f"head -n {count} {file_name}", shown, target)
+ print(target)
for name, url in PAGES.items():
if name.startswith(prefix):
print(capture_page(url, IMAGES / f"{name}.png"))