mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-22 23:31:12 +02:00
test(walmart): add parser and flow tests with e2e harness
This commit is contained in:
parent
a3f8a7e741
commit
7b5e09c528
8 changed files with 321 additions and 0 deletions
80
surfsense_backend/scripts/e2e_walmart_scraper.py
Normal file
80
surfsense_backend/scripts/e2e_walmart_scraper.py
Normal 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()))
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
138
surfsense_backend/tests/unit/platforms/walmart/test_flows.py
Normal file
138
surfsense_backend/tests/unit/platforms/walmart/test_flows.py
Normal 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"
|
||||||
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue