test(walmart): add parser and flow tests with e2e harness

This commit is contained in:
CREDO23 2026-07-19 08:19:22 +02:00
parent a3f8a7e741
commit 7b5e09c528
8 changed files with 321 additions and 0 deletions

View file

@ -0,0 +1,80 @@
"""Manual end-to-end check for the public Walmart scraper.
Run from the backend directory:
uv run python scripts/e2e_walmart_scraper.py
The script requires live network access and the configured residential proxy
(US exit). It is intentionally excluded from pytest.
"""
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
from dotenv import load_dotenv
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_BACKEND_ROOT))
for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
if _candidate.exists():
load_dotenv(_candidate)
break
from app.proprietary.platforms.walmart import ( # noqa: E402
WalmartReviewsInput,
WalmartScrapeInput,
scrape_products,
scrape_reviews,
)
_PRODUCT_URL = "https://www.walmart.com/ip/212092810"
_SEARCH_URL = "https://www.walmart.com/search?q=air+fryer"
def _check(label: str, passed: bool) -> bool:
print(f"[{'PASS' if passed else 'FAIL'}] {label}")
return passed
async def main() -> int:
ok = True
product_items = await scrape_products(
WalmartScrapeInput(startUrls=[_PRODUCT_URL]), limit=1
)
product = product_items[0] if product_items else {}
print(json.dumps(product, indent=2, ensure_ascii=False)[:2500])
ok &= _check(
"product detail has id and name",
bool(product.get("usItemId") and product.get("name")),
)
search_items = await scrape_products(
WalmartScrapeInput(
startUrls=[_SEARCH_URL], maxItemsPerStartUrl=3, includeDetails=False
),
limit=3,
)
ok &= _check(
"search returns product cards",
bool(search_items) and any("name" in item for item in search_items),
)
item_id = product.get("usItemId") or "212092810"
reviews = await scrape_reviews(
WalmartReviewsInput(itemIds=[item_id], maxReviews=15), limit=15
)
ok &= _check(
"reviews returned with rating and text",
bool(reviews) and any(r.get("rating") and r.get("text") for r in reviews),
)
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View file

@ -0,0 +1,4 @@
<!doctype html><html><head><title>Robot or human?</title></head><body>
<div id="px-captcha">Activate and hold the button to confirm that you're human.</div>
<p>Robot or human?</p>
</body></html>

View file

@ -0,0 +1,3 @@
<!doctype html><html><head><title>laptop - Walmart.com</title></head><body>
<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"initialData":{"searchResult":{"itemStacks":[{"count":2,"items":[{"__typename":"Product","usItemId":"791595618","name":"Restored Acer Chromebook","brand":null,"canonicalUrl":"/ip/Acer-Chromebook/791595618","averageRating":3.5,"numberOfReviews":46,"sellerId":"AB4FE","sellerName":"Ellison ProTech","availabilityStatusV2":{"value":"IN_STOCK"},"imageInfo":{"thumbnailUrl":"https://i5.walmartimages.com/a.jpg"},"priceInfo":{"currentPrice":{"price":79.99,"currencyUnit":"USD"}},"isSponsoredFlag":false},{"__typename":"Product","usItemId":"999001","name":"HP Laptop 15","canonicalUrl":"/ip/HP-Laptop/999001","isOutOfStock":true,"price":499.0}]}]}}}}}</script>
</body></html>

View file

@ -0,0 +1,3 @@
<!doctype html><html><head><title>Midea AC</title></head><body>
<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"initialData":{"data":{"product":{"usItemId":"212092810","id":"ABC123","name":"Midea 5,000 BTU Window Air Conditioner","brand":"Midea","availabilityStatus":"IN_STOCK","averageRating":4.5,"numberOfReviews":6287,"manufacturerName":"Midea","shortDescription":"Cools a small room fast.","sellerName":"Walmart.com","sellerId":"F55CT","priceInfo":{"currentPrice":{"price":149.0,"currencyUnit":"USD"},"wasPrice":{"price":199.0,"currencyUnit":"USD"}},"imageInfo":{"thumbnailUrl":"https://i5.walmartimages.com/t.jpg","allImages":[{"url":"https://i5.walmartimages.com/1.jpg"},{"url":"https://i5.walmartimages.com/2.jpg"}]},"category":{"path":"Home/Air Conditioners"},"variantCriteria":[{"name":"capacity"}]},"idml":{"longDescription":"<p>A powerful window unit.</p>"},"reviews":{"averageOverallRating":4.5,"totalReviewCount":6287,"aspects":[{"name":"Value","score":90}],"customerReviews":[{"reviewId":"r1","rating":5,"reviewTitle":"Great AC","reviewText":"Very cold air.","reviewSubmissionTime":"2024-04-28","userNickname":"Anne","positiveFeedback":3,"negativeFeedback":0,"badges":[{"id":"VerifiedPurchaser"}]}]}}}}}}</script>
</body></html>

View file

@ -0,0 +1,3 @@
<!doctype html><html><head><title>Reviews</title></head><body>
<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"initialData":{"data":{"reviews":{"customerReviews":[{"reviewId":"296013686","rating":5,"reviewTitle":"Awesome","reviewText":"The PS5 is awesome!","reviewSubmissionTime":"12/20/2022","userNickname":"JohnPaul","positiveFeedback":10,"negativeFeedback":3,"badges":[{"id":"VerifiedPurchaser"}],"photos":[{"normalUrl":"https://i5.walmartimages.com/r.jpg"}],"clientResponses":[{"response":"Thanks for the review!"}],"syndicationSource":null},{"reviewId":"296013687","rating":4,"reviewTitle":"Good","reviewText":"Solid console.","reviewSubmissionTime":"12/21/2022","userNickname":"Sam","positiveFeedback":1,"negativeFeedback":0,"badges":[]}]}}}}}}</script>
</body></html>

View file

@ -0,0 +1,138 @@
from __future__ import annotations
from pathlib import Path
from app.proprietary.platforms.walmart import (
WalmartReviewsInput,
WalmartScrapeInput,
scrape_products,
scrape_reviews,
scraper,
)
from app.proprietary.platforms.walmart.fetch import FetchResult
_FIXTURES = Path(__file__).parent / "fixtures"
def _fixture(name: str) -> str:
return (_FIXTURES / name).read_text(encoding="utf-8")
def _response(url: str, html: str, status: int = 200) -> FetchResult:
return FetchResult(status=status, html=html, url=url, cookies={})
async def test_product_flow_emits_parsed_item(monkeypatch):
async def fetch_page(url: str, **_kwargs):
return _response(url, _fixture("product.html"))
monkeypatch.setattr(scraper, "fetch_page", fetch_page)
items = await scrape_products(
WalmartScrapeInput(startUrls=["https://www.walmart.com/ip/212092810"])
)
assert len(items) == 1
assert items[0]["usItemId"] == "212092810"
assert items[0]["name"].startswith("Midea")
async def test_product_flow_maps_not_found_to_error_item(monkeypatch):
async def fetch_page(url: str, **_kwargs):
return _response(url, "", status=404)
monkeypatch.setattr(scraper, "fetch_page", fetch_page)
items = await scrape_products(
WalmartScrapeInput(startUrls=["https://www.walmart.com/ip/212092810"])
)
assert items[0]["error"] == "product_not_found"
async def test_listing_flow_card_only_honors_cap(monkeypatch):
calls: list[str] = []
async def fetch_page(url: str, **_kwargs):
calls.append(url)
html = _fixture("listing.html") if "page=1" in url else "<html></html>"
return _response(url, html)
monkeypatch.setattr(scraper, "fetch_page", fetch_page)
items = await scrape_products(
WalmartScrapeInput(
startUrls=["https://www.walmart.com/search?q=laptop"],
maxItemsPerStartUrl=1,
includeDetails=False,
)
)
assert len(items) == 1
assert items[0]["usItemId"] == "791595618"
assert len(calls) == 1
async def test_listing_flow_enriches_with_detail_pages(monkeypatch):
async def fetch_page(url: str, **_kwargs):
if "/ip/" in url:
return _response(url, _fixture("product.html"))
html = _fixture("listing.html") if "page=1" in url else "<html></html>"
return _response(url, html)
monkeypatch.setattr(scraper, "fetch_page", fetch_page)
items = await scrape_products(
WalmartScrapeInput(
startUrls=["https://www.walmart.com/search?q=laptop"],
maxItemsPerStartUrl=2,
includeDetails=True,
)
)
# Both cards enrich to the same product fixture (detail fetch wins).
assert all(item["longDescription"] for item in items)
async def test_invalid_url_yields_error_item(monkeypatch):
items = await scrape_products(
WalmartScrapeInput(startUrls=["https://example.com/not-walmart"])
)
assert items[0]["error"] == "invalid_url"
async def test_reviews_flow_paginates_until_empty(monkeypatch):
calls: list[str] = []
async def fetch_page(url: str, **_kwargs):
calls.append(url)
html = _fixture("reviews.html") if "page=1" in url else "<html></html>"
return _response(url, html)
monkeypatch.setattr(scraper, "fetch_page", fetch_page)
items = await scrape_reviews(
WalmartReviewsInput(itemIds=["212092810"], maxReviews=100)
)
assert len(items) == 2
assert items[0]["reviewId"] == "296013686"
# page=1 returned records, page=2 empty → stop. Two fetches total.
assert len(calls) == 2
async def test_reviews_flow_honors_max_reviews(monkeypatch):
async def fetch_page(url: str, **_kwargs):
return _response(url, _fixture("reviews.html"))
monkeypatch.setattr(scraper, "fetch_page", fetch_page)
items = await scrape_reviews(
WalmartReviewsInput(itemIds=["212092810"], maxReviews=1)
)
assert len(items) == 1
async def test_reviews_flow_maps_empty_to_error_item(monkeypatch):
async def fetch_page(url: str, **_kwargs):
return _response(url, "<html></html>")
monkeypatch.setattr(scraper, "fetch_page", fetch_page)
items = await scrape_reviews(WalmartReviewsInput(itemIds=["212092810"]))
assert items[0]["error"] == "reviews_not_found"

View file

@ -0,0 +1,90 @@
from __future__ import annotations
from pathlib import Path
from app.proprietary.platforms.walmart.fetch import is_blocked
from app.proprietary.platforms.walmart.next_data import extract_next_data
from app.proprietary.platforms.walmart.parsers import (
parse_listing_page,
parse_product,
parse_reviews_page,
)
from app.proprietary.platforms.walmart.url_resolver import extract_item_id, resolve_url
_FIXTURES = Path(__file__).parent / "fixtures"
def _fixture(name: str) -> str:
return (_FIXTURES / name).read_text(encoding="utf-8")
def test_product_parser_extracts_core_fields_and_review_sample():
data = extract_next_data(_fixture("product.html"))
item = parse_product(data, url="https://www.walmart.com/ip/212092810")
assert item["usItemId"] == "212092810"
assert item["name"].startswith("Midea")
assert item["price"] == {"value": 149.0, "currency": "USD"}
assert item["listPrice"] == {"value": 199.0, "currency": "USD"}
assert item["stars"] == 4.5
assert item["reviewsCount"] == 6287
assert item["inStock"] is True
assert item["seller"] == {"id": "F55CT", "name": "Walmart.com", "type": "WALMART"}
assert item["longDescription"] == "<p>A powerful window unit.</p>"
assert item["images"] == [
"https://i5.walmartimages.com/1.jpg",
"https://i5.walmartimages.com/2.jpg",
]
assert item["reviewsSample"]["totalReviewCount"] == 6287
assert item["reviewsSample"]["topReviews"][0]["verifiedPurchase"] is True
def test_listing_parser_normalizes_cards_and_marketplace_seller():
data = extract_next_data(_fixture("listing.html"))
cards = parse_listing_page(data)
assert [c["usItemId"] for c in cards] == ["791595618", "999001"]
first = cards[0]
assert first["url"] == "https://www.walmart.com/ip/Acer-Chromebook/791595618"
assert first["price"] == {"value": 79.99, "currency": "USD"}
assert first["seller"]["type"] == "MARKETPLACE"
assert first["inStock"] is True
# Fallback price + out-of-stock derivation for the second card.
assert cards[1]["price"] == {"value": 499.0}
assert cards[1]["inStock"] is False
def test_reviews_parser_extracts_all_records():
data = extract_next_data(_fixture("reviews.html"))
reviews = parse_reviews_page(data)
assert len(reviews) == 2
assert reviews[0]["reviewId"] == "296013686"
assert reviews[0]["author"] == "JohnPaul"
assert reviews[0]["verifiedPurchase"] is True
assert reviews[0]["images"] == ["https://i5.walmartimages.com/r.jpg"]
assert reviews[0]["sellerResponse"] == "Thanks for the review!"
assert reviews[1]["verifiedPurchase"] is False
def test_block_detection_handles_status_and_body():
assert is_blocked(_fixture("blocked.html"), 200)
assert is_blocked("", 412)
assert is_blocked("", 429)
assert not is_blocked(_fixture("product.html"), 200)
def test_missing_next_data_yields_none():
assert extract_next_data("<html><body>no script here</body></html>") is None
assert parse_product(extract_next_data("") or {}, url="x") == {}
def test_url_resolver_classifies_and_extracts_ids():
assert resolve_url("https://www.walmart.com/ip/Foo/123456789").kind == "product"
assert (
extract_item_id("https://www.walmart.com/reviews/product/212092810")
== "212092810"
)
assert resolve_url("https://www.walmart.com/search?q=tv").kind == "listing"
assert resolve_url("https://www.walmart.com/cp/tvs/3944").kind == "listing"
assert resolve_url("https://example.com/ip/1") is None