From 34e70b31dcfb9bfb72de0af9cea14491fe0b0d71 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:48:21 +0800 Subject: [PATCH 1/2] MIL-005 live Amazon page (Step 4) Delivers MIL-005 (lecture Step 4): the product URL comes from `PRODUCT_URL` (default: the lecture's Amazon page), price and title selectors and robot-check markers live in `constants.py`, and a robot-check page raises `RobotCheckError` with a clear message. The code does not try to solve or bypass the check. Outcome on the live page (2026-10-28): Amazon returned HTTP 200 with a robot-check page and no price; the page is kept (tokens redacted) as a test fixture and shown in the README. Two live requests were made in total, one to inspect the response and one for the screenshot; there are no retries or loops. Review: code against QC-PY-001 as RC-014 (Go, autonomous, not independent). 56 pytest tests (no network), ruff and mypy --strict pass. Task: MIL-005#1 Task: MIL-005#2 Task: MIL-005#3 Task: MIL-005#4 Task: MIL-005#5 --- .env.example | 5 +- docs/artifact-registry.md | 2 +- docs/images/step-4-amazon-robot-check.png | Bin 0 -> 16097 bytes docs/images/step-4-run.png | Bin 0 -> 8322 bytes docs/sqa/reviews/rc-014-code-mil-005.md | 61 +++++++++++++ src/amazon_price_tracker/constants.py | 16 ++++ src/amazon_price_tracker/domain/errors.py | 8 ++ .../infrastructure/scraper.py | 58 +++++++++++-- .../infrastructure/settings.py | 20 ++++- src/amazon_price_tracker/main.py | 35 ++++++-- tests/fixtures/amazon_live_page.html | 10 +++ tests/fixtures/amazon_robot_check.html | 82 ++++++++++++++++++ tests/unit/infrastructure/test_scraper.py | 38 ++++++++ tests/unit/infrastructure/test_settings.py | 34 +++++++- tests/unit/test_constants.py | 17 ++++ 15 files changed, 365 insertions(+), 21 deletions(-) create mode 100644 docs/images/step-4-amazon-robot-check.png create mode 100644 docs/images/step-4-run.png create mode 100644 docs/sqa/reviews/rc-014-code-mil-005.md create mode 100644 tests/fixtures/amazon_live_page.html create mode 100644 tests/fixtures/amazon_robot_check.html diff --git a/.env.example b/.env.example index 74ece5d..1176fc1 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,6 @@ SMTP_ADDRESS="smtp.gmail.com" EMAIL_ADDRESS="you@example.com" EMAIL_PASSWORD="Use_an_App_Password_from_your_email_provider" -# Optional: track another product instead of the default URL -# PRODUCT_URL="https://www.amazon.com/dp/B075CYMYK6" +# Optional: the default is the Amazon product page. This value gives the +# practice page instead. +# PRODUCT_URL="https://appbrewery.github.io/instant_pot/" diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 3b2f546..5f3b514 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 | 007 | -| 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/step-4-amazon-robot-check.png b/docs/images/step-4-amazon-robot-check.png new file mode 100644 index 0000000000000000000000000000000000000000..93d7bd1134b350e68bf7a576d8a1037d176828bc GIT binary patch literal 16097 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 A3;+NC literal 0 HcmV?d00001 diff --git a/docs/images/step-4-run.png b/docs/images/step-4-run.png new file mode 100644 index 0000000000000000000000000000000000000000..ebf3aec44f9e66e692daf6d269a053cbe6a61955 GIT binary patch literal 8322 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 None` | Pass | All signatures annotated; mypy --strict passes: 56 pytest tests (no network), ruff 0 findings, mypy strict 0 errors (26 files) | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except; errors are specific and chained with `raise ... from` where a cause exists | +| 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 | No open files or connections without a context manager | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules and functions have docstrings with Doxygen tags | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No secrets are logged; the invalid-setting error names the variable and never echoes the value; stderr messages only | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists; the milestone's technical tasks need none (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 exist, are named for behaviour and use no network or real mail server: 56 pytest tests (no network), ruff 0 findings, mypy strict 0 errors (26 files) | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | mypy --strict passes; no Any | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | Dependencies are in pyproject.toml with lower bounds; none 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 | Constants live only in constants.py | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | Imports point inward; no cycles | +| 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 | Selectors and markers are data in constants.py; detection is a separate function, so a new marker or selector is an extension, not an edit in several places | +| 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 | Layers and tests/unit/ mirror as before | +| 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 | File names are English snake_case | + +## Overall Verdict + +Go — all Mandatory criteria pass or are not applicable. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None: no Fail and no Pending criterion | S01 | 2026-10-28 | + +--- + +[MIL-005]: ../../milestones/mil-005-live-amazon.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/src/amazon_price_tracker/constants.py b/src/amazon_price_tracker/constants.py index a874669..7edf330 100644 --- a/src/amazon_price_tracker/constants.py +++ b/src/amazon_price_tracker/constants.py @@ -49,6 +49,22 @@ DEFAULT_HEADERS: Final[Mapping[str, str]] = MappingProxyType( } ) +## @brief CSS selectors for the price, tried in order. +# The first one skips decoy .a-offscreen spans (coupons) outside .a-price; +# the second serves the practice page. +PRICE_SELECTORS: Final[tuple[str, ...]] = (".a-price .a-offscreen", ".a-offscreen") + +## @brief CSS selectors for the product title, tried in order. +TITLE_SELECTORS: Final[tuple[str, ...]] = ("#productTitle",) + +## @brief Text found only on Amazon's robot-check page. +ROBOT_CHECK_MARKERS: Final[tuple[str, ...]] = ( + "/errors_page/validateCaptcha", + "Click the button below to continue shopping", + "Enter the characters you see below", + "api-services-support@amazon.com", +) + ## @brief SMTP submission port (STARTTLS). SMTP_PORT: Final[int] = 587 diff --git a/src/amazon_price_tracker/domain/errors.py b/src/amazon_price_tracker/domain/errors.py index d2e3eaf..7cfb8bb 100644 --- a/src/amazon_price_tracker/domain/errors.py +++ b/src/amazon_price_tracker/domain/errors.py @@ -17,5 +17,13 @@ class TitleNotFoundError(ProductParseError): """The page has no product title.""" +class RobotCheckError(ProductParseError): + """The page is a robot check, not a product page.""" + + class MissingSettingError(Exception): """A required setting is missing or empty.""" + + +class InvalidSettingError(Exception): + """A setting has a value that is not allowed.""" diff --git a/src/amazon_price_tracker/infrastructure/scraper.py b/src/amazon_price_tracker/infrastructure/scraper.py index 8971501..dfab64b 100644 --- a/src/amazon_price_tracker/infrastructure/scraper.py +++ b/src/amazon_price_tracker/infrastructure/scraper.py @@ -4,40 +4,80 @@ @brief Day 47 step 1: get the title and the price as a float. """ -from bs4 import BeautifulSoup +from bs4 import BeautifulSoup, Tag +from amazon_price_tracker.constants import ( + PRICE_SELECTORS, + ROBOT_CHECK_MARKERS, + TITLE_SELECTORS, +) from amazon_price_tracker.domain.errors import ( PriceNotFoundError, ProductParseError, + RobotCheckError, TitleNotFoundError, ) from amazon_price_tracker.domain.product import Product +def is_robot_check(html: str) -> bool: + """Tell whether html is Amazon's robot-check page. + + @param html Page HTML. + @return True when any robot-check marker is present. + """ + return any(marker in html for marker in ROBOT_CHECK_MARKERS) + + +def _ensure_not_robot_check(html: str) -> None: + if is_robot_check(html): + raise RobotCheckError( + "The page is a robot check, not a product page. " + "The bot does not try to get past it." + ) + + +def _select_first(html: str, selectors: tuple[str, ...]) -> Tag | None: + soup = BeautifulSoup(html, "html.parser") + for selector in selectors: + element = soup.select_one(selector) + if element is not None: + return element + return None + + def get_title(html: str) -> str: - """Return the text of the element with id productTitle, whitespace collapsed. + """Return the text of the product title element, whitespace collapsed. @param html Page HTML. @return The product title. + @throws RobotCheckError When the page is a robot check. @throws TitleNotFoundError When the element is missing. """ - element = BeautifulSoup(html, "html.parser").select_one("#productTitle") + _ensure_not_robot_check(html) + element = _select_first(html, TITLE_SELECTORS) if element is None: - raise TitleNotFoundError("No element with id 'productTitle' on the page") + raise TitleNotFoundError( + f"No element matching {', '.join(TITLE_SELECTORS)} on the page" + ) return " ".join(element.get_text().split()) def get_price(html: str) -> float: - """Return the price of the first element with class a-offscreen. + """Return the price of the first element matching a price selector. @param html Page HTML. @return The price as a floating point number. - @throws PriceNotFoundError When the element is missing. + @throws RobotCheckError When the page is a robot check. + @throws PriceNotFoundError When no element matches. @throws ProductParseError When the price text is not a number. """ - element = BeautifulSoup(html, "html.parser").select_one(".a-offscreen") + _ensure_not_robot_check(html) + element = _select_first(html, PRICE_SELECTORS) if element is None: - raise PriceNotFoundError("No element with class 'a-offscreen' on the page") + raise PriceNotFoundError( + f"No element matching {', '.join(PRICE_SELECTORS)} on the page" + ) text = element.get_text().strip() # Text looks like "$99.99": drop the currency sign, as the hint suggests. amount = text.split("$")[-1].replace(",", "") @@ -52,8 +92,10 @@ def parse_product(html: str) -> Product: @param html Page HTML. @return The product with title and price. + @throws RobotCheckError When the page is a robot check. @throws ProductParseError When the title or the price cannot be read. """ + _ensure_not_robot_check(html) return Product(title=get_title(html), price=get_price(html)) diff --git a/src/amazon_price_tracker/infrastructure/settings.py b/src/amazon_price_tracker/infrastructure/settings.py index b07af30..b4f5b13 100644 --- a/src/amazon_price_tracker/infrastructure/settings.py +++ b/src/amazon_price_tracker/infrastructure/settings.py @@ -8,11 +8,13 @@ from collections.abc import Mapping from dataclasses import dataclass, field from amazon_price_tracker.constants import ( + AMAZON_URL, ENV_EMAIL_ADDRESS, ENV_EMAIL_PASSWORD, + ENV_PRODUCT_URL, ENV_SMTP_ADDRESS, ) -from amazon_price_tracker.domain.errors import MissingSettingError +from amazon_price_tracker.domain.errors import InvalidSettingError, MissingSettingError @dataclass(frozen=True, slots=True) @@ -32,6 +34,22 @@ def _require(environ: Mapping[str, str], name: str) -> str: return value +def load_product_url(environ: Mapping[str, str]) -> str: + """Return the product URL from environ, or the Amazon default. + + @param environ Environment variables, for example os.environ. + @return The URL, stripped. + @throws InvalidSettingError When the value does not start with https://. + """ + value = environ.get(ENV_PRODUCT_URL, "").strip() + if not value: + return AMAZON_URL + if not value.startswith("https://"): + # The value is not echoed: it may be long or carry a token. + raise InvalidSettingError(f"Invalid setting: {ENV_PRODUCT_URL} must be https") + return value + + def load_mail_settings(environ: Mapping[str, str]) -> MailSettings: """Return the mail settings found in environ. diff --git a/src/amazon_price_tracker/main.py b/src/amazon_price_tracker/main.py index 9210817..3f04b50 100644 --- a/src/amazon_price_tracker/main.py +++ b/src/amazon_price_tracker/main.py @@ -10,12 +10,20 @@ import sys from dotenv import find_dotenv, load_dotenv from amazon_price_tracker.application.alert import check_price -from amazon_price_tracker.constants import PRACTICE_URL, TARGET_PRICE -from amazon_price_tracker.domain.errors import MissingSettingError +from amazon_price_tracker.constants import TARGET_PRICE +from amazon_price_tracker.domain.errors import ( + InvalidSettingError, + MissingSettingError, + ProductParseError, + RobotCheckError, +) from amazon_price_tracker.infrastructure.http_client import RequestsPageFetcher from amazon_price_tracker.infrastructure.mailer import SmtpNotifier from amazon_price_tracker.infrastructure.scraper import SoupProductParser -from amazon_price_tracker.infrastructure.settings import load_mail_settings +from amazon_price_tracker.infrastructure.settings import ( + load_mail_settings, + load_product_url, +) def main() -> None: @@ -23,13 +31,26 @@ def main() -> None: load_dotenv(find_dotenv(usecwd=True)) try: settings = load_mail_settings(os.environ) - except MissingSettingError as error: + url = load_product_url(os.environ) + except (MissingSettingError, InvalidSettingError) as error: print(f"{error}. Copy .env.example to .env and fill it in.", file=sys.stderr) return notifier = SmtpNotifier(settings) - result = check_price( - RequestsPageFetcher(), SoupProductParser(), notifier, PRACTICE_URL, TARGET_PRICE - ) + try: + result = check_price( + RequestsPageFetcher(), SoupProductParser(), notifier, url, TARGET_PRICE + ) + except RobotCheckError: + print( + "Amazon returned a robot check page, so there is no price to read. " + "Try again later or set PRODUCT_URL to another page. " + "The bot does not try to get past the check.", + file=sys.stderr, + ) + return + except ProductParseError as error: + print(f"Could not read the product: {error}", file=sys.stderr) + return print(result.product.title) print(result.product.price) print("Alert sent" if result.alert_sent else "Price is not below the target") diff --git a/tests/fixtures/amazon_live_page.html b/tests/fixtures/amazon_live_page.html new file mode 100644 index 0000000..4da33c2 --- /dev/null +++ b/tests/fixtures/amazon_live_page.html @@ -0,0 +1,10 @@ + + + +Amazon.com: Instant Pot + + Instant Pot Duo 7-in-1 +Save $5.00 with coupon +$79.95 + + diff --git a/tests/fixtures/amazon_robot_check.html b/tests/fixtures/amazon_robot_check.html new file mode 100644 index 0000000..b9b0788 --- /dev/null +++ b/tests/fixtures/amazon_robot_check.html @@ -0,0 +1,82 @@ + + + + + + + + + + +Amazon.com + + + + + + + + + +

+ +
+ +
+ +
+
+ +

Click the button below to continue shopping

+
+
+ +
+ +
+
+ +
+ + +
+ +
+ + + + + +
+ +
+
+ +
+
+ +
+ +
+ +
+ + +
+ © 1996-2025, Amazon.com, Inc. or its affiliates +
+
+ + diff --git a/tests/unit/infrastructure/test_scraper.py b/tests/unit/infrastructure/test_scraper.py index aa62e72..0290324 100644 --- a/tests/unit/infrastructure/test_scraper.py +++ b/tests/unit/infrastructure/test_scraper.py @@ -5,6 +5,7 @@ import pytest from amazon_price_tracker.domain.errors import ( PriceNotFoundError, ProductParseError, + RobotCheckError, TitleNotFoundError, ) from amazon_price_tracker.domain.product import Product @@ -12,6 +13,7 @@ from amazon_price_tracker.infrastructure.scraper import ( SoupProductParser, get_price, get_title, + is_robot_check, parse_product, ) @@ -60,6 +62,42 @@ def test_parse_product_returns_product() -> None: assert product.price == 99.99 +def test_is_robot_check_true_for_robot_check_page() -> None: + assert is_robot_check(_read("amazon_robot_check.html")) + + +@pytest.mark.parametrize("name", ["practice_page.html", "amazon_live_page.html"]) +def test_is_robot_check_false_for_product_page(name: str) -> None: + assert not is_robot_check(_read(name)) + + +def test_get_price_raises_robot_check_error_on_robot_check() -> None: + with pytest.raises(RobotCheckError): + get_price(_read("amazon_robot_check.html")) + + +def test_get_title_raises_robot_check_error_on_robot_check() -> None: + with pytest.raises(RobotCheckError): + get_title(_read("amazon_robot_check.html")) + + +def test_parse_product_raises_robot_check_error_on_robot_check() -> None: + with pytest.raises(RobotCheckError): + parse_product(_read("amazon_robot_check.html")) + + +def test_robot_check_error_is_a_product_parse_error() -> None: + assert issubclass(RobotCheckError, ProductParseError) + + +def test_get_price_skips_decoy_coupon_on_live_page() -> None: + assert get_price(_read("amazon_live_page.html")) == 79.95 + + +def test_get_title_strips_title_on_live_page() -> None: + assert get_title(_read("amazon_live_page.html")) == "Instant Pot Duo 7-in-1" + + def test_soup_product_parser_parses_html() -> None: product = SoupProductParser().parse(_read("practice_page.html")) diff --git a/tests/unit/infrastructure/test_settings.py b/tests/unit/infrastructure/test_settings.py index c21ec52..5b561e1 100644 --- a/tests/unit/infrastructure/test_settings.py +++ b/tests/unit/infrastructure/test_settings.py @@ -1,12 +1,18 @@ import pytest from amazon_price_tracker.constants import ( + AMAZON_URL, ENV_EMAIL_ADDRESS, ENV_EMAIL_PASSWORD, + ENV_PRODUCT_URL, ENV_SMTP_ADDRESS, + PRACTICE_URL, +) +from amazon_price_tracker.domain.errors import InvalidSettingError, MissingSettingError +from amazon_price_tracker.infrastructure.settings import ( + load_mail_settings, + load_product_url, ) -from amazon_price_tracker.domain.errors import MissingSettingError -from amazon_price_tracker.infrastructure.settings import load_mail_settings FULL = { ENV_SMTP_ADDRESS: "smtp.example.test", @@ -43,6 +49,30 @@ def test_load_mail_settings_names_first_missing_variable() -> None: load_mail_settings({}) +def test_load_product_url_defaults_to_amazon() -> None: + assert load_product_url({}) == AMAZON_URL + + +def test_load_product_url_defaults_when_blank() -> None: + assert load_product_url({ENV_PRODUCT_URL: " "}) == AMAZON_URL + + +def test_load_product_url_uses_override() -> None: + assert load_product_url({ENV_PRODUCT_URL: PRACTICE_URL}) == PRACTICE_URL + + +def test_load_product_url_strips_whitespace() -> None: + assert load_product_url({ENV_PRODUCT_URL: f" {PRACTICE_URL}\n"}) == PRACTICE_URL + + +@pytest.mark.parametrize("value", ["http://example.test/", "ftp://x", "example.test"]) +def test_load_product_url_rejects_non_https(value: str) -> None: + with pytest.raises(InvalidSettingError, match=ENV_PRODUCT_URL) as info: + load_product_url({ENV_PRODUCT_URL: value}) + + assert value not in str(info.value) + + def test_mail_settings_repr_hides_password() -> None: assert "s3cret-pw" not in repr(load_mail_settings(FULL)) diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py index 837b56f..749acc2 100644 --- a/tests/unit/test_constants.py +++ b/tests/unit/test_constants.py @@ -42,6 +42,23 @@ def test_default_headers_are_non_empty() -> None: assert all(k.strip() and v.strip() for k, v in constants.DEFAULT_HEADERS.items()) +def test_selectors_and_markers_are_non_empty_tuples() -> None: + """Selectors and robot-check markers are non-empty tuples of text.""" + for group in ( + constants.PRICE_SELECTORS, + constants.TITLE_SELECTORS, + constants.ROBOT_CHECK_MARKERS, + ): + assert isinstance(group, tuple) + assert group + assert all(item.strip() for item in group) + + +def test_price_selectors_try_specific_before_generic() -> None: + """The a-price selector comes before the bare a-offscreen one.""" + assert constants.PRICE_SELECTORS == (".a-price .a-offscreen", ".a-offscreen") + + def test_smtp_port_is_starttls_port() -> None: """The SMTP port is 587.""" assert constants.SMTP_PORT == 587 -- 2.54.0 From b7aee83993c4eebfcadb41f568701de1ffbaec9a Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:48:25 +0800 Subject: [PATCH 2/2] Resolve pending commit links of MIL-005 --- docs/sqa/reviews/rc-014-code-mil-005.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-014-code-mil-005.md b/docs/sqa/reviews/rc-014-code-mil-005.md index 0fe276d..2bf2bc2 100644 --- a/docs/sqa/reviews/rc-014-code-mil-005.md +++ b/docs/sqa/reviews/rc-014-code-mil-005.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | +| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [34e70b3] | --- @@ -59,3 +59,4 @@ Go — all Mandatory criteria pass or are not applicable. [MIL-005]: ../../milestones/mil-005-live-amazon.md [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[34e70b3]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/047-amazon-price-tracker/commit/34e70b31dcfb9bfb72de0af9cea14491fe0b0d71 -- 2.54.0