diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..3f0dc9c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+tests/pages/**/*.html -text -diff
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 48acdf6..5886cca 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -43,3 +43,35 @@ jobs:
name: store-zip
path: dist-zips/*.zip
if-no-files-found: error
+
+ e2e:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ # The upgrade scenarios check out the v2.0 store commit.
+ fetch-depth: 0
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - run: npm ci
+
+ # Full Chromium, not the headless shell: extensions need it. The browser
+ # version is pinned by the @playwright/test version in package.json.
+ - name: Install Chromium
+ run: npx playwright install --with-deps --no-shell chromium
+
+ - name: Chromium harness
+ run: npx playwright test
+
+ - uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: e2e-report
+ path: |
+ tests/e2e/.report
+ tests/e2e/.results
diff --git a/.gitignore b/.gitignore
index 8dfb469..543baf4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,8 @@ dist/
build/
*.zip
dist-zips/
+
+# e2e harness output
+tests/e2e/.build/
+tests/e2e/.results/
+tests/e2e/.report/
diff --git a/CLAUDE.md b/CLAUDE.md
index 976c33a..adad14b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,6 +18,8 @@ AmazonSellerScraper/
│ │ ├── scraper.js # DOM scraping on Amazon pages
│ │ ├── chatbot.js # Floating AI chatbot widget (Shadow DOM)
│ │ └── offer-fetcher.js # Seller price fetching for spread analysis
+│ ├── lib/
+│ │ └── parsers.js # Pure search/offer parsing (global Parsers)
│ ├── background/
│ │ └── service-worker.js # Message routing + Gemini API calls
│ └── modules/
@@ -93,7 +95,7 @@ AmazonSellerScraper/
## Testing
-### JavaScript (Jest + JSDOM) — 214 tests
+### JavaScript (Jest + JSDOM)
```bash
npm test # Run all JS tests
diff --git a/README.md b/README.md
index eb0bd6d..7a1dc44 100644
--- a/README.md
+++ b/README.md
@@ -142,6 +142,8 @@ For local Firebase work, `npm run build:dev` points the build at the emulators u
`npm test` runs the Jest suite and the tool tests. `npm run check` builds, then runs the permission lock (nothing may be added over `tools/live-manifest.json`, the published v2.0 manifest), the version gate and the secret scan. `npm run zip` writes the store package to `dist-zips/` and refuses a dev build, a stray file, uncommitted changes (`node tools/zip.mjs --allow-dirty` overrides that for local tries), or any gate failure. CI runs all of these.
+The Jest suite includes a golden corpus of saved Amazon pages (`tests/pages/`, see its README). `npm run test:e2e` loads the built extension into Chromium and runs scrape scenarios against those pages, with every request answered locally. Run `npx playwright install --no-shell chromium` once first. Known bugs run as expected failures tagged with their audit finding id; `PROSCAN_SHOW_KNOWN=1 npm run test:e2e` shows what they fail on.
+
## Usage
1. Navigate to any Amazon search results or seller page
@@ -179,9 +181,11 @@ AmazonSellerScraper/
│ └── popup.js # UI state management and export handling
├── scripts/
│ ├── content/
-│ │ ├── scraper.js # DOM scraping with cascading selectors
+│ │ ├── scraper.js # Scrape loop: storage, messages, pagination
│ │ ├── chatbot.js # Floating AI chatbot (Shadow DOM)
│ │ └── offer-fetcher.js # Seller offer page fetching for spread analysis
+│ ├── lib/
+│ │ └── parsers.js # Pure search and offer page parsing
│ ├── background/
│ │ └── service-worker.js # Message routing + Gemini API
│ └── modules/
diff --git a/jest.config.js b/jest.config.js
index def4864..fa9d110 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -14,6 +14,7 @@ module.exports = {
coverageDirectory: 'coverage',
collectCoverageFrom: [
'scripts/modules/*.js',
+ 'scripts/lib/*.js',
'scripts/content/scraper.js',
'scripts/content/offer-fetcher.js',
'!**/node_modules/**'
diff --git a/manifest.json b/manifest.json
index 40894c9..8424448 100644
--- a/manifest.json
+++ b/manifest.json
@@ -29,6 +29,7 @@
"matches": ["*://*.amazon.com/*"],
"js": [
"scripts/modules/price.js",
+ "scripts/lib/parsers.js",
"scripts/modules/delta.js",
"scripts/content/scraper.js",
"scripts/content/chatbot.js",
diff --git a/package-lock.json b/package-lock.json
index c39af77..6732d39 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"firebase": "^12.15.0"
},
"devDependencies": {
+ "@playwright/test": "1.63.0",
"adm-zip": "^0.5.17",
"esbuild": "^0.28.0",
"jest": "^29.7.0",
@@ -1966,6 +1967,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -4944,6 +4961,35 @@
"node": ">=8"
}
},
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
diff --git a/package.json b/package.json
index 3b2be96..279823d 100644
--- a/package.json
+++ b/package.json
@@ -9,12 +9,14 @@
"test:unit": "jest tests/unit --verbose",
"test:integration": "jest tests/integration --verbose",
"test:tools": "node --test --test-concurrency=1 \"tools/tests/*.test.mjs\"",
+ "test:e2e": "playwright test",
"test:coverage": "jest --coverage",
"lock": "node tools/permission-lock.mjs && node tools/version-gate.mjs",
"scan:secrets": "node tools/secret-scan.mjs",
"check": "npm run build && npm run lock && npm run scan:secrets"
},
"devDependencies": {
+ "@playwright/test": "1.63.0",
"adm-zip": "^0.5.17",
"esbuild": "^0.28.0",
"jest": "^29.7.0",
diff --git a/playwright.config.mjs b/playwright.config.mjs
new file mode 100644
index 0000000..68d68c3
--- /dev/null
+++ b/playwright.config.mjs
@@ -0,0 +1,14 @@
+import { defineConfig } from '@playwright/test';
+
+// One Chromium at a time: every test loads the extension into its own profile.
+export default defineConfig({
+ testDir: 'tests/e2e',
+ testMatch: '*.spec.mjs',
+ globalSetup: './tests/e2e/global-setup.mjs',
+ workers: 1,
+ fullyParallel: false,
+ retries: 0,
+ timeout: 90_000,
+ reporter: process.env.CI ? [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/.report' }]] : 'list',
+ outputDir: 'tests/e2e/.results',
+});
diff --git a/popup/popup.html b/popup/popup.html
index 7032735..dc8622c 100644
--- a/popup/popup.html
+++ b/popup/popup.html
@@ -5,6 +5,7 @@
+
diff --git a/scripts/content/offer-fetcher.js b/scripts/content/offer-fetcher.js
index 198a9b2..bbb6eff 100644
--- a/scripts/content/offer-fetcher.js
+++ b/scripts/content/offer-fetcher.js
@@ -22,102 +22,9 @@
/** @type {boolean} Whether spread analysis is currently running */
let isAnalyzing = false;
-/**
- * CSS selectors for extracting prices from Amazon offer listing pages.
- * Multiple selectors for resilience against layout changes.
- * @const {Object}
- */
-const OFFER_SELECTORS = {
- // AOD (All Offers Display) format — modern Amazon
- aodPriceBlock: '.aod-information-block .a-price .a-offscreen',
-
- // Classic offer listing page format
- offerListPrice: '#olpOfferList .a-price .a-offscreen',
-
- // Legacy offer listing format
- legacyOfferPrice: '.olpOfferPrice',
-
- // Scoped fallback — prices inside an offer/seller container only.
- // Must NOT be the global '.a-price .a-offscreen' selector, which would
- // scoop up buy-box, sponsored, and accessory prices as fake seller offers.
- generalPrice: '#aod-offer-list .a-price .a-offscreen, [id^="aod-offer"] .a-price .a-offscreen, .olpOffer .a-price .a-offscreen'
-};
-
-/**
- * Build the offer listing URL for a given ASIN.
- * Uses the classic offer listing page format filtered to new condition.
- *
- * @param {string} asin - Amazon Standard Identification Number
- * @returns {string} Full URL for the offer listing page
- */
-function buildOfferUrl(asin) {
- return `https://www.amazon.com/gp/offer-listing/${asin}/ref=dp_olp_all_mbc?ie=UTF8&condition=new`;
-}
-
-/**
- * Build the AOD (All Offers Display) AJAX URL for a given ASIN.
- * This endpoint returns HTML fragments with all seller offers.
- *
- * @param {string} asin - Amazon Standard Identification Number
- * @returns {string} AJAX endpoint URL
- */
-function buildAodUrl(asin) {
- return `https://www.amazon.com/gp/aod/ajax?asin=${asin}&condition=new&m=&qid=&smid=&sourcecustomerorglistid=&sourcecustomerorglistitemid=&sr=&pc=dp`;
-}
-
-/**
- * Parse a price string from Amazon's DOM into a numeric value.
- * Handles currency symbols, commas, and whitespace.
- *
- * @param {string} text - Raw price text (e.g., "$19.99", "$1,299.00")
- * @returns {number} Parsed price, or 0 if unparseable
- */
-function parseOfferPrice(text) {
- if (!text) return 0;
- const cleaned = text.replace(/[^0-9.]/g, '');
- const price = parseFloat(cleaned);
- return isNaN(price) ? 0 : price;
-}
-
-/**
- * Extract all seller prices from an HTML document using cascading selectors.
- * Tries multiple selector strategies in order of specificity.
- *
- * @param {Document} doc - Parsed HTML document (from DOMParser)
- * @returns {number[]} Array of valid seller prices (> 0)
- */
-function extractPricesFromDocument(doc) {
- const prices = [];
-
- // Try each selector strategy in order
- const selectorStrategies = [
- OFFER_SELECTORS.aodPriceBlock,
- OFFER_SELECTORS.offerListPrice,
- OFFER_SELECTORS.legacyOfferPrice,
- OFFER_SELECTORS.generalPrice
- ];
-
- for (const selector of selectorStrategies) {
- const elements = doc.querySelectorAll(selector);
- if (elements.length > 0) {
- elements.forEach(el => {
- const price = parseOfferPrice(el.textContent);
- if (price > 0) {
- prices.push(price);
- }
- });
- // If we found prices with this selector, don't try fallbacks
- if (prices.length > 0) break;
- }
- }
-
- // Return all prices as-is. Do NOT dedup: multiple distinct sellers
- // legitimately list the same price, and each is a real data point for
- // the spread statistics (sellerCount, mean, stdDev, CV, confidence factor).
- // The cascade `break`s after the first selector that yields prices, so a
- // single physical price element is never matched twice.
- return prices;
-}
+// Pure parsing lives in scripts/lib/parsers.js. These names stay for the fetch
+// loop below and for the unit tests that load this file.
+var { buildOfferUrl, buildAodUrl, parseOfferPrice, extractPricesFromDocument } = Parsers;
/**
* Fetch the offer listing page for a single ASIN and extract seller prices.
diff --git a/scripts/content/scraper.js b/scripts/content/scraper.js
index 779a50c..2c71481 100644
--- a/scripts/content/scraper.js
+++ b/scripts/content/scraper.js
@@ -22,43 +22,6 @@
let isScrapingActive = false;
let itemCount = 0;
-/**
- * CSS selectors for Amazon product page elements.
- * Updated February 2026 to reflect current Amazon DOM structure.
- * Each field has primary + fallback selectors for resilience.
- *
- * @const {Object}
- */
-const SELECTORS = {
- /** Product result container -- filtered to exclude empty ASINs (ad placeholders) */
- productItem: '.s-result-item[data-asin]:not([data-asin=""])',
-
- /** Title -- structural selector (h2 > span) is more stable than class-based */
- title: 'h2 span',
- titleAlt: '.a-size-base-plus.a-color-base.a-text-normal',
-
- /** Price -- data-attribute selector targets the primary displayed price */
- price: '.a-price[data-a-size="xl"] .a-offscreen',
- priceAlt: '.a-price .a-offscreen',
-
- /** Rating -- cascading from most to least stable */
- rating: '[data-cy="reviews-ratings-slot"] .a-icon-alt',
- ratingAlt: '.a-icon-star-mini .a-icon-alt',
- ratingLegacy: '.a-icon-star-small .a-icon-alt',
- ratingText: '[data-cy="reviews-block"] span.a-size-base.a-color-secondary',
-
- /** Review count -- aria-label has full number; display text may use K/M abbreviations */
- reviewCount: 'a[aria-label$="ratings"]',
- reviewCountAlt: '.a-size-mini.puis-normal-weight-text.s-underline-text',
- reviewCountLegacy: '.a-size-base.puis-normal-weight-text.s-underline-text',
-
- productLink: '.a-link-normal.s-no-outline',
- primeBadge: '.a-icon-prime, .s-prime',
- nextPageDisabled: '.s-pagination-next.s-pagination-disabled',
- resultsText: 'h2.a-size-base.a-spacing-small.a-spacing-top-small span',
- resultsToolbar: '.s-desktop-toolbar .a-spacing-small span'
-};
-
/**
* Initialize the scraper on page load.
* Checks storage to see if a scraping session is in progress
@@ -75,263 +38,31 @@ function initialize() {
});
}
-/**
- * Safely extract text content from a DOM element using cascading selectors.
- *
- * @param {HTMLElement} element - Parent element to search within
- * @param {string} selector - Primary CSS selector
- * @param {string|null} [fallbackSelector=null] - Fallback CSS selector
- * @returns {string|null} Trimmed text content, or null if no match
- */
-function getText(element, selector, fallbackSelector = null) {
- let el = element.querySelector(selector);
- if (!el && fallbackSelector) {
- el = element.querySelector(fallbackSelector);
- }
- return el ? el.innerText.trim() : null;
-}
-
-/**
- * Extract the product price from a listing element.
- * Tries the primary price selector (xl size, usually the main price)
- * then falls back to any .a-price element.
- *
- * @param {HTMLElement} element - Product listing DOM element
- * @returns {string} Price string (e.g., "$19.99") or "N/A"
- */
-function extractPrice(element) {
- const priceEl = element.querySelector(SELECTORS.price) ||
- element.querySelector(SELECTORS.priceAlt);
-
- if (priceEl) {
- const text = priceEl.innerText || priceEl.textContent;
- return text ? text.trim() : 'N/A';
- }
- return 'N/A';
-}
-
-/**
- * Parse a numeric rating from text like "4.7 out of 5 stars" or "4.7".
- *
- * @param {string|null} text - Raw rating text from Amazon DOM
- * @returns {number} Parsed rating (0-5), or 0 if unparseable
- */
-function parseRatingText(text) {
- if (!text) return 0;
- const match = text.match(/(\d+\.?\d*)/);
- return match ? parseFloat(match[1]) : 0;
-}
-
-/**
- * Extract the product rating using a four-level cascading selector strategy:
- * 1. data-cy attribute (most stable across Amazon updates)
- * 2. Current star class (a-icon-star-mini)
- * 3. Legacy star class (a-icon-star-small)
- * 4. Plain text in reviews block
- *
- * @param {HTMLElement} element - Product listing DOM element
- * @returns {number} Rating value (0-5), or 0 if not found
- */
-function extractRating(element) {
- // 1. data-cy attribute (most stable across Amazon updates)
- const dataCy = element.querySelector(SELECTORS.rating);
- if (dataCy) {
- const val = parseRatingText(dataCy.textContent);
- if (val > 0) return val;
- }
-
- // 2. Current Amazon star class (a-icon-star-mini)
- const starMini = element.querySelector(SELECTORS.ratingAlt);
- if (starMini) {
- const val = parseRatingText(starMini.textContent);
- if (val > 0) return val;
- }
-
- // 3. Legacy star class (a-icon-star-small)
- const starSmall = element.querySelector(SELECTORS.ratingLegacy);
- if (starSmall) {
- const val = parseRatingText(starSmall.textContent);
- if (val > 0) return val;
- }
-
- // 4. Plain text rating shown in reviews block (e.g. "4.7" as visible text)
- const ratingSpan = element.querySelector(SELECTORS.ratingText);
- if (ratingSpan) {
- const val = parseRatingText(ratingSpan.textContent);
- if (val > 0 && val <= 5) return val;
- }
-
- return 0;
-}
-
-/**
- * Parse abbreviated review count text into an integer.
- * Handles formats like "(64)", "(108.3K)", "(77K)", "(1.2M)".
- *
- * @param {string|null} text - Raw review count text
- * @returns {number} Parsed integer review count, or 0
- *
- * @example
- * parseReviewText("(108.3K)") // => 108300
- * parseReviewText("(1.2M)") // => 1200000
- */
-function parseReviewText(text) {
- if (!text) return 0;
- const cleaned = text.replace(/[()]/g, '').trim();
- const match = cleaned.match(/^([\d,.]+)\s*([KMkm])?/);
- if (!match) return 0;
+// Pure parsing lives in scripts/lib/parsers.js. These names stay for the page
+// logic below and for the unit tests that load this file.
+var {
+ getText, extractPrice, parseRatingText, extractRating, parseReviewText,
+ extractReviewCount, hasPrimeBadge, scrapeProduct
+} = Parsers;
- let num = parseFloat(match[1].replace(/,/g, ''));
- const suffix = (match[2] || '').toUpperCase();
- if (suffix === 'K') num *= 1000;
- if (suffix === 'M') num *= 1000000;
- return Math.round(num);
-}
-
-/**
- * Extract the review count from a product listing.
- * Tries three sources in order of accuracy:
- * 1. aria-label on ratings link (exact number, e.g., "108,373 ratings")
- * 2. Current display text (may use K/M abbreviations)
- * 3. Legacy display text format
- *
- * @param {HTMLElement} element - Product listing DOM element
- * @returns {number} Review count, or 0 if not found
- */
-function extractReviewCount(element) {
- // 1. aria-label on the ratings link -- most accurate
- const ariaLink = element.querySelector(SELECTORS.reviewCount);
- if (ariaLink) {
- const label = ariaLink.getAttribute('aria-label');
- if (label) {
- const cleaned = label.replace(/[^0-9]/g, '');
- const count = parseInt(cleaned);
- if (count > 0) return count;
- }
- }
-
- // 2. Current display text (a-size-mini) -- may have K/M suffix
- const miniEl = element.querySelector(SELECTORS.reviewCountAlt);
- if (miniEl) {
- const count = parseReviewText(miniEl.textContent);
- if (count > 0) return count;
- }
-
- // 3. Legacy display text (a-size-base)
- const baseEl = element.querySelector(SELECTORS.reviewCountLegacy);
- if (baseEl) {
- const count = parseReviewText(baseEl.textContent);
- if (count > 0) return count;
- }
-
- return 0;
-}
-
-/**
- * Check if a product listing has an Amazon Prime badge.
- *
- * @param {HTMLElement} element - Product listing DOM element
- * @returns {boolean} True if Prime-eligible
- */
-function hasPrimeBadge(element) {
- return element.querySelector(SELECTORS.primeBadge) !== null;
-}
-
-/**
- * Extract all data from a single product listing element.
- *
- * @param {HTMLElement} listing - Product listing DOM element with data-asin attribute
- * @returns {Object|null} Product object or null if ASIN is missing
- * @returns {string} return.name - Product title
- * @returns {string} return.asin - Amazon Standard Identification Number
- * @returns {string} return.price - Price string (e.g., "$19.99")
- * @returns {number} return.rating - Rating value (0-5)
- * @returns {number} return.reviewCount - Total review count
- * @returns {boolean} return.isPrime - Prime eligibility
- * @returns {string} return.url - Full Amazon product URL
- * @returns {string} return.scrapedAt - ISO timestamp of when the product was scraped
- */
-function scrapeProduct(listing) {
- const asin = listing.dataset.asin;
- if (!asin) return null;
-
- const title = getText(listing, SELECTORS.title, SELECTORS.titleAlt) || 'N/A';
- const price = extractPrice(listing);
- const rating = extractRating(listing);
- const reviewCount = extractReviewCount(listing);
- const isPrime = hasPrimeBadge(listing);
-
- // Get product URL
- const linkEl = listing.querySelector(SELECTORS.productLink);
- const productUrl = linkEl
- ? `https://www.amazon.com${linkEl.getAttribute('href')}`
- : 'N/A';
-
- return {
- name: title,
- asin: asin,
- price: price,
- // Canonical integer-cents price for cloud sync + delta math; null when
- // the display price is absent/unparseable (Price loaded via manifest).
- priceCents: Price.priceToCents(price),
- rating: rating,
- reviewCount: reviewCount,
- isPrime: isPrime,
- url: productUrl,
- scrapedAt: new Date().toISOString()
- };
-}
-
-/**
- * Extract the total number of search results from the Amazon results page header.
- *
- * @returns {number} Total result count, or 0 if not found
- */
function getTotalResults() {
- const resultsEl = document.querySelector(SELECTORS.resultsText);
- if (resultsEl) {
- const text = resultsEl.innerText;
- const match = text.match(/of (\d+[\d,]*) results/);
- if (match) {
- return parseInt(match[1].replace(/,/g, ''));
- }
- }
- return 0;
+ return Parsers.getTotalResults(document);
}
-/**
- * Construct the URL for the next page of search results.
- * Increments the page parameter and updates the ref parameter.
- *
- * @returns {string} Full URL for the next results page
- */
function getNextPageUrl() {
- const currentUrl = new URL(window.location.href);
- const currentPage = parseInt(currentUrl.searchParams.get('page')) || 1;
- const nextPage = currentPage + 1;
-
- currentUrl.searchParams.set('page', nextPage);
- currentUrl.searchParams.set('ref', `sr_pg_${nextPage}`);
-
- return currentUrl.toString();
+ return Parsers.getNextPageUrl(window.location.href);
}
-/**
- * Check if there is a next page of results available.
- * Returns false if the "Next" pagination button is disabled.
- *
- * @returns {boolean} True if more pages exist
- */
function hasNextPage() {
- return !document.querySelector(SELECTORS.nextPageDisabled);
+ return Parsers.hasNextPage(document);
}
/**
* Main scraping function for the current page.
*
* Workflow:
- * 1. Query all product listing elements on the page
- * 2. Extract data from each listing via scrapeProduct()
+ * 1. Parse the page with Parsers.parseSearchPage()
+ * 2. Stop if it has no listings
* 3. Send progress update to popup via chrome.runtime
* 4. Append results to chrome.storage.local
* 5. If more pages exist, navigate after a 2-second delay
@@ -340,24 +71,17 @@ function hasNextPage() {
function scrapeCurrentPage() {
if (!isScrapingActive) return;
- const listings = document.querySelectorAll(SELECTORS.productItem);
- const results = [];
+ const page = Parsers.parseSearchPage(document, window.location.href);
+ const results = page.products;
- console.log(`[ProScan] Found ${listings.length} product listings`);
+ console.log(`[ProScan] Found ${results.length} product listings`);
- if (listings.length === 0) {
+ if (page.kind === 'empty') {
console.log('[ProScan] No listings found on this page');
finishScraping(itemCount);
return;
}
- listings.forEach(listing => {
- const product = scrapeProduct(listing);
- if (product) {
- results.push(product);
- }
- });
-
console.log(`[ProScan] Scraped ${results.length} products`);
// Send progress update to popup
@@ -411,7 +135,7 @@ function scrapeCurrentPage() {
// the autonomous safety net if the popup/page closes first.
chrome.runtime.sendMessage({ type: 'ENQUEUE_SYNC', runId: runId, pageIndex: pageIndex });
- // Check for next page
+ // Checked after the write, as before the parser split
if (hasNextPage() && isScrapingActive) {
const nextUrl = getNextPageUrl();
console.log(`[ProScan] Navigating to next page: ${nextUrl}`);
diff --git a/scripts/lib/parsers.js b/scripts/lib/parsers.js
new file mode 100644
index 0000000..a95c9fc
--- /dev/null
+++ b/scripts/lib/parsers.js
@@ -0,0 +1,361 @@
+/**
+ * @fileoverview Pure page parsing for search results and seller offers.
+ *
+ * Everything here takes a Document, an element or a string and returns data.
+ * Nothing reads storage, sends messages or navigates. It is loaded as a plain
+ * global (`Parsers`) after price.js in the content scripts and the popup, and
+ * as a CommonJS module in Jest and Node.
+ *
+ * The logic was moved here unchanged from scraper.js, offer-fetcher.js and
+ * analyzer.js, known bugs included. The golden tests in tests/golden mark
+ * each of those with its finding id.
+ *
+ * @module Parsers
+ */
+
+const Parsers = (() => {
+ const PriceLib = typeof Price !== 'undefined' ? Price : require('../modules/price.js');
+
+ /** Amazon search page selectors, primary then fallback. */
+ const SEARCH_SELECTORS = {
+ // Filtered to exclude empty ASINs (ad placeholders)
+ productItem: '.s-result-item[data-asin]:not([data-asin=""])',
+
+ title: 'h2 span',
+ titleAlt: '.a-size-base-plus.a-color-base.a-text-normal',
+
+ price: '.a-price[data-a-size="xl"] .a-offscreen',
+ priceAlt: '.a-price .a-offscreen',
+
+ rating: '[data-cy="reviews-ratings-slot"] .a-icon-alt',
+ ratingAlt: '.a-icon-star-mini .a-icon-alt',
+ ratingLegacy: '.a-icon-star-small .a-icon-alt',
+ ratingText: '[data-cy="reviews-block"] span.a-size-base.a-color-secondary',
+
+ // aria-label has the full number; display text may use K/M
+ reviewCount: 'a[aria-label$="ratings"]',
+ reviewCountAlt: '.a-size-mini.puis-normal-weight-text.s-underline-text',
+ reviewCountLegacy: '.a-size-base.puis-normal-weight-text.s-underline-text',
+
+ productLink: '.a-link-normal.s-no-outline',
+ primeBadge: '.a-icon-prime, .s-prime',
+ nextPageDisabled: '.s-pagination-next.s-pagination-disabled',
+ resultsText: 'h2.a-size-base.a-spacing-small.a-spacing-top-small span',
+ resultsToolbar: '.s-desktop-toolbar .a-spacing-small span'
+ };
+
+ /** Offer listing selectors, tried in order. */
+ const OFFER_SELECTORS = {
+ // AOD (All Offers Display) format
+ aodPriceBlock: '.aod-information-block .a-price .a-offscreen',
+ // Classic offer listing page
+ offerListPrice: '#olpOfferList .a-price .a-offscreen',
+ // Legacy offer listing
+ legacyOfferPrice: '.olpOfferPrice',
+ // Scoped to offer containers. The global '.a-price .a-offscreen' would
+ // pick up buy-box, sponsored and accessory prices as fake offers.
+ generalPrice: '#aod-offer-list .a-price .a-offscreen, [id^="aod-offer"] .a-price .a-offscreen, .olpOffer .a-price .a-offscreen'
+ };
+
+ // ── Search results ──────────────────────────────────────────
+
+ /** Trimmed innerText of the first match, or null. */
+ function getText(element, selector, fallbackSelector = null) {
+ let el = element.querySelector(selector);
+ if (!el && fallbackSelector) {
+ el = element.querySelector(fallbackSelector);
+ }
+ return el ? el.innerText.trim() : null;
+ }
+
+ /** Display price string such as "$19.99", or "N/A". */
+ function extractPrice(element) {
+ const priceEl = element.querySelector(SEARCH_SELECTORS.price) ||
+ element.querySelector(SEARCH_SELECTORS.priceAlt);
+
+ if (priceEl) {
+ const text = priceEl.innerText || priceEl.textContent;
+ return text ? text.trim() : 'N/A';
+ }
+ return 'N/A';
+ }
+
+ /** "4.7 out of 5 stars" -> 4.7, or 0. */
+ function parseRatingText(text) {
+ if (!text) return 0;
+ const match = text.match(/(\d+\.?\d*)/);
+ return match ? parseFloat(match[1]) : 0;
+ }
+
+ /** Rating from data-cy, star-mini, star-small, then plain text. 0 if none. */
+ function extractRating(element) {
+ const dataCy = element.querySelector(SEARCH_SELECTORS.rating);
+ if (dataCy) {
+ const val = parseRatingText(dataCy.textContent);
+ if (val > 0) return val;
+ }
+
+ const starMini = element.querySelector(SEARCH_SELECTORS.ratingAlt);
+ if (starMini) {
+ const val = parseRatingText(starMini.textContent);
+ if (val > 0) return val;
+ }
+
+ const starSmall = element.querySelector(SEARCH_SELECTORS.ratingLegacy);
+ if (starSmall) {
+ const val = parseRatingText(starSmall.textContent);
+ if (val > 0) return val;
+ }
+
+ const ratingSpan = element.querySelector(SEARCH_SELECTORS.ratingText);
+ if (ratingSpan) {
+ const val = parseRatingText(ratingSpan.textContent);
+ if (val > 0 && val <= 5) return val;
+ }
+
+ return 0;
+ }
+
+ /** "(108.3K)" -> 108300, "(1.2M)" -> 1200000, or 0. */
+ function parseReviewText(text) {
+ if (!text) return 0;
+ const cleaned = text.replace(/[()]/g, '').trim();
+ const match = cleaned.match(/^([\d,.]+)\s*([KMkm])?/);
+ if (!match) return 0;
+
+ let num = parseFloat(match[1].replace(/,/g, ''));
+ const suffix = (match[2] || '').toUpperCase();
+ if (suffix === 'K') num *= 1000;
+ if (suffix === 'M') num *= 1000000;
+ return Math.round(num);
+ }
+
+ /** Review count from the aria-label, then the display text. 0 if none. */
+ function extractReviewCount(element) {
+ const ariaLink = element.querySelector(SEARCH_SELECTORS.reviewCount);
+ if (ariaLink) {
+ const label = ariaLink.getAttribute('aria-label');
+ if (label) {
+ const cleaned = label.replace(/[^0-9]/g, '');
+ const count = parseInt(cleaned);
+ if (count > 0) return count;
+ }
+ }
+
+ const miniEl = element.querySelector(SEARCH_SELECTORS.reviewCountAlt);
+ if (miniEl) {
+ const count = parseReviewText(miniEl.textContent);
+ if (count > 0) return count;
+ }
+
+ const baseEl = element.querySelector(SEARCH_SELECTORS.reviewCountLegacy);
+ if (baseEl) {
+ const count = parseReviewText(baseEl.textContent);
+ if (count > 0) return count;
+ }
+
+ return 0;
+ }
+
+ function hasPrimeBadge(element) {
+ return element.querySelector(SEARCH_SELECTORS.primeBadge) !== null;
+ }
+
+ /** One product record from a result card, or null without an ASIN. */
+ function scrapeProduct(listing) {
+ const asin = listing.dataset.asin;
+ if (!asin) return null;
+
+ const title = getText(listing, SEARCH_SELECTORS.title, SEARCH_SELECTORS.titleAlt) || 'N/A';
+ const price = extractPrice(listing);
+ const rating = extractRating(listing);
+ const reviewCount = extractReviewCount(listing);
+ const isPrime = hasPrimeBadge(listing);
+
+ const linkEl = listing.querySelector(SEARCH_SELECTORS.productLink);
+ const productUrl = linkEl
+ ? `https://www.amazon.com${linkEl.getAttribute('href')}`
+ : 'N/A';
+
+ return {
+ name: title,
+ asin: asin,
+ price: price,
+ // Integer cents for sync and delta math; null when unparseable
+ priceCents: PriceLib.priceToCents(price),
+ rating: rating,
+ reviewCount: reviewCount,
+ isPrime: isPrime,
+ url: productUrl,
+ scrapedAt: new Date().toISOString()
+ };
+ }
+
+ /** Result count from the header ("1-48 of 523 results"), or 0. */
+ function getTotalResults(doc) {
+ const resultsEl = doc.querySelector(SEARCH_SELECTORS.resultsText);
+ if (resultsEl) {
+ const text = resultsEl.innerText;
+ const match = text.match(/of (\d+[\d,]*) results/);
+ if (match) {
+ return parseInt(match[1].replace(/,/g, ''));
+ }
+ }
+ return 0;
+ }
+
+ /** The current URL with page incremented and ref set to sr_pg_N. */
+ function getNextPageUrl(href) {
+ const currentUrl = new URL(href);
+ const currentPage = parseInt(currentUrl.searchParams.get('page')) || 1;
+ const nextPage = currentPage + 1;
+
+ currentUrl.searchParams.set('page', nextPage);
+ currentUrl.searchParams.set('ref', `sr_pg_${nextPage}`);
+
+ return currentUrl.toString();
+ }
+
+ /** False only when a disabled Next button is present. */
+ function hasNextPage(doc) {
+ return !doc.querySelector(SEARCH_SELECTORS.nextPageDisabled);
+ }
+
+ /** Share of products with an ASIN, a title and a price. */
+ function fillRates(products) {
+ const n = products.length;
+ const share = (ok) => (n ? products.filter(ok).length / n : 0);
+ return {
+ asin: share((p) => !!p.asin),
+ title: share((p) => !!p.name && p.name !== 'N/A'),
+ price: share((p) => p.priceCents !== null && p.priceCents !== undefined)
+ };
+ }
+
+ /**
+ * Parses one search page the way the scraper does today.
+ *
+ * kind is 'empty' when no result card matched, 'last' when the Next
+ * button is disabled, else 'results'. nextHref is the hand-built next URL.
+ *
+ * @param {Document} doc
+ * @param {string} url - the page's address
+ * @returns {{kind: string, products: Object[], nextHref: string|null, total: number, fill: Object}}
+ */
+ function parseSearchPage(doc, url) {
+ const listings = doc.querySelectorAll(SEARCH_SELECTORS.productItem);
+ const total = getTotalResults(doc);
+ if (listings.length === 0) {
+ return { kind: 'empty', products: [], nextHref: null, total, fill: fillRates([]) };
+ }
+
+ const products = [];
+ listings.forEach(listing => {
+ const product = scrapeProduct(listing);
+ if (product) products.push(product);
+ });
+
+ const more = hasNextPage(doc);
+ return {
+ kind: more ? 'results' : 'last',
+ products,
+ nextHref: more ? getNextPageUrl(url) : null,
+ total,
+ fill: fillRates(products)
+ };
+ }
+
+ // ── Offers ──────────────────────────────────────────────────
+
+ function buildOfferUrl(asin) {
+ return `https://www.amazon.com/gp/offer-listing/${asin}/ref=dp_olp_all_mbc?ie=UTF8&condition=new`;
+ }
+
+ function buildAodUrl(asin) {
+ return `https://www.amazon.com/gp/aod/ajax?asin=${asin}&condition=new&m=&qid=&smid=&sourcecustomerorglistid=&sourcecustomerorglistitemid=&sr=&pc=dp`;
+ }
+
+ /** Offer price text -> dollars, or 0. Keeps every digit and dot (F-38). */
+ function parseOfferPrice(text) {
+ if (!text) return 0;
+ const cleaned = text.replace(/[^0-9.]/g, '');
+ const price = parseFloat(cleaned);
+ return isNaN(price) ? 0 : price;
+ }
+
+ /**
+ * Seller prices in dollars from an offer page, from the first selector
+ * that yields any. Not deduped: sellers often share a price.
+ */
+ function extractPricesFromDocument(doc) {
+ const prices = [];
+ const selectorStrategies = [
+ OFFER_SELECTORS.aodPriceBlock,
+ OFFER_SELECTORS.offerListPrice,
+ OFFER_SELECTORS.legacyOfferPrice,
+ OFFER_SELECTORS.generalPrice
+ ];
+
+ for (const selector of selectorStrategies) {
+ const elements = doc.querySelectorAll(selector);
+ if (elements.length > 0) {
+ elements.forEach(el => {
+ const price = parseOfferPrice(el.textContent);
+ if (price > 0) {
+ prices.push(price);
+ }
+ });
+ if (prices.length > 0) break;
+ }
+ }
+ return prices;
+ }
+
+ // ── Analyzer coercions ──────────────────────────────────────
+
+ /** Analyzer's price coercion: dollars, or 0. */
+ function toDollars(priceStr) {
+ if (!priceStr || priceStr === 'N/A') return 0;
+ return parseFloat(String(priceStr).replace(/[^0-9.]/g, '')) || 0;
+ }
+
+ function toRating(rating) {
+ if (!rating || rating === 'N/A') return 0;
+ return parseFloat(rating) || 0;
+ }
+
+ function toReviewCount(count) {
+ if (!count) return 0;
+ return parseInt(String(count).replace(/[^0-9]/g, '')) || 0;
+ }
+
+ return {
+ SEARCH_SELECTORS,
+ OFFER_SELECTORS,
+ priceToCents: (input) => PriceLib.priceToCents(input),
+ getText,
+ extractPrice,
+ parseRatingText,
+ extractRating,
+ parseReviewText,
+ extractReviewCount,
+ hasPrimeBadge,
+ scrapeProduct,
+ getTotalResults,
+ getNextPageUrl,
+ hasNextPage,
+ fillRates,
+ parseSearchPage,
+ buildOfferUrl,
+ buildAodUrl,
+ parseOfferPrice,
+ extractPricesFromDocument,
+ toDollars,
+ toRating,
+ toReviewCount
+ };
+})();
+
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = Parsers;
+}
diff --git a/scripts/modules/analyzer.js b/scripts/modules/analyzer.js
index f0b0bfa..d91cf75 100644
--- a/scripts/modules/analyzer.js
+++ b/scripts/modules/analyzer.js
@@ -15,6 +15,8 @@
* @module Analyzer
*/
+const AnalyzerParsers = typeof Parsers !== 'undefined' ? Parsers : require('../lib/parsers.js');
+
const Analyzer = {
/**
* Parse a price string into a numeric value.
@@ -28,8 +30,7 @@ const Analyzer = {
* Analyzer.parsePrice("N/A") // => 0
*/
parsePrice(priceStr) {
- if (!priceStr || priceStr === 'N/A') return 0;
- return parseFloat(String(priceStr).replace(/[^0-9.]/g, '')) || 0;
+ return AnalyzerParsers.toDollars(priceStr);
},
/**
@@ -39,8 +40,7 @@ const Analyzer = {
* @returns {number} Numeric rating, or 0 if unparseable
*/
parseRating(rating) {
- if (!rating || rating === 'N/A') return 0;
- return parseFloat(rating) || 0;
+ return AnalyzerParsers.toRating(rating);
},
/**
@@ -51,8 +51,7 @@ const Analyzer = {
* @returns {number} Integer review count, or 0 if unparseable
*/
parseReviewCount(count) {
- if (!count) return 0;
- return parseInt(String(count).replace(/[^0-9]/g, '')) || 0;
+ return AnalyzerParsers.toReviewCount(count);
},
/**
diff --git a/tests/e2e/global-setup.mjs b/tests/e2e/global-setup.mjs
new file mode 100644
index 0000000..a57a49e
--- /dev/null
+++ b/tests/e2e/global-setup.mjs
@@ -0,0 +1,7 @@
+// Builds the prod extension once for the whole e2e run.
+import { buildExtension } from '../../tools/build.mjs';
+import { EXT_DIR } from './lib/extension.mjs';
+
+export default async function globalSetup() {
+ await buildExtension({ env: 'prod', outDir: EXT_DIR });
+}
diff --git a/tests/e2e/lib/amazon.mjs b/tests/e2e/lib/amazon.mjs
new file mode 100644
index 0000000..d34e6fd
--- /dev/null
+++ b/tests/e2e/lib/amazon.mjs
@@ -0,0 +1,90 @@
+// Fake amazon.com for the e2e harness. Every request the browser makes is
+// answered here: search pages are generated or read from the corpus, product
+// pages come from the corpus, and anything else gets an empty 404. Nothing
+// leaves the machine.
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+export const CORPUS = path.resolve(HERE, '../../pages');
+
+export function corpusPage(rel) {
+ return fs.readFileSync(path.join(CORPUS, rel), 'utf8');
+}
+
+export function asinFor(keyword, page, i) {
+ const k = keyword.replace(/[^a-z]/gi, '').toUpperCase().padEnd(3, 'X').slice(0, 3);
+ return `B0${k}${String(page).padStart(2, '0')}${String(i).padStart(3, '0')}`;
+}
+
+/** One organic result card for item `i` of `page`. */
+export function card(keyword, page, i) {
+ const asin = asinFor(keyword, page, i);
+ return `
+
+
+
${keyword} item ${page}-${i}
+
$${10 + i}.${String(page).padStart(2, '0')}
+
4.${i} out of 5 stars
+
(${100 * (i + 1)})
+
`;
+}
+
+/**
+ * A search results page in current card markup.
+ * opts: per (cards per page), last (disable Next), extraCards (raw HTML).
+ */
+export function searchPage(keyword, page, { per = 4, last = false, extraCards = '' } = {}) {
+ let cards = '';
+ for (let i = 0; i < per; i++) cards += card(keyword, page, i);
+ const q = encodeURIComponent(keyword).replace(/%20/g, '+');
+ const next = last
+ ? ''
+ : ``;
+ return `Amazon.com : ${keyword}
+${cards}${extraCards}
+`;
+}
+
+export const CAPTCHA = () => corpusPage('2026-09/captcha-synthetic.html');
+export const PRODUCT = () => corpusPage('2026-09/product-dp.html');
+
+/**
+ * Routes every request of `context` to `plan`, which is called with
+ * {url, keyword, page} for /s search requests and returns
+ * {body, delayMs?} or null for a 404. /dp/ requests get the corpus product
+ * page. Returns the log of served search pages as [{keyword, page, tab}].
+ */
+export async function serveAmazon(context, plan) {
+ const served = [];
+ await context.route('**/*', async (route) => {
+ const req = route.request();
+ let u;
+ try { u = new URL(req.url()); } catch { return route.abort(); }
+ if (u.protocol === 'chrome-extension:' || u.protocol === 'data:') return route.continue();
+ if (!/(^|\.)amazon\.com$/.test(u.hostname) || req.resourceType() !== 'document') {
+ return route.fulfill({ status: 404, body: '' });
+ }
+ if (u.pathname === '/s') {
+ const keyword = u.searchParams.get('k') || '';
+ const page = Number(u.searchParams.get('page') || 1);
+ const out = plan({ url: u, keyword, page });
+ served.push({ keyword, page });
+ if (!out) return route.fulfill({ status: 404, body: '' });
+ if (out.delayMs) await new Promise((r) => setTimeout(r, out.delayMs));
+ return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: out.body }).catch(() => {});
+ }
+ if (u.pathname.includes('/dp/')) {
+ return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: PRODUCT() });
+ }
+ return route.fulfill({ status: 404, body: '' });
+ });
+ return served;
+}
+
+/** A plan serving `pages` generated pages per keyword, the last one disabled. */
+export function simplePlan(pages, opts = {}) {
+ return ({ keyword, page }) => (page > pages ? null : { body: searchPage(keyword, page, { ...opts, last: page >= pages }) });
+}
diff --git a/tests/e2e/lib/extension.mjs b/tests/e2e/lib/extension.mjs
new file mode 100644
index 0000000..5e91d39
--- /dev/null
+++ b/tests/e2e/lib/extension.mjs
@@ -0,0 +1,153 @@
+// Loads an unpacked build into Chromium and drives it the way a user does:
+// through the real popup page.
+
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { chromium } from '@playwright/test';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+export const BUILD_DIR = path.resolve(HERE, '../.build');
+export const EXT_DIR = path.join(BUILD_DIR, 'ext');
+
+export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+/**
+ * Launches Chromium with `extDir` loaded, in a fresh profile. Extensions need
+ * the full Chromium build, so this uses channel "chromium" (new headless).
+ * PROSCAN_CHROMIUM points at another binary when the pinned one is missing.
+ */
+export async function launch(extDir = EXT_DIR) {
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'proscan-e2e-'));
+ const opts = {
+ headless: process.env.HEADED ? false : true,
+ viewport: { width: 1200, height: 800 },
+ args: [`--disable-extensions-except=${extDir}`, `--load-extension=${extDir}`],
+ };
+ if (process.env.PROSCAN_CHROMIUM) opts.executablePath = process.env.PROSCAN_CHROMIUM;
+ else opts.channel = 'chromium';
+ const context = await chromium.launchPersistentContext(profile, opts);
+ const sw = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker', { timeout: 15000 });
+ const extId = new URL(sw.url()).host;
+ const close = async () => {
+ await context.close().catch(() => {});
+ fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
+ };
+ return { context, sw, extId, close };
+}
+
+/** An extension page used to read and write chrome.storage. */
+export async function extPage(ext) {
+ const page = await ext.context.newPage();
+ await page.goto(`chrome-extension://${ext.extId}/popup/popup.html`);
+ return page;
+}
+
+export async function getState(page) {
+ return page.evaluate(() => chrome.storage.local.get(null));
+}
+
+/**
+ * Opens the real popup in a tab, points its active-tab query at `tab`, and
+ * clicks the main button. Returns the popup page.
+ */
+export async function clickStart(ext, tab) {
+ const popup = await ext.context.newPage();
+ await popup.goto(`chrome-extension://${ext.extId}/popup/popup.html`);
+ await aimPopupAt(popup, tab);
+ await popup.click('#actionButton');
+ return popup;
+}
+
+export async function aimPopupAt(popup, tab) {
+ const url = tab.url();
+ await popup.evaluate((target) => {
+ const orig = chrome.tabs.query.bind(chrome.tabs);
+ chrome.tabs.query = (q, cb) => orig({}, (tabs) => {
+ const hit = tabs.filter((t) => t.url === target);
+ return cb ? cb(hit) : hit;
+ });
+ }, url);
+}
+
+/** Polls until fn(state) is truthy or the timeout passes; returns the last state. */
+export async function waitForState(page, fn, { timeout = 20000, interval = 250 } = {}) {
+ const end = Date.now() + timeout;
+ let state = await getState(page);
+ while (!fn(state) && Date.now() < end) {
+ await sleep(interval);
+ state = await getState(page);
+ }
+ return state;
+}
+
+/**
+ * How the run ended, as the user is told. Today the popup says "complete"
+ * whenever the flag is off. When the rebuild stores a real end reason,
+ * read it here and nowhere else.
+ */
+export function endReason(state) {
+ if (state.scrapeEndReason) return state.scrapeEndReason;
+ if (state.run && state.run.status) return state.run.status;
+ return state.isScrapingActive ? 'running' : 'complete';
+}
+
+/** The source a run was recorded under, or null. */
+export function runMetaFor(state, runId) {
+ if (state.scrapeRuns && state.scrapeRuns[runId]) return state.scrapeRuns[runId];
+ if (state.scrapeRunId === runId) return state.scrapeRunMeta || null;
+ return null;
+}
+
+/** Service worker target ids, via CDP on `page`. */
+export async function workerTargets(cdp) {
+ const { targetInfos } = await cdp.send('Target.getTargets');
+ return targetInfos.filter((t) => t.type === 'service_worker').map((t) => t.targetId);
+}
+
+/**
+ * Stops the extension's service worker the way Chrome does when idle.
+ * Resolves to true once the old worker is gone (a new one may already be up).
+ */
+export async function killServiceWorker(ext, page) {
+ const cdp = await ext.context.newCDPSession(page);
+ const old = await workerTargets(cdp);
+ for (const targetId of old) await cdp.send('Target.closeTarget', { targetId });
+ const end = Date.now() + 5000;
+ while (Date.now() < end) {
+ const now = await workerTargets(cdp);
+ if (old.length > 0 && !old.some((id) => now.includes(id))) return true;
+ await sleep(100);
+ }
+ return false;
+}
+
+/** Writes the tree of git revision `rev` into `dir`, without touching the index. */
+export function checkoutRevision(rev, dir) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ fs.mkdirSync(dir, { recursive: true });
+ const index = path.join(os.tmpdir(), `proscan-index-${process.pid}-${Date.now()}`);
+ const env = { ...process.env, GIT_INDEX_FILE: index };
+ const root = path.resolve(HERE, '../../..');
+ execFileSync('git', ['read-tree', rev], { cwd: root, env });
+ execFileSync('git', ['checkout-index', '-a', `--prefix=${dir.split(path.sep).join('/')}/`], { cwd: root, env });
+ fs.rmSync(index, { force: true });
+}
+
+export function copyDir(src, dst) {
+ fs.rmSync(dst, { recursive: true, force: true });
+ fs.cpSync(src, dst, { recursive: true });
+}
+
+/**
+ * Turns developer mode on. A fresh profile has it off, and then reloading an
+ * unpacked extension from new files disables it (unsupportedDeveloperExtension).
+ */
+export async function enableDeveloperMode(ext) {
+ const page = await ext.context.newPage();
+ await page.goto('chrome://extensions');
+ await page.evaluate(() => new Promise((r) => chrome.developerPrivate.updateProfileConfiguration({ inDeveloperMode: true }, r)));
+ await page.close();
+}
diff --git a/tests/e2e/scrape.spec.mjs b/tests/e2e/scrape.spec.mjs
new file mode 100644
index 0000000..75f2b5a
--- /dev/null
+++ b/tests/e2e/scrape.spec.mjs
@@ -0,0 +1,262 @@
+// Pagination in real Chromium (audit report 6.2, layer 3).
+//
+// Each scenario first checks the setup worked, then calls bug() right before
+// the checks today's code fails, so a broken harness still fails loudly while
+// a known bug counts as an expected failure. When a fix lands, Playwright
+// reports the scenario as "expected to fail, but passed": delete the bug()
+// line then. PROSCAN_SHOW_KNOWN=1 shows what the known failures fail on.
+import { createRequire } from 'node:module';
+import { test as base, expect } from '@playwright/test';
+import {
+ launch, extPage, getState, clickStart, aimPopupAt, waitForState, endReason,
+ runMetaFor, killServiceWorker, sleep,
+} from './lib/extension.mjs';
+import { serveAmazon, simplePlan, searchPage, card, asinFor, corpusPage, CAPTCHA } from './lib/amazon.mjs';
+
+const require = createRequire(import.meta.url);
+
+const test = base.extend({
+ // eslint-disable-next-line no-empty-pattern
+ ext: async ({}, use) => {
+ const ext = await launch();
+ await use(ext);
+ await ext.close();
+ },
+});
+
+// PROSCAN_SHOW_KNOWN=1 runs known failures as normal tests to see their errors.
+function bug(fid, what) {
+ test.fail(!process.env.PROSCAN_SHOW_KNOWN, `${fid}: ${what}`);
+}
+
+const searchUrl = (keyword, page) =>
+ `https://www.amazon.com/s?k=${encodeURIComponent(keyword).replace(/%20/g, '+')}${page ? `&page=${page}` : ''}`;
+
+async function openSearch(ext, keyword) {
+ const tab = await ext.context.newPage();
+ await tab.goto(searchUrl(keyword));
+ await sleep(800);
+ return tab;
+}
+
+const pagesOf = (served, keyword) => served.filter((p) => p.keyword === keyword).map((p) => p.page);
+const allAsins = (keyword, pages, per = 4) =>
+ pages.flatMap((p) => Array.from({ length: per }, (_, i) => asinFor(keyword, p, i)));
+
+test('a clean 3 page run scrapes every page once and completes', async ({ ext }) => {
+ const served = await serveAmazon(ext.context, simplePlan(3));
+ const tab = await openSearch(ext, 'garden hose');
+ const store = await extPage(ext);
+ await clickStart(ext, tab);
+ const s = await waitForState(store, (x) => x.scrapeRunPages?.length >= 3 && !x.isScrapingActive, { timeout: 30000 });
+
+ expect(pagesOf(served, 'garden hose')).toEqual([1, 2, 3]);
+ expect(s.results.map((r) => r.asin)).toEqual(allAsins('garden hose', [1, 2, 3]));
+ expect(s.results[0]).toMatchObject({ priceCents: 1001, rating: 4, reviewCount: 100 });
+ expect(s.scrapeRunPages.map((p) => p.pageIndex)).toEqual([1, 2, 3]);
+ expect(endReason(s)).toBe('complete');
+});
+
+test('real markup: Chromium scrapes the saved yoga mat page like the parser does', async ({ ext }) => {
+ const yoga = corpusPage('2026-09/search-yoga-mat.html');
+ const last = corpusPage('2026-02/search-lastpage.html');
+ const served = await serveAmazon(ext.context, ({ page }) => ({ body: page === 1 ? yoga : last }));
+ const tab = await openSearch(ext, 'yoga mat');
+ const store = await extPage(ext);
+ await clickStart(ext, tab);
+ const s = await waitForState(store, (x) => x.scrapeRunPages?.length >= 2 && !x.isScrapingActive, { timeout: 30000 });
+
+ expect(pagesOf(served, 'yoga mat')).toEqual([1, 2]);
+ const page1 = s.results.filter((r) => r.pageIndex === 1);
+
+ const Parsers = require('../../scripts/lib/parsers.js');
+ const { parseDoc } = require('../setup/corpus.js');
+ const golden = Parsers.parseSearchPage(parseDoc(yoga, searchUrl('yoga mat')), searchUrl('yoga mat')).products;
+ expect(page1.map((r) => r.asin)).toEqual(golden.map((r) => r.asin));
+ expect(page1.map((r) => r.priceCents)).toEqual(golden.map((r) => r.priceCents));
+ expect(page1.map((r) => r.url)).toEqual(golden.map((r) => r.url));
+ const fill = Parsers.fillRates(page1);
+ expect(fill.title).toBeGreaterThanOrEqual(0.9);
+ expect(fill.price).toBeGreaterThanOrEqual(0.9);
+
+ bug('F-27', 'repeats and carousel cards are stored as products');
+ expect(new Set(page1.map((r) => r.asin)).size).toBe(page1.length);
+});
+
+test('a captcha at page 2 ends the run as blocked', async ({ ext }) => {
+ const served = await serveAmazon(ext.context, ({ keyword, page }) =>
+ page === 2 ? { body: CAPTCHA() } : { body: searchPage(keyword, page, { last: page >= 4 }) });
+ const tab = await openSearch(ext, 'usb cable');
+ const store = await extPage(ext);
+ await clickStart(ext, tab);
+ const s = await waitForState(store, (x) => !x.isScrapingActive && x.scrapeRunPages?.length >= 1, { timeout: 30000 });
+ await sleep(2500);
+
+ expect(pagesOf(served, 'usb cable')).toEqual([1, 2]);
+ expect(s.results.map((r) => r.asin)).toEqual(allAsins('usb cable', [1]));
+
+ bug('F-12', 'a captcha page ends the run as complete');
+ expect(endReason(s)).toBe('blocked');
+});
+
+test('duplicates across pages are stored once', async ({ ext }) => {
+ const sponsored = `
+
+
+
+
Sponsored widget
+
$5.00
+
`;
+ const repeat = (page) => (page > 1 ? card('dupes', 1, 0) : '');
+ const served = await serveAmazon(ext.context, ({ keyword, page }) =>
+ ({ body: searchPage(keyword, page, { last: page >= 3, extraCards: sponsored + repeat(page) }) }));
+ const tab = await openSearch(ext, 'dupes');
+ const store = await extPage(ext);
+ await clickStart(ext, tab);
+ const s = await waitForState(store, (x) => x.scrapeRunPages?.length >= 3 && !x.isScrapingActive, { timeout: 30000 });
+
+ expect(pagesOf(served, 'dupes')).toEqual([1, 2, 3]);
+ const asins = s.results.map((r) => r.asin);
+ expect(asins).toEqual(expect.arrayContaining([...allAsins('dupes', [1, 2, 3]), 'B0SPONSOR1']));
+
+ bug('F-27', 'no ASIN dedupe within or across pages');
+ expect(asins.length).toBe(new Set(asins).size);
+});
+
+test('Stop halts the run before the next page loads', async ({ ext }) => {
+ const served = await serveAmazon(ext.context, simplePlan(4));
+ const tab = await openSearch(ext, 'stop me');
+ const store = await extPage(ext);
+ const popup = await clickStart(ext, tab);
+ await waitForState(store, (x) => x.scrapeRunPages?.length >= 1, { timeout: 15000, interval: 100 });
+ await popup.click('#actionButton');
+ await sleep(4500);
+ const s = await getState(store);
+
+ expect(s.isScrapingActive).toBe(false);
+ expect(s.results.map((r) => r.asin)).toEqual(allAsins('stop me', [1]));
+
+ bug('F-13', 'the pending navigation still fires and the run is never finalized as stopped');
+ expect(pagesOf(served, 'stop me')).toEqual([1]);
+ expect(endReason(s)).toBe('stopped');
+});
+
+test('a second search tab opened mid-run does not join the run', async ({ ext }) => {
+ const served = await serveAmazon(ext.context, simplePlan(3));
+ const tab = await openSearch(ext, 'alpha');
+ const store = await extPage(ext);
+ await clickStart(ext, tab);
+ await waitForState(store, (x) => x.scrapeRunPages?.length >= 1, { timeout: 15000, interval: 100 });
+ const other = await ext.context.newPage();
+ await other.goto(searchUrl('beta'));
+ const s = await waitForState(store, (x) => !x.isScrapingActive, { timeout: 30000 });
+ await sleep(2500);
+
+ expect(pagesOf(served, 'beta')[0]).toBe(1);
+
+ bug('F-11', 'every Amazon tab reads the one global flag and scrapes into the run');
+ expect(s.results.filter((r) => r.name.startsWith('beta'))).toEqual([]);
+ expect(pagesOf(served, 'alpha')).toEqual([1, 2, 3]);
+ expect(s.results.map((r) => r.asin)).toEqual(allAsins('alpha', [1, 2, 3]));
+});
+
+test('a product page opened mid-run does not end the run', async ({ ext }) => {
+ const served = await serveAmazon(ext.context, simplePlan(3));
+ const tab = await openSearch(ext, 'gamma');
+ const store = await extPage(ext);
+ await clickStart(ext, tab);
+ await waitForState(store, (x) => x.scrapeRunPages?.length >= 1, { timeout: 15000, interval: 100 });
+ const product = await ext.context.newPage();
+ await product.goto('https://www.amazon.com/dp/B09B8V1LZ3');
+ await sleep(6000);
+ const s = await waitForState(store, (x) => !x.isScrapingActive, { timeout: 30000 });
+
+ expect(await product.title()).not.toBe('');
+
+ bug('F-11', 'a tab with zero listings ends the run for everyone');
+ expect(pagesOf(served, 'gamma')).toEqual([1, 2, 3]);
+ expect(s.results.map((r) => r.asin)).toEqual(allAsins('gamma', [1, 2, 3]));
+});
+
+test('a slow page is scraped once and the run still completes', async ({ ext }) => {
+ const served = await serveAmazon(ext.context, ({ keyword, page }) =>
+ ({ body: searchPage(keyword, page, { last: page >= 3 }), delayMs: page === 2 ? 4000 : 0 }));
+ const tab = await openSearch(ext, 'slow');
+ const store = await extPage(ext);
+ await clickStart(ext, tab);
+ const s = await waitForState(store, (x) => x.scrapeRunPages?.length >= 3 && !x.isScrapingActive, { timeout: 40000 });
+ await sleep(1000);
+
+ expect(pagesOf(served, 'slow')).toEqual([1, 2, 3]);
+ expect(s.scrapeRunPages.map((p) => p.pageIndex)).toEqual([1, 2, 3]);
+ expect(s.results.map((r) => r.asin)).toEqual(allAsins('slow', [1, 2, 3]));
+ expect(endReason(s)).toBe('complete');
+});
+
+test('a full storage quota fails the run loudly', async ({ ext }) => {
+ const served = await serveAmazon(ext.context, simplePlan(2));
+ const store = await extPage(ext);
+ // Fill storage with queued products until about 2 KB is left.
+ const left = await store.evaluate(async () => {
+ const one = { name: 'x'.repeat(100), asin: 'B0FILL0000', price: '$1.00', priceCents: 100, url: 'https://www.amazon.com/dp/B0FILL0000', scrapedAt: new Date().toISOString(), runId: 'old' };
+ const quota = chrome.storage.local.QUOTA_BYTES;
+ const per = JSON.stringify(one).length + 1;
+ let n = Math.floor((quota - 4096) / per);
+ for (;;) {
+ try { await chrome.storage.local.set({ syncQueue: Array.from({ length: n }, () => one) }); break; } catch { n -= 50; }
+ }
+ return quota - await chrome.storage.local.getBytesInUse(null);
+ });
+ expect(left).toBeLessThan(4096);
+
+ const tab = await openSearch(ext, 'full disk');
+ await clickStart(ext, tab);
+ const s = await waitForState(store, (x) => !x.isScrapingActive, { timeout: 30000 });
+ await sleep(2500);
+
+ expect(pagesOf(served, 'full disk').length).toBeGreaterThanOrEqual(1);
+
+ bug('F-26', 'storage writes ignore lastError, so the run reports complete with nothing saved');
+ expect(endReason(s)).toBe('storage_full');
+});
+
+test('two runs without an export keep their own attribution', async ({ ext }) => {
+ await serveAmazon(ext.context, simplePlan(2));
+ const store = await extPage(ext);
+
+ const tab1 = await openSearch(ext, 'garden hose');
+ await clickStart(ext, tab1);
+ await waitForState(store, (x) => x.scrapeRunPages?.length >= 2 && !x.isScrapingActive, { timeout: 30000 });
+
+ const tab2 = await openSearch(ext, 'yoga mat');
+ const popup = await ext.context.newPage();
+ await popup.goto(`chrome-extension://${ext.extId}/popup/popup.html`);
+ await aimPopupAt(popup, tab2);
+ await popup.click('#actionButton');
+ await sleep(300);
+ const s = await waitForState(store, (x) => x.scrapeRunPages?.length >= 2 && !x.isScrapingActive &&
+ (x.syncQueue || []).length >= 16, { timeout: 30000 });
+
+ const runIds = [...new Set(s.syncQueue.map((p) => p.runId))];
+ expect(runIds).toHaveLength(2);
+
+ bug('F-20', 'only the newest run keeps its source; older queued items lose theirs');
+ for (const item of s.syncQueue) {
+ const meta = runMetaFor(s, item.runId);
+ expect(meta && meta.keyword).toBe(item.name.startsWith('garden hose') ? 'garden hose' : 'yoga mat');
+ }
+});
+
+test('a service worker stopped between pages does not break the run', async ({ ext }) => {
+ const served = await serveAmazon(ext.context, simplePlan(3));
+ const tab = await openSearch(ext, 'sleepy');
+ const store = await extPage(ext);
+ await clickStart(ext, tab);
+ await waitForState(store, (x) => x.scrapeRunPages?.length >= 1, { timeout: 15000, interval: 100 });
+ expect(await killServiceWorker(ext, tab)).toBe(true);
+
+ const s = await waitForState(store, (x) => x.scrapeRunPages?.length >= 3 && !x.isScrapingActive, { timeout: 30000 });
+ expect(pagesOf(served, 'sleepy')).toEqual([1, 2, 3]);
+ expect(s.results.map((r) => r.asin)).toEqual(allAsins('sleepy', [1, 2, 3]));
+ expect(endReason(s)).toBe('complete');
+});
diff --git a/tests/e2e/upgrade.spec.mjs b/tests/e2e/upgrade.spec.mjs
new file mode 100644
index 0000000..0a4d9af
--- /dev/null
+++ b/tests/e2e/upgrade.spec.mjs
@@ -0,0 +1,93 @@
+// Upgrade in place from the live store build (v2.0, commit 7c2ba1c): load it
+// unpacked, scrape, then swap the folder to the current build and reload, as
+// a Chrome Web Store auto-update does. Based on the audit's upgrade harness.
+import path from 'node:path';
+import { test, expect } from '@playwright/test';
+import {
+ launch, getState, waitForState, sleep, checkoutRevision, copyDir, enableDeveloperMode, BUILD_DIR, EXT_DIR,
+} from './lib/extension.mjs';
+import { serveAmazon, simplePlan, asinFor } from './lib/amazon.mjs';
+
+const LIVE_REV = '7c2ba1c';
+const V20 = path.join(BUILD_DIR, 'v2.0');
+const SLOT = path.join(BUILD_DIR, 'upgrade-slot');
+
+function bug(fid, what) {
+ test.fail(!process.env.PROSCAN_SHOW_KNOWN, `${fid}: ${what}`);
+}
+
+test.beforeAll(() => {
+ checkoutRevision(LIVE_REV, V20);
+});
+
+/**
+ * Starts a v2.0 scrape of `keyword` and waits for `pages` pages, then
+ * updates the extension. Returns a page of the updated extension.
+ */
+async function scrapeThenUpdate(ext, keyword, pages) {
+ const v20 = await ext.context.newPage();
+ await v20.goto(`chrome-extension://${ext.extId}/popup/popup.html`);
+ expect(await v20.evaluate(() => chrome.runtime.getManifest().version)).toBe('2.0');
+
+ const tab = await ext.context.newPage();
+ await tab.goto(`https://www.amazon.com/s?k=${keyword}`);
+ await sleep(1000);
+ // What the v2.0 popup's Start does.
+ const started = await v20.evaluate(async (k) => {
+ await chrome.storage.local.set({ results: [], currentItemCount: 0, isScrapingActive: true });
+ const [t] = await chrome.tabs.query({ url: `*://www.amazon.com/s?k=${k}*` });
+ return new Promise((r) => chrome.tabs.sendMessage(t.id, { type: 'START_SCRAPING' }, (resp) => r(resp)));
+ }, keyword);
+ expect(started).toEqual({ status: 'started' });
+ const before = await waitForState(v20, (s) => (s.currentItemCount || 0) >= pages * 4, { timeout: 30000 });
+
+ // The auto-update: new files in the same folder, then a reload.
+ await enableDeveloperMode(ext);
+ copyDir(EXT_DIR, SLOT);
+ await ext.sw.evaluate(() => chrome.runtime.reload()).catch(() => {});
+ await sleep(4000);
+
+ const after = await ext.context.newPage();
+ await after.goto(`chrome-extension://${ext.extId}/popup/popup.html`);
+ expect(await after.evaluate(() => chrome.runtime.getManifest().version)).toBe('2.1.0');
+ return { before, after, tab };
+}
+
+test('a v2.0 user keeps their last scrape through the update', async () => {
+ copyDir(V20, SLOT);
+ const ext = await launch(SLOT);
+ try {
+ await serveAmazon(ext.context, simplePlan(2));
+ const { before, after } = await scrapeThenUpdate(ext, 'mats', 2);
+ const s = await waitForState(after, () => true);
+ const asins = [1, 2].flatMap((p) => [0, 1, 2, 3].map((i) => asinFor('mats', p, i)));
+
+ expect(before.isScrapingActive).toBe(false);
+ expect(s.results.map((r) => r.asin)).toEqual(asins);
+
+ bug('F-100', 'onInstalled(update) does nothing: no schemaVersion, no lastValues seeded from results');
+ expect(s.schemaVersion).toBe(3);
+ expect(Object.keys(s.lastValues || {}).sort()).toEqual([...asins].sort());
+ } finally {
+ await ext.close();
+ }
+});
+
+test('a scrape running during the update is stopped, not resumed without a run', async () => {
+ copyDir(V20, SLOT);
+ const ext = await launch(SLOT);
+ try {
+ await serveAmazon(ext.context, simplePlan(4));
+ const { after } = await scrapeThenUpdate(ext, 'cables', 1);
+ await sleep(9000);
+ const s = await getState(after);
+
+ expect(s.results.length).toBeGreaterThanOrEqual(4);
+
+ bug('F-101', 'the new content script resumes the old run with no runId');
+ expect((s.syncQueue || []).filter((p) => !p.runId).map((p) => p.asin)).toEqual([]);
+ expect((s.scrapeRunPages || []).filter((p) => !p.runId)).toEqual([]);
+ } finally {
+ await ext.close();
+ }
+});
diff --git a/tests/golden/__snapshots__/characterize.test.js.snap b/tests/golden/__snapshots__/characterize.test.js.snap
new file mode 100644
index 0000000..52cca40
--- /dev/null
+++ b/tests/golden/__snapshots__/characterize.test.js.snap
@@ -0,0 +1,2006 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`current offer parsing on the corpus 2026-02/aod-synthetic 1`] = `
+[
+ 24.99,
+ 31.5,
+ 27,
+ 22.99,
+ 35,
+]
+`;
+
+exports[`current offer parsing on the corpus 2026-02/offer-classic-synthetic 1`] = `
+[
+ 22.5,
+ 28,
+ 19.99,
+]
+`;
+
+exports[`current offer parsing on the corpus 2026-02/search-lastpage 1`] = `[]`;
+
+exports[`current offer parsing on the corpus 2026-02/search-results 1`] = `[]`;
+
+exports[`current offer parsing on the corpus 2026-09/aod-pinned-only 1`] = `[]`;
+
+exports[`current offer parsing on the corpus 2026-09/captcha-synthetic 1`] = `[]`;
+
+exports[`current offer parsing on the corpus 2026-09/interstitial-akamai 1`] = `[]`;
+
+exports[`current offer parsing on the corpus 2026-09/product-dp 1`] = `[]`;
+
+exports[`current offer parsing on the corpus 2026-09/search-no-pagination-synthetic 1`] = `[]`;
+
+exports[`current offer parsing on the corpus 2026-09/search-title-recipe-synthetic 1`] = `[]`;
+
+exports[`current offer parsing on the corpus 2026-09/search-yoga-mat 1`] = `[]`;
+
+exports[`current offer parsing on the corpus urls 1`] = `
+[
+ "https://www.amazon.com/gp/aod/ajax?asin=B0TEST0001&condition=new&m=&qid=&smid=&sourcecustomerorglistid=&sourcecustomerorglistitemid=&sr=&pc=dp",
+ "https://www.amazon.com/gp/offer-listing/B0TEST0001/ref=dp_olp_all_mbc?ie=UTF8&condition=new",
+]
+`;
+
+exports[`current price, rating and review parsers price strings 1`] = `
+[
+ {
+ "analyzer": 19.99,
+ "cents": 1999,
+ "input": "$19.99",
+ "offer": 19.99,
+ },
+ {
+ "analyzer": 1299,
+ "cents": 129900,
+ "input": "$1,299.00",
+ "offer": 1299,
+ },
+ {
+ "analyzer": 12.9924,
+ "cents": 1299,
+ "input": "$12.99 - $24.99",
+ "offer": 12.9924,
+ },
+ {
+ "analyzer": 19.99,
+ "cents": 1999,
+ "input": "$19.99 ($0.33/Ounce)",
+ "offer": 19.99,
+ },
+ {
+ "analyzer": 0,
+ "cents": null,
+ "input": "See price in cart",
+ "offer": 0,
+ },
+ {
+ "analyzer": 1999,
+ "cents": 199900,
+ "input": "€19,99",
+ "offer": 1999,
+ },
+ {
+ "analyzer": 24.99,
+ "cents": 2499,
+ "input": "CDN$ 24.99",
+ "offer": 24.99,
+ },
+ {
+ "analyzer": 0,
+ "cents": 0,
+ "input": "$0.00",
+ "offer": 0,
+ },
+ {
+ "analyzer": 0,
+ "cents": null,
+ "input": "FREE",
+ "offer": 0,
+ },
+ {
+ "analyzer": 24.995,
+ "cents": 2500,
+ "input": "$24.99 + $5.99 shipping",
+ "offer": 24.995,
+ },
+ {
+ "analyzer": 1.005,
+ "cents": 100,
+ "input": "$1.005",
+ "offer": 1.005,
+ },
+ {
+ "analyzer": 30.002,
+ "cents": 3000,
+ "input": "Was: $30.00 Now: $20.00",
+ "offer": 30.002,
+ },
+ {
+ "analyzer": 0,
+ "cents": null,
+ "input": "N/A",
+ "offer": 0,
+ },
+ {
+ "analyzer": 0,
+ "cents": null,
+ "input": "",
+ "offer": 0,
+ },
+ {
+ "analyzer": 0,
+ "cents": null,
+ "input": null,
+ "offer": 0,
+ },
+ {
+ "analyzer": 19.99,
+ "cents": 1999,
+ "input": 19.99,
+ "offer": "throws TypeError",
+ },
+]
+`;
+
+exports[`current price, rating and review parsers rating and review strings 1`] = `
+[
+ {
+ "analyzerRating": 4.5,
+ "analyzerReviews": 455,
+ "input": "4.5 out of 5 stars",
+ "ratingText": 4.5,
+ "reviewText": 5,
+ },
+ {
+ "analyzerRating": 4.7,
+ "analyzerReviews": 47,
+ "input": "4.7",
+ "ratingText": 4.7,
+ "reviewText": 5,
+ },
+ {
+ "analyzerRating": 0,
+ "analyzerReviews": 0,
+ "input": "",
+ "ratingText": 0,
+ "reviewText": 0,
+ },
+ {
+ "analyzerRating": 0,
+ "analyzerReviews": 0,
+ "input": null,
+ "ratingText": 0,
+ "reviewText": 0,
+ },
+ {
+ "analyzerRating": 0,
+ "analyzerReviews": 1083,
+ "input": "(108.3K)",
+ "ratingText": 108.3,
+ "reviewText": 108300,
+ },
+ {
+ "analyzerRating": 0,
+ "analyzerReviews": 12,
+ "input": "(1.2M)",
+ "ratingText": 1.2,
+ "reviewText": 1200000,
+ },
+ {
+ "analyzerRating": 0,
+ "analyzerReviews": 64,
+ "input": "(64)",
+ "ratingText": 64,
+ "reviewText": 64,
+ },
+ {
+ "analyzerRating": 12,
+ "analyzerReviews": 12847,
+ "input": "12,847",
+ "ratingText": 12,
+ "reviewText": 12847,
+ },
+ {
+ "analyzerRating": 0,
+ "analyzerReviews": 0,
+ "input": "N/A",
+ "ratingText": 0,
+ "reviewText": 0,
+ },
+ {
+ "analyzerRating": 0,
+ "analyzerReviews": 0,
+ "input": "calificaciones",
+ "ratingText": 0,
+ "reviewText": 0,
+ },
+]
+`;
+
+exports[`current scraper behavior on the corpus 2026-02/aod-synthetic 1`] = `
+{
+ "currentItemCount": 0,
+ "helpers": {
+ "hasNext": true,
+ "nextUrl": "https://www.amazon.com/gp/aod/ajax?asin=B0TEST001&page=2&ref=sr_pg_2",
+ "total": 0,
+ },
+ "isScrapingActive": false,
+ "nav": [],
+ "results": [],
+ "runPages": [],
+ "sent": [
+ "SCRAPING_COMPLETE",
+ ],
+}
+`;
+
+exports[`current scraper behavior on the corpus 2026-02/offer-classic-synthetic 1`] = `
+{
+ "currentItemCount": 0,
+ "helpers": {
+ "hasNext": true,
+ "nextUrl": "https://www.amazon.com/gp/offer-listing/B0TEST001?page=2&ref=sr_pg_2",
+ "total": 0,
+ },
+ "isScrapingActive": false,
+ "nav": [],
+ "results": [],
+ "runPages": [],
+ "sent": [
+ "SCRAPING_COMPLETE",
+ ],
+}
+`;
+
+exports[`current scraper behavior on the corpus 2026-02/search-lastpage 1`] = `
+{
+ "currentItemCount": 1,
+ "helpers": {
+ "hasNext": false,
+ "nextUrl": "https://www.amazon.com/s?k=test+widgets&page=6&ref=sr_pg_6",
+ "total": 0,
+ },
+ "isScrapingActive": false,
+ "nav": [],
+ "results": [
+ {
+ "asin": "B0LAST001",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Last Page Product",
+ "pageIndex": 1,
+ "price": "$19.99",
+ "priceCents": 1999,
+ "rating": 4,
+ "reviewCount": 500,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/dp/B0LAST001/ref=sr_1_1",
+ },
+ ],
+ "runPages": [
+ {
+ "count": 1,
+ "pageIndex": 1,
+ "url": "https://www.amazon.com/s?k=test+widgets&page=5",
+ },
+ ],
+ "sent": [
+ "UPDATE_PROGRESS",
+ "ENQUEUE_SYNC",
+ "SCRAPING_COMPLETE",
+ ],
+}
+`;
+
+exports[`current scraper behavior on the corpus 2026-02/search-results 1`] = `
+{
+ "currentItemCount": 4,
+ "helpers": {
+ "hasNext": true,
+ "nextUrl": "https://www.amazon.com/s?k=test+widgets&page=2&ref=sr_pg_2",
+ "total": 523,
+ },
+ "isScrapingActive": true,
+ "nav": [
+ "[ProScan] Navigating to next page: https://www.amazon.com/s?k=test+widgets&page=2&ref=sr_pg_2",
+ ],
+ "results": [
+ {
+ "asin": "B0TEST001",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": true,
+ "name": "Test Widget Pro 2000",
+ "pageIndex": 1,
+ "price": "$29.99",
+ "priceCents": 2999,
+ "rating": 4.5,
+ "reviewCount": 12847,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/dp/B0TEST001/ref=sr_1_1",
+ },
+ {
+ "asin": "B0TEST002",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Budget Gadget Basic",
+ "pageIndex": 1,
+ "price": "$9.99",
+ "priceCents": 999,
+ "rating": 3.8,
+ "reviewCount": 247,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/dp/B0TEST002/ref=sr_1_2",
+ },
+ {
+ "asin": "B0TEST003",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Mystery Item No Data",
+ "pageIndex": 1,
+ "price": "N/A",
+ "priceCents": null,
+ "rating": 0,
+ "reviewCount": 0,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/dp/B0TEST003/ref=sr_1_3",
+ },
+ {
+ "asin": "B0TEST004",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": true,
+ "name": "Legacy Star Product",
+ "pageIndex": 1,
+ "price": "$49.99",
+ "priceCents": 4999,
+ "rating": 4.2,
+ "reviewCount": 1500,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/dp/B0TEST004/ref=sr_1_4",
+ },
+ ],
+ "runPages": [
+ {
+ "count": 4,
+ "pageIndex": 1,
+ "url": "https://www.amazon.com/s?k=test+widgets",
+ },
+ ],
+ "sent": [
+ "UPDATE_PROGRESS",
+ "ENQUEUE_SYNC",
+ ],
+}
+`;
+
+exports[`current scraper behavior on the corpus 2026-09/aod-pinned-only 1`] = `
+{
+ "currentItemCount": 0,
+ "helpers": {
+ "hasNext": true,
+ "nextUrl": "https://www.amazon.com/gp/product/ajax/aodAjaxMain/?asin=B09B8V1LZ3&page=2&ref=sr_pg_2",
+ "total": 0,
+ },
+ "isScrapingActive": false,
+ "nav": [],
+ "results": [],
+ "runPages": [],
+ "sent": [
+ "SCRAPING_COMPLETE",
+ ],
+}
+`;
+
+exports[`current scraper behavior on the corpus 2026-09/captcha-synthetic 1`] = `
+{
+ "currentItemCount": 0,
+ "helpers": {
+ "hasNext": true,
+ "nextUrl": "https://www.amazon.com/s?k=yoga+mat&page=4&ref=sr_pg_4",
+ "total": 0,
+ },
+ "isScrapingActive": false,
+ "nav": [],
+ "results": [],
+ "runPages": [],
+ "sent": [
+ "SCRAPING_COMPLETE",
+ ],
+}
+`;
+
+exports[`current scraper behavior on the corpus 2026-09/interstitial-akamai 1`] = `
+{
+ "currentItemCount": 0,
+ "helpers": {
+ "hasNext": true,
+ "nextUrl": "https://www.amazon.com/s?k=usb+c+cable&page=3&ref=sr_pg_3",
+ "total": 0,
+ },
+ "isScrapingActive": false,
+ "nav": [],
+ "results": [],
+ "runPages": [],
+ "sent": [
+ "SCRAPING_COMPLETE",
+ ],
+}
+`;
+
+exports[`current scraper behavior on the corpus 2026-09/product-dp 1`] = `
+{
+ "currentItemCount": 0,
+ "helpers": {
+ "hasNext": true,
+ "nextUrl": "https://www.amazon.com/dp/B09B8V1LZ3?page=2&ref=sr_pg_2",
+ "total": 0,
+ },
+ "isScrapingActive": false,
+ "nav": [],
+ "results": [],
+ "runPages": [],
+ "sent": [
+ "SCRAPING_COMPLETE",
+ ],
+}
+`;
+
+exports[`current scraper behavior on the corpus 2026-09/search-no-pagination-synthetic 1`] = `
+{
+ "currentItemCount": 2,
+ "helpers": {
+ "hasNext": true,
+ "nextUrl": "https://www.amazon.com/s?k=rare+widget&page=2&ref=sr_pg_2",
+ "total": 0,
+ },
+ "isScrapingActive": true,
+ "nav": [
+ "[ProScan] Navigating to next page: https://www.amazon.com/s?k=rare+widget&page=2&ref=sr_pg_2",
+ ],
+ "results": [
+ {
+ "asin": "B0ONLYONE1",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Rare Widget, single result",
+ "pageIndex": 1,
+ "price": "$41.00",
+ "priceCents": 4100,
+ "rating": 0,
+ "reviewCount": 0,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Rare-Widget/dp/B0ONLYONE1/ref=sr_1_1",
+ },
+ {
+ "asin": "B0ONLYONE2",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Rare Widget, second result",
+ "pageIndex": 1,
+ "price": "$1,249.00",
+ "priceCents": 124900,
+ "rating": 0,
+ "reviewCount": 0,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Rare-Widget-2/dp/B0ONLYONE2/ref=sr_1_2",
+ },
+ ],
+ "runPages": [
+ {
+ "count": 2,
+ "pageIndex": 1,
+ "url": "https://www.amazon.com/s?k=rare+widget",
+ },
+ ],
+ "sent": [
+ "UPDATE_PROGRESS",
+ "ENQUEUE_SYNC",
+ ],
+}
+`;
+
+exports[`current scraper behavior on the corpus 2026-09/search-title-recipe-synthetic 1`] = `
+{
+ "currentItemCount": 4,
+ "helpers": {
+ "hasNext": true,
+ "nextUrl": "https://www.amazon.com/s?k=widget&page=2&ref=sr_pg_2",
+ "total": 312,
+ },
+ "isScrapingActive": true,
+ "nav": [
+ "[ProScan] Navigating to next page: https://www.amazon.com/s?k=widget&page=2&ref=sr_pg_2",
+ ],
+ "results": [
+ {
+ "asin": "B0SPONS001",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Acme",
+ "pageIndex": 1,
+ "price": "$12.99",
+ "priceCents": 1299,
+ "rating": 4.4,
+ "reviewCount": 2310,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FAcme-Widget%2Fdp%2FB0SPONS001%2Fref%3Dsr_1_1_sspa",
+ },
+ {
+ "asin": "B0ORGANIC1",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Widget Basic, 3 ft",
+ "pageIndex": 1,
+ "price": "$7.49",
+ "priceCents": 749,
+ "rating": 4.1,
+ "reviewCount": 512,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Widget-Basic/dp/B0ORGANIC1/ref=sr_1_2",
+ },
+ {
+ "asin": "B0UNITONLY",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Bulk pack of widgets, 24 count",
+ "pageIndex": 1,
+ "price": "$0.35",
+ "priceCents": 35,
+ "rating": 0,
+ "reviewCount": 0,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Bulk-Pack/dp/B0UNITONLY/ref=sr_1_3",
+ },
+ {
+ "asin": "B0SPONS001",
+ "delta": {
+ "dPriceCents": 0,
+ "dRating": 0,
+ "dReviews": 0,
+ "isNew": false,
+ },
+ "isPrime": false,
+ "name": "Acme Widget Pro, 6 ft, Braided",
+ "pageIndex": 1,
+ "price": "$12.99",
+ "priceCents": 1299,
+ "rating": 4.4,
+ "reviewCount": 2310,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Acme-Widget/dp/B0SPONS001/ref=sr_1_4",
+ },
+ ],
+ "runPages": [
+ {
+ "count": 4,
+ "pageIndex": 1,
+ "url": "https://www.amazon.com/s?k=widget",
+ },
+ ],
+ "sent": [
+ "UPDATE_PROGRESS",
+ "ENQUEUE_SYNC",
+ ],
+}
+`;
+
+exports[`current scraper behavior on the corpus 2026-09/search-yoga-mat 1`] = `
+{
+ "currentItemCount": 70,
+ "helpers": {
+ "hasNext": true,
+ "nextUrl": "https://www.amazon.com/s?k=yoga+mat&page=2&ref=sr_pg_2",
+ "total": 0,
+ },
+ "isScrapingActive": true,
+ "nav": [
+ "[ProScan] Navigating to next page: https://www.amazon.com/s?k=yoga+mat&page=2&ref=sr_pg_2",
+ ],
+ "results": [
+ {
+ "asin": "B07H9PZ6MH",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Thick Yoga Mat, Exercise & Fitness Mat with Easy-Cinch Carrying Strap",
+ "pageIndex": 1,
+ "price": "$21.23",
+ "priceCents": 2123,
+ "rating": 4.6,
+ "reviewCount": 46641,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FGaiam-Essentials-Fitness-Exercise-Easy-Cinch%2Fdp%2FB07H9PZ6MH%2Fref%3Dsr_1_1_sspa%3Fdib%3DeyJ2IjoiMSJ9.McxGXA1uRR2X5VLLlTKa9FQsjFvdk3IZrCSKVG4gnGsqhtvlRyi1C6_kldSd63B_TFiqGQ_OWZjN3pEjhfMqb89jRixpMthJuFEMv-mmZEgJfpmqxAAmbcIxqe9Rks-W5iex1_ZX1yrdWGM8vbudNFr0rHfaeICEln6LGsZA5tnGVceP3omV3LRIWlzJAn7uiz1qArrcjQee6_KiP7rMefddx3-fWWMY1PkQbwvtTsQnWsAmqazPHedvvMK0n__FDfpLmtUeHpwG0CYYseh6I4Qv9br7na0VPh0XSKy-1hg.wUg0v-BAHC7fJYSVGHbYwCGPzj113BZi3GTmKTGN-_U%26dib_tag%3Dse%26keywords%3Dyoga%2Bmat%26qid%3D1790222636%26sr%3D8-1-spons%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9hdGY%26psc%3D1",
+ },
+ {
+ "asin": "B091FX694F",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Retrospec Solana Yoga Mat Thick 1/2in Non-Slip Workout Mat with Nylon Strap - 72x24in Exercise Mat for Pilates, Stretching & Fitness - BPA Free, Easy Clean",
+ "pageIndex": 1,
+ "price": "$24.99",
+ "priceCents": 2499,
+ "rating": 4.4,
+ "reviewCount": 9310,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FRetrospec-Solana-Thick-Nylon-Strap%2Fdp%2FB091FX694F%2Fref%3Dsr_1_2_sspa%3Fdib%3DeyJ2IjoiMSJ9.McxGXA1uRR2X5VLLlTKa9FQsjFvdk3IZrCSKVG4gnGsqhtvlRyi1C6_kldSd63B_TFiqGQ_OWZjN3pEjhfMqb89jRixpMthJuFEMv-mmZEgJfpmqxAAmbcIxqe9Rks-W5iex1_ZX1yrdWGM8vbudNFr0rHfaeICEln6LGsZA5tnGVceP3omV3LRIWlzJAn7uiz1qArrcjQee6_KiP7rMefddx3-fWWMY1PkQbwvtTsQnWsAmqazPHedvvMK0n__FDfpLmtUeHpwG0CYYseh6I4Qv9br7na0VPh0XSKy-1hg.wUg0v-BAHC7fJYSVGHbYwCGPzj113BZi3GTmKTGN-_U%26dib_tag%3Dse%26keywords%3Dyoga%2Bmat%26qid%3D1790222636%26sr%3D8-2-spons%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9hdGY%26psc%3D1",
+ },
+ {
+ "asin": "B0H25VF5Y1",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Yoga Mat 72" x 24" x 6mm – Non Slip Exercise Mat",
+ "pageIndex": 1,
+ "price": "$29.99",
+ "priceCents": 2999,
+ "rating": 4.8,
+ "reviewCount": 9,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FYoga-Mat-72-24-6mm%2Fdp%2FB0H25VF5Y1%2Fref%3Dsr_1_3_sspa%3Fdib%3DeyJ2IjoiMSJ9.McxGXA1uRR2X5VLLlTKa9FQsjFvdk3IZrCSKVG4gnGsqhtvlRyi1C6_kldSd63B_TFiqGQ_OWZjN3pEjhfMqb89jRixpMthJuFEMv-mmZEgJfpmqxAAmbcIxqe9Rks-W5iex1_ZX1yrdWGM8vbudNFr0rHfaeICEln6LGsZA5tnGVceP3omV3LRIWlzJAn7uiz1qArrcjQee6_KiP7rMefddx3-fWWMY1PkQbwvtTsQnWsAmqazPHedvvMK0n__FDfpLmtUeHpwG0CYYseh6I4Qv9br7na0VPh0XSKy-1hg.wUg0v-BAHC7fJYSVGHbYwCGPzj113BZi3GTmKTGN-_U%26dib_tag%3Dse%26keywords%3Dyoga%2Bmat%26qid%3D1790222636%26sr%3D8-3-spons%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9hdGY%26psc%3D1",
+ },
+ {
+ "asin": "B092Y9XX85",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Retrospec Solana Yoga Mat 1" Thick w/Nylon Strap for Men & Women - Non Slip Exercise Mat for Home Yoga, Pilates, Stretching, Floor & Fitness Workouts",
+ "pageIndex": 1,
+ "price": "$39.99",
+ "priceCents": 3999,
+ "rating": 4.5,
+ "reviewCount": 14999,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FRetrospec-Solana-Thick-Nylon-Strap%2Fdp%2FB092Y9XX85%2Fref%3Dsr_1_4_sspa%3Fdib%3DeyJ2IjoiMSJ9.McxGXA1uRR2X5VLLlTKa9FQsjFvdk3IZrCSKVG4gnGsqhtvlRyi1C6_kldSd63B_TFiqGQ_OWZjN3pEjhfMqb89jRixpMthJuFEMv-mmZEgJfpmqxAAmbcIxqe9Rks-W5iex1_ZX1yrdWGM8vbudNFr0rHfaeICEln6LGsZA5tnGVceP3omV3LRIWlzJAn7uiz1qArrcjQee6_KiP7rMefddx3-fWWMY1PkQbwvtTsQnWsAmqazPHedvvMK0n__FDfpLmtUeHpwG0CYYseh6I4Qv9br7na0VPh0XSKy-1hg.wUg0v-BAHC7fJYSVGHbYwCGPzj113BZi3GTmKTGN-_U%26dib_tag%3Dse%26keywords%3Dyoga%2Bmat%26qid%3D1790222636%26sr%3D8-4-spons%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9hdGY%26psc%3D1",
+ },
+ {
+ "asin": "B07H4G664R",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gruper Yoga Mat Non Slip, Eco Friendly Exercise Mat with Carrying Strap",
+ "pageIndex": 1,
+ "price": "$25.19",
+ "priceCents": 2519,
+ "rating": 4.4,
+ "reviewCount": 10640,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gruper-Friendly-Exercise-Exercises-Thickness-6mm/dp/B07H4G664R/ref=sr_1_5?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-5",
+ },
+ {
+ "asin": "B0FNX94PPX",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Thick Yoga Mat, Exercise & Fitness Mat with Easy-Cinch Carrying Strap",
+ "pageIndex": 1,
+ "price": "$24.98",
+ "priceCents": 2498,
+ "rating": 4.6,
+ "reviewCount": 46641,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gaiam-Essentials-Fitness-Exercise-Easy-Cinch/dp/B0FNX94PPX/ref=sr_1_6?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-6",
+ },
+ {
+ "asin": "B0HC3HNW26",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Yoga Knee Pad Cushion, 0.6 Inch (15mm) Mini Yoga Mat for Home Workout",
+ "pageIndex": 1,
+ "price": "$8.99",
+ "priceCents": 899,
+ "rating": 4.5,
+ "reviewCount": 2,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Yoga-Knee-Cushion-Inch-Workout/dp/B0HC3HNW26/ref=sr_1_7?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-7",
+ },
+ {
+ "asin": "B091FX694F",
+ "delta": {
+ "dPriceCents": 0,
+ "dRating": 0,
+ "dReviews": 0,
+ "isNew": false,
+ },
+ "isPrime": false,
+ "name": "Retrospec Solana Yoga Mat Thick 1/2in Non-Slip Workout Mat with Nylon Strap - 72x24in Exercise Mat for Pilates, Stretching & Fitness - BPA Free, Easy Clean",
+ "pageIndex": 1,
+ "price": "$24.99",
+ "priceCents": 2499,
+ "rating": 4.4,
+ "reviewCount": 9310,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FRetrospec-Solana-Thick-Nylon-Strap%2Fdp%2FB091FX694F%2Fref%3Dsxin_15_pa_sp_search_thematic_sspa%3Fcontent-id%3Damzn1.sym.f3ef5b6e-928d-46a7-b777-dc583fd8199d%253Aamzn1.sym.f3ef5b6e-928d-46a7-b777-dc583fd8199d%26cv_ct_cx%3Dyoga%2Bmat%26keywords%3Dyoga%2Bmat%26pd_rd_i%3DB091FX694F%26pd_rd_r%3D9218c877-048e-4da2-b898-b224c6d7ca99%26pd_rd_w%3DvRN8K%26pd_rd_wg%3DIXpD5%26pf_rd_p%3Df3ef5b6e-928d-46a7-b777-dc583fd8199d%26pf_rd_r%3DP96Q3KMBPB9SP02HFAJV%26qid%3D1790222636%26sbo%3DRZvfv%252F%252FHxDF%252BO5021pAnSA%253D%253D%26sr%3D1-1-6024b2a3-78e4-4fed-8fed-e1613be3bcce-spons%26aref%3DjEpz1k69cj%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9zZWFyY2hfdGhlbWF0aWM%26psc%3D1&aref=x",
+ },
+ {
+ "asin": "B092XTMNCC",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Retrospec Solana Yoga Mat 1" Thick w/Nylon Strap for Men & Women - Non Slip Exercise Mat for Home Yoga, Pilates, Stretching, Floor & Fitness Workouts",
+ "pageIndex": 1,
+ "price": "$39.99",
+ "priceCents": 3999,
+ "rating": 4.5,
+ "reviewCount": 14999,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FRetrospec-Solana-Thick-Nylon-Strap%2Fdp%2FB092XTMNCC%2Fref%3Dsxin_15_pa_sp_search_thematic_sspa%3Fcontent-id%3Damzn1.sym.f3ef5b6e-928d-46a7-b777-dc583fd8199d%253Aamzn1.sym.f3ef5b6e-928d-46a7-b777-dc583fd8199d%26cv_ct_cx%3Dyoga%2Bmat%26keywords%3Dyoga%2Bmat%26pd_rd_i%3DB092XTMNCC%26pd_rd_r%3D9218c877-048e-4da2-b898-b224c6d7ca99%26pd_rd_w%3DvRN8K%26pd_rd_wg%3DIXpD5%26pf_rd_p%3Df3ef5b6e-928d-46a7-b777-dc583fd8199d%26pf_rd_r%3DP96Q3KMBPB9SP02HFAJV%26qid%3D1790222636%26sbo%3DRZvfv%252F%252FHxDF%252BO5021pAnSA%253D%253D%26sr%3D1-2-6024b2a3-78e4-4fed-8fed-e1613be3bcce-spons%26aref%3DkhrKLD1Lha%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9zZWFyY2hfdGhlbWF0aWM%26psc%3D1&aref=x",
+ },
+ {
+ "asin": "B09M8D4JMD",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "COOLMOON Yoga Mat Non Slip, Anti-Tear 1/4 Thick TPE Yoga Mats for Women and Men, 72"x24" Exercise & Fitness Mat with Carrying Strap, Workout Mats for Yoga, Pilates and Floor Exercise",
+ "pageIndex": 1,
+ "price": "$36.99",
+ "priceCents": 3699,
+ "rating": 4.2,
+ "reviewCount": 111,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FCOOLMOON-Double-Sided-Carrying-Friendly-Exercises%2Fdp%2FB09M8D4JMD%2Fref%3Dsxin_15_pa_sp_search_thematic_sspa%3Fcontent-id%3Damzn1.sym.f3ef5b6e-928d-46a7-b777-dc583fd8199d%253Aamzn1.sym.f3ef5b6e-928d-46a7-b777-dc583fd8199d%26cv_ct_cx%3Dyoga%2Bmat%26keywords%3Dyoga%2Bmat%26pd_rd_i%3DB09M8D4JMD%26pd_rd_r%3D9218c877-048e-4da2-b898-b224c6d7ca99%26pd_rd_w%3DvRN8K%26pd_rd_wg%3DIXpD5%26pf_rd_p%3Df3ef5b6e-928d-46a7-b777-dc583fd8199d%26pf_rd_r%3DP96Q3KMBPB9SP02HFAJV%26qid%3D1790222636%26sbo%3DRZvfv%252F%252FHxDF%252BO5021pAnSA%253D%253D%26sr%3D1-3-6024b2a3-78e4-4fed-8fed-e1613be3bcce-spons%26aref%3Dkv247ZQBuu%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9zZWFyY2hfdGhlbWF0aWM%26psc%3D1&aref=x",
+ },
+ {
+ "asin": "B0GJSKH1GN",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "KEEP 72" x 24" Yoga Mat, 7mm Thick Non Slip TPE Mat",
+ "pageIndex": 1,
+ "price": "$24.99",
+ "priceCents": 2499,
+ "rating": 4.3,
+ "reviewCount": 91,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FKEEP-Yoga-Thick-Slip-pink%2Fdp%2FB0GJSKH1GN%2Fref%3Dsxin_15_pa_sp_search_thematic_sspa%3Fcontent-id%3Damzn1.sym.f3ef5b6e-928d-46a7-b777-dc583fd8199d%253Aamzn1.sym.f3ef5b6e-928d-46a7-b777-dc583fd8199d%26cv_ct_cx%3Dyoga%2Bmat%26keywords%3Dyoga%2Bmat%26pd_rd_i%3DB0GJSKH1GN%26pd_rd_r%3D9218c877-048e-4da2-b898-b224c6d7ca99%26pd_rd_w%3DvRN8K%26pd_rd_wg%3DIXpD5%26pf_rd_p%3Df3ef5b6e-928d-46a7-b777-dc583fd8199d%26pf_rd_r%3DP96Q3KMBPB9SP02HFAJV%26qid%3D1790222636%26sbo%3DRZvfv%252F%252FHxDF%252BO5021pAnSA%253D%253D%26sr%3D1-4-6024b2a3-78e4-4fed-8fed-e1613be3bcce-spons%26aref%3DjWsZ8N7oas%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9zZWFyY2hfdGhlbWF0aWM%26psc%3D1&aref=x",
+ },
+ {
+ "asin": "B0GNWFRM3X",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Stakt Foldable Yoga Mat Pro - Non Slip 2 in 1 Fitness Exercise Mat",
+ "pageIndex": 1,
+ "price": "$128.00",
+ "priceCents": 12800,
+ "rating": 4.2,
+ "reviewCount": 57,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FStakt-Foldable-Yoga-Mat-Pro%2Fdp%2FB0GNWFRM3X%2Fref%3Dsxin_15_pa_sp_search_thematic_sspa%3Fcontent-id%3Damzn1.sym.f3ef5b6e-928d-46a7-b777-dc583fd8199d%253Aamzn1.sym.f3ef5b6e-928d-46a7-b777-dc583fd8199d%26cv_ct_cx%3Dyoga%2Bmat%26keywords%3Dyoga%2Bmat%26pd_rd_i%3DB0GNWFRM3X%26pd_rd_r%3D9218c877-048e-4da2-b898-b224c6d7ca99%26pd_rd_w%3DvRN8K%26pd_rd_wg%3DIXpD5%26pf_rd_p%3Df3ef5b6e-928d-46a7-b777-dc583fd8199d%26pf_rd_r%3DP96Q3KMBPB9SP02HFAJV%26qid%3D1790222636%26sbo%3DRZvfv%252F%252FHxDF%252BO5021pAnSA%253D%253D%26sr%3D1-5-6024b2a3-78e4-4fed-8fed-e1613be3bcce-spons%26aref%3DGbzcv5v6VI%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9zZWFyY2hfdGhlbWF0aWM%26psc%3D1&aref=x",
+ },
+ {
+ "asin": "B01LP0UX9G",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Amazon Basics Extra Thick Exercise Yoga Mat with Carrying Strap",
+ "pageIndex": 1,
+ "price": "N/A",
+ "priceCents": null,
+ "rating": 4.6,
+ "reviewCount": 70632,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/AmazonBasics-Extra-Thick-Exercise-Carrying/dp/B01LP0UX9G/ref=sr_1_8?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-8",
+ },
+ {
+ "asin": "B07H9PZDQW",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Thick Yoga Mat, Exercise & Fitness Mat with Easy-Cinch Carrying Strap",
+ "pageIndex": 1,
+ "price": "$25.02",
+ "priceCents": 2502,
+ "rating": 4.6,
+ "reviewCount": 46641,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gaiam-Essentials-Fitness-Exercise-Easy-Cinch/dp/B07H9PZDQW/ref=sr_1_9?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-9",
+ },
+ {
+ "asin": "B07H9KV2YN",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Thick Yoga Mat, Exercise & Fitness Mat with Easy-Cinch Carrying Strap",
+ "pageIndex": 1,
+ "price": "$21.11",
+ "priceCents": 2111,
+ "rating": 4.6,
+ "reviewCount": 46641,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gaiam-Essentials-Fitness-Exercise-Easy-Cinch/dp/B07H9KV2YN/ref=sr_1_10?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-10",
+ },
+ {
+ "asin": "B0H28L9MPP",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "HAPBEAR Foldable Yoga Mat with Towel Set - 72"x24"x0.24" (6mm), Non-Slip TPE Exercise Mat with Absorbent Yoga Towel for Home Workout, Yoga, Pilates, Stretching & Travel, Durable & Eco-Friendly, Includes Carry Bag",
+ "pageIndex": 1,
+ "price": "$39.99",
+ "priceCents": 3999,
+ "rating": 4.7,
+ "reviewCount": 26,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FHAPBEAR-Foldable-Yoga-Mat-Towel%2Fdp%2FB0H28L9MPP%2Fref%3Dsr_1_11_sspa%3Fdib%3DeyJ2IjoiMSJ9.McxGXA1uRR2X5VLLlTKa9FQsjFvdk3IZrCSKVG4gnGsqhtvlRyi1C6_kldSd63B_TFiqGQ_OWZjN3pEjhfMqb89jRixpMthJuFEMv-mmZEgJfpmqxAAmbcIxqe9Rks-W5iex1_ZX1yrdWGM8vbudNFr0rHfaeICEln6LGsZA5tnGVceP3omV3LRIWlzJAn7uiz1qArrcjQee6_KiP7rMefddx3-fWWMY1PkQbwvtTsQnWsAmqazPHedvvMK0n__FDfpLmtUeHpwG0CYYseh6I4Qv9br7na0VPh0XSKy-1hg.wUg0v-BAHC7fJYSVGHbYwCGPzj113BZi3GTmKTGN-_U%26dib_tag%3Dse%26keywords%3Dyoga%2Bmat%26qid%3D1790222636%26sr%3D8-11-spons%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9tdGY%26psc%3D1",
+ },
+ {
+ "asin": "B09M8D4JMD",
+ "delta": {
+ "dPriceCents": 0,
+ "dRating": 0,
+ "dReviews": 0,
+ "isNew": false,
+ },
+ "isPrime": false,
+ "name": "COOLMOON Yoga Mat Non Slip, Anti-Tear 1/4 Thick TPE Yoga Mats for Women and Men, 72"x24" Exercise & Fitness Mat with Carrying Strap, Workout Mats for Yoga, Pilates and Floor Exercise",
+ "pageIndex": 1,
+ "price": "$36.99",
+ "priceCents": 3699,
+ "rating": 4.2,
+ "reviewCount": 111,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FCOOLMOON-Double-Sided-Carrying-Friendly-Exercises%2Fdp%2FB09M8D4JMD%2Fref%3Dsr_1_12_sspa%3Fdib%3DeyJ2IjoiMSJ9.McxGXA1uRR2X5VLLlTKa9FQsjFvdk3IZrCSKVG4gnGsqhtvlRyi1C6_kldSd63B_TFiqGQ_OWZjN3pEjhfMqb89jRixpMthJuFEMv-mmZEgJfpmqxAAmbcIxqe9Rks-W5iex1_ZX1yrdWGM8vbudNFr0rHfaeICEln6LGsZA5tnGVceP3omV3LRIWlzJAn7uiz1qArrcjQee6_KiP7rMefddx3-fWWMY1PkQbwvtTsQnWsAmqazPHedvvMK0n__FDfpLmtUeHpwG0CYYseh6I4Qv9br7na0VPh0XSKy-1hg.wUg0v-BAHC7fJYSVGHbYwCGPzj113BZi3GTmKTGN-_U%26dib_tag%3Dse%26keywords%3Dyoga%2Bmat%26qid%3D1790222636%26sr%3D8-12-spons%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9tdGY%26psc%3D1",
+ },
+ {
+ "asin": "B0GG8RY26S",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "PAIDU Foldable Yoga Mat, 70"x24"x0.31" TPE Non-Slip Mat with Bag",
+ "pageIndex": 1,
+ "price": "$42.65",
+ "priceCents": 4265,
+ "rating": 4.4,
+ "reviewCount": 31,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FPAIDU-Foldable-Double-Sided-Non-Slip-Carrying%2Fdp%2FB0GG8RY26S%2Fref%3Dsr_1_13_sspa%3Fdib%3DeyJ2IjoiMSJ9.McxGXA1uRR2X5VLLlTKa9FQsjFvdk3IZrCSKVG4gnGsqhtvlRyi1C6_kldSd63B_TFiqGQ_OWZjN3pEjhfMqb89jRixpMthJuFEMv-mmZEgJfpmqxAAmbcIxqe9Rks-W5iex1_ZX1yrdWGM8vbudNFr0rHfaeICEln6LGsZA5tnGVceP3omV3LRIWlzJAn7uiz1qArrcjQee6_KiP7rMefddx3-fWWMY1PkQbwvtTsQnWsAmqazPHedvvMK0n__FDfpLmtUeHpwG0CYYseh6I4Qv9br7na0VPh0XSKy-1hg.wUg0v-BAHC7fJYSVGHbYwCGPzj113BZi3GTmKTGN-_U%26dib_tag%3Dse%26keywords%3Dyoga%2Bmat%26qid%3D1790222636%26sr%3D8-13-spons%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9tdGY%26psc%3D1",
+ },
+ {
+ "asin": "B0DFBSMMZS",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "PAIDU Foldable Yoga Mat 10mm Thick, 75"x26" Non-Slip Fitness Exercise Mat",
+ "pageIndex": 1,
+ "price": "$51.49",
+ "priceCents": 5149,
+ "rating": 4.3,
+ "reviewCount": 100,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FPAIDU-Foldable-material-portable-multi-functional%2Fdp%2FB0DFBSMMZS%2Fref%3Dsr_1_14_sspa%3Fdib%3DeyJ2IjoiMSJ9.McxGXA1uRR2X5VLLlTKa9FQsjFvdk3IZrCSKVG4gnGsqhtvlRyi1C6_kldSd63B_TFiqGQ_OWZjN3pEjhfMqb89jRixpMthJuFEMv-mmZEgJfpmqxAAmbcIxqe9Rks-W5iex1_ZX1yrdWGM8vbudNFr0rHfaeICEln6LGsZA5tnGVceP3omV3LRIWlzJAn7uiz1qArrcjQee6_KiP7rMefddx3-fWWMY1PkQbwvtTsQnWsAmqazPHedvvMK0n__FDfpLmtUeHpwG0CYYseh6I4Qv9br7na0VPh0XSKy-1hg.wUg0v-BAHC7fJYSVGHbYwCGPzj113BZi3GTmKTGN-_U%26dib_tag%3Dse%26keywords%3Dyoga%2Bmat%26qid%3D1790222636%26sr%3D8-14-spons%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9tdGY%26psc%3D1",
+ },
+ {
+ "asin": "B0HGFH7QVH",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "6.2x3ft Folding Gym Mat, 3" Thick",
+ "pageIndex": 1,
+ "price": "$59.99",
+ "priceCents": 5999,
+ "rating": 5,
+ "reviewCount": 4,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/STMHOM-6-2x3ft-Folding-Gym-Thick/dp/B0HGFH7QVH/ref=sr_1_15?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-15",
+ },
+ {
+ "asin": "B07JQCVBBZ",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gruper Yoga Mat Non Slip, Eco Friendly Exercise Mat with Carrying Strap",
+ "pageIndex": 1,
+ "price": "$28.16",
+ "priceCents": 2816,
+ "rating": 4.4,
+ "reviewCount": 10640,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gruper-Friendly-Exercise-Exercises-Thickness-6mm/dp/B07JQCVBBZ/ref=sr_1_16?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-16",
+ },
+ {
+ "asin": "B09WF85T8M",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Yoga Mat - Premium 6mm Print Reversible Extra Thick Non Slip Exercise & Fitness Mat for All Types of Yoga, Pilates & Floor Workouts (68" x 24" x 6mm Thick)",
+ "pageIndex": 1,
+ "price": "$22.75",
+ "priceCents": 2275,
+ "rating": 4.6,
+ "reviewCount": 15587,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gaiam-Premium-Reversible-Exercise-Workouts/dp/B09WF85T8M/ref=sr_1_17?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-17",
+ },
+ {
+ "asin": "B091FX694F",
+ "delta": {
+ "dPriceCents": 0,
+ "dRating": 0,
+ "dReviews": 0,
+ "isNew": false,
+ },
+ "isPrime": false,
+ "name": "Retrospec Solana Yoga Mat Thick 1/2in Non-Slip Workout Mat with Nylon Strap - 72x24in Exercise Mat for Pilates, Stretching & Fitness - BPA Free, Easy Clean",
+ "pageIndex": 1,
+ "price": "$24.99",
+ "priceCents": 2499,
+ "rating": 4.4,
+ "reviewCount": 9310,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Retrospec-Solana-Thick-Nylon-Strap/dp/B091FX694F/ref=sr_1_18?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-18",
+ },
+ {
+ "asin": "B0GNWFRM3X",
+ "delta": {
+ "dPriceCents": 0,
+ "dRating": 0,
+ "dReviews": 0,
+ "isNew": false,
+ },
+ "isPrime": false,
+ "name": "Stakt Foldable Yoga Mat Pro - Non Slip 2 in 1 Fitness Exercise Mat",
+ "pageIndex": 1,
+ "price": "$128.00",
+ "priceCents": 12800,
+ "rating": 4.2,
+ "reviewCount": 57,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FStakt-Foldable-Yoga-Mat-Pro%2Fdp%2FB0GNWFRM3X%2Fref%3Dsr_1_19_sspa%3Fdib%3DeyJ2IjoiMSJ9.McxGXA1uRR2X5VLLlTKa9FQsjFvdk3IZrCSKVG4gnGsqhtvlRyi1C6_kldSd63B_TFiqGQ_OWZjN3pEjhfMqb89jRixpMthJuFEMv-mmZEgJfpmqxAAmbcIxqe9Rks-W5iex1_ZX1yrdWGM8vbudNFr0rHfaeICEln6LGsZA5tnGVceP3omV3LRIWlzJAn7uiz1qArrcjQee6_KiP7rMefddx3-fWWMY1PkQbwvtTsQnWsAmqazPHedvvMK0n__FDfpLmtUeHpwG0CYYseh6I4Qv9br7na0VPh0XSKy-1hg.wUg0v-BAHC7fJYSVGHbYwCGPzj113BZi3GTmKTGN-_U%26dib_tag%3Dse%26keywords%3Dyoga%2Bmat%26qid%3D1790222636%26sr%3D8-19-spons%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9tdGY%26psc%3D1",
+ },
+ {
+ "asin": "B0FHGMLGVG",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Foldable Yoga Mat Extra Large 74"×31.5"",
+ "pageIndex": 1,
+ "price": "$45.99",
+ "priceCents": 4599,
+ "rating": 4.4,
+ "reviewCount": 77,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FFoldable-Yoga-Extra-Large-%25C3%259731-5%2Fdp%2FB0FHGMLGVG%2Fref%3Dsr_1_20_sspa%3Fdib%3DeyJ2IjoiMSJ9.McxGXA1uRR2X5VLLlTKa9FQsjFvdk3IZrCSKVG4gnGsqhtvlRyi1C6_kldSd63B_TFiqGQ_OWZjN3pEjhfMqb89jRixpMthJuFEMv-mmZEgJfpmqxAAmbcIxqe9Rks-W5iex1_ZX1yrdWGM8vbudNFr0rHfaeICEln6LGsZA5tnGVceP3omV3LRIWlzJAn7uiz1qArrcjQee6_KiP7rMefddx3-fWWMY1PkQbwvtTsQnWsAmqazPHedvvMK0n__FDfpLmtUeHpwG0CYYseh6I4Qv9br7na0VPh0XSKy-1hg.wUg0v-BAHC7fJYSVGHbYwCGPzj113BZi3GTmKTGN-_U%26dib_tag%3Dse%26keywords%3Dyoga%2Bmat%26qid%3D1790222636%26sr%3D8-20-spons%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9tdGY%26psc%3D1",
+ },
+ {
+ "asin": "B0DRJ8VH3Q",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Yoga Mat, 80cm Wide, 1/3 Inch Thick Exercise Mat, Carry Strap, Fitness",
+ "pageIndex": 1,
+ "price": "$29.99",
+ "priceCents": 2999,
+ "rating": 4.5,
+ "reviewCount": 47,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FNon-Slip-Fitness-Exercise-Environmental-Elastic%2Fdp%2FB0DRJ8VH3Q%2Fref%3Dsr_1_21_sspa%3Fdib%3DeyJ2IjoiMSJ9.McxGXA1uRR2X5VLLlTKa9FQsjFvdk3IZrCSKVG4gnGsqhtvlRyi1C6_kldSd63B_TFiqGQ_OWZjN3pEjhfMqb89jRixpMthJuFEMv-mmZEgJfpmqxAAmbcIxqe9Rks-W5iex1_ZX1yrdWGM8vbudNFr0rHfaeICEln6LGsZA5tnGVceP3omV3LRIWlzJAn7uiz1qArrcjQee6_KiP7rMefddx3-fWWMY1PkQbwvtTsQnWsAmqazPHedvvMK0n__FDfpLmtUeHpwG0CYYseh6I4Qv9br7na0VPh0XSKy-1hg.wUg0v-BAHC7fJYSVGHbYwCGPzj113BZi3GTmKTGN-_U%26dib_tag%3Dse%26keywords%3Dyoga%2Bmat%26qid%3D1790222636%26sr%3D8-21-spons%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9tdGY%26psc%3D1",
+ },
+ {
+ "asin": "B0GJSKH1GN",
+ "delta": {
+ "dPriceCents": 0,
+ "dRating": 0,
+ "dReviews": 0,
+ "isNew": false,
+ },
+ "isPrime": false,
+ "name": "KEEP 72" x 24" Yoga Mat, 7mm Thick Non Slip TPE Mat",
+ "pageIndex": 1,
+ "price": "$24.99",
+ "priceCents": 2499,
+ "rating": 4.3,
+ "reviewCount": 91,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FKEEP-Yoga-Thick-Slip-pink%2Fdp%2FB0GJSKH1GN%2Fref%3Dsr_1_22_sspa%3Fdib%3DeyJ2IjoiMSJ9.McxGXA1uRR2X5VLLlTKa9FQsjFvdk3IZrCSKVG4gnGsqhtvlRyi1C6_kldSd63B_TFiqGQ_OWZjN3pEjhfMqb89jRixpMthJuFEMv-mmZEgJfpmqxAAmbcIxqe9Rks-W5iex1_ZX1yrdWGM8vbudNFr0rHfaeICEln6LGsZA5tnGVceP3omV3LRIWlzJAn7uiz1qArrcjQee6_KiP7rMefddx3-fWWMY1PkQbwvtTsQnWsAmqazPHedvvMK0n__FDfpLmtUeHpwG0CYYseh6I4Qv9br7na0VPh0XSKy-1hg.wUg0v-BAHC7fJYSVGHbYwCGPzj113BZi3GTmKTGN-_U%26dib_tag%3Dse%26keywords%3Dyoga%2Bmat%26qid%3D1790222636%26sr%3D8-22-spons%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9tdGY%26psc%3D1",
+ },
+ {
+ "asin": "B0C7SFV8RH",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "CAP Barbell 1/2-Inch High Density Exercise Yoga Mat with Strap | Multiple Options",
+ "pageIndex": 1,
+ "price": "$15.99",
+ "priceCents": 1599,
+ "rating": 4.6,
+ "reviewCount": 3854,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/CAP-Barbell-Density-Exercise-strap/dp/B0C7SFV8RH/ref=sr_1_23?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-23",
+ },
+ {
+ "asin": "B0FQKYGYGX",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "CAP Barbell 1/2-Inch High Density Exercise Yoga Mat with Strap | Multiple Options",
+ "pageIndex": 1,
+ "price": "$15.99",
+ "priceCents": 1599,
+ "rating": 4.6,
+ "reviewCount": 3854,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/CAP-Barbell-Density-Exercise-strap/dp/B0FQKYGYGX/ref=sr_1_24?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-24",
+ },
+ {
+ "asin": "B0B74MRJS3",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "KEEP Extra Wide Yoga Mat, 7mm Thick 72" x 32" Exercise Mat",
+ "pageIndex": 1,
+ "price": "$29.98",
+ "priceCents": 2998,
+ "rating": 4.5,
+ "reviewCount": 735,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/KEEP-Non-Slip-Large-Yoga-All/dp/B0B74MRJS3/ref=sr_1_25?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-25",
+ },
+ {
+ "asin": "B0GLQH4453",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "18 Pcs EVA Foam Floor Tiles, Puzzle Exercise Mat Set for Gym",
+ "pageIndex": 1,
+ "price": "$22.49",
+ "priceCents": 2249,
+ "rating": 4.2,
+ "reviewCount": 1115,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Haulcove-Floor-Tiles-Puzzle-Exercise/dp/B0GLQH4453/ref=sr_1_26?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-26",
+ },
+ {
+ "asin": "B092XNCFF7",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Retrospec Solana Yoga Mat 1" Thick w/Nylon Strap for Men & Women - Non Slip Exercise Mat for Home Yoga, Pilates, Stretching, Floor & Fitness Workouts",
+ "pageIndex": 1,
+ "price": "$39.99",
+ "priceCents": 3999,
+ "rating": 4.5,
+ "reviewCount": 14999,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Retrospec-Solana-Thick-Nylon-Strap/dp/B092XNCFF7/ref=sr_1_27?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-27",
+ },
+ {
+ "asin": "B0D9MWTQ9K",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "CAP Barbell Folding Exercise Mat – Durable, Anti-Tear, Thick Padding for Fitness, Aerobics, Gymnastics & Home Workouts. 72"L x 24"W x 2"Thick. BLACK",
+ "pageIndex": 1,
+ "price": "$36.97",
+ "priceCents": 3697,
+ "rating": 4.7,
+ "reviewCount": 1486,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Purpose-Folding-Exercise-Training-Gymnastics/dp/B0D9MWTQ9K/ref=sr_1_28?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-28",
+ },
+ {
+ "asin": "B0FNXCBRGL",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Thick Yoga Mat, Exercise & Fitness Mat with Easy-Cinch Carrying Strap",
+ "pageIndex": 1,
+ "price": "$24.98",
+ "priceCents": 2498,
+ "rating": 4.6,
+ "reviewCount": 46641,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gaiam-Essentials-Fitness-Exercise-Easy-Cinch/dp/B0FNXCBRGL/ref=sr_1_29?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-29",
+ },
+ {
+ "asin": "B0FX3RZ2YN",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gruper Yoga Mat Non Slip, Anti-Tear Workout Mat with Carrying Strap and Bag",
+ "pageIndex": 1,
+ "price": "$22.49",
+ "priceCents": 2249,
+ "rating": 4.4,
+ "reviewCount": 548,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gruper-Anti-Tear-Exercise-Carrying-Exercises/dp/B0FX3RZ2YN/ref=sr_1_30?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-30",
+ },
+ {
+ "asin": "B01IZDFWPG",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Fitvids All Purpose 1/4-Inch High Density Anti-Tear Exercise Yoga Mat with Carrying Strap with Optional Yoga Blocks, Multiple Colors",
+ "pageIndex": 1,
+ "price": "$16.99",
+ "priceCents": 1699,
+ "rating": 4.4,
+ "reviewCount": 47391,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/BalanceFrom-Purpose-Non-Slip-Exercise-Carrying/dp/B01IZDFWPG/ref=sr_1_31?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-31",
+ },
+ {
+ "asin": "B0C359D7JX",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Yoga Mat - Premium 5mm Solid Thick Non Slip Exercise & Fitness Mat for All Types of Yoga, Pilates & Floor Workouts (68" x 24" x 5mm)",
+ "pageIndex": 1,
+ "price": "$20.81",
+ "priceCents": 2081,
+ "rating": 4.4,
+ "reviewCount": 4305,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gaiam-Premium-Exercise-Workouts-Sagebrush/dp/B0C359D7JX/ref=sr_1_32?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-32",
+ },
+ {
+ "asin": "B0BYFFX51Z",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Premium Yoga Mat, 6mm Thick Print Mat",
+ "pageIndex": 1,
+ "price": "$19.29",
+ "priceCents": 1929,
+ "rating": 4.7,
+ "reviewCount": 22194,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gaiam-Yoga-Mat-Exercise-Workouts/dp/B0BYFFX51Z/ref=sr_1_33?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-33",
+ },
+ {
+ "asin": "B09WF4GPPC",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Premium Yoga Mat, 6mm Thick Print Mat",
+ "pageIndex": 1,
+ "price": "$21.00",
+ "priceCents": 2100,
+ "rating": 4.7,
+ "reviewCount": 22194,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gaiam-Premium-Exercise-Fitness-Workouts/dp/B09WF4GPPC/ref=sr_1_34?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-34",
+ },
+ {
+ "asin": "B0DG3WYD54",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "CAP Barbell 1/2-Inch High Density Exercise Yoga Mat with Strap | Multiple Options",
+ "pageIndex": 1,
+ "price": "$18.99",
+ "priceCents": 1899,
+ "rating": 4.6,
+ "reviewCount": 3854,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/CAP-Barbell-Density-Exercise-strap/dp/B0DG3WYD54/ref=sr_1_35?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-35",
+ },
+ {
+ "asin": "B091FZL25K",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Retrospec Solana Yoga Mat Thick 1/2in Non-Slip Workout Mat with Nylon Strap - 72x24in Exercise Mat for Pilates, Stretching & Fitness - BPA Free, Easy Clean",
+ "pageIndex": 1,
+ "price": "$24.99",
+ "priceCents": 2499,
+ "rating": 4.4,
+ "reviewCount": 9310,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Retrospec-Solana-Thick-Nylon-Strap/dp/B091FZL25K/ref=sr_1_36?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-36",
+ },
+ {
+ "asin": "B0GFZ72SJR",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "KEEP Extra Thick NBR Yoga Mat for Women & Men, 10mm Thick, 72”x32” Large Exercise Mat with Non-Slip Workout Mat for Yoga, Pilates, Stretching, Meditation – Wide & Cushioned Fitness Mat",
+ "pageIndex": 1,
+ "price": "$21.99",
+ "priceCents": 2199,
+ "rating": 4.5,
+ "reviewCount": 295,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/KEEP-Exercise-Non-Slip-Stretching-Meditation/dp/B0GFZ72SJR/ref=sr_1_37?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-37",
+ },
+ {
+ "asin": "B01MY5MZSQ",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Yoga Mat Classic Print, 4mm Non-Slip Mat",
+ "pageIndex": 1,
+ "price": "$24.38",
+ "priceCents": 2438,
+ "rating": 4.5,
+ "reviewCount": 12620,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gaiam-Classic-Exercise-Exercises-Marrakesh/dp/B01MY5MZSQ/ref=sr_1_38?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-38",
+ },
+ {
+ "asin": "B005NZ7PEQ",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Manduka PRO Yoga Mat - 6mm | Lifetime Durability | Hygienic Construction | Premium Studio Quality | Teacher Approved",
+ "pageIndex": 1,
+ "price": "$144.00",
+ "priceCents": 14400,
+ "rating": 4.5,
+ "reviewCount": 9005,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Manduka-Yoga-Pilates-Black-Sage/dp/B005NZ7PEQ/ref=sr_1_39?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-39",
+ },
+ {
+ "asin": "B0CJ39LHNX",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "YOGATI® Yoga Mat with Strap with Alignment Lines. Home Workout Mat for Women, Men and Kids. Thick Non Slip Yoga Mat for Pilates and Fitness. Brown, Pink and Purple Yoga Mats",
+ "pageIndex": 1,
+ "price": "$19.98",
+ "priceCents": 1998,
+ "rating": 4.5,
+ "reviewCount": 7726,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/YOGATI-workout-Pilates-Fitness-Exercise/dp/B0CJ39LHNX/ref=sr_1_40?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-40",
+ },
+ {
+ "asin": "B0CP47T6ZP",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Voyage 5mm Studio Yoga Mats, 6 Pack, 72x24in",
+ "pageIndex": 1,
+ "price": "$99.99",
+ "priceCents": 9999,
+ "rating": 4.8,
+ "reviewCount": 125,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Sunshine-Yoga-Exercise-Workout-Anti-Tear/dp/B0CP47T6ZP/ref=sr_1_41?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-41",
+ },
+ {
+ "asin": "B0CL9CR6FJ",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Economy 3mm Bulk Yoga Mats, 10 Pack, 68x24in",
+ "pageIndex": 1,
+ "price": "$89.99",
+ "priceCents": 8999,
+ "rating": 4.7,
+ "reviewCount": 21,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Upward-Economy-Bulk-68x24in-Assorted/dp/B0CL9CR6FJ/ref=sr_1_42?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-42",
+ },
+ {
+ "asin": "B087F16GKW",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Yoga Mat - Premium 5mm Solid Thick Non Slip Exercise & Fitness Mat for All Types of Yoga, Pilates & Floor Workouts (68" x 24" x 5mm)",
+ "pageIndex": 1,
+ "price": "$22.65",
+ "priceCents": 2265,
+ "rating": 4.4,
+ "reviewCount": 4305,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gaiam-Black-Yoga-Mat-Mm/dp/B087F16GKW/ref=sr_1_43?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-43",
+ },
+ {
+ "asin": "B0F6426TRG",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Manduka PROlite Yoga Mat 4.7mm | Lifetime Durability | Hygienic Construction | Teacher Approved",
+ "pageIndex": 1,
+ "price": "$99.99",
+ "priceCents": 9999,
+ "rating": 4.5,
+ "reviewCount": 4599,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Manduka-PROlite-Yoga-Mat-Recommended/dp/B0F6426TRG/ref=sr_1_44?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-44",
+ },
+ {
+ "asin": "B01LP0U5X0",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Amazon Basics Extra Thick Exercise Yoga Mat with Carrying Strap",
+ "pageIndex": 1,
+ "price": "N/A",
+ "priceCents": null,
+ "rating": 4.6,
+ "reviewCount": 70632,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/AmazonBasics-Extra-Thick-Exercise-Carrying/dp/B01LP0U5X0/ref=sr_1_45?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-45",
+ },
+ {
+ "asin": "B0DQC2WV8Y",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Fitvids 1/2-inch Thick High Density Exercise Yoga Mat with Carrying Strap",
+ "pageIndex": 1,
+ "price": "$20.99",
+ "priceCents": 2099,
+ "rating": 4.5,
+ "reviewCount": 94420,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Fitvids-Exercise-Anti-Tear-Carrying-Exercises/dp/B0DQC2WV8Y/ref=sr_1_46?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-46",
+ },
+ {
+ "asin": "B01I5CFUHM",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Manduka PRO Yoga Mat - 6mm | Lifetime Durability | Hygienic Construction | Premium Studio Quality | Teacher Approved",
+ "pageIndex": 1,
+ "price": "$144.00",
+ "priceCents": 14400,
+ "rating": 4.5,
+ "reviewCount": 9005,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Manduka-PRO-Yoga-Pilates-Odyssey/dp/B01I5CFUHM/ref=sr_1_47?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-47",
+ },
+ {
+ "asin": "B07H9KV1GG",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Essentials 1/4" Thin (6mm) Yoga & Pilates, Fitness & Exercise Mat with Easy-Cinch Carrier Strap Cusion Support For Fitness and Gym Workouts",
+ "pageIndex": 1,
+ "price": "$20.69",
+ "priceCents": 2069,
+ "rating": 4.6,
+ "reviewCount": 14969,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gaiam-Essentials-Premium-Carrier-Sling/dp/B07H9KV1GG/ref=sr_1_48?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-48",
+ },
+ {
+ "asin": "B0BPC5YDDV",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Funtery 3 mm Thin Yoga Mats Bulk, 68 x 24 in",
+ "pageIndex": 1,
+ "price": "$129.99",
+ "priceCents": 12999,
+ "rating": 3.6,
+ "reviewCount": 146,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Funtery-Pack-Thin-Yoga-Multicolored/dp/B0BPC5YDDV/ref=sr_1_49?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-49",
+ },
+ {
+ "asin": "B0DSR7K8CL",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Sunny Health & Fitness Exercise Yoga Mats - Non-Slip Support for Home & Gym",
+ "pageIndex": 1,
+ "price": "$18.99",
+ "priceCents": 1899,
+ "rating": 4.4,
+ "reviewCount": 6024,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Sunny-Health-Fitness-Lightweight-Stretching/dp/B0DSR7K8CL/ref=sr_1_50?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-50",
+ },
+ {
+ "asin": "B0D4MFT49T",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Premium Yoga Mat, 6mm Thick Print Mat",
+ "pageIndex": 1,
+ "price": "$39.99",
+ "priceCents": 3999,
+ "rating": 4.7,
+ "reviewCount": 22194,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gaiam-Yoga-Mat-Exercise-Workouts/dp/B0D4MFT49T/ref=sr_1_51?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-51",
+ },
+ {
+ "asin": "B0F7YCXYHV",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Cork Yoga Mat with Natural Rubber Base, Extra Size, Thickness and Support, Excellent Cushion & Grip, Non-Slip, Non-Toxic, Sweat-Resistant, Sustainable, Eco-friendly Exercise Mat",
+ "pageIndex": 1,
+ "price": "$89.00",
+ "priceCents": 8900,
+ "rating": 4.6,
+ "reviewCount": 255,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Satori-Concept-Sweat-Resistant-Sustainable-Eco-friendly/dp/B0F7YCXYHV/ref=sr_1_52?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-52",
+ },
+ {
+ "asin": "B0FNN64N3C",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "SPRI Hanging Exercise Mats for Gyms, Closed-Cell Foam Yoga & Fitness Mats with Grommets, Thick Workout Mats for Studios & Group Fitness Classes",
+ "pageIndex": 1,
+ "price": "$233.01",
+ "priceCents": 23301,
+ "rating": 4.7,
+ "reviewCount": 8,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/SPRI-Hanging-Exercise-6-Pack-Black/dp/B0FNN64N3C/ref=sr_1_53?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-53",
+ },
+ {
+ "asin": "B0DM6JN33F",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Amazon Basics 1/4 Inch Thick TPE Exercise Yoga Mat with Carrying Strap",
+ "pageIndex": 1,
+ "price": "$19.27",
+ "priceCents": 1927,
+ "rating": 4.6,
+ "reviewCount": 968,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Amazon-Basics-Thick-Exercise-Carrying/dp/B0DM6JN33F/ref=sr_1_54?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-54",
+ },
+ {
+ "asin": "B0876QTBKZ",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "CAMBIVO Extra Long Wide Yoga Mat, Thick PVC Workout Mat",
+ "pageIndex": 1,
+ "price": "$39.99",
+ "priceCents": 3999,
+ "rating": 4.6,
+ "reviewCount": 3287,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/CAMBIVO-Yoga-Mat-Exercise-Barefoot/dp/B0876QTBKZ/ref=sr_1_55?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-55",
+ },
+ {
+ "asin": "B0C35Q9B95",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gaiam Yoga Mat - Premium 5mm Solid Thick Non Slip Exercise & Fitness Mat for All Types of Yoga, Pilates & Floor Workouts (68" x 24" x 5mm)",
+ "pageIndex": 1,
+ "price": "$23.47",
+ "priceCents": 2347,
+ "rating": 4.4,
+ "reviewCount": 4305,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Gaiam-Premium-Exercise-Workouts-Sunburnt/dp/B0C35Q9B95/ref=sr_1_56?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-56",
+ },
+ {
+ "asin": "B08R5ZG89X",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Heathyoga Yoga Mat Non Slip TPE Pilates Exercise Mats for Home Workout Gym Fitness Eco Friendly Thick Hot Yoga Mat with Strap Body Alignment,72"x 26" Thickness 1/4"",
+ "pageIndex": 1,
+ "price": "$36.99",
+ "priceCents": 3699,
+ "rating": 4.5,
+ "reviewCount": 9506,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Heathyoga-Friendly-Alignment-Certified-Material/dp/B08R5ZG89X/ref=sr_1_57?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-57",
+ },
+ {
+ "asin": "B07664K1CW",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "ProsourceFit Exercise Balance Pad, Non-Slip Cushioned Foam Mat & Knee Pad for Fitness and Stability Training, Yoga, Physical Therapy",
+ "pageIndex": 1,
+ "price": "$26.99",
+ "priceCents": 2699,
+ "rating": 4.8,
+ "reviewCount": 17261,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/ProsourceFit-Exercise-Balance-Pad-Cushioned/dp/B07664K1CW/ref=sr_1_58?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-58",
+ },
+ {
+ "asin": "B07234M1LY",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "CAP Non-Slip Yoga Mat for Home Workout & Pilates, Lightweight Exercise Fitness Mat with Textured Grip Surface, Portable Roll-Up Yoga Mat for Women & Men",
+ "pageIndex": 1,
+ "price": "$11.99",
+ "priceCents": 1199,
+ "rating": 4.2,
+ "reviewCount": 5199,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/CAP-Non-Slip-Exercises-Lightweight-Exercise/dp/B07234M1LY/ref=sr_1_59?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-59",
+ },
+ {
+ "asin": "B0CF64T5JY",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Liforme Travel Yoga Mat - Patented Alignment Design, Advanced Non-Slip Grip",
+ "pageIndex": 1,
+ "price": "$160.00",
+ "priceCents": 16000,
+ "rating": 4.4,
+ "reviewCount": 984,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/Liforme-Travel-Yoga-Biodegradable-Ultra-Lightweight/dp/B0CF64T5JY/ref=sr_1_60?dib=x&dib_tag=x&keywords=yoga+mat&qid=x&sr=8-60",
+ },
+ {
+ "asin": "B092XMWXK7",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Retrospec Solana Yoga Mat 1" Thick w/Nylon Strap for Men & Women - Non Slip Exercise Mat for Home Yoga, Pilates, Stretching, Floor & Fitness Workouts",
+ "pageIndex": 1,
+ "price": "$39.99",
+ "priceCents": 3999,
+ "rating": 4.5,
+ "reviewCount": 14999,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FRetrospec-Solana-Thick-Nylon-Strap%2Fdp%2FB092XMWXK7%2Fref%3Dsxbs_pa_sp_search_thematic_btf_sspa%3Fcontent-id%3Damzn1.sym.230701df-77c6-4675-b11d-39cf91b8bb1b%253Aamzn1.sym.230701df-77c6-4675-b11d-39cf91b8bb1b%26cv_ct_cx%3Dyoga%2Bmat%26keywords%3Dyoga%2Bmat%26pd_rd_i%3DB092XMWXK7%26pd_rd_r%3Dde21bbd9-1795-44ef-a802-9f896189ba48%26pd_rd_w%3DjO2FJ%26pd_rd_wg%3DgaFhJ%26pf_rd_p%3D230701df-77c6-4675-b11d-39cf91b8bb1b%26pf_rd_r%3DP96Q3KMBPB9SP02HFAJV%26qid%3D1790222636%26sbo%3DRZvfv%252F%252FHxDF%252BO5021pAnSA%253D%253D%26sr%3D1-1-2111c337-e22b-460d-97fe-5be1c77b95e4-spons%26aref%3DoP6DnEEjSV%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9zZWFyY2hfdGhlbWF0aWNfYnRm%26psc%3D1&aref=x",
+ },
+ {
+ "asin": "B0GNTSW3GY",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Stakt Foldable Yoga Mat Pro - Non Slip 2 in 1 Fitness Exercise Mat",
+ "pageIndex": 1,
+ "price": "$128.00",
+ "priceCents": 12800,
+ "rating": 4.2,
+ "reviewCount": 57,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FStakt-Foldable-Yoga-Mat-Pro%2Fdp%2FB0GNTSW3GY%2Fref%3Dsxbs_pa_sp_search_thematic_btf_sspa%3Fcontent-id%3Damzn1.sym.230701df-77c6-4675-b11d-39cf91b8bb1b%253Aamzn1.sym.230701df-77c6-4675-b11d-39cf91b8bb1b%26cv_ct_cx%3Dyoga%2Bmat%26keywords%3Dyoga%2Bmat%26pd_rd_i%3DB0GNTSW3GY%26pd_rd_r%3Dde21bbd9-1795-44ef-a802-9f896189ba48%26pd_rd_w%3DjO2FJ%26pd_rd_wg%3DgaFhJ%26pf_rd_p%3D230701df-77c6-4675-b11d-39cf91b8bb1b%26pf_rd_r%3DP96Q3KMBPB9SP02HFAJV%26qid%3D1790222636%26sbo%3DRZvfv%252F%252FHxDF%252BO5021pAnSA%253D%253D%26sr%3D1-2-2111c337-e22b-460d-97fe-5be1c77b95e4-spons%26aref%3DvpBIlzoJbf%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9zZWFyY2hfdGhlbWF0aWNfYnRm%26psc%3D1&aref=x",
+ },
+ {
+ "asin": "B00DJQFAV6",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Manduka PRO Yoga Mat - 6mm | Lifetime Durability | Hygienic Construction | Premium Studio Quality | Teacher Approved",
+ "pageIndex": 1,
+ "price": "$144.00",
+ "priceCents": 14400,
+ "rating": 4.5,
+ "reviewCount": 9005,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FManduka-PRO-Yoga-Pilates-Verve%2Fdp%2FB00DJQFAV6%2Fref%3Dsxbs_pa_sp_search_thematic_btf_sspa%3Fcontent-id%3Damzn1.sym.230701df-77c6-4675-b11d-39cf91b8bb1b%253Aamzn1.sym.230701df-77c6-4675-b11d-39cf91b8bb1b%26cv_ct_cx%3Dyoga%2Bmat%26keywords%3Dyoga%2Bmat%26pd_rd_i%3DB00DJQFAV6%26pd_rd_r%3Dde21bbd9-1795-44ef-a802-9f896189ba48%26pd_rd_w%3DjO2FJ%26pd_rd_wg%3DgaFhJ%26pf_rd_p%3D230701df-77c6-4675-b11d-39cf91b8bb1b%26pf_rd_r%3DP96Q3KMBPB9SP02HFAJV%26qid%3D1790222636%26sbo%3DRZvfv%252F%252FHxDF%252BO5021pAnSA%253D%253D%26sr%3D1-3-2111c337-e22b-460d-97fe-5be1c77b95e4-spons%26aref%3D63wuFgi1b8%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9zZWFyY2hfdGhlbWF0aWNfYnRm%26psc%3D1&aref=x",
+ },
+ {
+ "asin": "B0DFBSMMZS",
+ "delta": {
+ "dPriceCents": 0,
+ "dRating": 0,
+ "dReviews": 0,
+ "isNew": false,
+ },
+ "isPrime": false,
+ "name": "PAIDU Foldable Yoga Mat 10mm Thick, 75"x26" Non-Slip Fitness Exercise Mat",
+ "pageIndex": 1,
+ "price": "$51.49",
+ "priceCents": 5149,
+ "rating": 4.3,
+ "reviewCount": 100,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FPAIDU-Foldable-material-portable-multi-functional%2Fdp%2FB0DFBSMMZS%2Fref%3Dsxbs_pa_sp_search_thematic_btf_sspa%3Fcontent-id%3Damzn1.sym.230701df-77c6-4675-b11d-39cf91b8bb1b%253Aamzn1.sym.230701df-77c6-4675-b11d-39cf91b8bb1b%26cv_ct_cx%3Dyoga%2Bmat%26keywords%3Dyoga%2Bmat%26pd_rd_i%3DB0DFBSMMZS%26pd_rd_r%3Dde21bbd9-1795-44ef-a802-9f896189ba48%26pd_rd_w%3DjO2FJ%26pd_rd_wg%3DgaFhJ%26pf_rd_p%3D230701df-77c6-4675-b11d-39cf91b8bb1b%26pf_rd_r%3DP96Q3KMBPB9SP02HFAJV%26qid%3D1790222636%26sbo%3DRZvfv%252F%252FHxDF%252BO5021pAnSA%253D%253D%26sr%3D1-4-2111c337-e22b-460d-97fe-5be1c77b95e4-spons%26aref%3DD9jRgmzR9f%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9zZWFyY2hfdGhlbWF0aWNfYnRm%26psc%3D1&aref=x",
+ },
+ {
+ "asin": "B0HF4FK2QS",
+ "delta": {
+ "dPriceCents": null,
+ "dRating": null,
+ "dReviews": null,
+ "isNew": true,
+ },
+ "isPrime": false,
+ "name": "Gruper Yoga Mat Non Slip, TPE Extra Wide Yoga Mat with Alignment Mark",
+ "pageIndex": 1,
+ "price": "$35.99",
+ "priceCents": 3599,
+ "rating": 4.4,
+ "reviewCount": 474,
+ "runId": "run-golden",
+ "scrapedAt": "string",
+ "url": "https://www.amazon.com/sspa/click?ie=UTF8&spc=x&url=%2FGruper-TPE-Blue-Black-Yoga-Mat%2Fdp%2FB0HF4FK2QS%2Fref%3Dsxbs_pa_sp_search_thematic_btf_sspa%3Fcontent-id%3Damzn1.sym.230701df-77c6-4675-b11d-39cf91b8bb1b%253Aamzn1.sym.230701df-77c6-4675-b11d-39cf91b8bb1b%26cv_ct_cx%3Dyoga%2Bmat%26keywords%3Dyoga%2Bmat%26pd_rd_i%3DB0HF4FK2QS%26pd_rd_r%3Dde21bbd9-1795-44ef-a802-9f896189ba48%26pd_rd_w%3DjO2FJ%26pd_rd_wg%3DgaFhJ%26pf_rd_p%3D230701df-77c6-4675-b11d-39cf91b8bb1b%26pf_rd_r%3DP96Q3KMBPB9SP02HFAJV%26qid%3D1790222636%26sbo%3DRZvfv%252F%252FHxDF%252BO5021pAnSA%253D%253D%26sr%3D1-5-2111c337-e22b-460d-97fe-5be1c77b95e4-spons%26aref%3DRf9xQFU1RZ%26sp_csd%3Dd2lkZ2V0TmFtZT1zcF9zZWFyY2hfdGhlbWF0aWNfYnRm%26psc%3D1&aref=x",
+ },
+ ],
+ "runPages": [
+ {
+ "count": 70,
+ "pageIndex": 1,
+ "url": "https://www.amazon.com/s?k=yoga+mat",
+ },
+ ],
+ "sent": [
+ "UPDATE_PROGRESS",
+ "ENQUEUE_SYNC",
+ ],
+}
+`;
diff --git a/tests/golden/characterize.test.js b/tests/golden/characterize.test.js
new file mode 100644
index 0000000..13b6977
--- /dev/null
+++ b/tests/golden/characterize.test.js
@@ -0,0 +1,113 @@
+/**
+ * Records what today's code does on every corpus page, bugs included, so
+ * refactors can prove they changed nothing. The snapshot is the contract:
+ * update it only in a commit that means to change behavior.
+ */
+const { loadContentScript } = require('../setup/dom-helpers');
+const { corpus } = require('../setup/corpus');
+const Analyzer = require('../../scripts/modules/analyzer');
+const Price = require('../../scripts/modules/price');
+
+const PAGES = corpus();
+const PRICE_STRINGS = [
+ '$19.99', '$1,299.00', '$12.99 - $24.99', '$19.99 ($0.33/Ounce)', 'See price in cart',
+ '€19,99', 'CDN$ 24.99', '$0.00', 'FREE', '$24.99 + $5.99 shipping', '$1.005',
+ 'Was: $30.00 Now: $20.00', 'N/A', '', null, 19.99,
+];
+
+// Fake timers keep the 2 second page navigation from firing.
+beforeAll(() => jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask', 'Date'] }));
+afterAll(() => jest.useRealTimers());
+
+function attempt(fn) {
+ try { return fn(); } catch (e) { return `throws ${e.constructor.name}`; }
+}
+
+function runScrape(page) {
+ let listener = null;
+ const sent = [];
+ const origAdd = chrome.runtime.onMessage.addListener;
+ const origSend = chrome.runtime.sendMessage;
+ chrome.runtime.onMessage.addListener = (fn) => { listener = fn; };
+ chrome.runtime.sendMessage = (msg) => { sent.push(msg.type); };
+ chrome.storage.local._reset();
+ try {
+ const ctx = loadContentScript('scripts/content/scraper.js', page.html, page.expected.url);
+ chrome.storage.local.set({ scrapeRunId: 'run-golden', scrapeRunPageIndex: 0 });
+ listener({ type: 'START_SCRAPING' }, {}, () => {});
+ const store = chrome.storage.local._getStore();
+ const nav = ctx.console.log.mock.calls.map((c) => c[0]).filter((l) => /Navigating/.test(l));
+ return {
+ sent,
+ nav,
+ isScrapingActive: store.isScrapingActive,
+ currentItemCount: store.currentItemCount,
+ runPages: (store.scrapeRunPages || []).map(({ pageIndex, count, url }) => ({ pageIndex, count, url })),
+ results: (store.results || []).map((r) => ({ ...r, scrapedAt: typeof r.scrapedAt })),
+ helpers: {
+ total: ctx.getTotalResults(),
+ hasNext: ctx.hasNextPage(),
+ nextUrl: ctx.getNextPageUrl(),
+ },
+ };
+ } finally {
+ chrome.storage.local._reset();
+ chrome.runtime.onMessage.addListener = origAdd;
+ chrome.runtime.sendMessage = origSend;
+ jest.clearAllTimers();
+ }
+}
+
+describe('current scraper behavior on the corpus', () => {
+ test.each(PAGES.map((p) => [p.id, p]))('%s', (_id, page) => {
+ expect(runScrape(page)).toMatchSnapshot();
+ });
+});
+
+describe('current offer parsing on the corpus', () => {
+ let ctx;
+ beforeAll(() => {
+ ctx = loadContentScript('scripts/content/offer-fetcher.js', '', 'https://www.amazon.com/s?k=x');
+ });
+
+ test.each(PAGES.map((p) => [p.id, p]))('%s', (_id, page) => {
+ const doc = new ctx.DOMParser().parseFromString(page.html, 'text/html');
+ expect(ctx.extractPricesFromDocument(doc)).toMatchSnapshot();
+ });
+
+ test('urls', () => {
+ expect([ctx.buildAodUrl('B0TEST0001'), ctx.buildOfferUrl('B0TEST0001')]).toMatchSnapshot();
+ });
+});
+
+describe('current price, rating and review parsers', () => {
+ let scraper;
+ let offers;
+ beforeAll(() => {
+ const blank = '';
+ scraper = loadContentScript('scripts/content/scraper.js', blank);
+ offers = loadContentScript('scripts/content/offer-fetcher.js', blank);
+ });
+
+ test('price strings', () => {
+ const rows = PRICE_STRINGS.map((s) => ({
+ input: s,
+ offer: attempt(() => offers.parseOfferPrice(s)),
+ analyzer: attempt(() => Analyzer.parsePrice(s)),
+ cents: Price.priceToCents(s),
+ }));
+ expect(rows).toMatchSnapshot();
+ });
+
+ test('rating and review strings', () => {
+ const inputs = ['4.5 out of 5 stars', '4.7', '', null, '(108.3K)', '(1.2M)', '(64)', '12,847', 'N/A', 'calificaciones'];
+ const rows = inputs.map((s) => ({
+ input: s,
+ ratingText: scraper.parseRatingText(s),
+ reviewText: scraper.parseReviewText(s),
+ analyzerRating: Analyzer.parseRating(s),
+ analyzerReviews: Analyzer.parseReviewCount(s),
+ }));
+ expect(rows).toMatchSnapshot();
+ });
+});
diff --git a/tests/golden/golden.test.js b/tests/golden/golden.test.js
new file mode 100644
index 0000000..11b83c2
--- /dev/null
+++ b/tests/golden/golden.test.js
@@ -0,0 +1,161 @@
+/**
+ * Golden corpus: Parsers against the expected.json written from each page.
+ *
+ * Checks today's code gets wrong are listed in KNOWN with their finding id
+ * and run as test.failing. When a fix lands the check passes, test.failing
+ * reports that, and the entry comes out of KNOWN.
+ *
+ * A test.failing passes on any error, a crash included, so every page also
+ * gets plain checks that must hold today: the parsers run without throwing
+ * and return the right shape. A broken harness or parser fails those.
+ *
+ * NEW-PARSE-1 has no audit finding: the header count selector does not match
+ * the "of over 10,000 results" header, so total comes back 0.
+ */
+const Parsers = require('../../scripts/lib/parsers');
+const { corpus, parseDoc } = require('../setup/corpus');
+
+const KNOWN = {
+ '2026-09/search-yoga-mat': {
+ 'each ASIN once': 'F-27',
+ 'only search-result cards': 'F-27',
+ 'sponsored count': 'F-16',
+ 'no sspa ad links': 'F-16',
+ 'next href is the page link': 'F-15',
+ 'total results': 'NEW-PARSE-1',
+ },
+ '2026-09/search-title-recipe-synthetic': {
+ 'each ASIN once': 'F-27',
+ 'sponsored count': 'F-16',
+ 'no sspa ad links': 'F-16',
+ 'next href is the page link': 'F-15',
+ 'spot B0SPONS001': 'F-17',
+ 'spot B0UNITONLY': 'F-17',
+ },
+ '2026-09/search-no-pagination-synthetic': {
+ 'page kind': 'F-15',
+ 'next page': 'F-15',
+ },
+ '2026-09/interstitial-akamai': { 'page kind': 'F-12' },
+ '2026-09/captcha-synthetic': { 'page kind': 'F-12' },
+ '2026-09/product-dp': { 'page kind': 'F-11' },
+ '2026-09/aod-pinned-only': {
+ 'seller prices': 'F-30',
+ 'total offer count': 'F-37',
+ },
+};
+
+const used = new Set();
+function check(id, name, fn) {
+ const fid = KNOWN[id] && KNOWN[id][name];
+ if (fid) {
+ used.add(`${id}|${name}`);
+ test.failing(`${name} [known failure ${fid}]`, fn);
+ } else {
+ test(name, fn);
+ }
+}
+
+const pageNumber = (href, base) => Number(new URL(href, base).searchParams.get('page'));
+
+for (const page of corpus()) {
+ const { id, expected: exp } = page;
+
+ describe(id, () => {
+ let doc;
+ let parsed;
+ beforeAll(() => {
+ doc = parseDoc(page.html, exp.url);
+ parsed = Parsers.parseSearchPage(doc, exp.url);
+ });
+
+ test('parses without throwing', () => {
+ expect(() => Parsers.parseSearchPage(doc, exp.url)).not.toThrow();
+ expect(Array.isArray(parsed.products)).toBe(true);
+ expect(typeof parsed.kind).toBe('string');
+ });
+
+ if (exp.type === 'search') {
+ check(id, 'page kind', () => {
+ expect(parsed.kind).toBe(exp.kind);
+ });
+
+ check(id, 'each ASIN once', () => {
+ const asins = parsed.products.map((p) => p.asin);
+ expect(asins.length).toBe(new Set(asins).size);
+ });
+
+ check(id, 'only search-result cards', () => {
+ const found = [...new Set(parsed.products.map((p) => p.asin))].sort();
+ expect(found).toEqual([...exp.asins].sort());
+ });
+
+ check(id, 'sponsored count', () => {
+ expect(parsed.products.filter((p) => p.sponsored === true).length).toBe(exp.sponsored);
+ });
+
+ check(id, 'no sspa ad links', () => {
+ expect(parsed.products.filter((p) => /\/sspa\//.test(p.url || ''))).toEqual([]);
+ });
+
+ check(id, 'next page', () => {
+ if (exp.next === null) {
+ expect(parsed.nextHref).toBeNull();
+ } else {
+ expect(parsed.nextHref).not.toBeNull();
+ expect(pageNumber(parsed.nextHref, exp.url)).toBe(pageNumber(exp.next, exp.url));
+ }
+ });
+
+ if (exp.nextExact) {
+ check(id, 'next href is the page link', () => {
+ expect(new URL(parsed.nextHref, exp.url).href).toBe(new URL(exp.next, exp.url).href);
+ });
+ }
+
+ check(id, 'total results', () => {
+ expect(parsed.total).toBe(exp.total);
+ });
+
+ if (exp.minFill !== undefined) {
+ check(id, 'fill rate', () => {
+ expect(parsed.fill.asin).toBeGreaterThanOrEqual(exp.minFill);
+ expect(parsed.fill.title).toBeGreaterThanOrEqual(exp.minFill);
+ expect(parsed.fill.price).toBeGreaterThanOrEqual(exp.minFill);
+ });
+ }
+
+ for (const [asin, want] of Object.entries(exp.spot || {})) {
+ check(id, `spot ${asin}`, () => {
+ const got = parsed.products.find((p) => p.asin === asin);
+ expect(got).toBeDefined();
+ expect({ name: got.name, priceCents: got.priceCents }).toEqual(want);
+ });
+ }
+ }
+
+ if (exp.offers) {
+ test('offer parser runs on an offers page', () => {
+ let prices;
+ expect(() => { prices = Parsers.extractPricesFromDocument(doc); }).not.toThrow();
+ expect(Array.isArray(prices)).toBe(true);
+ });
+
+ check(id, 'seller prices', () => {
+ expect(Parsers.extractPricesFromDocument(doc)).toEqual(exp.offers.prices);
+ });
+
+ if (exp.offers.totalOfferCount !== undefined) {
+ check(id, 'total offer count', () => {
+ expect(typeof Parsers.totalOfferCount).toBe('function');
+ expect(Parsers.totalOfferCount(doc)).toBe(exp.offers.totalOfferCount);
+ });
+ }
+ }
+ });
+}
+
+test('every known failure names a real check', () => {
+ const listed = Object.entries(KNOWN).flatMap(([id, checks]) => Object.keys(checks).map((n) => `${id}|${n}`));
+ expect(listed.filter((k) => !used.has(k))).toEqual([]);
+});
diff --git a/tests/pages/2026-02/aod-synthetic.expected.json b/tests/pages/2026-02/aod-synthetic.expected.json
new file mode 100644
index 0000000..5f0cfc9
--- /dev/null
+++ b/tests/pages/2026-02/aod-synthetic.expected.json
@@ -0,0 +1,15 @@
+{
+ "type": "offers",
+ "synthetic": true,
+ "url": "https://www.amazon.com/gp/aod/ajax?asin=B0TEST001",
+ "source": "Copy of tests/fixtures/amazon-offer-aod.html. Invented markup (F-82): real AOD responses have no .aod-information-block.",
+ "offers": {
+ "prices": [
+ 24.99,
+ 31.5,
+ 27,
+ 22.99,
+ 35
+ ]
+ }
+}
diff --git a/tests/pages/2026-02/aod-synthetic.html b/tests/pages/2026-02/aod-synthetic.html
new file mode 100644
index 0000000..f7d4c86
--- /dev/null
+++ b/tests/pages/2026-02/aod-synthetic.html
@@ -0,0 +1,23 @@
+
+
+AOD Offer Listing Fixture
+
+
+
+
diff --git a/tests/pages/2026-02/offer-classic-synthetic.expected.json b/tests/pages/2026-02/offer-classic-synthetic.expected.json
new file mode 100644
index 0000000..1e68a60
--- /dev/null
+++ b/tests/pages/2026-02/offer-classic-synthetic.expected.json
@@ -0,0 +1,13 @@
+{
+ "type": "offers",
+ "synthetic": true,
+ "url": "https://www.amazon.com/gp/offer-listing/B0TEST001",
+ "source": "Copy of tests/fixtures/amazon-offer-classic.html. The live classic URL redirects to the product page (F-32).",
+ "offers": {
+ "prices": [
+ 22.5,
+ 28,
+ 19.99
+ ]
+ }
+}
diff --git a/tests/pages/2026-02/offer-classic-synthetic.html b/tests/pages/2026-02/offer-classic-synthetic.html
new file mode 100644
index 0000000..58dd539
--- /dev/null
+++ b/tests/pages/2026-02/offer-classic-synthetic.html
@@ -0,0 +1,14 @@
+
+
+Classic Offer Listing Fixture
+
+
+
$22.50
+
$28.00
+
$19.99
+
+
+$35.99
+$40.00
+
+
diff --git a/tests/pages/2026-02/search-lastpage.expected.json b/tests/pages/2026-02/search-lastpage.expected.json
new file mode 100644
index 0000000..765288e
--- /dev/null
+++ b/tests/pages/2026-02/search-lastpage.expected.json
@@ -0,0 +1,15 @@
+{
+ "type": "search",
+ "synthetic": true,
+ "url": "https://www.amazon.com/s?k=test+widgets&page=5",
+ "source": "Copy of tests/fixtures/amazon-search-lastpage.html.",
+ "kind": "last",
+ "asins": [
+ "B0LAST001"
+ ],
+ "placements": 1,
+ "sponsored": 0,
+ "next": null,
+ "total": 0,
+ "minFill": 1
+}
diff --git a/tests/pages/2026-02/search-lastpage.html b/tests/pages/2026-02/search-lastpage.html
new file mode 100644
index 0000000..6911335
--- /dev/null
+++ b/tests/pages/2026-02/search-lastpage.html
@@ -0,0 +1,25 @@
+
+
+Amazon Search Results - Last Page
+
+
+
+
+
+
Last Page Product
+
+ $19.99
+
+
+ 4.0 out of 5 stars
+
+
(500)
+
+
+
+
+
+
+
+
+
diff --git a/tests/pages/2026-02/search-results.expected.json b/tests/pages/2026-02/search-results.expected.json
new file mode 100644
index 0000000..e574147
--- /dev/null
+++ b/tests/pages/2026-02/search-results.expected.json
@@ -0,0 +1,29 @@
+{
+ "type": "search",
+ "synthetic": true,
+ "url": "https://www.amazon.com/s?k=test+widgets",
+ "source": "Copy of tests/fixtures/amazon-search-results.html (hand-written to the Feb 2026 selectors).",
+ "kind": "results",
+ "asins": [
+ "B0TEST001",
+ "B0TEST002",
+ "B0TEST003",
+ "B0TEST004"
+ ],
+ "placements": 4,
+ "sponsored": 0,
+ "next": "/s?k=test+widgets&page=2",
+ "nextExact": false,
+ "total": 523,
+ "minFill": 0.75,
+ "spot": {
+ "B0TEST001": {
+ "name": "Test Widget Pro 2000",
+ "priceCents": 2999
+ },
+ "B0TEST003": {
+ "name": "Mystery Item No Data",
+ "priceCents": null
+ }
+ }
+}
diff --git a/tests/pages/2026-02/search-results.html b/tests/pages/2026-02/search-results.html
new file mode 100644
index 0000000..dd21bab
--- /dev/null
+++ b/tests/pages/2026-02/search-results.html
@@ -0,0 +1,67 @@
+
+
+Amazon Search Results Test Fixture
+
+
+
+
+
+
Test Widget Pro 2000
+
+ $29.99
+
+
+ 4.5 out of 5 stars
+
+
(12.8K)
+
+
+
+
+
+
+
Budget Gadget Basic
+
+ $9.99
+
+
+ 3.8 out of 5 stars
+
+
(247)
+
+
+
+
+
+
Mystery Item No Data
+
+
+
+
+
+
+
+
+
Legacy Star Product
+
+ $49.99
+
+
+ 4.2 out of 5 stars
+
+
(1.5K)
+
+
+
+
+
+
+
+
+ 1-48 of 523 results for "test widgets"
+
+
+
+
+
+
diff --git a/tests/pages/2026-09/aod-pinned-only.expected.json b/tests/pages/2026-09/aod-pinned-only.expected.json
new file mode 100644
index 0000000..f0cf899
--- /dev/null
+++ b/tests/pages/2026-09/aod-pinned-only.expected.json
@@ -0,0 +1,11 @@
+{
+ "type": "offers",
+ "url": "https://www.amazon.com/gp/product/ajax/aodAjaxMain/?asin=B09B8V1LZ3",
+ "source": "Live aodAjaxMain fragment on 2026-09-23 (audit lane offers-price): the pinned buy-box offer only, aod-total-offer-count 0.",
+ "offers": {
+ "prices": [
+ 79.99
+ ],
+ "totalOfferCount": 0
+ }
+}
diff --git a/tests/pages/2026-09/aod-pinned-only.html b/tests/pages/2026-09/aod-pinned-only.html
new file mode 100644
index 0000000..9e721c2
--- /dev/null
+++ b/tests/pages/2026-09/aod-pinned-only.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/pages/2026-09/captcha-synthetic.expected.json b/tests/pages/2026-09/captcha-synthetic.expected.json
new file mode 100644
index 0000000..5aaaf68
--- /dev/null
+++ b/tests/pages/2026-09/captcha-synthetic.expected.json
@@ -0,0 +1,12 @@
+{
+ "type": "search",
+ "synthetic": true,
+ "url": "https://www.amazon.com/s?k=yoga+mat&page=3",
+ "source": "Hand-written from the Amazon captcha form (validateCaptcha, captchacharacters).",
+ "kind": "captcha",
+ "asins": [],
+ "placements": 0,
+ "sponsored": 0,
+ "next": null,
+ "total": 0
+}
diff --git a/tests/pages/2026-09/captcha-synthetic.html b/tests/pages/2026-09/captcha-synthetic.html
new file mode 100644
index 0000000..470bbbc
--- /dev/null
+++ b/tests/pages/2026-09/captcha-synthetic.html
@@ -0,0 +1,15 @@
+
+Amazon.com
+
+
+
Enter the characters you see below
+
Sorry, we just need to make sure you're not a robot. For best results, please make sure your browser is accepting cookies.
+
+
+
diff --git a/tests/pages/2026-09/interstitial-akamai.expected.json b/tests/pages/2026-09/interstitial-akamai.expected.json
new file mode 100644
index 0000000..af6e089
--- /dev/null
+++ b/tests/pages/2026-09/interstitial-akamai.expected.json
@@ -0,0 +1,11 @@
+{
+ "type": "search",
+ "url": "https://www.amazon.com/s?k=usb+c+cable&page=2",
+ "source": "Live GET on 2026-09-23 (audit lane scraper-pagination) that got an Akamai bot interstitial, sanitized.",
+ "kind": "interstitial",
+ "asins": [],
+ "placements": 0,
+ "sponsored": 0,
+ "next": null,
+ "total": 0
+}
diff --git a/tests/pages/2026-09/interstitial-akamai.html b/tests/pages/2026-09/interstitial-akamai.html
new file mode 100644
index 0000000..1827078
--- /dev/null
+++ b/tests/pages/2026-09/interstitial-akamai.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tests/pages/2026-09/product-dp.expected.json b/tests/pages/2026-09/product-dp.expected.json
new file mode 100644
index 0000000..080f849
--- /dev/null
+++ b/tests/pages/2026-09/product-dp.expected.json
@@ -0,0 +1,14 @@
+{
+ "type": "search",
+ "url": "https://www.amazon.com/dp/B09B8V1LZ3",
+ "source": "Live logged-out product page GET on 2026-09-23 (audit lane offers-price), sanitized. Also what the classic offer-listing fallback redirects to.",
+ "kind": "unknown",
+ "asins": [],
+ "placements": 0,
+ "sponsored": 0,
+ "next": null,
+ "total": 0,
+ "offers": {
+ "prices": []
+ }
+}
diff --git a/tests/pages/2026-09/product-dp.html b/tests/pages/2026-09/product-dp.html
new file mode 100644
index 0000000..df92930
--- /dev/null
+++ b/tests/pages/2026-09/product-dp.html
@@ -0,0 +1,12426 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+2022 Echo Dot 5th Gen Smart Speaker | Charcoal | Amazon
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Get full access to Alexa+ across compatible devices, Alexa.com, and the Alexa app free with Prime membership. For non-Prime customers, the Alexa+ Standard plan is $19.99/month. Learn more.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Small size, big possibilities
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Our most popular smart speaker delivers vibrant sound in a compact design. With Echo Dot, you can stay on track with help from Alexa, control compatible smart home devices and start routines with your motion.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Climate Pledge
+Friendly
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Compact speaker, big vibrant sound
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Echo Dot delivers crisp vocals in a compact design. Just ask Alexa to play music, podcasts, and audiobooks and enjoy a dynamic audio experience anywhere in your home. Learn more about Alexa Entertainment
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Technical Details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Echo Dot (5th Gen)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Size
+
+
+
+
+
+
+
+
+
+
+ 3.9” x 3.9” x 3.5” (100mm x 100mm x 89 mm)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Weight
+
+
+
+
+
+
+
+
+
+
+ 10.7 oz (304g) Actual size and weight may vary by manufacturing process
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Audio Bandwidth
+
+
+
+
+
+
+
+
+
+
+ 62 Hz - 19 kHz (volume 6)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loudspeaker Driver Dimensions
+
+
+
+
+
+
+
+
+
+
+ 1.6” full range driver
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Playback Channels
+
+
+
+
+
+
+
+
+
+
+ Mono
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Audio Signal Processing Technology
+
+
+
+
+
+
+
+
+
+
+ N/A
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Wifi connectivity
+
+
+
+
+
+
+
+
+
+
+ Dual-band Wi-Fi supports 802.11a/b/g/n/ac (2.4 and 5 GHz) networks. Does not support connecting to ad-hoc (or peer-to-peer) wifi networks.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Amazon Sidewalk
+
+
+
+
+
+
+
+
+
+
+ Sidewalk uses Echo and Ring devices to securely keep other devices connected and help find lost items by reporting their approximate location to the owner. Sidewalk can enable supported devices to remain connected even when wifi is lost or the supported devices are outside the range of their home wifi. Sidewalk uses a small portion of your internet bandwidth and the approximate location of your devices to provide these benefits to you and your neighbors. Sidewalk is turned on for your device unless you have previously turned it off. You can turn off Sidewalk at any time. Learn more about Amazon Sidewalk.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Smart Home Device Compatibility
+
+
+
+
+
+
+
+
+
+
+ WiFi, Bluetooth Low Energy Mesh, and Matter
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bluetooth connectivity
+
+
+
+
+
+
+
+
+
+
+ Advanced Audio Distribution Profile (A2DP) support for audio streaming from your mobile device to Echo Dot or from Echo Dot to your Bluetooth speaker. Audio/Video Remote Control Profile (AVRCP) for voice control of connected mobile devices. Hands-free voice control is not supported for Mac OS X devices. Bluetooth speakers requiring PIN codes are not supported.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ eero Built-in
+
+
+
+
+
+
+
+
+
+
+ Your wifi network must use a compatible eero router and your Echo device must stay in range of a compatible eero device. You will need to link your eero and Amazon accounts and manage eero Built-in from the eero mobile application to use this feature. eero Built-in compatible Echo devices support up to 1,000 sq. ft. of additional coverage, speeds up to 100 Mbps, and 10 or fewer connected devices in the 5 GHz band. Actual performance can vary and certain eero, Echo, or Alexa features may be incompatible with eero Built-in. Learn more about eero Built-in performance, availability and compatibility . Use of eero and eero-related products and services, including eero Built-in, requires creation of an eero account and agreement to eero’s Terms of Service . Review eero’s Privacy Notice .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ System requirements
+
+
+
+
+
+
+
+
+
+
+ Echo Dot comes ready to connect to your wifi. The Alexa app is compatible with Fire OS, Android, and iOS devices and also accessible via your web browser. Supported operating systems . Certain skills and services may require subscription or other fees.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Setup technology
+
+
+
+
+
+
+
+
+
+
+ Amazon Wi-Fi simple setup enables customers to connect smart devices to their wifi network in a few easy steps. Wi-Fi simple setup is another way Alexa is always getting smarter. Learn more about Wi-Fi simple setup .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Warranty and service
+
+
+
+
+
+
+
+
+
+
+ 90-day limited warranty and service . Optional 1-year , 2-year , and 3-year extended warranty available for U.S. customers sold separately. Use of Echo Dot is subject to these terms .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Accessibility features
+
+
+
+
+
+
+
+
+
+
+ To enable features that personalize Alexa to your abilities, go to Settings → Accessibility in the Alexa App.
+• Adaptive Listening provides customers more time to finish speaking before Alexa responds.
+• Preferred Speaking Rate allows customers to control how fast or slow Alexa speaks. To enable, say “Alexa, speak slower” or “Alexa, speak faster.”
+• Request Sound will play an audible tone in addition to the visual indicator when you speak to Alexa. To enable, say “Alexa, turn on Start of Request Sound.”
+• Kindle Read Aloud will have Alexa read your Kindle books aloud.
+• Notify When Nearby will play notification sounds when you’re detected near your Echo device.
+• Alexa Accessibility features also include several other settings for customers with accessibility needs related to vision, hearing, mobility, and speech.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Included in the box
+
+
+
+
+
+
+
+
+
+
+ Echo Dot, glacier white power adapter (15W), and Quick Start Guide.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Generation
+
+
+
+
+
+
+
+
+
+
+ Echo Dot (5th Gen) - 2022 release
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Privacy features
+
+
+
+
+
+
+
+
+
+
+ Wake word technology, streaming indicators, microphone off button, the ability to view and delete your voice recordings, and more. Visit the Alexa Privacy Hub to explore how Alexa and Echo devices are designed to protect your privacy.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Language
+
+
+
+
+
+
+
+
+
+
+ Alexa speaks English and Spanish
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Alexa Skills & Features
+
+
+
+
+
+
+
+
+
+
+ Alexa makes your life easier, more meaningful, and more fun by letting you voice control your world. Alexa can help you get more out of the things you already love and discover new possibilities you've never imagined. Discover and learn what Alexa can do .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Software Security Updates
+
+
+
+
+
+
+
+
+
+
+ Learn more about these software security updates . If you already own an Amazon Echo, visit Manage Your Content and Devices for information specific to your device.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/pages/2026-09/search-no-pagination-synthetic.expected.json b/tests/pages/2026-09/search-no-pagination-synthetic.expected.json
new file mode 100644
index 0000000..3935cf8
--- /dev/null
+++ b/tests/pages/2026-09/search-no-pagination-synthetic.expected.json
@@ -0,0 +1,22 @@
+{
+ "type": "search",
+ "synthetic": true,
+ "url": "https://www.amazon.com/s?k=rare+widget",
+ "source": "A results page with no pagination strip at all.",
+ "kind": "last",
+ "asins": [
+ "B0ONLYONE1",
+ "B0ONLYONE2"
+ ],
+ "placements": 2,
+ "sponsored": 0,
+ "next": null,
+ "total": 0,
+ "minFill": 1,
+ "spot": {
+ "B0ONLYONE2": {
+ "name": "Rare Widget, second result",
+ "priceCents": 124900
+ }
+ }
+}
diff --git a/tests/pages/2026-09/search-no-pagination-synthetic.html b/tests/pages/2026-09/search-no-pagination-synthetic.html
new file mode 100644
index 0000000..5435dd5
--- /dev/null
+++ b/tests/pages/2026-09/search-no-pagination-synthetic.html
@@ -0,0 +1,16 @@
+
+Amazon.com : rare widget
+
+
+
+
+
Rare Widget, single result
+
$41.00
+
+
+
+
Rare Widget, second result
+
$1,249.00
+
+
+
diff --git a/tests/pages/2026-09/search-title-recipe-synthetic.expected.json b/tests/pages/2026-09/search-title-recipe-synthetic.expected.json
new file mode 100644
index 0000000..05b2d18
--- /dev/null
+++ b/tests/pages/2026-09/search-title-recipe-synthetic.expected.json
@@ -0,0 +1,28 @@
+{
+ "type": "search",
+ "synthetic": true,
+ "url": "https://www.amazon.com/s?k=widget",
+ "source": "Current title-recipe card markup from the scraper-pagination audit fixtures: brand h2 before the title h2, an sspa sponsored card repeated as organic, and a card with only a unit price.",
+ "kind": "results",
+ "asins": [
+ "B0SPONS001",
+ "B0ORGANIC1",
+ "B0UNITONLY"
+ ],
+ "placements": 4,
+ "sponsored": 1,
+ "next": "/s?k=widget&page=2&ref=sr_pg_1",
+ "nextExact": true,
+ "total": 312,
+ "minFill": 0.6,
+ "spot": {
+ "B0SPONS001": {
+ "name": "Acme Widget Pro, 6 ft, Braided",
+ "priceCents": 1299
+ },
+ "B0UNITONLY": {
+ "name": "Bulk pack of widgets, 24 count",
+ "priceCents": null
+ }
+ }
+}
diff --git a/tests/pages/2026-09/search-title-recipe-synthetic.html b/tests/pages/2026-09/search-title-recipe-synthetic.html
new file mode 100644
index 0000000..f218941
--- /dev/null
+++ b/tests/pages/2026-09/search-title-recipe-synthetic.html
@@ -0,0 +1,39 @@
+
+Amazon.com : widget
+
+
1-4 of 312 results for "widget"
+
+
+
+
+
$12.99
+
4.4 out of 5 stars
+
(2.3K)
+
+
+
+
Widget Basic, 3 ft
+
$7.49
+
4.1 out of 5 stars
+
(512)
+
+
+
+
Bulk pack of widgets, 24 count
+
($0.35 /Count)
+
$8.99 (12 used & new offers)
+
+
+
+
Acme Widget Pro, 6 ft, Braided
+
$12.99
+
4.4 out of 5 stars
+
(2.3K)
+
+
+
+
diff --git a/tests/pages/2026-09/search-yoga-mat.expected.json b/tests/pages/2026-09/search-yoga-mat.expected.json
new file mode 100644
index 0000000..b092579
--- /dev/null
+++ b/tests/pages/2026-09/search-yoga-mat.expected.json
@@ -0,0 +1,83 @@
+{
+ "type": "search",
+ "url": "https://www.amazon.com/s?k=yoga+mat",
+ "source": "Live logged-out GET on 2026-09-23 (audit lane tests-build), sanitized.",
+ "kind": "results",
+ "asins": [
+ "B07H9PZ6MH",
+ "B091FX694F",
+ "B0H25VF5Y1",
+ "B092Y9XX85",
+ "B07H4G664R",
+ "B0FNX94PPX",
+ "B0HC3HNW26",
+ "B01LP0UX9G",
+ "B07H9PZDQW",
+ "B07H9KV2YN",
+ "B0H28L9MPP",
+ "B09M8D4JMD",
+ "B0GG8RY26S",
+ "B0DFBSMMZS",
+ "B0HGFH7QVH",
+ "B07JQCVBBZ",
+ "B09WF85T8M",
+ "B0GNWFRM3X",
+ "B0FHGMLGVG",
+ "B0DRJ8VH3Q",
+ "B0GJSKH1GN",
+ "B0C7SFV8RH",
+ "B0FQKYGYGX",
+ "B0B74MRJS3",
+ "B0GLQH4453",
+ "B092XNCFF7",
+ "B0D9MWTQ9K",
+ "B0FNXCBRGL",
+ "B0FX3RZ2YN",
+ "B01IZDFWPG",
+ "B0C359D7JX",
+ "B0BYFFX51Z",
+ "B09WF4GPPC",
+ "B0DG3WYD54",
+ "B091FZL25K",
+ "B0GFZ72SJR",
+ "B01MY5MZSQ",
+ "B005NZ7PEQ",
+ "B0CJ39LHNX",
+ "B0CP47T6ZP",
+ "B0CL9CR6FJ",
+ "B087F16GKW",
+ "B0F6426TRG",
+ "B01LP0U5X0",
+ "B0DQC2WV8Y",
+ "B01I5CFUHM",
+ "B07H9KV1GG",
+ "B0BPC5YDDV",
+ "B0DSR7K8CL",
+ "B0D4MFT49T",
+ "B0F7YCXYHV",
+ "B0FNN64N3C",
+ "B0DM6JN33F",
+ "B0876QTBKZ",
+ "B0C35Q9B95",
+ "B08R5ZG89X",
+ "B07664K1CW",
+ "B07234M1LY",
+ "B0CF64T5JY"
+ ],
+ "placements": 60,
+ "sponsored": 12,
+ "next": "/s?k=yoga+mat&page=2&xpid=6P3iGP5SAU5uo&qid=x&ref=sr_pg_1",
+ "nextExact": true,
+ "total": 10000,
+ "minFill": 0.9,
+ "spot": {
+ "B07H9PZ6MH": {
+ "name": "Gaiam Thick Yoga Mat, Exercise & Fitness Mat with Easy-Cinch Carrying Strap",
+ "priceCents": 2123
+ },
+ "B091FX694F": {
+ "name": "Retrospec Solana Yoga Mat Thick 1/2in Non-Slip Workout Mat with Nylon Strap - 72x24in Exercise Mat for Pilates, Stretching & Fitness - BPA Free, Easy Clean",
+ "priceCents": 2499
+ }
+ }
+}
diff --git a/tests/pages/2026-09/search-yoga-mat.html b/tests/pages/2026-09/search-yoga-mat.html
new file mode 100644
index 0000000..5cba8e2
--- /dev/null
+++ b/tests/pages/2026-09/search-yoga-mat.html
@@ -0,0 +1,8295 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Amazon.com : yoga mat
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/pages/README.md b/tests/pages/README.md
new file mode 100644
index 0000000..5cc70ed
--- /dev/null
+++ b/tests/pages/README.md
@@ -0,0 +1,31 @@
+# Page corpus
+
+Saved Amazon pages the parsers and the Chromium harness run against. No test
+here touches the network.
+
+Each page is `/.html` plus `.expected.json`. The folder is
+the month the page was captured, so a layout change shows up as a new folder
+rather than an edited fixture.
+
+`expected.json` holds what a correct parser should report, written from the
+page itself and not from the current code:
+
+- `type`: `search` or `offers`
+- `url`: the address the page was served at
+- `kind`: `results`, `last`, `empty`, `interstitial`, `captcha`, `signin` or `unknown`
+- `asins`: the search-result ASINs in page order, each once
+- `placements`: search-result cards, repeats included
+- `sponsored`: sponsored cards
+- `next`: the next-page link, or null on the last page. `nextExact` says whether
+ the href must match exactly or only the page number
+- `total`: the result count in the header, 0 when there is none
+- `minFill`: the share of products that must have an ASIN, title and price
+- `spot`: a few products checked field by field
+- `offers.prices`: seller prices in dollars, for offer pages
+- `synthetic`: true for hand-written pages
+
+Where today's code disagrees, the golden test marks that check as failing with
+the finding id from the audit, so the fix flips it.
+
+Captured pages are sanitized with `tools/lib/sanitize-page.mjs`. To capture new
+ones, run `node tools/refresh-fixtures.mjs` by hand. It never runs in CI.
diff --git a/tests/setup/corpus.js b/tests/setup/corpus.js
new file mode 100644
index 0000000..e0a91fd
--- /dev/null
+++ b/tests/setup/corpus.js
@@ -0,0 +1,47 @@
+/**
+ * Reads the golden page corpus in tests/pages//.
+ */
+const fs = require('fs');
+const path = require('path');
+const { JSDOM } = require('jsdom');
+const { quietConsole } = require('./dom-helpers');
+
+const PAGES = path.resolve(__dirname, '../pages');
+
+/** Every page with an expected.json, sorted, as {id, html, expected}. */
+function corpus() {
+ const out = [];
+ for (const month of fs.readdirSync(PAGES).sort()) {
+ const dir = path.join(PAGES, month);
+ if (!fs.statSync(dir).isDirectory()) continue;
+ for (const f of fs.readdirSync(dir).sort()) {
+ if (!f.endsWith('.expected.json')) continue;
+ const name = f.replace(/\.expected\.json$/, '');
+ out.push({
+ id: `${month}/${name}`,
+ expected: JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')),
+ html: fs.readFileSync(path.join(dir, `${name}.html`), 'utf8'),
+ });
+ }
+ }
+ return out;
+}
+
+/**
+ * A Document for `html` at `url`. jsdom has no innerText, so it falls back to
+ * textContent as in the content-script loader. Chromium's innerText can
+ * differ, which the e2e harness covers.
+ */
+function parseDoc(html, url) {
+ const dom = new JSDOM(html, { url, virtualConsole: quietConsole() });
+ const proto = dom.window.HTMLElement.prototype;
+ if (!Object.prototype.hasOwnProperty.call(proto, 'innerText')) {
+ Object.defineProperty(proto, 'innerText', {
+ get() { return this.textContent; },
+ configurable: true
+ });
+ }
+ return dom.window.document;
+}
+
+module.exports = { corpus, parseDoc, PAGES };
diff --git a/tests/setup/dom-helpers.js b/tests/setup/dom-helpers.js
index 1527547..becb3b9 100644
--- a/tests/setup/dom-helpers.js
+++ b/tests/setup/dom-helpers.js
@@ -7,7 +7,16 @@
const vm = require('vm');
const fs = require('fs');
const path = require('path');
-const { JSDOM } = require('jsdom');
+const { JSDOM, VirtualConsole } = require('jsdom');
+
+// Real Amazon pages carry CSS jsdom cannot parse; keep that out of test output.
+function quietConsole() {
+ const vc = new VirtualConsole();
+ vc.on('jsdomError', (err) => {
+ if (!/Could not parse CSS stylesheet/.test(err.message)) console.error(err);
+ });
+ return vc;
+}
/**
* Load a content script into a JSDOM context with Chrome API mocks.
@@ -19,7 +28,7 @@ const { JSDOM } = require('jsdom');
* @returns {Object} VM context with all script functions accessible
*/
function loadContentScript(scriptPath, html, url = 'https://www.amazon.com/s?k=test&page=1') {
- const dom = new JSDOM(html, { url, runScripts: 'outside-only' });
+ const dom = new JSDOM(html, { url, runScripts: 'outside-only', virtualConsole: quietConsole() });
// Polyfill innerText (JSDOM doesn't implement it)
if (!dom.window.HTMLElement.prototype.hasOwnProperty('innerText')) {
@@ -69,13 +78,13 @@ function loadContentScript(scriptPath, html, url = 'https://www.amazon.com/s?k=t
const absolutePath = path.resolve(__dirname, '../../', scriptPath);
- // Pre-load shared content-modules (Price, Delta) into the SAME script scope
+ // Pre-load shared content-modules (Price, Parsers, Delta) into the SAME script scope
// so content scripts can reference them as globals — mirroring how the
// manifest lists them before their consumers at runtime. Top-level `const`
// declarations share one lexical environment only within a single
// runInContext call, so they are concatenated ahead of the target script.
let preamble = '';
- for (const rel of ['scripts/modules/price.js', 'scripts/modules/delta.js']) {
+ for (const rel of ['scripts/modules/price.js', 'scripts/lib/parsers.js', 'scripts/modules/delta.js']) {
const dep = path.resolve(__dirname, '../../', rel);
if (fs.existsSync(dep)) preamble += fs.readFileSync(dep, 'utf8') + '\n';
}
@@ -98,4 +107,4 @@ function wrapHTML(bodyContent) {
return `${bodyContent}`;
}
-module.exports = { loadContentScript, wrapHTML };
+module.exports = { loadContentScript, wrapHTML, quietConsole };
diff --git a/tests/unit/parsers.test.js b/tests/unit/parsers.test.js
new file mode 100644
index 0000000..c3b39e1
--- /dev/null
+++ b/tests/unit/parsers.test.js
@@ -0,0 +1,65 @@
+const Parsers = require('../../scripts/lib/parsers');
+const Price = require('../../scripts/modules/price');
+const { parseDoc } = require('../setup/corpus');
+
+const card = (asin, price) => `
+
+
Item ${asin}
+ ${price ? `
${price}
` : ''}
+
+
`;
+const page = (body) => `${body}`;
+const URL_P1 = 'https://www.amazon.com/s?k=widget';
+
+describe('Parsers.parseSearchPage', () => {
+ test('a page with results and a live Next button', () => {
+ const doc = parseDoc(page(card('B0A', '$1.00') + card('B0B', '$2.50') + ''), URL_P1);
+ const r = Parsers.parseSearchPage(doc, URL_P1);
+ expect(r.kind).toBe('results');
+ expect(r.products.map((p) => p.asin)).toEqual(['B0A', 'B0B']);
+ expect(r.products[1].priceCents).toBe(250);
+ expect(r.nextHref).toBe('https://www.amazon.com/s?k=widget&page=2&ref=sr_pg_2');
+ });
+
+ test('a disabled Next button makes it the last page', () => {
+ const doc = parseDoc(page(card('B0A', '$1.00') + ''), URL_P1);
+ const r = Parsers.parseSearchPage(doc, URL_P1);
+ expect(r.kind).toBe('last');
+ expect(r.nextHref).toBeNull();
+ });
+
+ test('no result cards is empty', () => {
+ const doc = parseDoc(page('nothing
'), URL_P1);
+ const r = Parsers.parseSearchPage(doc, URL_P1);
+ expect(r).toEqual({ kind: 'empty', products: [], nextHref: null, total: 0, fill: { asin: 0, title: 0, price: 0 } });
+ });
+
+ test('fill counts products with a price', () => {
+ const doc = parseDoc(page(card('B0A', '$1.00') + card('B0B', null)), URL_P1);
+ expect(Parsers.parseSearchPage(doc, URL_P1).fill).toEqual({ asin: 1, title: 1, price: 0.5 });
+ });
+});
+
+describe('Parsers helpers', () => {
+ test('getNextPageUrl increments the page', () => {
+ expect(Parsers.getNextPageUrl('https://www.amazon.com/s?k=a&page=3')).toBe('https://www.amazon.com/s?k=a&page=4&ref=sr_pg_4');
+ });
+
+ test('priceToCents is the one from Price', () => {
+ for (const s of ['$1,299.99', 'N/A', '$0.35', null]) {
+ expect(Parsers.priceToCents(s)).toBe(Price.priceToCents(s));
+ }
+ });
+
+ test('analyzer coercions keep their old results', () => {
+ expect(Parsers.toDollars('$1,299.99')).toBe(1299.99);
+ expect(Parsers.toDollars('N/A')).toBe(0);
+ expect(Parsers.toRating('4.5')).toBe(4.5);
+ expect(Parsers.toReviewCount('12,847')).toBe(12847);
+ });
+
+ test('offer prices come from the first selector that has any', () => {
+ const doc = parseDoc(page('$9.00 '), URL_P1);
+ expect(Parsers.extractPricesFromDocument(doc)).toEqual([5]);
+ });
+});
diff --git a/tools/build.mjs b/tools/build.mjs
index 2828c4e..d03a49d 100644
--- a/tools/build.mjs
+++ b/tools/build.mjs
@@ -28,6 +28,7 @@ export const COPY_FILES = [
'scripts/content/scraper.js',
'scripts/content/chatbot.js',
'scripts/content/offer-fetcher.js',
+ 'scripts/lib/parsers.js',
'scripts/modules/price.js',
'scripts/modules/delta.js',
'scripts/modules/storage.js',
diff --git a/tools/lib/sanitize-page.mjs b/tools/lib/sanitize-page.mjs
new file mode 100644
index 0000000..7ad8060
--- /dev/null
+++ b/tools/lib/sanitize-page.mjs
@@ -0,0 +1,45 @@
+// Strips a saved Amazon page down to markup the corpus can keep: no scripts,
+// no frames, no inline handlers, and no session or tracking tokens. A meta
+// refresh is kept (with its token scrubbed) because it marks a bot interstitial.
+
+const TOKEN_PARAMS = [
+ 'dib', 'dib_tag', 'qid', 'crid', 'sprefix', 'bm-verify', 'session-id',
+ 'pf_rd_r', 'pf_rd_p', 'pf_rd_s', 'pf_rd_t', 'pf_rd_i', 'pf_rd_m',
+ 'pd_rd_r', 'pd_rd_w', 'pd_rd_wg', 'content-id', 'aref', 'sp_csd', 'spc',
+];
+
+export function sanitizeHtml(html) {
+ let out = html;
+ out = out.replace(/' +
+ 'x '
+ );
+ assert.doesNotMatch(out, /'), ['script tag']);
+});