diff --git a/surfsense_backend/app/proprietary/platforms/amazon/fetch.py b/surfsense_backend/app/proprietary/platforms/amazon/fetch.py index bf4f403ab..d1ed7bc15 100644 --- a/surfsense_backend/app/proprietary/platforms/amazon/fetch.py +++ b/surfsense_backend/app/proprietary/platforms/amazon/fetch.py @@ -195,8 +195,13 @@ async def fetch_aod_html( cookies: dict[str, str] | None = None, proxy: str | None = None, ) -> str | None: - """Fetch the public all-offers panel for one product.""" - url = f"{_origin(domain)}/gp/product/ajax?asin={asin}&experienceId=aodAjaxMain" + """Fetch the public all-offers panel for one product. + + ``aodAjaxMain`` is a path segment, not a query param. Amazon also serves this + panel as a JS-only modal for many (especially US) ASINs, so a 404 here is + expected and the caller falls back to the PDP buy-box winner. + """ + url = f"{_origin(domain)}/gp/product/ajax/aodAjaxMain/?asin={asin}" return await fetch_html(url, cookies=cookies, proxy=proxy) diff --git a/surfsense_backend/app/proprietary/platforms/amazon/parsers.py b/surfsense_backend/app/proprietary/platforms/amazon/parsers.py index dfcfa26cd..434973677 100644 --- a/surfsense_backend/app/proprietary/platforms/amazon/parsers.py +++ b/surfsense_backend/app/proprietary/platforms/amazon/parsers.py @@ -258,14 +258,20 @@ def parse_product( price_text = _first_text( doc, "#corePrice_feature_div .a-offscreen", + "#corePriceDisplay_desktop_feature_div .a-offscreen", + "#apex_desktop .a-price .a-offscreen", "#priceblock_ourprice", "#priceblock_dealprice", ".priceToPay .a-offscreen", + "#price_inside_buybox", + "#centerCol .a-price .a-offscreen", ) list_price_text = _first_text( doc, "#corePrice_feature_div .basisPrice .a-offscreen", + "#corePriceDisplay_desktop_feature_div .basisPrice .a-offscreen", ".priceBlockStrikePriceString", + "#centerCol .a-price.a-text-price .a-offscreen", ) availability = _first_text(doc, "#availability", "#outOfStock") availability_lower = (availability or "").lower() @@ -330,6 +336,13 @@ def parse_product( ) seller_href = _attr(seller_link, "href") seller_id = (parse_qs(urlparse(seller_href or "").query).get("seller") or [None])[0] + # Fall back to the buy-box "Sold by" name when there is no seller profile link + # (Amazon-direct items and some link-less third-party merchants). + seller_name = _text(seller_link) or _first_text( + doc, + "#tabular-buybox [tabular-attribute-name='Sold by'] .tabular-buybox-text", + "#merchantInfoFeature_feature_div .offer-display-feature-text-message", + ) return { "title": title, "url": url, @@ -425,10 +438,10 @@ def parse_product( "seller": ( { "id": seller_id, - "name": _text(seller_link), + "name": seller_name, "url": _absolute(domain, seller_href), } - if seller_link is not None + if (seller_link is not None or seller_name) else None ), "bestsellerRanks": ranks, diff --git a/surfsense_backend/app/proprietary/platforms/amazon/scraper.py b/surfsense_backend/app/proprietary/platforms/amazon/scraper.py index 5100aaf01..796173401 100644 --- a/surfsense_backend/app/proprietary/platforms/amazon/scraper.py +++ b/surfsense_backend/app/proprietary/platforms/amazon/scraper.py @@ -49,6 +49,28 @@ def _error( ).model_dump() +def _buybox_offer(fields: dict[str, Any]) -> dict[str, Any] | None: + """Synthesize a single offer from the PDP buy box. + + Amazon serves the All-Offers-Display panel as a JS-only modal for many + (especially US) ASINs, so the AOD ajax endpoint 404s. Fall back to the + featured buy-box winner already parsed from the product page. + """ + price = fields.get("price") + seller = fields.get("seller") + seller = seller if isinstance(seller, dict) else None + if not price and not (seller and seller.get("name")): + return None + return { + "position": 1, + "price": price, + "condition": fields.get("condition") or "New", + "delivery": fields.get("delivery"), + "seller": seller, + "isPinnedOffer": True, + } + + def _page_url(url: str, page: int) -> str: parsed = urlparse(url) query = dict(parse_qsl(parsed.query, keep_blank_values=True)) @@ -154,10 +176,14 @@ async def _product_flow( cookies=offer_cookies or cookies, proxy=offer_proxy or proxy, ) - if offers_html: - fields["offers"] = parse_aod_offers(offers_html, domain=resolved.domain)[ - : input_model.maxOffers - ] + offers = ( + parse_aod_offers(offers_html, domain=resolved.domain) if offers_html else [] + ) + if not offers: + buybox = _buybox_offer(fields) + offers = [buybox] if buybox else [] + if offers: + fields["offers"] = offers[: input_model.maxOffers] if input_model.scrapeSellers: seller_ids: list[str] = []