MIL-004: top 100 movies scraper #38

Merged
Tirsvad merged 7 commits from mil-004-top-100-movies into main 2026-10-28 07:33:37 +01:00
9 changed files with 122 additions and 56 deletions
Showing only changes of commit 2553960dce - Show all commits
+46
View File
@@ -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. - **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`. - **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)
![Step 1: the page](docs/images/movies-1-page.png)
**2. Inspect the markup** (the 2020 page has `<h3 class="title">`; the current page has `<h2><strong>` headings that start with a rank)
```bash
python -m scrape_movies movies markup
```
![Step 2: markup](docs/images/movies-2-markup.png)
**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
```
![Step 3: titles](docs/images/movies-3-titles.png)
**4. Reverse the list** (`titles[::-1]`, so the list counts up from 1)
```bash
python -m scrape_movies movies reversed
```
![Step 4: reversed](docs/images/movies-4-reversed.png)
**5. Write `movies.txt`** (a `with open(...)` block, one title per line)
```bash
python -m scrape_movies movies write
```
![Step 5: write](docs/images/movies-5-write.png)
**6. The result**
![Step 6: movies.txt](docs/images/movies-6-file.png)
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 ## 🧪 Tests
```bash ```bash
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+59 -56
View File
@@ -1,56 +1,59 @@
"""@brief Save a screenshot of a web page with a headless Chromium browser. """@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 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. mouse or keyboard is used. It needs Edge, Chrome or Chromium on the machine.
""" """
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
CANDIDATES = ( CANDIDATES = (
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Google\Chrome\Application\chrome.exe", r"C:\Program Files\Google\Chrome\Application\chrome.exe",
"google-chrome", "google-chrome",
"chromium", "chromium",
"chromium-browser", "chromium-browser",
) )
WINDOW = "1280,1000" WINDOW = "1280,1000"
def find_browser() -> str: def find_browser() -> str:
"""@brief Locate a Chromium-based browser. """@brief Locate a Chromium-based browser.
@return The path or command name of the browser. @return The path or command name of the browser.
@throws FileNotFoundError When none is installed. @throws FileNotFoundError When none is installed.
""" """
for candidate in CANDIDATES: for candidate in CANDIDATES:
if Path(candidate).exists() or shutil.which(candidate): if Path(candidate).exists() or shutil.which(candidate):
return candidate return candidate
raise FileNotFoundError("Install Edge, Chrome or Chromium to capture pages.") raise FileNotFoundError("Install Edge, Chrome or Chromium to capture pages.")
def capture_page(url: str, target: Path) -> Path: def capture_page(url: str, target: Path) -> Path:
"""@brief Render a page and save it as a PNG. """@brief Render a page and save it as a PNG.
@param url The page address. @param url The page address.
@param target The PNG file to write. @param target The PNG file to write.
@return The path of the PNG. @return The path of the PNG.
""" """
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
subprocess.run( browser = find_browser()
[ for mode in ("new", "old"):
find_browser(), result = subprocess.run(
"--headless=new", [
"--disable-gpu", browser,
"--hide-scrollbars", f"--headless={mode}",
f"--window-size={WINDOW}", "--disable-gpu",
f"--screenshot={target}", "--hide-scrollbars",
url, f"--window-size={WINDOW}",
], f"--screenshot={target}",
check=True, url,
capture_output=True, ],
timeout=120, capture_output=True,
) timeout=120,
return target )
if result.returncode == 0 and target.exists():
return target
raise RuntimeError(f"The browser could not capture {url}")
+17
View File
@@ -29,10 +29,20 @@ STEPS: dict[str, list[str]] = {
"hn-4-links": ["hacker-news", "links"], "hn-4-links": ["hacker-news", "links"],
"hn-5-upvotes": ["hacker-news", "upvotes"], "hn-5-upvotes": ["hacker-news", "upvotes"],
"hn-6-top": ["hacker-news", "top"], "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. ## Screenshots of real web pages: image name and the address of the page.
PAGES: dict[str, str] = { PAGES: dict[str, str] = {
"hn-1-page": "https://appbrewery.github.io/news.ycombinator.com/", "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(): for name, args in STEPS.items():
if name.startswith(prefix): if name.startswith(prefix):
print(capture(name, args)) 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(): for name, url in PAGES.items():
if name.startswith(prefix): if name.startswith(prefix):
print(capture_page(url, IMAGES / f"{name}.png")) print(capture_page(url, IMAGES / f"{name}.png"))