MIL-005 live Amazon page (Step 4) #36
+3
-2
@@ -2,5 +2,6 @@
|
|||||||
SMTP_ADDRESS="smtp.gmail.com"
|
SMTP_ADDRESS="smtp.gmail.com"
|
||||||
EMAIL_ADDRESS="you@example.com"
|
EMAIL_ADDRESS="you@example.com"
|
||||||
EMAIL_PASSWORD="Use_an_App_Password_from_your_email_provider"
|
EMAIL_PASSWORD="Use_an_App_Password_from_your_email_provider"
|
||||||
# Optional: track another product instead of the default URL
|
# Optional: the default is the Amazon product page. This value gives the
|
||||||
# PRODUCT_URL="https://www.amazon.com/dp/B075CYMYK6"
|
# practice page instead.
|
||||||
|
# PRODUCT_URL="https://appbrewery.github.io/instant_pot/"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
|||||||
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
||||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 007 |
|
| 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
|
## Languages
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@@ -0,0 +1,61 @@
|
|||||||
|
# RC-014 Code review of MIL-005
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-014 |
|
||||||
|
| CrossReference | [MIL-005], [QC-PY-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: [MIL-005]
|
||||||
|
- Checklist used: [QC-PY-001]
|
||||||
|
- Scope: full review
|
||||||
|
- Language and domain: n/a (technical)
|
||||||
|
- Language reviewer: none
|
||||||
|
- Mode: autonomous, not independent (AI as stand-in for S01); the author and the stand-in are both the AI working 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 (price, target, headers); no abbreviations left unexplained |
|
||||||
|
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | ruff format and ruff check pass: 56 pytest tests (no network), ruff 0 findings, mypy strict 0 errors (26 files) |
|
||||||
|
| 4 | Every function and method signature is type-annotated, including `-> 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
|
||||||
@@ -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).
|
## @brief SMTP submission port (STARTTLS).
|
||||||
SMTP_PORT: Final[int] = 587
|
SMTP_PORT: Final[int] = 587
|
||||||
|
|
||||||
|
|||||||
@@ -17,5 +17,13 @@ class TitleNotFoundError(ProductParseError):
|
|||||||
"""The page has no product title."""
|
"""The page has no product title."""
|
||||||
|
|
||||||
|
|
||||||
|
class RobotCheckError(ProductParseError):
|
||||||
|
"""The page is a robot check, not a product page."""
|
||||||
|
|
||||||
|
|
||||||
class MissingSettingError(Exception):
|
class MissingSettingError(Exception):
|
||||||
"""A required setting is missing or empty."""
|
"""A required setting is missing or empty."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidSettingError(Exception):
|
||||||
|
"""A setting has a value that is not allowed."""
|
||||||
|
|||||||
@@ -4,40 +4,80 @@
|
|||||||
@brief Day 47 step 1: get the title and the price as a float.
|
@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 (
|
from amazon_price_tracker.domain.errors import (
|
||||||
PriceNotFoundError,
|
PriceNotFoundError,
|
||||||
ProductParseError,
|
ProductParseError,
|
||||||
|
RobotCheckError,
|
||||||
TitleNotFoundError,
|
TitleNotFoundError,
|
||||||
)
|
)
|
||||||
from amazon_price_tracker.domain.product import Product
|
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:
|
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.
|
@param html Page HTML.
|
||||||
@return The product title.
|
@return The product title.
|
||||||
|
@throws RobotCheckError When the page is a robot check.
|
||||||
@throws TitleNotFoundError When the element is missing.
|
@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:
|
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())
|
return " ".join(element.get_text().split())
|
||||||
|
|
||||||
|
|
||||||
def get_price(html: str) -> float:
|
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.
|
@param html Page HTML.
|
||||||
@return The price as a floating point number.
|
@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.
|
@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:
|
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 = element.get_text().strip()
|
||||||
# Text looks like "$99.99": drop the currency sign, as the hint suggests.
|
# Text looks like "$99.99": drop the currency sign, as the hint suggests.
|
||||||
amount = text.split("$")[-1].replace(",", "")
|
amount = text.split("$")[-1].replace(",", "")
|
||||||
@@ -52,8 +92,10 @@ def parse_product(html: str) -> Product:
|
|||||||
|
|
||||||
@param html Page HTML.
|
@param html Page HTML.
|
||||||
@return The product with title and price.
|
@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.
|
@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))
|
return Product(title=get_title(html), price=get_price(html))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ from collections.abc import Mapping
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from amazon_price_tracker.constants import (
|
from amazon_price_tracker.constants import (
|
||||||
|
AMAZON_URL,
|
||||||
ENV_EMAIL_ADDRESS,
|
ENV_EMAIL_ADDRESS,
|
||||||
ENV_EMAIL_PASSWORD,
|
ENV_EMAIL_PASSWORD,
|
||||||
|
ENV_PRODUCT_URL,
|
||||||
ENV_SMTP_ADDRESS,
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -32,6 +34,22 @@ def _require(environ: Mapping[str, str], name: str) -> str:
|
|||||||
return value
|
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:
|
def load_mail_settings(environ: Mapping[str, str]) -> MailSettings:
|
||||||
"""Return the mail settings found in environ.
|
"""Return the mail settings found in environ.
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,20 @@ import sys
|
|||||||
from dotenv import find_dotenv, load_dotenv
|
from dotenv import find_dotenv, load_dotenv
|
||||||
|
|
||||||
from amazon_price_tracker.application.alert import check_price
|
from amazon_price_tracker.application.alert import check_price
|
||||||
from amazon_price_tracker.constants import PRACTICE_URL, TARGET_PRICE
|
from amazon_price_tracker.constants import TARGET_PRICE
|
||||||
from amazon_price_tracker.domain.errors import MissingSettingError
|
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.http_client import RequestsPageFetcher
|
||||||
from amazon_price_tracker.infrastructure.mailer import SmtpNotifier
|
from amazon_price_tracker.infrastructure.mailer import SmtpNotifier
|
||||||
from amazon_price_tracker.infrastructure.scraper import SoupProductParser
|
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:
|
def main() -> None:
|
||||||
@@ -23,13 +31,26 @@ def main() -> None:
|
|||||||
load_dotenv(find_dotenv(usecwd=True))
|
load_dotenv(find_dotenv(usecwd=True))
|
||||||
try:
|
try:
|
||||||
settings = load_mail_settings(os.environ)
|
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)
|
print(f"{error}. Copy .env.example to .env and fill it in.", file=sys.stderr)
|
||||||
return
|
return
|
||||||
notifier = SmtpNotifier(settings)
|
notifier = SmtpNotifier(settings)
|
||||||
|
try:
|
||||||
result = check_price(
|
result = check_price(
|
||||||
RequestsPageFetcher(), SoupProductParser(), notifier, PRACTICE_URL, TARGET_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.title)
|
||||||
print(result.product.price)
|
print(result.product.price)
|
||||||
print("Alert sent" if result.alert_sent else "Price is not below the target")
|
print("Alert sent" if result.alert_sent else "Price is not below the target")
|
||||||
|
|||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Synthetic example of live Amazon product markup: a coupon span with class a-offscreen comes before the real price, which sits in .a-price. -->
|
||||||
|
<html lang="en-us">
|
||||||
|
<head><title>Amazon.com: Instant Pot</title></head>
|
||||||
|
<body>
|
||||||
|
<span id="productTitle"> Instant Pot Duo 7-in-1 </span>
|
||||||
|
<span class="a-size-base"><span class="a-offscreen">Save $5.00 with coupon</span></span>
|
||||||
|
<span class="a-price a-text-price a-size-medium apexPriceToPay"><span class="a-offscreen">$79.95</span><span aria-hidden="true">$79.95</span></span>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Robot-check page that www.amazon.com returned to the bot on 2026-10-28 (tokens redacted). -->
|
||||||
|
<!--[if lt IE 7]> <html lang="en-us" class="a-no-js a-lt-ie9 a-lt-ie8 a-lt-ie7"> <![endif]-->
|
||||||
|
<!--[if IE 7]> <html lang="en-us" class="a-no-js a-lt-ie9 a-lt-ie8"> <![endif]-->
|
||||||
|
<!--[if IE 8]> <html lang="en-us" class="a-no-js a-lt-ie9"> <![endif]-->
|
||||||
|
<!--[if gt IE 8]><!-->
|
||||||
|
<html class="a-no-js" lang="en-us"><!--<![endif]--><head>
|
||||||
|
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||||
|
<title dir="ltr">Amazon.com</title>
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<link rel="stylesheet" href="https://images-na.ssl-images-amazon.com/images/G/01/AUIClients/AmazonUI-3c913031596ca78a3768f4e934b1cc02ce238101.secure.min._V1_.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
To discuss automated access to Amazon data please contact api-services-support@amazon.com.
|
||||||
|
For information about migrating to our APIs refer to our Marketplace APIs at https://developer.amazonservices.com/ref=rm_c_sv, or our Product Advertising API at https://affiliate-program.amazon.com/gp/advertising/api/detail/main.html/ref=rm_c_ac for advertising use cases.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Correios.DoNotSend
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="a-container a-padding-double-large" style="min-width:350px;padding:44px 0 !important">
|
||||||
|
|
||||||
|
<div class="a-row a-spacing-double-large" style="width: 350px; margin: 0 auto">
|
||||||
|
|
||||||
|
<div class="a-row a-spacing-medium a-text-center"><i class="a-icon a-logo" alt="Amazon logo"></i></div>
|
||||||
|
|
||||||
|
<div class="a-box a-alert a-alert-info a-spacing-base">
|
||||||
|
<div class="a-box-inner">
|
||||||
|
<i class="a-icon a-icon-alert" alt="Alert icon"></i>
|
||||||
|
<h4>Click the button below to continue shopping</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-section">
|
||||||
|
|
||||||
|
<div class="a-box a-color-offset-background">
|
||||||
|
<div class="a-box-inner a-padding-extra-large">
|
||||||
|
|
||||||
|
<form method="get" action="/errors_page/validateCaptcha" name="">
|
||||||
|
<input type="hidden" REDACTED" /><input type="hidden" name="amzn-r" value="/" />
|
||||||
|
<input type="hidden" REDACTED" />
|
||||||
|
<div class="a-section a-spacing-extra-large">
|
||||||
|
|
||||||
|
<div class="a-row">
|
||||||
|
<span class="a-button a-button-primary a-span12">
|
||||||
|
<span class="a-button-inner">
|
||||||
|
<button type="submit" class="a-button-text" alt="Continue shopping">Continue shopping</button>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-divider a-divider-section"><div class="a-divider-inner"></div></div>
|
||||||
|
|
||||||
|
<div class="a-text-center a-spacing-small a-size-mini">
|
||||||
|
<a href="https://www.amazon.com/gp/help/customer/display.html/ref=footer_cou?ie=UTF8&nodeId=508088">Conditions of Use</a>
|
||||||
|
<span class="a-letter-space"></span>
|
||||||
|
<span class="a-letter-space"></span>
|
||||||
|
<span class="a-letter-space"></span>
|
||||||
|
<span class="a-letter-space"></span>
|
||||||
|
<a href="https://www.amazon.com/gp/help/customer/display.html/ref=footer_privacy?ie=UTF8&nodeId=468496">Privacy Policy</a>
|
||||||
|
</div>
|
||||||
|
<div class="a-text-center a-size-mini a-color-base">
|
||||||
|
© 1996-2025, Amazon.com, Inc. or its affiliates
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ import pytest
|
|||||||
from amazon_price_tracker.domain.errors import (
|
from amazon_price_tracker.domain.errors import (
|
||||||
PriceNotFoundError,
|
PriceNotFoundError,
|
||||||
ProductParseError,
|
ProductParseError,
|
||||||
|
RobotCheckError,
|
||||||
TitleNotFoundError,
|
TitleNotFoundError,
|
||||||
)
|
)
|
||||||
from amazon_price_tracker.domain.product import Product
|
from amazon_price_tracker.domain.product import Product
|
||||||
@@ -12,6 +13,7 @@ from amazon_price_tracker.infrastructure.scraper import (
|
|||||||
SoupProductParser,
|
SoupProductParser,
|
||||||
get_price,
|
get_price,
|
||||||
get_title,
|
get_title,
|
||||||
|
is_robot_check,
|
||||||
parse_product,
|
parse_product,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,6 +62,42 @@ def test_parse_product_returns_product() -> None:
|
|||||||
assert product.price == 99.99
|
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:
|
def test_soup_product_parser_parses_html() -> None:
|
||||||
product = SoupProductParser().parse(_read("practice_page.html"))
|
product = SoupProductParser().parse(_read("practice_page.html"))
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from amazon_price_tracker.constants import (
|
from amazon_price_tracker.constants import (
|
||||||
|
AMAZON_URL,
|
||||||
ENV_EMAIL_ADDRESS,
|
ENV_EMAIL_ADDRESS,
|
||||||
ENV_EMAIL_PASSWORD,
|
ENV_EMAIL_PASSWORD,
|
||||||
|
ENV_PRODUCT_URL,
|
||||||
ENV_SMTP_ADDRESS,
|
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 = {
|
FULL = {
|
||||||
ENV_SMTP_ADDRESS: "smtp.example.test",
|
ENV_SMTP_ADDRESS: "smtp.example.test",
|
||||||
@@ -43,6 +49,30 @@ def test_load_mail_settings_names_first_missing_variable() -> None:
|
|||||||
load_mail_settings({})
|
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:
|
def test_mail_settings_repr_hides_password() -> None:
|
||||||
assert "s3cret-pw" not in repr(load_mail_settings(FULL))
|
assert "s3cret-pw" not in repr(load_mail_settings(FULL))
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
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:
|
def test_smtp_port_is_starttls_port() -> None:
|
||||||
"""The SMTP port is 587."""
|
"""The SMTP port is 587."""
|
||||||
assert constants.SMTP_PORT == 587
|
assert constants.SMTP_PORT == 587
|
||||||
|
|||||||
Reference in New Issue
Block a user