MIL-003: scraping Hacker News #37
@@ -104,6 +104,63 @@ python -m scrape_movies soup select
|
||||
|
||||
The screenshots are made by `python tools/make_screenshots.py`, which renders the command output as an image; no desktop is captured.
|
||||
|
||||
### Lecture: Scraping Hacker News
|
||||
|
||||
The code is `src/scrape_movies/infrastructure/hacker_news.py` (parsing) and `http_client.py` (one `requests.get` with a timeout and a `User-Agent`). By default the commands read the course's static copy of the front page, `--source live` reads <https://news.ycombinator.com/news> and `--source fixture` reads the saved copy in `tests/fixtures/` without any network.
|
||||
|
||||
**1. Look at the page you will scrape** (live data instead of a local file)
|
||||
|
||||

|
||||
|
||||
**2. Inspect the markup** (browser developer tools show the tags to look for: a story row `tr.athing`, its title link `.titleline > a`, and a `span.score` in the next row)
|
||||
|
||||
```bash
|
||||
python -m scrape_movies hacker-news markup
|
||||
```
|
||||
|
||||

|
||||
|
||||
**3. Titles** (`find_all` on the title links, then `get_text()`)
|
||||
|
||||
```bash
|
||||
python -m scrape_movies hacker-news titles
|
||||
```
|
||||
|
||||

|
||||
|
||||
**4. Links** (`get("href")` of the same anchors)
|
||||
|
||||
```bash
|
||||
python -m scrape_movies hacker-news links
|
||||
```
|
||||
|
||||

|
||||
|
||||
**5. Upvotes** (the text `38 points` is split and converted with `int()` so it can be compared)
|
||||
|
||||
```bash
|
||||
python -m scrape_movies hacker-news upvotes
|
||||
```
|
||||
|
||||

|
||||
|
||||
**6. The top story** (Python's `max` over the upvotes)
|
||||
|
||||
```bash
|
||||
python -m scrape_movies hacker-news top
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Lecture: Is web scraping legal and ethical?
|
||||
|
||||
A short summary in our own words; it is not legal advice.
|
||||
|
||||
- **Public, non-copyrighted data** is generally fine to read. Court cases such as Genius v. Google and hiQ v. LinkedIn show that the line is not simple.
|
||||
- **Copyrighted content** (a video, a blog post) must not be copied, and **data behind a login** is covered by the site's user agreement, so do not scrape it.
|
||||
- **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`.
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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 | 013 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 014 |
|
||||
|
||||
## Languages
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,62 @@
|
||||
# Review of MIL-003 Python code
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-013 |
|
||||
| CrossReference | [MIL-003], [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 | [81af6fa] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-003]
|
||||
- Checklist used: [QC-PY-001]
|
||||
- Scope: full review. Code reviewed: Story, errors, ports, use_cases, http_client, hacker_news, hacker_news_steps, capture_page, tests/unit.
|
||||
- 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_html` catches only `requests.RequestException` and raises `FetchError` `from` the cause; a test checks `__cause__`. |
|
||||
| 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 | files are read with `Path.read_text`; the HTTP call is a single `requests.get` with no open session. |
|
||||
| 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 | no class hierarchy; use cases take callables (ports), so a new source needs no 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 are in `tests/unit` (network calls are faked; integration and e2e folders stay empty until needed). |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
[MIL-003]: ../../milestones/mil-003-hacker-news.md
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[81af6fa]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/045-scrape-movies/commit/81af6fa2e9675b4a27740caca50b7b4056688399
|
||||
+2
-1
@@ -38,7 +38,8 @@ extend-exclude = ["framework", ".agents", ".claude"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "UP", "B", "D"]
|
||||
ignore = ["D203", "D213"]
|
||||
# UP040 off: mypy 1.11 (the pinned CI version) does not support PEP 695 aliases.
|
||||
ignore = ["D203", "D213", "UP040"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["D"]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""@brief The ports (interfaces) the use cases need from the outside world."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TypeAlias
|
||||
|
||||
from scrape_movies.domain.story import Story
|
||||
|
||||
## Fetches the HTML text of a page address.
|
||||
HtmlFetcher: TypeAlias = Callable[[str], str]
|
||||
## Turns Hacker News HTML into stories.
|
||||
StoryParser: TypeAlias = Callable[[str], list[Story]]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""@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))
|
||||
@@ -32,3 +32,6 @@ EMPIRE_LIVE_URL: str = "https://www.empireonline.com/movies/features/best-movies
|
||||
|
||||
## File the movie list is written to.
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""@brief Errors the project raises."""
|
||||
|
||||
|
||||
class FetchError(Exception):
|
||||
"""@brief A web page could not be fetched."""
|
||||
@@ -0,0 +1,31 @@
|
||||
"""@brief The Story entity (a Hacker News article) and the top-story rule."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Story:
|
||||
"""@brief An article on the Hacker News front page.
|
||||
|
||||
@param title The headline.
|
||||
@param link The address the headline points to.
|
||||
@param upvotes The number of points; 0 when the story shows none.
|
||||
"""
|
||||
|
||||
title: str
|
||||
link: str
|
||||
upvotes: int
|
||||
|
||||
|
||||
def top_story(stories: Sequence[Story]) -> Story | None:
|
||||
"""@brief Find the story with the most upvotes.
|
||||
|
||||
@param stories The stories in page order.
|
||||
@return The first story with the highest score, or None when there are none.
|
||||
"""
|
||||
best: Story | None = None
|
||||
for story in stories:
|
||||
if best is None or story.upvotes > best.upvotes:
|
||||
best = story
|
||||
return best
|
||||
@@ -0,0 +1,81 @@
|
||||
"""@brief Reading titles, links and upvotes from a Hacker News page."""
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
from scrape_movies.domain.story import Story
|
||||
from scrape_movies.infrastructure.soup_basics import make_soup
|
||||
|
||||
STORY_ROW = "tr.athing"
|
||||
TITLE_LINK = ".titleline > a"
|
||||
SCORE = ".score"
|
||||
|
||||
|
||||
def _upvotes(row: Tag) -> int:
|
||||
"""@brief Read the score shown in the row after a story, 0 when it has none."""
|
||||
following = row.find_next_sibling("tr")
|
||||
score = following.select_one(SCORE) if isinstance(following, Tag) else None
|
||||
if score is None:
|
||||
return 0
|
||||
return int(score.get_text().split()[0])
|
||||
|
||||
|
||||
def _story_rows(soup: BeautifulSoup) -> list[Tag]:
|
||||
return [row for row in soup.select(STORY_ROW) if row.select_one(TITLE_LINK)]
|
||||
|
||||
|
||||
def parse_stories(html: str) -> list[Story]:
|
||||
"""@brief Read every story of a front page.
|
||||
|
||||
@param html The HTML of the page.
|
||||
@return The stories in page order.
|
||||
"""
|
||||
stories: list[Story] = []
|
||||
for row in _story_rows(make_soup(html)):
|
||||
anchor = row.select_one(TITLE_LINK)
|
||||
href = anchor.get("href") if anchor is not None else None
|
||||
if anchor is not None and isinstance(href, str):
|
||||
stories.append(
|
||||
Story(title=anchor.get_text(), link=href, upvotes=_upvotes(row))
|
||||
)
|
||||
return stories
|
||||
|
||||
|
||||
def get_titles(html: str) -> list[str]:
|
||||
"""@brief Return the headlines of a front page.
|
||||
|
||||
@param html The HTML of the page.
|
||||
@return The titles in page order.
|
||||
"""
|
||||
return [story.title for story in parse_stories(html)]
|
||||
|
||||
|
||||
def get_links(html: str) -> list[str]:
|
||||
"""@brief Return the addresses the headlines point to.
|
||||
|
||||
@param html The HTML of the page.
|
||||
@return The links in page order.
|
||||
"""
|
||||
return [story.link for story in parse_stories(html)]
|
||||
|
||||
|
||||
def get_upvotes(html: str) -> list[int]:
|
||||
"""@brief Return the scores as numbers, not text such as "38 points".
|
||||
|
||||
@param html The HTML of the page.
|
||||
@return The upvotes in page order.
|
||||
"""
|
||||
return [story.upvotes for story in parse_stories(html)]
|
||||
|
||||
|
||||
def first_story_markup(html: str) -> str:
|
||||
"""@brief Show the markup of the first story, as seen with "Inspect" in a browser.
|
||||
|
||||
@param html The HTML of the page.
|
||||
@return The indented HTML of its title row and score row, or "" when none.
|
||||
"""
|
||||
rows = _story_rows(make_soup(html))
|
||||
if not rows:
|
||||
return ""
|
||||
following = rows[0].find_next_sibling("tr")
|
||||
parts = [rows[0], following] if isinstance(following, Tag) else [rows[0]]
|
||||
return "\n".join(str(part.prettify()) for part in parts)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""@brief Fetching web pages with Requests."""
|
||||
|
||||
import requests
|
||||
|
||||
from scrape_movies.constants import REQUEST_TIMEOUT_SECONDS, USER_AGENT
|
||||
from scrape_movies.domain.errors import FetchError
|
||||
|
||||
|
||||
def fetch_html(url: str) -> str:
|
||||
"""@brief Download a page with one GET request.
|
||||
|
||||
Sends a User-Agent and gives up after a timeout. Call it once per run:
|
||||
scraping more often than once a minute is not polite.
|
||||
|
||||
@param url The address of the page.
|
||||
@return The HTML text of the response.
|
||||
@throws FetchError When the request fails or the server answers with an error.
|
||||
"""
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as error:
|
||||
raise FetchError(f"Could not fetch {url}: {error}") from error
|
||||
return str(response.text)
|
||||
@@ -3,7 +3,7 @@
|
||||
import argparse
|
||||
from collections.abc import Sequence
|
||||
|
||||
from scrape_movies.presentation import soup_steps
|
||||
from scrape_movies.presentation import hacker_news_steps, soup_steps
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
@@ -18,6 +18,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
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))
|
||||
news = commands.add_parser("hacker-news", help="scrape the Hacker News front page")
|
||||
news.add_argument("step", choices=hacker_news_steps.STEPS)
|
||||
news.add_argument(
|
||||
"--source",
|
||||
choices=hacker_news_steps.SOURCES,
|
||||
default="mirror",
|
||||
help="the course mirror (default), the live site or the saved copy",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -32,5 +40,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
if args.command == "soup":
|
||||
print(soup_steps.render_step(args.step))
|
||||
return 0
|
||||
if args.command == "hacker-news":
|
||||
print(hacker_news_steps.render_step(args.step, args.source))
|
||||
return 0
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""@brief Text output of each "Scraping Hacker News" lecture step."""
|
||||
|
||||
from scrape_movies.application import use_cases
|
||||
from scrape_movies.application.ports import HtmlFetcher
|
||||
from scrape_movies.constants import (
|
||||
HACKER_NEWS_FIXTURE_PATH,
|
||||
HACKER_NEWS_LIVE_URL,
|
||||
HACKER_NEWS_URL,
|
||||
)
|
||||
from scrape_movies.infrastructure import hacker_news, http_client, soup_basics
|
||||
|
||||
## Where a step reads the page: the course mirror, the live site or the saved copy.
|
||||
SOURCES: tuple[str, ...] = ("mirror", "live", "fixture")
|
||||
_URLS: dict[str, str] = {
|
||||
"mirror": HACKER_NEWS_URL,
|
||||
"live": HACKER_NEWS_LIVE_URL,
|
||||
"fixture": str(HACKER_NEWS_FIXTURE_PATH),
|
||||
}
|
||||
|
||||
|
||||
def _fetcher(source: str) -> HtmlFetcher:
|
||||
if source == "fixture":
|
||||
return lambda path: soup_basics.load_html(HACKER_NEWS_FIXTURE_PATH)
|
||||
return http_client.fetch_html
|
||||
|
||||
|
||||
def _numbered(lines: list[str]) -> str:
|
||||
return "\n".join(f"{number}. {line}" for number, line in enumerate(lines, 1))
|
||||
|
||||
|
||||
def _run(source: str, step: str) -> str:
|
||||
fetch, url = _fetcher(source), _URLS[source]
|
||||
if step == "markup":
|
||||
return hacker_news.first_story_markup(fetch(url)).rstrip()
|
||||
stories = use_cases.list_stories(fetch, hacker_news.parse_stories, url)
|
||||
if step == "titles":
|
||||
return _numbered([story.title for story in stories])
|
||||
if step == "links":
|
||||
return _numbered([story.link for story in stories])
|
||||
if step == "upvotes":
|
||||
return _numbered([str(story.upvotes) for story in stories])
|
||||
top = use_cases.find_top_story(fetch, hacker_news.parse_stories, url)
|
||||
if top is None:
|
||||
return "No stories found."
|
||||
return f"{top.title}\n{top.link}\n{top.upvotes} points"
|
||||
|
||||
|
||||
## The lecture steps by command-line name, in lecture order.
|
||||
STEPS: tuple[str, ...] = ("markup", "titles", "links", "upvotes", "top")
|
||||
|
||||
|
||||
def render_step(step: str, source: str) -> str:
|
||||
"""@brief Run one lecture step.
|
||||
|
||||
@param step One of STEPS.
|
||||
@param source One of SOURCES.
|
||||
@return The text the step prints.
|
||||
@throws KeyError When the step or the source is unknown.
|
||||
"""
|
||||
if step not in STEPS:
|
||||
raise KeyError(step)
|
||||
return _run(source, step)
|
||||
Vendored
+1987
File diff suppressed because it is too large
Load Diff
+18
-1
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from scrape_movies.presentation import cli, soup_steps
|
||||
from scrape_movies.presentation import cli, hacker_news_steps, soup_steps
|
||||
|
||||
|
||||
def test_main_when_soup_step_is_given_prints_output(
|
||||
@@ -22,3 +22,20 @@ def test_render_step_when_title_returns_page_title() -> None:
|
||||
assert soup_steps.render_step("title") == (
|
||||
"soup.title.string -> Angela's Personal Site"
|
||||
)
|
||||
|
||||
|
||||
def test_main_when_hacker_news_top_from_fixture_prints_top_story(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert cli.main(["hacker-news", "top", "--source", "fixture"]) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Mozilla lays off 250 employees" in out
|
||||
assert "1312 points" in out
|
||||
|
||||
|
||||
def test_main_when_every_hacker_news_step_runs_from_fixture_prints_output(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
for step in hacker_news_steps.STEPS:
|
||||
assert cli.main(["hacker-news", step, "--source", "fixture"]) == 0
|
||||
assert capsys.readouterr().out.strip(), step
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from scrape_movies.constants import HACKER_NEWS_FIXTURE_PATH
|
||||
from scrape_movies.domain.story import Story
|
||||
from scrape_movies.infrastructure import hacker_news
|
||||
from scrape_movies.infrastructure.soup_basics import load_html
|
||||
|
||||
PAGE = """
|
||||
<table><tr class="athing" id="1"><td><span class="titleline">
|
||||
<a href="https://example.com/one">One</a></span></td></tr>
|
||||
<tr><td><span class="score">12 points</span></td></tr>
|
||||
<tr class="athing" id="2"><td><span class="titleline">
|
||||
<a href="https://example.com/job">A job ad</a></span></td></tr>
|
||||
<tr><td>no score here</td></tr></table>
|
||||
"""
|
||||
|
||||
|
||||
def _fixture() -> str:
|
||||
return load_html(HACKER_NEWS_FIXTURE_PATH)
|
||||
|
||||
|
||||
def test_parse_stories_when_page_has_score_reads_integer() -> None:
|
||||
assert hacker_news.parse_stories(PAGE)[0] == Story(
|
||||
"One", "https://example.com/one", 12
|
||||
)
|
||||
|
||||
|
||||
def test_parse_stories_when_story_has_no_score_uses_zero() -> None:
|
||||
assert hacker_news.parse_stories(PAGE)[1].upvotes == 0
|
||||
|
||||
|
||||
def test_parse_stories_when_page_has_no_stories_returns_empty() -> None:
|
||||
assert hacker_news.parse_stories("<p>nothing</p>") == []
|
||||
|
||||
|
||||
def test_parse_stories_when_fixture_returns_thirty_stories() -> None:
|
||||
assert len(hacker_news.parse_stories(_fixture())) == 30
|
||||
|
||||
|
||||
def test_get_titles_when_fixture_starts_with_first_headline() -> None:
|
||||
assert hacker_news.get_titles(_fixture())[0] == "Joan Feynman 1927-2020"
|
||||
|
||||
|
||||
def test_get_links_when_fixture_returns_one_address_per_story() -> None:
|
||||
links = hacker_news.get_links(_fixture())
|
||||
assert len(links) == 30
|
||||
assert links[0].startswith("https://www.aps.org/")
|
||||
|
||||
|
||||
def test_get_upvotes_when_fixture_returns_integers() -> None:
|
||||
upvotes = hacker_news.get_upvotes(_fixture())
|
||||
assert upvotes[:2] == [38, 205]
|
||||
assert all(isinstance(points, int) for points in upvotes)
|
||||
|
||||
|
||||
def test_first_story_markup_when_page_has_story_shows_title_row() -> None:
|
||||
assert 'class="titleline"' in hacker_news.first_story_markup(PAGE)
|
||||
|
||||
|
||||
def test_first_story_markup_when_page_has_no_story_returns_empty() -> None:
|
||||
assert hacker_news.first_story_markup("<p>x</p>") == ""
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from scrape_movies.constants import REQUEST_TIMEOUT_SECONDS, USER_AGENT
|
||||
from scrape_movies.domain.errors import FetchError
|
||||
from scrape_movies.infrastructure import http_client
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, text: str, error: Exception | None = None) -> None:
|
||||
self.text = text
|
||||
self._error = error
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
|
||||
|
||||
def test_fetch_html_when_request_succeeds_sends_user_agent_and_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> _Response:
|
||||
calls.append({"url": url, **kwargs})
|
||||
return _Response("<html></html>")
|
||||
|
||||
monkeypatch.setattr(requests, "get", fake_get)
|
||||
assert http_client.fetch_html("http://example.test") == "<html></html>"
|
||||
assert calls == [
|
||||
{
|
||||
"url": "http://example.test",
|
||||
"headers": {"User-Agent": USER_AGENT},
|
||||
"timeout": REQUEST_TIMEOUT_SECONDS,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_fetch_html_when_server_errors_raises_fetch_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
error = requests.HTTPError("500")
|
||||
monkeypatch.setattr(requests, "get", lambda *a, **k: _Response("", error))
|
||||
with pytest.raises(FetchError) as caught:
|
||||
http_client.fetch_html("http://example.test")
|
||||
assert caught.value.__cause__ is error
|
||||
|
||||
|
||||
def test_fetch_html_when_connection_fails_raises_fetch_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fail(*args: Any, **kwargs: Any) -> _Response:
|
||||
raise requests.ConnectionError("down")
|
||||
|
||||
monkeypatch.setattr(requests, "get", fail)
|
||||
with pytest.raises(FetchError):
|
||||
http_client.fetch_html("http://example.test")
|
||||
@@ -0,0 +1,15 @@
|
||||
from scrape_movies.domain.story import Story, top_story
|
||||
|
||||
|
||||
def test_top_story_when_scores_differ_returns_highest() -> None:
|
||||
stories = [Story("a", "/a", 5), Story("b", "/b", 50), Story("c", "/c", 7)]
|
||||
assert top_story(stories) == Story("b", "/b", 50)
|
||||
|
||||
|
||||
def test_top_story_when_scores_tie_returns_first() -> None:
|
||||
stories = [Story("a", "/a", 9), Story("b", "/b", 9)]
|
||||
assert top_story(stories) == Story("a", "/a", 9)
|
||||
|
||||
|
||||
def test_top_story_when_no_stories_returns_none() -> None:
|
||||
assert top_story([]) is None
|
||||
@@ -0,0 +1,24 @@
|
||||
from scrape_movies.application import use_cases
|
||||
from scrape_movies.domain.story import Story
|
||||
|
||||
|
||||
def _fetch(url: str) -> str:
|
||||
return f"html of {url}"
|
||||
|
||||
|
||||
def _parse(html: str) -> list[Story]:
|
||||
return [Story(html, "/x", 3), Story("best", "/y", 99)]
|
||||
|
||||
|
||||
def test_list_stories_when_called_passes_page_through_parser() -> None:
|
||||
stories = use_cases.list_stories(_fetch, _parse, "http://x")
|
||||
assert stories[0].title == "html of http://x"
|
||||
|
||||
|
||||
def test_find_top_story_when_stories_exist_returns_highest() -> None:
|
||||
top = use_cases.find_top_story(_fetch, _parse, "http://x")
|
||||
assert top == Story("best", "/y", 99)
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,56 @@
|
||||
"""@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
|
||||
@@ -4,12 +4,16 @@ Usage: python tools/make_screenshots.py [name-prefix]
|
||||
The images are written to docs/images/ and used by the README and the wiki.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from capture_page import capture_page
|
||||
from render_terminal_png import render
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
IMAGES = ROOT / "docs" / "images"
|
||||
|
||||
@@ -20,6 +24,15 @@ STEPS: dict[str, list[str]] = {
|
||||
"soup-3-find-all": ["soup", "find-all"],
|
||||
"soup-4-find": ["soup", "find"],
|
||||
"soup-5-select": ["soup", "select"],
|
||||
"hn-2-markup": ["hacker-news", "markup"],
|
||||
"hn-3-titles": ["hacker-news", "titles"],
|
||||
"hn-4-links": ["hacker-news", "links"],
|
||||
"hn-5-upvotes": ["hacker-news", "upvotes"],
|
||||
"hn-6-top": ["hacker-news", "top"],
|
||||
}
|
||||
## 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/",
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +46,7 @@ def capture(name: str, args: list[str]) -> Path:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "scrape_movies", *args],
|
||||
cwd=ROOT,
|
||||
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
@@ -49,6 +63,9 @@ def main() -> None:
|
||||
for name, args in STEPS.items():
|
||||
if name.startswith(prefix):
|
||||
print(capture(name, args))
|
||||
for name, url in PAGES.items():
|
||||
if name.startswith(prefix):
|
||||
print(capture_page(url, IMAGES / f"{name}.png"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user